diff --git a/core/video/enrichment/clients.py b/core/video/enrichment/clients.py index d0a5228f..c8d5c620 100644 --- a/core/video/enrichment/clients.py +++ b/core/video/enrichment/clients.py @@ -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, diff --git a/database/video_database.py b/database/video_database.py index d6f7d5a0..c11fce4e 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -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), } diff --git a/database/video_schema.sql b/database/video_schema.sql index 7a528d92..d49ebe9f 100644 --- a/database/video_schema.sql +++ b/database/video_schema.sql @@ -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, diff --git a/tests/test_video_database.py b/tests/test_video_database.py index b0ec87b5..636c6c44 100644 --- a/tests/test_video_database.py +++ b/tests/test_video_database.py @@ -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, diff --git a/tests/test_video_enrichment.py b/tests/test_video_enrichment.py index 4ce004c7..dcc295ae 100644 --- a/tests/test_video_enrichment.py +++ b/tests/test_video_enrichment.py @@ -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 diff --git a/webui/index.html b/webui/index.html index c1a7d95d..e9d8eac5 100644 --- a/webui/index.html +++ b/webui/index.html @@ -828,6 +828,7 @@
+

@@ -874,6 +875,7 @@
+

diff --git a/webui/static/video/video-detail.js b/webui/static/video/video-detail.js index a64bb861..cff404a1 100644 --- a/webui/static/video/video-detail.js +++ b/webui/static/video/video-detail.js @@ -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' } }) diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index a653a470..c8a57aa6 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -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; +}