From c55a4fa10ede6931af59a2ff9c9f0abaefd56767 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Thu, 18 Jun 2026 21:58:06 -0700 Subject: [PATCH] video detail: surface subtitle availability (OpenSubtitles) + fix B023 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenSubtitles backfill worker already collects which subtitle languages exist for each title (movies.subtitle_langs / shows.subtitle_langs), but nothing showed it. Now the detail payload returns it (parsed to a list) and the hero renders a 'Subtitles: English · Spanish · …' CC-tinted chip row under the genres — so you can tell subs exist before grabbing the file. Hidden entirely when there's no data. (The clearlogo hero was already implemented, so this targets the one genuinely invisible enrichment field.) Also bound the per-iteration loop var in the ratings-breakdown counter (pre-existing ruff B023). 154 video tests green, ruff clean. --- database/video_database.py | 20 ++++++++++++++++-- webui/index.html | 2 ++ webui/static/video/video-detail.js | 33 ++++++++++++++++++++++++++++++ webui/static/video/video-side.css | 12 +++++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/database/video_database.py b/database/video_database.py index b81d2b15..3641c3e7 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -17,6 +17,7 @@ verbatim on first init. from __future__ import annotations import hashlib +import json import os import re import sqlite3 @@ -150,6 +151,19 @@ _COLUMN_MIGRATIONS = [ ] +def _subtitle_langs_list(raw) -> list: + """Parse the stored OpenSubtitles ``subtitle_langs`` JSON array into a list of + language codes for the detail payload. Returns [] for null/garbage so the UI + can simply hide the row when empty.""" + if not raw: + return [] + try: + v = json.loads(raw) + return [str(x) for x in v if x] if isinstance(v, list) else [] + except (ValueError, TypeError): + return [] + + def youtube_surrogate_id(source_id: str) -> int: """A stable positive 60-bit int derived from a YouTube id, used as the NOT NULL ``tmdb_id`` surrogate for non-tmdb rows so the existing @@ -775,8 +789,8 @@ class VideoDatabase: out = {} try: for kind, tbl in (("movie", "movies"), ("show", "shows")): - def c(where): - return conn.execute(f"SELECT COUNT(*) FROM {tbl} WHERE {where}").fetchone()[0] + def c(where, _tbl=tbl): # bind tbl per-iteration (ruff B023) + return conn.execute(f"SELECT COUNT(*) FROM {_tbl} WHERE {where}").fetchone()[0] out[kind] = { "matched": c("imdb_rating IS NOT NULL"), "not_found": c("ratings_synced=1 AND imdb_rating IS NULL AND imdb_id IS NOT NULL"), @@ -1550,6 +1564,7 @@ class VideoDatabase: "tmdb_id": show["tmdb_id"], "tvdb_id": show["tvdb_id"], "imdb_id": show["imdb_id"], "has_poster": bool(show["poster_url"]), "has_backdrop": bool(show["backdrop_url"]), "logo": show["logo_url"], + "subtitle_langs": _subtitle_langs_list(show["subtitle_langs"]), "episodes_synced": bool(show["episodes_synced"]), "monitored": bool(show["monitored"]), "season_count": len(out_seasons), @@ -2597,6 +2612,7 @@ class VideoDatabase: "tmdb_id": m["tmdb_id"], "imdb_id": m["imdb_id"], "has_poster": bool(m["poster_url"]), "has_backdrop": bool(m["backdrop_url"]), "logo": m["logo_url"], + "subtitle_langs": _subtitle_langs_list(m["subtitle_langs"]), "owned": bool(m["has_file"]), "monitored": bool(m["monitored"]), "file": (dict(files[0]) if files else None), # best version (compat) "files": [dict(x) for x in files], # all versions/editions diff --git a/webui/index.html b/webui/index.html index 73bbbba0..749251ae 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1222,6 +1222,7 @@
+ @@ -1319,6 +1320,7 @@

+ diff --git a/webui/static/video/video-detail.js b/webui/static/video/video-detail.js index 6c38d6aa..6f3d5e1b 100644 --- a/webui/static/video/video-detail.js +++ b/webui/static/video/video-detail.js @@ -227,12 +227,45 @@ return '' + esc(gn) + ''; }).join(''); } + renderSubtitles(d); renderRatings(d); renderCrewLine(d); renderNextEpisode(d); renderCast(d); } + // Subtitle availability (OpenSubtitles backfill, #video-enrichment): a chip row + // of the languages subtitles EXIST in for this title, so you know before you grab + // it. Hidden entirely when we have no data. + var SUB_LANG_NAMES = { + en: 'English', es: 'Spanish', fr: 'French', de: 'German', it: 'Italian', + pt: 'Portuguese', 'pt-br': 'Portuguese (BR)', nl: 'Dutch', pl: 'Polish', + ru: 'Russian', ja: 'Japanese', ko: 'Korean', zh: 'Chinese', 'zh-cn': 'Chinese', + ar: 'Arabic', tr: 'Turkish', sv: 'Swedish', da: 'Danish', fi: 'Finnish', + no: 'Norwegian', cs: 'Czech', el: 'Greek', he: 'Hebrew', hi: 'Hindi', + hu: 'Hungarian', ro: 'Romanian', th: 'Thai', uk: 'Ukrainian', vi: 'Vietnamese', + id: 'Indonesian' + }; + function subLangLabel(code) { + var c = String(code || '').toLowerCase(); + return SUB_LANG_NAMES[c] || code.toUpperCase(); + } + function renderSubtitles(d) { + var el = q('[data-vd-subs]'); + if (!el) return; + var langs = (d && d.subtitle_langs) || []; + if (!langs.length) { el.hidden = true; el.innerHTML = ''; return; } + var shown = langs.slice(0, 12); + var chips = shown.map(function (c) { + return '' + esc(subLangLabel(c)) + ''; + }); + if (langs.length > shown.length) { + chips.push('+' + (langs.length - shown.length) + ''); + } + el.innerHTML = 'Subtitles' + chips.join(''); + el.hidden = false; + } + // "Directed by …" (movie) / "Created by …" (show) surfaced in the hero. // A crew member's name, clickable → person page when we have a TMDB id. function personName(c) { diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index fbc395fb..f9c02452 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -418,6 +418,18 @@ body[data-side="video"] .dashboard-header-sweep { color: rgba(255,255,255,0.85); background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.14); backdrop-filter: blur(6px); } .vd-meta-rating { padding: 1px 7px; border: 1px solid rgba(255,255,255,0.35); border-radius: 4px; font-size: 12px; } + +/* Subtitle availability (OpenSubtitles backfill) — a labeled chip row of the + languages subtitles exist in. Cooler blue tint to read as "captions", distinct + from the neutral genre chips. */ +.vd-subs { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-top: 12px; } +.vd-sub-label { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; font-weight: 700; + letter-spacing: 0.08em; text-transform: uppercase; color: rgba(120,170,255,0.9); margin-right: 2px; } +.vd-sub-label::before { content: 'CC'; font: 700 9px/1.2 'JetBrains Mono', ui-monospace, monospace; + padding: 2px 4px; border-radius: 3px; background: rgba(120,170,255,0.18); border: 1px solid rgba(120,170,255,0.35); } +.vd-sub { padding: 4px 11px; border-radius: 999px; font-size: 12px; font-weight: 600; + color: rgba(200,220,255,0.92); background: rgba(120,170,255,0.1); border: 1px solid rgba(120,170,255,0.22); } +.vd-sub--more { color: rgba(255,255,255,0.55); background: rgba(255,255,255,0.06); border-color: rgba(255,255,255,0.12); } .vd-status { color: rgb(var(--vd-accent-rgb)); } .vd-overview { font-size: 16px; line-height: 1.6; color: rgba(255,255,255,0.86); margin: 0 0 22px; max-width: 640px; text-shadow: 0 2px 12px rgba(0,0,0,0.6);