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);