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 from __future__ import annotations
import hashlib import hashlib
import json
import os import os
import re import re
import sqlite3 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: def youtube_surrogate_id(source_id: str) -> int:
"""A stable positive 60-bit int derived from a YouTube id, used as the """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 NOT NULL ``tmdb_id`` surrogate for non-tmdb rows so the existing
@ -775,8 +789,8 @@ class VideoDatabase:
out = {} out = {}
try: try:
for kind, tbl in (("movie", "movies"), ("show", "shows")): for kind, tbl in (("movie", "movies"), ("show", "shows")):
def c(where): def c(where, _tbl=tbl): # bind tbl per-iteration (ruff B023)
return conn.execute(f"SELECT COUNT(*) FROM {tbl} WHERE {where}").fetchone()[0] return conn.execute(f"SELECT COUNT(*) FROM {_tbl} WHERE {where}").fetchone()[0]
out[kind] = { out[kind] = {
"matched": c("imdb_rating IS NOT NULL"), "matched": c("imdb_rating IS NOT NULL"),
"not_found": c("ratings_synced=1 AND imdb_rating IS NULL AND imdb_id 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"], "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"]), "has_poster": bool(show["poster_url"]), "has_backdrop": bool(show["backdrop_url"]),
"logo": show["logo_url"], "logo": show["logo_url"],
"subtitle_langs": _subtitle_langs_list(show["subtitle_langs"]),
"episodes_synced": bool(show["episodes_synced"]), "episodes_synced": bool(show["episodes_synced"]),
"monitored": bool(show["monitored"]), "monitored": bool(show["monitored"]),
"season_count": len(out_seasons), "season_count": len(out_seasons),
@ -2597,6 +2612,7 @@ class VideoDatabase:
"tmdb_id": m["tmdb_id"], "imdb_id": m["imdb_id"], "tmdb_id": m["tmdb_id"], "imdb_id": m["imdb_id"],
"has_poster": bool(m["poster_url"]), "has_backdrop": bool(m["backdrop_url"]), "has_poster": bool(m["poster_url"]), "has_backdrop": bool(m["backdrop_url"]),
"logo": m["logo_url"], "logo": m["logo_url"],
"subtitle_langs": _subtitle_langs_list(m["subtitle_langs"]),
"owned": bool(m["has_file"]), "monitored": bool(m["monitored"]), "owned": bool(m["has_file"]), "monitored": bool(m["monitored"]),
"file": (dict(files[0]) if files else None), # best version (compat) "file": (dict(files[0]) if files else None), # best version (compat)
"files": [dict(x) for x in files], # all versions/editions "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. --> <!-- Action buttons reuse the exact artist-detail button styles. -->
<div class="vd-actions artist-hero-actions" data-vd-actions></div> <div class="vd-actions artist-hero-actions" data-vd-actions></div>
<div class="vd-genres" data-vd-genres></div> <div class="vd-genres" data-vd-genres></div>
<div class="vd-subs" data-vd-subs hidden></div>
</div> </div>
<!-- offscreen poster, only used to sample the accent color --> <!-- offscreen poster, only used to sample the accent color -->
<img data-vd-poster crossorigin="anonymous" alt="" style="display:none" /> <img data-vd-poster crossorigin="anonymous" alt="" style="display:none" />
@ -1319,6 +1320,7 @@
<p class="vd-overview" data-vd-overview></p> <p class="vd-overview" data-vd-overview></p>
<div class="vd-actions artist-hero-actions" data-vd-actions></div> <div class="vd-actions artist-hero-actions" data-vd-actions></div>
<div class="vd-genres" data-vd-genres></div> <div class="vd-genres" data-vd-genres></div>
<div class="vd-subs" data-vd-subs hidden></div>
</div> </div>
<img data-vd-poster crossorigin="anonymous" alt="" style="display:none" /> <img data-vd-poster crossorigin="anonymous" alt="" style="display:none" />
</div> </div>

View file

@ -227,12 +227,45 @@
return '<span class="vd-genre">' + esc(gn) + '</span>'; return '<span class="vd-genre">' + esc(gn) + '</span>';
}).join(''); }).join('');
} }
renderSubtitles(d);
renderRatings(d); renderRatings(d);
renderCrewLine(d); renderCrewLine(d);
renderNextEpisode(d); renderNextEpisode(d);
renderCast(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. // "Directed by …" (movie) / "Created by …" (show) surfaced in the hero.
// A crew member's name, clickable → person page when we have a TMDB id. // A crew member's name, clickable → person page when we have a TMDB id.
function personName(c) { 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); 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); } 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; } .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-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; .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); text-shadow: 0 2px 12px rgba(0,0,0,0.6);