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:
parent
59c88fa0db
commit
efa0632883
8 changed files with 76 additions and 6 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' } })
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue