Improve library identification and track inspection

This commit is contained in:
scoped
2026-05-15 17:04:26 +00:00
parent 1ffb68e74c
commit 79308a84b9
6 changed files with 530 additions and 51 deletions

View File

@@ -2,6 +2,8 @@ from __future__ import annotations
import json import json
import os import os
import threading
import time
from http import HTTPStatus from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -9,10 +11,10 @@ from urllib.parse import parse_qs, unquote
from .config import load_config, public_config from .config import load_config, public_config
from .downloads import downloads_snapshot from .downloads import downloads_snapshot
from .library import library_snapshot, normalize_library from .library import enrich_library_metadata, library_snapshot, normalize_library
from .logging_setup import configure_logging from .logging_setup import configure_logging
from .media_probe import edit_track, media_probe from .media_probe import edit_track, media_probe
from .metadata import test_tmdb from .metadata import movie_metadata_by_id, search_metadata, series_metadata_by_id, test_tmdb
from .organizer import execute_bundle_plan from .organizer import execute_bundle_plan
from .releases import fetch_releases from .releases import fetch_releases
from .scanner import Scanner from .scanner import Scanner
@@ -155,6 +157,73 @@ configure_logging(CONFIG["paths"]["logs"], CONFIG["app"].get("log_level", "INFO"
STORE = JsonStore(CONFIG["paths"]["data"]) STORE = JsonStore(CONFIG["paths"]["data"])
apply_settings(CONFIG, STORE.snapshot().get("settings", {})) apply_settings(CONFIG, STORE.snapshot().get("settings", {}))
SCANNER = Scanner(CONFIG, STORE) SCANNER = Scanner(CONFIG, STORE)
METADATA_REFRESH = {"running": False, "started_at": None, "finished_at": None, "error": None}
METADATA_LOCK = threading.Lock()
def start_metadata_refresh() -> bool:
with METADATA_LOCK:
if METADATA_REFRESH["running"]:
return False
METADATA_REFRESH.update({"running": True, "started_at": time.time(), "finished_at": None, "error": None})
def worker() -> None:
try:
snap = STORE.snapshot()
library = snap.get("library") or {}
if not library.get("items"):
library = library_snapshot(CONFIG, snap.get("library"))
enriched = enrich_library_metadata(CONFIG, library)
STORE.set_library(enriched)
with METADATA_LOCK:
METADATA_REFRESH.update({"running": False, "finished_at": time.time(), "error": None})
except Exception as exc:
with METADATA_LOCK:
METADATA_REFRESH.update({"running": False, "finished_at": time.time(), "error": str(exc)})
threading.Thread(target=worker, daemon=True).start()
return True
def public_library_payload(library: dict) -> dict:
public = normalize_library(library)
public.pop("items", None)
return public
def find_collection(library: dict, key: str, media_library: str) -> dict | None:
collections = library.get("collections") or {}
group = "series" if media_library == "tv" else "movies"
return next((item for item in collections.get(group, []) if item.get("key") == key), None)
def identify_collection(payload: dict) -> dict:
media_library = "tv" if payload.get("library") == "tv" else "movie"
key = str(payload.get("key") or "")
tmdb_id = int(payload.get("tmdb_id") or 0)
snap = STORE.snapshot()
library = snap.get("library") or {}
collection = find_collection(library, key, media_library)
if not collection:
raise ValueError("library item was not found")
if media_library == "tv":
seasons = {int(season.get("season") or 0) for season in collection.get("seasons", []) if int(season.get("season") or 0) > 0}
metadata = series_metadata_by_id(CONFIG, tmdb_id, seasons)
else:
metadata = movie_metadata_by_id(CONFIG, tmdb_id)
if metadata.get("release_date"):
collection["year"] = int(metadata["release_date"][:4])
collection["metadata"] = metadata
collection["title"] = metadata.get("title") or collection.get("title")
identifications = library.setdefault("identifications", {})
identifications[key] = {
"library": media_library,
"tmdb_id": tmdb_id,
"metadata": metadata,
"updated_at": time.time(),
}
STORE.set_library(library)
return collection
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
@@ -222,9 +291,9 @@ class Handler(BaseHTTPRequestHandler):
"truncated": False, "truncated": False,
"cached": False, "cached": False,
} }
library = normalize_library(library) self.send_json({"library": public_library_payload(library)})
library.pop("items", None) elif path == "/api/library/metadata/status":
self.send_json({"library": library}) self.send_json({"metadata": METADATA_REFRESH})
elif path == "/api/downloads": elif path == "/api/downloads":
self.send_json({"downloads": downloads_snapshot(CONFIG, STORE.snapshot())}) self.send_json({"downloads": downloads_snapshot(CONFIG, STORE.snapshot())})
elif path == "/api/releases": elif path == "/api/releases":
@@ -294,9 +363,25 @@ class Handler(BaseHTTPRequestHandler):
STORE.set_organizer_queue(updated) STORE.set_organizer_queue(updated)
self.send_json({"ok": True}) self.send_json({"ok": True})
elif path == "/api/library/scan": elif path == "/api/library/scan":
library = library_snapshot(CONFIG) snap = STORE.snapshot()
library = library_snapshot(CONFIG, snap.get("library"))
STORE.set_library(library) STORE.set_library(library)
self.send_json({"library": library}) self.send_json({"library": library})
elif path == "/api/library/metadata/refresh":
started = start_metadata_refresh()
self.send_json({"started": started, "metadata": METADATA_REFRESH}, HTTPStatus.ACCEPTED)
elif path == "/api/library/identify/search":
length = int(self.headers.get("Content-Length", "0") or "0")
body = self.rfile.read(length).decode() if length else "{}"
payload = json.loads(body)
results = search_metadata(CONFIG, "tv" if payload.get("library") == "tv" else "movie", str(payload.get("query") or ""), payload.get("year"))
self.send_json({"results": results})
elif path == "/api/library/identify/apply":
length = int(self.headers.get("Content-Length", "0") or "0")
body = self.rfile.read(length).decode() if length else "{}"
payload = json.loads(body)
collection = identify_collection(payload)
self.send_json({"collection": collection})
elif path == "/api/tools/transcoder/run-next": elif path == "/api/tools/transcoder/run-next":
result = run_next_transcode(CONFIG, STORE.snapshot().get("library")) result = run_next_transcode(CONFIG, STORE.snapshot().get("library"))
STORE.add_event("info", f"transcoder: {result.get('status')}") STORE.add_event("info", f"transcoder: {result.get('status')}")

View File

@@ -266,7 +266,28 @@ def build_collections(config: dict, items: list[dict], enrich: bool = False) ->
} }
def library_snapshot(config: dict) -> dict: def preserve_metadata(collections: dict, previous_library: dict | None) -> dict:
previous = (previous_library or {}).get("collections") or {}
previous_by_key = {
item.get("key"): item
for group in ("movies", "series")
for item in previous.get(group, [])
if item.get("key")
}
for group in ("movies", "series"):
for item in collections.get(group, []):
old = previous_by_key.get(item.get("key"))
old_meta = (old or {}).get("metadata") or {}
if old_meta.get("source") == "tmdb":
item["metadata"] = old_meta
if old_meta.get("manual"):
item["title"] = old_meta.get("title") or item.get("title")
if item.get("library") == "movie" and old_meta.get("release_date"):
item["year"] = int(old_meta["release_date"][:4])
return collections
def library_snapshot(config: dict, previous_library: dict | None = None) -> dict:
items = [] items = []
extensions = Counter() extensions = Counter()
ignored_dirs = {"$RECYCLE.BIN", "System Volume Information", ".Trash-1000"} ignored_dirs = {"$RECYCLE.BIN", "System Volume Information", ".Trash-1000"}
@@ -320,12 +341,27 @@ def library_snapshot(config: dict) -> dict:
}) })
enrich_limit = int(app.get("library_metadata_enrich_max_items", 500)) enrich_limit = int(app.get("library_metadata_enrich_max_items", 500))
should_enrich = bool(config.get("metadata", {}).get("tmdb_enabled", True)) and len(items) <= enrich_limit should_enrich = bool(config.get("metadata", {}).get("tmdb_enabled", True)) and len(items) <= enrich_limit
collections = build_collections(config, items, enrich=should_enrich)
if not should_enrich:
collections = preserve_metadata(collections, previous_library)
return normalize_library({ return normalize_library({
"drives": drive_stats(config), "drives": drive_stats(config),
"items": sorted(items, key=lambda item: item["modified"], reverse=True), "items": sorted(items, key=lambda item: item["modified"], reverse=True),
"collections": build_collections(config, items, enrich=should_enrich), "collections": collections,
"extensions": dict(extensions.most_common()), "extensions": dict(extensions.most_common()),
"scanned_files": scanned, "scanned_files": scanned,
"truncated": truncated, "truncated": truncated,
"metadata_enriched": should_enrich, "metadata_enriched": should_enrich,
"identifications": (previous_library or {}).get("identifications", {}),
}) })
def enrich_library_metadata(config: dict, library: dict) -> dict:
items = library.get("items") or []
enriched = {
**library,
"collections": build_collections(config, items, enrich=True),
"metadata_enriched": True,
"metadata_refreshed_at": time.time(),
}
return normalize_library(enriched)

View File

@@ -117,6 +117,88 @@ def movie_metadata(config: dict, title: str, year: int | None = None) -> dict:
} }
def search_metadata(config: dict, library: str, query: str, year: int | None = None) -> list[dict]:
media_type = "tv" if library == "tv" else "movie"
if not tmdb_available(config) or not query:
return []
params = {"query": query}
if year and media_type == "movie":
params["year"] = year
elif year:
params["first_air_date_year"] = year
payload = tmdb_get(config, f"/search/{media_type}", params)
results = payload.get("results") or []
normalized = []
for item in results[:12]:
title = item.get("name") if media_type == "tv" else item.get("title")
date = item.get("first_air_date") if media_type == "tv" else item.get("release_date")
normalized.append({
"tmdb_id": item.get("id"),
"library": library,
"title": title,
"date": date,
"year": int(date[:4]) if date and date[:4].isdigit() else None,
"overview": item.get("overview") or "",
"poster": poster_url(config, item.get("poster_path")),
"backdrop": poster_url(config, item.get("backdrop_path")),
"vote_average": item.get("vote_average"),
})
return normalized
def movie_metadata_by_id(config: dict, tmdb_id: int) -> dict:
payload = tmdb_get(config, f"/movie/{tmdb_id}")
return {
"source": "tmdb",
"manual": True,
"tmdb_id": payload.get("id"),
"title": payload.get("title") or payload.get("original_title") or "",
"overview": payload.get("overview") or "",
"poster": poster_url(config, payload.get("poster_path")),
"backdrop": poster_url(config, payload.get("backdrop_path")),
"release_date": payload.get("release_date"),
"vote_average": payload.get("vote_average"),
}
def series_metadata_by_id(config: dict, tmdb_id: int, seasons: set[int]) -> dict:
payload = tmdb_get(config, f"/tv/{tmdb_id}")
metadata = {
"source": "tmdb",
"manual": True,
"tmdb_id": payload.get("id"),
"title": payload.get("name") or payload.get("original_name") or "",
"overview": payload.get("overview") or "",
"poster": poster_url(config, payload.get("poster_path")),
"backdrop": poster_url(config, payload.get("backdrop_path")),
"first_air_date": payload.get("first_air_date"),
"vote_average": payload.get("vote_average"),
"seasons": {},
}
for season in sorted(seasons):
try:
season_payload = tmdb_get(config, f"/tv/{tmdb_id}/season/{season}")
except Exception:
continue
metadata["seasons"][str(season)] = {
"name": season_payload.get("name"),
"air_date": season_payload.get("air_date"),
"episode_count": len(season_payload.get("episodes") or []),
"episodes": [
{
"season": season,
"episode": episode.get("episode_number"),
"title": episode.get("name"),
"overview": episode.get("overview") or "",
"air_date": episode.get("air_date"),
"still": poster_url(config, episode.get("still_path")),
}
for episode in season_payload.get("episodes") or []
],
}
return metadata
def series_metadata(config: dict, title: str, seasons: set[int]) -> dict: def series_metadata(config: dict, title: str, seasons: set[int]) -> dict:
result = first_result(config, "tv", title) result = first_result(config, "tv", title)
if not result: if not result:

View File

@@ -108,6 +108,7 @@ const state = {
libraryTab: "all", libraryTab: "all",
libraryLimit: 120, libraryLimit: 120,
selectedMedia: null, selectedMedia: null,
detailModalOpen: false,
}; };
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
@@ -257,7 +258,11 @@ function libraryCollections() {
const all = [ const all = [
...collections.movies.map((item) => ({ ...item, library: "movie" })), ...collections.movies.map((item) => ({ ...item, library: "movie" })),
...collections.series.map((item) => ({ ...item, library: "tv" })), ...collections.series.map((item) => ({ ...item, library: "tv" })),
]; ].sort((a, b) => {
const aTitle = (a.metadata?.title || a.title || "").toLowerCase();
const bTitle = (b.metadata?.title || b.title || "").toLowerCase();
return aTitle.localeCompare(bTitle);
});
return all.filter((item) => { return all.filter((item) => {
const meta = item.metadata || {}; const meta = item.metadata || {};
const matchesTab = state.libraryTab === "all" || item.library === state.libraryTab; const matchesTab = state.libraryTab === "all" || item.library === state.libraryTab;
@@ -303,11 +308,6 @@ function renderLibrary() {
renderLibrary(); renderLibrary();
}); });
} }
if (!state.selectedMedia && visible[0]) {
selectMedia(visible[0].key, false);
} else if (state.selectedMedia) {
renderMediaDetail(state.selectedMedia);
}
} }
function mediaCard(item) { function mediaCard(item) {
@@ -334,39 +334,68 @@ function findMedia(key) {
return [...collections.movies, ...collections.series].find((item) => item.key === key); return [...collections.movies, ...collections.series].find((item) => item.key === key);
} }
function selectMedia(key, scroll = true) { function selectMedia(key) {
const item = findMedia(key); const item = findMedia(key);
if (!item) return; if (!item) return;
state.selectedMedia = item; state.selectedMedia = item;
state.detailModalOpen = true;
document.querySelectorAll("[data-media-key]").forEach((button) => button.classList.toggle("active", button.dataset.mediaKey === key)); document.querySelectorAll("[data-media-key]").forEach((button) => button.classList.toggle("active", button.dataset.mediaKey === key));
renderMediaDetail(item); renderMediaDetail(item);
if (scroll) $("libraryDetail").scrollIntoView({ behavior: "smooth", block: "start" });
} }
function renderMediaDetail(item) { function renderMediaDetail(item) {
closeMediaModal();
const meta = item.metadata || {}; const meta = item.metadata || {};
const files = item.files || []; const files = item.files || [];
const title = meta.title || item.title; const title = meta.title || item.title;
const cover = meta.poster ? `<img src="${esc(meta.poster)}" alt="">` : `<span class="poster-placeholder">${esc(title.slice(0, 1) || "?")}</span>`; const cover = meta.poster ? `<img src="${esc(meta.poster)}" alt="">` : `<span class="poster-placeholder">${esc(title.slice(0, 1) || "?")}</span>`;
const detail = item.library === "tv" ? renderSeriesDetail(item) : renderMovieDetail(item); const detail = item.library === "tv" ? renderSeriesDetail(item) : renderMovieDetail(item);
$("libraryDetail").innerHTML = `<article class="detail-shell"> const modal = document.createElement("div");
<div class="poster detail-poster">${cover}</div> modal.id = "mediaModal";
<div class="detail-body"> modal.className = "modal-backdrop";
<div class="section-head"> modal.innerHTML = `<article class="media-modal" role="dialog" aria-modal="true" aria-label="${esc(title)}">
<div> <button class="modal-close" type="button" data-modal-close aria-label="Close">x</button>
<h2>${esc(title)}</h2> <div class="detail-shell">
<p class="muted">${esc(item.library === "tv" ? "TV Series" : "Movie")} ${meta.source === "tmdb" ? "from TMDb metadata" : "from local filenames"}</p> <div class="poster detail-poster">${cover}</div>
<div class="detail-body">
<div class="section-head">
<div>
<h2>${esc(title)}</h2>
<p class="muted">${esc(item.library === "tv" ? "TV Series" : "Movie")} ${meta.source === "tmdb" ? "from TMDb metadata" : "from local filenames"}</p>
</div>
<div class="actions">
${files[0] ? `<button data-probe-path="${esc(files[0].path)}">Inspect media</button>` : ""}
<button data-identify-media type="button">Identify</button>
</div>
</div> </div>
${files[0] ? `<button data-probe-path="${esc(files[0].path)}">Inspect media</button>` : ""} ${meta.overview ? `<p>${esc(meta.overview)}</p>` : ""}
<div id="identifyOutput" class="identify-output"></div>
<div id="probeOutput" class="probe-output"></div>
${detail}
</div> </div>
${meta.overview ? `<p>${esc(meta.overview)}</p>` : ""}
${detail}
<div id="probeOutput" class="probe-output"></div>
</div> </div>
</article>`; </article>`;
document.body.appendChild(modal);
document.body.classList.add("modal-open");
state.detailModalOpen = true;
modal.addEventListener("click", (event) => {
if (event.target === modal || event.target.closest("[data-modal-close]")) {
closeMediaModal();
}
});
document.querySelectorAll("[data-probe-path]").forEach((button) => { document.querySelectorAll("[data-probe-path]").forEach((button) => {
button.addEventListener("click", () => inspectMedia(button.dataset.probePath)); button.addEventListener("click", () => inspectMedia(button.dataset.probePath));
}); });
document.querySelectorAll("[data-identify-media]").forEach((button) => {
button.addEventListener("click", () => openIdentifyPanel(item));
});
}
function closeMediaModal() {
const modal = $("mediaModal");
if (modal) modal.remove();
document.body.classList.remove("modal-open");
state.detailModalOpen = false;
} }
function renderMovieDetail(item) { function renderMovieDetail(item) {
@@ -386,13 +415,100 @@ function renderMovieDetail(item) {
function versionRow(version) { function versionRow(version) {
const tags = (version.tags || []).map((tag) => `<span>${esc(tag)}</span>`).join(""); const tags = (version.tags || []).map((tag) => `<span>${esc(tag)}</span>`).join("");
return `<div class="download"> return `<div class="download">
<strong>${esc(version.name || "")}</strong> <div class="bundle-head">
<div>
<strong>${esc(version.name || "")}</strong>
<small class="muted">${esc(version.path || "")}</small>
</div>
${version.path ? `<button data-probe-path="${esc(version.path)}" type="button">Inspect</button>` : ""}
</div>
<div class="kv"><span>${esc(version.drive || "")}</span><span>${bytes(version.size)}</span></div> <div class="kv"><span>${esc(version.drive || "")}</span><span>${bytes(version.size)}</span></div>
${tags ? `<div class="subtitle-chips">${tags}</div>` : ""} ${tags ? `<div class="subtitle-chips">${tags}</div>` : ""}
<small class="muted">${esc(version.path || "")}</small>
</div>`; </div>`;
} }
function identifyYear(item) {
const meta = item.metadata || {};
const raw = item.year || meta.release_date || meta.first_air_date || "";
const match = String(raw).match(/(19\d{2}|20\d{2})/);
return match ? Number(match[1]) : "";
}
function openIdentifyPanel(item) {
const output = $("identifyOutput");
if (!output) return;
const meta = item.metadata || {};
const title = meta.title || item.title || "";
const year = identifyYear(item);
output.innerHTML = `<div class="identify-panel">
<div class="section-head">
<div>
<h3>Identify ${item.library === "tv" ? "Series" : "Movie"}</h3>
<p class="muted">Search TMDb and apply the correct match to this library item.</p>
</div>
</div>
<div class="identify-search">
<input id="identifyQuery" value="${esc(title)}" placeholder="Title">
<input id="identifyYear" value="${esc(year)}" placeholder="Year" type="number" min="1900" max="2100">
<button id="identifySearchButton" type="button">Search</button>
</div>
<div id="identifyResults" class="identify-results"></div>
</div>`;
$("identifySearchButton").addEventListener("click", () => searchIdentify(item));
searchIdentify(item).catch((error) => {
$("identifyResults").innerHTML = `<p class="muted">Search failed: ${esc(error.message)}</p>`;
});
}
async function searchIdentify(item) {
const results = $("identifyResults");
const query = $("identifyQuery").value.trim();
const year = Number($("identifyYear").value) || null;
results.innerHTML = "<p class='muted'>Searching TMDb...</p>";
const payload = await api("/api/library/identify/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ library: item.library, query, year }),
});
results.innerHTML = (payload.results || []).map((result) => `
<article class="identify-result">
<div class="poster tiny">${result.poster ? `<img src="${esc(result.poster)}" alt="">` : `<span class="poster-placeholder">${esc((result.title || "?").slice(0, 1))}</span>`}</div>
<div>
<strong>${esc(result.title || "Untitled")}</strong>
<small class="muted">${esc(result.year || result.date || "")} ${result.vote_average ? `- ${esc(result.vote_average)}` : ""}</small>
${result.overview ? `<p class="muted">${esc(result.overview)}</p>` : ""}
</div>
<button data-apply-identify="${esc(result.tmdb_id)}" type="button">Apply</button>
</article>
`).join("") || "<p class='muted'>No TMDb matches found.</p>";
document.querySelectorAll("[data-apply-identify]").forEach((button) => {
button.addEventListener("click", () => applyIdentify(item, Number(button.dataset.applyIdentify)));
});
}
function replaceCollection(collection) {
const collections = activeLibrary().collections || {};
const group = collection.library === "tv" ? "series" : "movies";
const list = collections[group] || [];
const idx = list.findIndex((item) => item.key === collection.key);
if (idx >= 0) list[idx] = collection;
state.selectedMedia = collection;
}
async function applyIdentify(item, tmdbId) {
const results = $("identifyResults");
results.insertAdjacentHTML("afterbegin", "<p class='muted'>Applying match...</p>");
const payload = await api("/api/library/identify/apply", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: item.key, library: item.library, tmdb_id: tmdbId }),
});
replaceCollection(payload.collection);
renderLibrary();
renderMediaDetail(payload.collection);
toast("Identification applied.", "success");
}
function renderSeriesDetail(item) { function renderSeriesDetail(item) {
return `<div class="season-list">${(item.seasons || []).map((season) => ` return `<div class="season-list">${(item.seasons || []).map((season) => `
<details open> <details open>
@@ -423,51 +539,86 @@ function fileRow(file) {
async function inspectMedia(path) { async function inspectMedia(path) {
const output = $("probeOutput"); const output = $("probeOutput");
output.innerHTML = "<p class='muted'>Inspecting media streams...</p>"; output.innerHTML = "<div class='detail-block'><p class='muted'>Inspecting media streams...</p></div>";
const modal = document.querySelector(".media-modal");
if (modal) modal.scrollTo({ top: 0, behavior: "smooth" });
const payload = await api(`/api/media/probe?path=${encodeURIComponent(path)}`); const payload = await api(`/api/media/probe?path=${encodeURIComponent(path)}`);
const media = payload.media; const media = payload.media;
state.currentProbePath = path; state.currentProbePath = path;
output.innerHTML = `<div class="detail-block"> const filename = path.split("/").pop();
<h3>Media Info</h3> const dryRun = state.dashboard?.dry_run;
<div class="stream-grid"> output.innerHTML = `<div class="detail-block media-inspector">
<section><strong>Video</strong>${streamRows(media.video)}</section> <div class="section-head">
<section><strong>Audio Tracks</strong>${streamRows(media.audio)}</section> <div>
<section><strong>Subtitles</strong>${streamRows(media.subtitles)}</section> <h3>Media Tracks</h3>
<p class="muted">${esc(filename || path)}</p>
</div>
${dryRun ? "<span class='status-pill warn'>Dry-run</span>" : "<span class='status-pill good'>Edits enabled</span>"}
</div> </div>
<p class="muted">Track edits remux the selected file. Dry-run mode reports the command without changing the file.</p> ${dryRun ? "<p class='muted'>Dry-run is enabled, so track changes will preview the ffmpeg command without modifying the file.</p>" : "<p class='muted'>Track changes remux this file in place. Use one action at a time.</p>"}
<div class="stream-grid">
<section><strong>Video</strong>${streamRows(media.video, "video")}</section>
<section><strong>Audio Tracks</strong>${streamRows(media.audio, "audio")}</section>
<section><strong>Subtitle Tracks</strong>${streamRows(media.subtitles, "subtitle")}</section>
</div>
<div id="trackEditStatus"></div>
</div>`; </div>`;
document.querySelectorAll("[data-track-action]").forEach((button) => { document.querySelectorAll("[data-track-action]").forEach((button) => {
button.addEventListener("click", () => editTrack(path, button.dataset.trackAction, Number(button.dataset.streamIndex))); button.addEventListener("click", () => editTrack(path, button.dataset.trackAction, Number(button.dataset.streamIndex)));
}); });
} }
function streamRows(streams = []) { function streamTitle(stream, type) {
const tags = stream.tags || {};
if (type === "audio") {
return `${tags.language || "und"} ${stream.channels ? `${stream.channels} ch` : ""} ${stream.codec_name || ""}`.trim();
}
if (type === "subtitle") {
return `${tags.language || "und"} ${stream.codec_name || "subtitle"}`.trim();
}
return `${stream.codec_name || "video"} ${stream.width && stream.height ? `${stream.width}x${stream.height}` : ""}`.trim();
}
function streamMeta(stream) {
const tags = stream.tags || {};
return [tags.title, stream.disposition?.default ? "Default" : "", stream.bit_rate ? `${Math.round(Number(stream.bit_rate) / 1000)} kbps` : ""]
.filter(Boolean)
.join(" - ");
}
function streamRows(streams = [], type = "") {
return streams.map((stream) => { return streams.map((stream) => {
const tags = stream.tags || {}; const tags = stream.tags || {};
return `<div class="stream-row"> const isEditable = stream.codec_type === "audio" || stream.codec_type === "subtitle";
<span>${esc(stream.codec_name || stream.codec_type || "unknown")}</span> return `<div class="stream-row ${stream.disposition?.default ? "is-default" : ""}">
<small>${esc(tags.language || "und")} ${esc(tags.title || "")} ${stream.channels ? `${stream.channels} ch` : ""}</small> <div>
${stream.codec_type === "audio" || stream.codec_type === "subtitle" ? `<span class="track-actions"> <strong>${esc(streamTitle(stream, type))}</strong>
<button data-track-action="set-default" data-stream-index="${stream.index}">Set default</button> <small>${esc(streamMeta(stream) || `Stream ${stream.index}`)}</small>
<button data-track-action="remove" data-stream-index="${stream.index}">Remove</button> </div>
${tags.title ? `<small class="muted">${esc(tags.title)}</small>` : ""}
${isEditable ? `<span class="track-actions">
<button data-track-action="set-default" data-stream-index="${stream.index}">Make default</button>
<button class="danger" data-track-action="remove" data-stream-index="${stream.index}">Remove</button>
</span>` : ""} </span>` : ""}
</div>`; </div>`;
}).join("") || "<p class='muted'>None detected.</p>"; }).join("") || "<p class='muted'>None detected.</p>";
} }
async function editTrack(path, action, streamIndex) { async function editTrack(path, action, streamIndex) {
const output = $("probeOutput"); const output = $("trackEditStatus") || $("probeOutput");
output.innerHTML = "<div class='event'><strong>Applying track change...</strong></div>";
const payload = await api("/api/media/tracks", { const payload = await api("/api/media/tracks", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, action, stream_index: streamIndex }), body: JSON.stringify({ path, action, stream_index: streamIndex }),
}); });
const result = payload.media; const result = payload.media;
output.insertAdjacentHTML("afterbegin", `<div class="event ${result.status === "failed" ? "error" : ""}"> output.innerHTML = `<div class="event ${result.status === "failed" ? "error" : result.status === "updated" ? "success" : ""}">
<strong>Track edit: ${esc(result.status)}</strong> <strong>Track edit: ${esc(result.status)}</strong>
${result.status === "dry-run" ? "<small class='muted'>Dry-run is enabled. Disable dry-run in Settings to apply track edits.</small>" : ""}
${result.command ? `<code>${esc(result.command.join(" "))}</code>` : ""} ${result.command ? `<code>${esc(result.command.join(" "))}</code>` : ""}
${result.stderr ? `<pre>${esc(result.stderr)}</pre>` : ""} ${result.stderr ? `<pre>${esc(result.stderr)}</pre>` : ""}
</div>`); </div>`;
if (result.status === "updated") { if (result.status === "updated") {
await inspectMedia(path); await inspectMedia(path);
} }
@@ -937,6 +1088,9 @@ function showStartupError(error) {
function init() { function init() {
setTheme(localStorage.getItem("sortarr-theme") || "slate"); setTheme(localStorage.getItem("sortarr-theme") || "slate");
window.addEventListener("hashchange", renderRoute); window.addEventListener("hashchange", renderRoute);
window.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeMediaModal();
});
renderRoute(); renderRoute();
$("refreshButton").addEventListener("click", loadDashboard); $("refreshButton").addEventListener("click", loadDashboard);
$("scanButton").addEventListener("click", runScan); $("scanButton").addEventListener("click", runScan);

View File

@@ -146,6 +146,6 @@
</main> </main>
</div> </div>
<div id="toastHost" class="toast-host" aria-live="polite"></div> <div id="toastHost" class="toast-host" aria-live="polite"></div>
<script src="/app.js?v=20260514-3"></script> <script src="/app.js?v=20260515-4"></script>
</body> </body>
</html> </html>

View File

@@ -146,6 +146,14 @@ td:first-child {
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
} }
.poster.tiny {
width: 54px;
min-width: 54px;
aspect-ratio: 2 / 3;
}
.poster.tiny .poster-placeholder {
font-size: 20px;
}
.poster-placeholder { .poster-placeholder {
display: grid; display: grid;
place-items: center; place-items: center;
@@ -182,6 +190,47 @@ td:first-child {
.media-detail { .media-detail {
margin-top: 22px; margin-top: 22px;
} }
.modal-open {
overflow: hidden;
}
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: grid;
align-items: start;
justify-items: center;
overflow: auto;
padding: 32px;
background: rgba(0, 0, 0, .62);
}
.media-modal {
position: relative;
width: min(1120px, 100%);
max-height: calc(100vh - 64px);
overflow: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
box-shadow: 0 24px 80px rgba(0, 0, 0, .45);
padding: 22px;
}
.media-modal .detail-shell {
border-top: 0;
padding-top: 0;
}
.modal-close {
position: sticky;
top: 0;
float: right;
z-index: 2;
width: 34px;
height: 34px;
padding: 0;
border-radius: 999px;
font-size: 18px;
line-height: 1;
}
.detail-shell { .detail-shell {
display: grid; display: grid;
grid-template-columns: 190px minmax(0, 1fr); grid-template-columns: 190px minmax(0, 1fr);
@@ -198,10 +247,42 @@ td:first-child {
min-width: 0; min-width: 0;
} }
.detail-block, .detail-block,
.season-list { .season-list,
.identify-output {
display: grid; display: grid;
gap: 12px; gap: 12px;
} }
.identify-panel {
display: grid;
gap: 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface-2);
padding: 12px;
}
.identify-search {
display: grid;
grid-template-columns: minmax(0, 1fr) 110px auto;
gap: 10px;
}
.identify-results {
display: grid;
gap: 10px;
}
.identify-result {
display: grid;
grid-template-columns: 54px minmax(0, 1fr) auto;
gap: 12px;
align-items: start;
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
}
.identify-result p {
margin-top: 4px;
line-height: 1.35;
}
.season-list details { .season-list details {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
@@ -257,19 +338,60 @@ td:first-child {
} }
.stream-row { .stream-row {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 6px; gap: 6px;
padding-top: 8px; align-items: center;
border-top: 1px solid var(--border); padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
}
.stream-row.is-default {
border-color: var(--good);
}
.stream-row strong,
.stream-row small {
display: block;
overflow-wrap: anywhere;
} }
.track-actions { .track-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px; gap: 6px;
justify-content: flex-end;
} }
.track-actions button { .track-actions button {
padding: 6px 8px; padding: 6px 8px;
font-size: 12px; font-size: 12px;
} }
button.danger {
border-color: color-mix(in srgb, var(--bad) 60%, var(--border));
color: var(--bad);
}
.status-pill {
display: inline-grid;
place-items: center;
min-height: 28px;
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: 999px;
font-size: 12px;
font-weight: 700;
}
.status-pill.good {
color: var(--good);
border-color: color-mix(in srgb, var(--good) 55%, var(--border));
}
.status-pill.warn {
color: var(--warn);
border-color: color-mix(in srgb, var(--warn) 55%, var(--border));
}
.media-inspector {
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface-2);
padding: 12px;
}
.downloads-layout { .downloads-layout {
display: grid; display: grid;
grid-template-columns: minmax(320px, 1fr) minmax(0, 1.2fr) minmax(320px, .8fr); grid-template-columns: minmax(320px, 1fr) minmax(0, 1.2fr) minmax(320px, .8fr);