video detail: clearlogo hero (TMDB images — no new key)

Phase 3: the stylized transparent title logo replaces the text title in the
billboard (the big Plex/Netflix 'premium' jump). Sourced from TMDB images
(append_to_response=images, include_image_language=en,null) in the same detail
call — no Fanart key needed.

- schema v4: movies.logo_url / shows.logo_url (idempotent migration).
- TMDB client picks an English logo (then language-neutral, then any); enrichment
  backfills logo_url gap-only; show/movie payloads return 'logo'.
- Billboard shows the logo img (with graceful fallback to the text title on error
  / when absent; title kept visually-hidden for a11y). Lazy on-view refresh now
  also triggers when the logo is missing, so existing libraries fill it in.

Seam tests: English-logo pick, backfill + payload, schema.
This commit is contained in:
BoulderBadgeDad 2026-06-14 21:36:14 -07:00
parent 59c88fa0db
commit efa0632883
8 changed files with 76 additions and 6 deletions

View file

@ -77,7 +77,8 @@ class TMDBClient:
detail_path = "/movie/" if kind == "movie" else "/tv/"
dr = requests.get(self.BASE + detail_path + str(tmdb_id),
params={"api_key": self.api_key,
"append_to_response": "external_ids,credits"},
"append_to_response": "external_ids,credits,images",
"include_image_language": "en,null"},
timeout=15).json() or {}
meta["overview"] = dr.get("overview") or meta.get("overview")
if dr.get("backdrop_path"):
@ -113,11 +114,26 @@ class TMDBClient:
if seasons:
meta["seasons"] = seasons
self._add_credits(meta, dr.get("credits") or {}, dr.get("created_by") or [])
logo = self._pick_logo((dr.get("images") or {}).get("logos") or [])
if logo:
meta["logo_url"] = self.LOGO + logo
except Exception:
logger.exception("TMDB details fetch failed for %s", title or tmdb_id)
return {"id": tmdb_id, "metadata": {k: v for k, v in meta.items() if v}}
PROFILE = "https://image.tmdb.org/t/p/w185"
LOGO = "https://image.tmdb.org/t/p/w500"
@staticmethod
def _pick_logo(logos):
"""Prefer an English title logo, then a language-neutral one, then any."""
if not logos:
return None
for lang in ("en", None):
for lg in logos:
if lg.get("iso_639_1") == lang and lg.get("file_path"):
return lg["file_path"]
return logos[0].get("file_path")
def _person(self, c, job=None, character=None):
return {"name": c["name"], "tmdb_id": c.get("id"), "job": job, "character": character,

View file

@ -29,7 +29,7 @@ logger = get_logger("video_database")
# Bump when video_schema.sql changes in a way worth recording. Stored in
# PRAGMA user_version as a backstop indicator (nothing gates on it yet).
SCHEMA_VERSION = 3
SCHEMA_VERSION = 4
_DEFAULT_DB_PATH = "database/video_library.db"
_SCHEMA_FILE = Path(__file__).resolve().parent / "video_schema.sql"
@ -62,10 +62,10 @@ _ENRICH = {
# Whitelist of metadata columns enrichment may write per table (guards against
# arbitrary keys; backfill semantics applied by the caller).
_ENRICH_META_COLS = {
"movies": {"overview", "backdrop_url", "release_date", "status", "content_rating",
"movies": {"overview", "backdrop_url", "logo_url", "release_date", "status", "content_rating",
"runtime_minutes", "studio", "tagline", "rating", "rating_critic",
"imdb_id", "tmdb_id"},
"shows": {"overview", "backdrop_url", "status", "network", "content_rating",
"shows": {"overview", "backdrop_url", "logo_url", "status", "network", "content_rating",
"tagline", "rating", "first_air_date", "last_air_date",
"imdb_id", "tmdb_id", "tvdb_id"},
}
@ -88,6 +88,8 @@ _COLUMN_MIGRATIONS = [
("shows", "last_air_date", "TEXT"),
("episodes", "still_url", "TEXT"),
("episodes", "rating", "REAL"),
("movies", "logo_url", "TEXT"),
("shows", "logo_url", "TEXT"),
]
@ -936,6 +938,7 @@ class VideoDatabase:
"genres": genres, "cast": credits["cast"], "crew": credits["crew"],
"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"],
"monitored": bool(show["monitored"]),
"season_count": len(out_seasons),
"episode_total": total, "episode_owned": owned_total,
@ -982,6 +985,7 @@ class VideoDatabase:
"cast": credits["cast"], "crew": credits["crew"],
"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"],
"owned": bool(m["has_file"]), "monitored": bool(m["monitored"]),
"file": (dict(f) if f else None),
}

View file

@ -82,6 +82,7 @@ CREATE TABLE IF NOT EXISTS movies (
rating_critic REAL, -- critic score (0-100) when offered
poster_url TEXT,
backdrop_url TEXT,
logo_url TEXT, -- transparent title logo (clearlogo)
monitored INTEGER NOT NULL DEFAULT 1, -- tracked for acquisition
has_file INTEGER NOT NULL DEFAULT 0, -- owned? (denormalized)
quality_profile_id INTEGER REFERENCES quality_profiles(id) ON DELETE SET NULL,
@ -123,6 +124,7 @@ CREATE TABLE IF NOT EXISTS shows (
last_air_date TEXT,
poster_url TEXT,
backdrop_url TEXT,
logo_url TEXT, -- transparent title logo (clearlogo)
monitored INTEGER NOT NULL DEFAULT 1, -- "following" (watchlist)
quality_profile_id INTEGER REFERENCES quality_profiles(id) ON DELETE SET NULL,
root_folder_id INTEGER REFERENCES root_folders(id) ON DELETE SET NULL,

View file

@ -555,6 +555,10 @@ def test_enrichment_backfills_cast_and_crew(db):
assert [c["name"] for c in d["cast"]] == ["Aidan Gillen", "Amanda Schull"] # billing order
assert d["cast"][0]["character"] == "James Cole" and d["cast"][0]["photo"] == "https://img/ag.jpg"
assert d["crew"] == [{"name": "Terry Matalas", "job": "Creator"}]
# Clearlogo backfills like the other art (gap-only) and rides in the payload.
db.enrichment_apply("tmdb", "show", sid, matched=True, external_id=1,
metadata={"logo_url": "https://img/logo.png"})
assert db.show_detail(sid)["logo"] == "https://img/logo.png"
# People are deduped across titles by tmdb_id.
mid = db.upsert_movie("plex", {"server_id": "m1", "title": "M"})
db.enrichment_apply("tmdb", "movie", mid, matched=True, external_id=2,

View file

@ -240,6 +240,19 @@ def test_tmdb_parses_cast_and_crew(monkeypatch):
assert not any(c["name"] == "Edit" for c in m["crew"]) # non-headline job dropped
def test_tmdb_picks_english_clearlogo(monkeypatch):
class _Resp:
def __init__(self, b): self._b = b
def raise_for_status(self): pass
def json(self): return self._b
detail = {"overview": "O", "external_ids": {}, "images": {"logos": [
{"iso_639_1": "de", "file_path": "/de.png"},
{"iso_639_1": "en", "file_path": "/en.png"}]}}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(detail)))
m = TMDBClient("KEY").match("movie", "M", 2020, known_id=1)["metadata"]
assert m["logo_url"] == "https://image.tmdb.org/t/p/w500/en.png" # English preferred
def test_tmdb_show_returns_season_posters(monkeypatch):
class _Resp:
def __init__(self, b): self._b = b

View file

@ -828,6 +828,7 @@
<div class="vd-bb-bg" data-vd-backdrop aria-hidden="true"></div>
<div class="vd-bb-fade" aria-hidden="true"></div>
<div class="vd-bb-content">
<img class="vd-logo" data-vd-logo alt="" hidden />
<h1 class="vd-title" data-vd-title></h1>
<div class="vd-tagline" data-vd-tagline hidden></div>
<div class="vd-meta" data-vd-meta></div>
@ -874,6 +875,7 @@
<div class="vd-bb-bg" data-vd-backdrop aria-hidden="true"></div>
<div class="vd-bb-fade" aria-hidden="true"></div>
<div class="vd-bb-content">
<img class="vd-logo" data-vd-logo alt="" hidden />
<h1 class="vd-title" data-vd-title></h1>
<div class="vd-tagline" data-vd-tagline hidden></div>
<div class="vd-meta" data-vd-meta></div>

View file

@ -99,6 +99,20 @@
setText('[data-vd-title]', d.title);
setText('[data-vd-overview]', d.overview);
// Clearlogo replaces the text title when available (Netflix/Plex feel).
var logo = q('[data-vd-logo]');
var titleEl = q('[data-vd-title]');
if (logo) {
if (d.logo) {
logo.src = d.logo; logo.alt = d.title || ''; logo.hidden = false;
logo.onerror = function () { logo.hidden = true; if (titleEl) titleEl.classList.remove('vd-title--logo'); };
if (titleEl) titleEl.classList.add('vd-title--logo');
} else {
logo.hidden = true; logo.removeAttribute('src');
if (titleEl) titleEl.classList.remove('vd-title--logo');
}
}
var art = '/' + d.kind + '/' + d.id;
var bg = q('[data-vd-backdrop]');
if (bg) {
@ -377,7 +391,8 @@
// Lazy: backfill a movie's cast/genres/art from TMDB on view if missing.
function maybeRefreshMovie(id) {
if (artAttemptedFor === id || !data || data.id !== id) return;
var needs = !(data.cast && data.cast.length) || !(data.genres && data.genres.length) || !data.has_backdrop;
var needs = !(data.cast && data.cast.length) || !(data.genres && data.genres.length)
|| !data.has_backdrop || !data.logo;
if (!needs) return;
artAttemptedFor = id;
fetch(DETAIL_URL + 'movie/' + id + '/refresh-art',
@ -424,7 +439,8 @@
// it (once per show), then re-render. Sidesteps "already matched, never re-runs".
function maybeRefreshArt(id) {
if (artAttemptedFor === id || !data || data.id !== id) return;
if (!(data.seasons || []).some(function (s) { return !s.has_poster; })) return;
var needs = !data.logo || (data.seasons || []).some(function (s) { return !s.has_poster; });
if (!needs) return;
artAttemptedFor = id;
fetch(DETAIL_URL + 'show/' + id + '/refresh-art',
{ method: 'POST', headers: { 'Accept': 'application/json' } })

View file

@ -714,3 +714,16 @@ body[data-side="video"] .dashboard-header-sweep {
.vd-detail-row { display: flex; flex-direction: column; gap: 3px; }
.vd-detail-k { font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; color: rgba(255, 255, 255, 0.45); }
.vd-detail-v { font-size: 14.5px; font-weight: 600; color: #fff; }
/* ── Clearlogo title (replaces the text title when available) ─────────────── */
.vd-logo {
display: block; max-width: min(440px, 60%); max-height: 150px; width: auto; height: auto;
margin: 0 0 16px; object-fit: contain; object-position: left bottom;
filter: drop-shadow(0 4px 24px rgba(0, 0, 0, 0.7));
animation: vdRise 0.7s cubic-bezier(0.2, 0.7, 0.2, 1) both;
}
/* Keep the title for screen readers, but hide it visually when the logo shows. */
.vd-title--logo {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}