video detail: surface subtitle availability (OpenSubtitles) + fix B023

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.
This commit is contained in:
BoulderBadgeDad 2026-06-18 21:58:06 -07:00
parent f7f21f44c5
commit c55a4fa10e
4 changed files with 65 additions and 2 deletions

View file

@ -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

View file

@ -1222,6 +1222,7 @@
<!-- Action buttons reuse the exact artist-detail button styles. -->
<div class="vd-actions artist-hero-actions" data-vd-actions></div>
<div class="vd-genres" data-vd-genres></div>
<div class="vd-subs" data-vd-subs hidden></div>
</div>
<!-- offscreen poster, only used to sample the accent color -->
<img data-vd-poster crossorigin="anonymous" alt="" style="display:none" />
@ -1319,6 +1320,7 @@
<p class="vd-overview" data-vd-overview></p>
<div class="vd-actions artist-hero-actions" data-vd-actions></div>
<div class="vd-genres" data-vd-genres></div>
<div class="vd-subs" data-vd-subs hidden></div>
</div>
<img data-vd-poster crossorigin="anonymous" alt="" style="display:none" />
</div>

View file

@ -227,12 +227,45 @@
return '<span class="vd-genre">' + esc(gn) + '</span>';
}).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 '<span class="vd-sub" title="' + esc(subLangLabel(c)) + '">' + esc(subLangLabel(c)) + '</span>';
});
if (langs.length > shown.length) {
chips.push('<span class="vd-sub vd-sub--more">+' + (langs.length - shown.length) + '</span>');
}
el.innerHTML = '<span class="vd-sub-label">Subtitles</span>' + 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) {

View file

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