From 79308a84b98da1037ffe9ab6ca8f6473e073d570 Mon Sep 17 00:00:00 2001 From: scoped Date: Fri, 15 May 2026 17:04:26 +0000 Subject: [PATCH] Improve library identification and track inspection --- backend/sortarr/app.py | 97 ++++++++++++++- backend/sortarr/library.py | 40 ++++++- backend/sortarr/metadata.py | 82 +++++++++++++ web/src/app.js | 232 ++++++++++++++++++++++++++++++------ web/src/index.html | 2 +- web/src/styles.css | 128 +++++++++++++++++++- 6 files changed, 530 insertions(+), 51 deletions(-) diff --git a/backend/sortarr/app.py b/backend/sortarr/app.py index 15acb9d..c52805c 100644 --- a/backend/sortarr/app.py +++ b/backend/sortarr/app.py @@ -2,6 +2,8 @@ from __future__ import annotations import json import os +import threading +import time from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import urlparse @@ -9,10 +11,10 @@ from urllib.parse import parse_qs, unquote from .config import load_config, public_config 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 .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 .releases import fetch_releases from .scanner import Scanner @@ -155,6 +157,73 @@ configure_logging(CONFIG["paths"]["logs"], CONFIG["app"].get("log_level", "INFO" STORE = JsonStore(CONFIG["paths"]["data"]) apply_settings(CONFIG, STORE.snapshot().get("settings", {})) 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): @@ -222,9 +291,9 @@ class Handler(BaseHTTPRequestHandler): "truncated": False, "cached": False, } - library = normalize_library(library) - library.pop("items", None) - self.send_json({"library": library}) + self.send_json({"library": public_library_payload(library)}) + elif path == "/api/library/metadata/status": + self.send_json({"metadata": METADATA_REFRESH}) elif path == "/api/downloads": self.send_json({"downloads": downloads_snapshot(CONFIG, STORE.snapshot())}) elif path == "/api/releases": @@ -294,9 +363,25 @@ class Handler(BaseHTTPRequestHandler): STORE.set_organizer_queue(updated) self.send_json({"ok": True}) elif path == "/api/library/scan": - library = library_snapshot(CONFIG) + snap = STORE.snapshot() + library = library_snapshot(CONFIG, snap.get("library")) STORE.set_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": result = run_next_transcode(CONFIG, STORE.snapshot().get("library")) STORE.add_event("info", f"transcoder: {result.get('status')}") diff --git a/backend/sortarr/library.py b/backend/sortarr/library.py index bc13807..7c5145e 100644 --- a/backend/sortarr/library.py +++ b/backend/sortarr/library.py @@ -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 = [] extensions = Counter() 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)) 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({ "drives": drive_stats(config), "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()), "scanned_files": scanned, "truncated": truncated, "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) diff --git a/backend/sortarr/metadata.py b/backend/sortarr/metadata.py index 74f203e..b3d7fdc 100644 --- a/backend/sortarr/metadata.py +++ b/backend/sortarr/metadata.py @@ -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: result = first_result(config, "tv", title) if not result: diff --git a/web/src/app.js b/web/src/app.js index b52e008..b4a8065 100644 --- a/web/src/app.js +++ b/web/src/app.js @@ -108,6 +108,7 @@ const state = { libraryTab: "all", libraryLimit: 120, selectedMedia: null, + detailModalOpen: false, }; const $ = (id) => document.getElementById(id); @@ -257,7 +258,11 @@ function libraryCollections() { const all = [ ...collections.movies.map((item) => ({ ...item, library: "movie" })), ...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) => { const meta = item.metadata || {}; const matchesTab = state.libraryTab === "all" || item.library === state.libraryTab; @@ -303,11 +308,6 @@ function renderLibrary() { renderLibrary(); }); } - if (!state.selectedMedia && visible[0]) { - selectMedia(visible[0].key, false); - } else if (state.selectedMedia) { - renderMediaDetail(state.selectedMedia); - } } function mediaCard(item) { @@ -334,39 +334,68 @@ function findMedia(key) { return [...collections.movies, ...collections.series].find((item) => item.key === key); } -function selectMedia(key, scroll = true) { +function selectMedia(key) { const item = findMedia(key); if (!item) return; state.selectedMedia = item; + state.detailModalOpen = true; document.querySelectorAll("[data-media-key]").forEach((button) => button.classList.toggle("active", button.dataset.mediaKey === key)); renderMediaDetail(item); - if (scroll) $("libraryDetail").scrollIntoView({ behavior: "smooth", block: "start" }); } function renderMediaDetail(item) { + closeMediaModal(); const meta = item.metadata || {}; const files = item.files || []; const title = meta.title || item.title; const cover = meta.poster ? `` : `${esc(title.slice(0, 1) || "?")}`; const detail = item.library === "tv" ? renderSeriesDetail(item) : renderMovieDetail(item); - $("libraryDetail").innerHTML = `
-
${cover}
-
-
-
-

${esc(title)}

-

${esc(item.library === "tv" ? "TV Series" : "Movie")} ${meta.source === "tmdb" ? "from TMDb metadata" : "from local filenames"}

+ const modal = document.createElement("div"); + modal.id = "mediaModal"; + modal.className = "modal-backdrop"; + modal.innerHTML = ``; + 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) => { 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) { @@ -386,13 +415,100 @@ function renderMovieDetail(item) { function versionRow(version) { const tags = (version.tags || []).map((tag) => `${esc(tag)}`).join(""); return `
- ${esc(version.name || "")} +
+
+ ${esc(version.name || "")} + ${esc(version.path || "")} +
+ ${version.path ? `` : ""} +
${esc(version.drive || "")}${bytes(version.size)}
${tags ? `
${tags}
` : ""} - ${esc(version.path || "")}
`; } +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 = `
+
+
+

Identify ${item.library === "tv" ? "Series" : "Movie"}

+

Search TMDb and apply the correct match to this library item.

+
+
+ +
+
`; + $("identifySearchButton").addEventListener("click", () => searchIdentify(item)); + searchIdentify(item).catch((error) => { + $("identifyResults").innerHTML = `

Search failed: ${esc(error.message)}

`; + }); +} + +async function searchIdentify(item) { + const results = $("identifyResults"); + const query = $("identifyQuery").value.trim(); + const year = Number($("identifyYear").value) || null; + results.innerHTML = "

Searching TMDb...

"; + 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) => ` +
+
${result.poster ? `` : `${esc((result.title || "?").slice(0, 1))}`}
+
+ ${esc(result.title || "Untitled")} + ${esc(result.year || result.date || "")} ${result.vote_average ? `- ${esc(result.vote_average)}` : ""} + ${result.overview ? `

${esc(result.overview)}

` : ""} +
+ +
+ `).join("") || "

No TMDb matches found.

"; + 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", "

Applying match...

"); + 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) { return `
${(item.seasons || []).map((season) => `
@@ -423,51 +539,86 @@ function fileRow(file) { async function inspectMedia(path) { const output = $("probeOutput"); - output.innerHTML = "

Inspecting media streams...

"; + output.innerHTML = "

Inspecting media streams...

"; + 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 media = payload.media; state.currentProbePath = path; - output.innerHTML = `
-

Media Info

-
-
Video${streamRows(media.video)}
-
Audio Tracks${streamRows(media.audio)}
-
Subtitles${streamRows(media.subtitles)}
+ const filename = path.split("/").pop(); + const dryRun = state.dashboard?.dry_run; + output.innerHTML = `
+
+
+

Media Tracks

+

${esc(filename || path)}

+
+ ${dryRun ? "Dry-run" : "Edits enabled"}
-

Track edits remux the selected file. Dry-run mode reports the command without changing the file.

+ ${dryRun ? "

Dry-run is enabled, so track changes will preview the ffmpeg command without modifying the file.

" : "

Track changes remux this file in place. Use one action at a time.

"} +
+
Video${streamRows(media.video, "video")}
+
Audio Tracks${streamRows(media.audio, "audio")}
+
Subtitle Tracks${streamRows(media.subtitles, "subtitle")}
+
+
`; document.querySelectorAll("[data-track-action]").forEach((button) => { 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) => { const tags = stream.tags || {}; - return `
- ${esc(stream.codec_name || stream.codec_type || "unknown")} - ${esc(tags.language || "und")} ${esc(tags.title || "")} ${stream.channels ? `${stream.channels} ch` : ""} - ${stream.codec_type === "audio" || stream.codec_type === "subtitle" ? ` - - + const isEditable = stream.codec_type === "audio" || stream.codec_type === "subtitle"; + return `
+
+ ${esc(streamTitle(stream, type))} + ${esc(streamMeta(stream) || `Stream ${stream.index}`)} +
+ ${tags.title ? `${esc(tags.title)}` : ""} + ${isEditable ? ` + + ` : ""}
`; }).join("") || "

None detected.

"; } async function editTrack(path, action, streamIndex) { - const output = $("probeOutput"); + const output = $("trackEditStatus") || $("probeOutput"); + output.innerHTML = "
Applying track change...
"; const payload = await api("/api/media/tracks", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path, action, stream_index: streamIndex }), }); const result = payload.media; - output.insertAdjacentHTML("afterbegin", `
+ output.innerHTML = `
Track edit: ${esc(result.status)} + ${result.status === "dry-run" ? "Dry-run is enabled. Disable dry-run in Settings to apply track edits." : ""} ${result.command ? `${esc(result.command.join(" "))}` : ""} ${result.stderr ? `
${esc(result.stderr)}
` : ""} -
`); +
`; if (result.status === "updated") { await inspectMedia(path); } @@ -937,6 +1088,9 @@ function showStartupError(error) { function init() { setTheme(localStorage.getItem("sortarr-theme") || "slate"); window.addEventListener("hashchange", renderRoute); + window.addEventListener("keydown", (event) => { + if (event.key === "Escape") closeMediaModal(); + }); renderRoute(); $("refreshButton").addEventListener("click", loadDashboard); $("scanButton").addEventListener("click", runScan); diff --git a/web/src/index.html b/web/src/index.html index d09d61b..5c045ae 100644 --- a/web/src/index.html +++ b/web/src/index.html @@ -146,6 +146,6 @@
- + diff --git a/web/src/styles.css b/web/src/styles.css index 3d88480..4ae13e3 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -146,6 +146,14 @@ td:first-child { height: 100%; object-fit: cover; } +.poster.tiny { + width: 54px; + min-width: 54px; + aspect-ratio: 2 / 3; +} +.poster.tiny .poster-placeholder { + font-size: 20px; +} .poster-placeholder { display: grid; place-items: center; @@ -182,6 +190,47 @@ td:first-child { .media-detail { 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 { display: grid; grid-template-columns: 190px minmax(0, 1fr); @@ -198,10 +247,42 @@ td:first-child { min-width: 0; } .detail-block, -.season-list { +.season-list, +.identify-output { display: grid; 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 { border: 1px solid var(--border); border-radius: var(--radius); @@ -257,19 +338,60 @@ td:first-child { } .stream-row { display: grid; + grid-template-columns: minmax(0, 1fr) auto; gap: 6px; - padding-top: 8px; - border-top: 1px solid var(--border); + align-items: center; + 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 { display: flex; flex-wrap: wrap; gap: 6px; + justify-content: flex-end; } .track-actions button { padding: 6px 8px; 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 { display: grid; grid-template-columns: minmax(320px, 1fr) minmax(0, 1.2fr) minmax(320px, .8fr);