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:
parent
f7f21f44c5
commit
c55a4fa10e
4 changed files with 65 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue