diff --git a/core/video/enrichment/clients.py b/core/video/enrichment/clients.py index 55075476..d0a5228f 100644 --- a/core/video/enrichment/clients.py +++ b/core/video/enrichment/clients.py @@ -76,7 +76,8 @@ class TMDBClient: try: 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"}, + params={"api_key": self.api_key, + "append_to_response": "external_ids,credits"}, timeout=15).json() or {} meta["overview"] = dr.get("overview") or meta.get("overview") if dr.get("backdrop_path"): @@ -111,10 +112,32 @@ class TMDBClient: seasons.append({"season_number": sn, "poster_url": self.IMG + pp}) if seasons: meta["seasons"] = seasons + self._add_credits(meta, dr.get("credits") or {}, dr.get("created_by") or []) 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" + + def _person(self, c, job=None, character=None): + return {"name": c["name"], "tmdb_id": c.get("id"), "job": job, "character": character, + "photo_url": (self.PROFILE + c["profile_path"]) if c.get("profile_path") else None} + + def _add_credits(self, meta, credits, created_by): + """Parse TMDB cast/crew into meta['cast'] / meta['crew'].""" + cast = [self._person(c, character=c.get("character")) + for c in (credits.get("cast") or [])[:20] if c.get("name")] + if cast: + meta["cast"] = cast + # Crew: headline jobs only (directors / writers); plus TV creators, which + # live in the top-level created_by, not the crew list. + wanted = {"Director", "Writer", "Screenplay"} + crew = [self._person(c, job=c.get("job")) for c in (credits.get("crew") or []) + if c.get("name") and c.get("job") in wanted] + crew += [self._person(c, job="Creator") for c in created_by if c.get("name")] + if crew: + meta["crew"] = crew + def season_episodes(self, tv_id, season_number): """Episode-level data for one season (still/overview/rating) — the show worker cascades over a show's seasons to backfill episodes the media diff --git a/database/video_database.py b/database/video_database.py index 03c14725..cac67c34 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 = 2 +SCHEMA_VERSION = 3 _DEFAULT_DB_PATH = "database/video_library.db" _SCHEMA_FILE = Path(__file__).resolve().parent / "video_schema.sql" @@ -256,6 +256,14 @@ class VideoDatabase: has = conn.execute(f"SELECT 1 FROM {lt} WHERE {oc}=? LIMIT 1", (item_id,)).fetchone() if not has: self._set_genres(conn, lt, oc, item_id, genres) + # Cast/crew backfill — only when the item has none yet (gap-fill). + cast = (metadata or {}).get("cast") + crew = (metadata or {}).get("crew") + if matched and (cast or crew) and tbl in ("movies", "shows"): + oc = "movie_id" if tbl == "movies" else "show_id" + has = conn.execute(f"SELECT 1 FROM credits WHERE {oc}=? LIMIT 1", (item_id,)).fetchone() + if not has: + self._set_credits(conn, oc, item_id, cast or [], crew or []) # Per-season poster backfill (TMDB) — fills only seasons the server # left without art. seasons_meta = (metadata or {}).get("seasons") @@ -566,6 +574,53 @@ class VideoDatabase: conn.rollback() # legacy UNIQUE on an id — keep the row, drop the id run(False) + @staticmethod + def _set_credits(conn, owner_col: str, owner_id: int, cast, crew) -> None: + """Replace the cast+crew for one owner (deduped people in the shared + people table). owner_col is internal ('movie_id'|'show_id').""" + conn.execute(f"DELETE FROM credits WHERE {owner_col}=?", (owner_id,)) + + def person_id(p): + tid = p.get("tmdb_id") + if tid is not None: + conn.execute("INSERT OR IGNORE INTO people (name, tmdb_id, photo_url) VALUES (?, ?, ?)", + (p["name"], tid, p.get("photo_url"))) + row = conn.execute("SELECT id FROM people WHERE tmdb_id=?", (tid,)).fetchone() + else: + conn.execute("INSERT INTO people (name, photo_url) VALUES (?, ?)", + (p["name"], p.get("photo_url"))) + row = conn.execute("SELECT last_insert_rowid() AS id").fetchone() + pid = row["id"] if row else None + if pid and p.get("photo_url"): + conn.execute("UPDATE people SET photo_url=COALESCE(NULLIF(photo_url, ''), ?) WHERE id=?", + (p["photo_url"], pid)) + return pid + + def add(group, department, job_default): + for i, c in enumerate(group or []): + if not c.get("name"): + continue + pid = person_id(c) + if not pid: + continue + conn.execute( + f"INSERT INTO credits (person_id, {owner_col}, department, job, character, sort_order) " + "VALUES (?, ?, ?, ?, ?, ?)", + (pid, owner_id, department, c.get("job") or job_default, c.get("character"), i)) + add(cast, "cast", "Actor") + add(crew, "crew", None) + + @staticmethod + def _credits_for(conn, owner_col: str, owner_id: int, cast_limit: int = 18) -> dict: + rows = conn.execute( + "SELECT p.name, p.photo_url, c.department, c.job, c.character " + f"FROM credits c JOIN people p ON p.id = c.person_id WHERE c.{owner_col}=? " + "ORDER BY c.department, c.sort_order", (owner_id,)).fetchall() + cast = [{"name": r["name"], "character": r["character"], "photo": r["photo_url"]} + for r in rows if r["department"] == "cast"][:cast_limit] + crew = [{"name": r["name"], "job": r["job"]} for r in rows if r["department"] == "crew"] + return {"cast": cast, "crew": crew} + def upsert_movie(self, server_source: str, item: dict) -> int: """Insert/update one movie (keyed on server id) and its file. Returns row id.""" conn = self._get_connection() @@ -813,6 +868,7 @@ class VideoDatabase: if not show: return None genres = self._genres_for(conn, "show_genres", "show_id", show_id) + credits = self._credits_for(conn, "show_id", show_id) seasons = conn.execute( "SELECT id, season_number, title, overview, " "(poster_url IS NOT NULL AND poster_url<>'') AS has_poster " @@ -867,7 +923,7 @@ class VideoDatabase: "content_rating": show["content_rating"], "runtime_minutes": show["runtime_minutes"], "tagline": show["tagline"], "rating": show["rating"], "first_air_date": show["first_air_date"], "last_air_date": show["last_air_date"], - "genres": genres, + "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"]), "monitored": bool(show["monitored"]), @@ -900,6 +956,7 @@ class VideoDatabase: if not m: return None genres = self._genres_for(conn, "movie_genres", "movie_id", movie_id) + credits = self._credits_for(conn, "movie_id", movie_id) f = conn.execute( "SELECT resolution, quality, video_codec, audio_codec, size_bytes " "FROM media_files WHERE movie_id=? ORDER BY size_bytes DESC LIMIT 1", @@ -912,6 +969,7 @@ class VideoDatabase: "release_date": m["release_date"], "runtime_minutes": m["runtime_minutes"], "content_rating": m["content_rating"], "tagline": m["tagline"], "rating": m["rating"], "rating_critic": m["rating_critic"], "genres": genres, + "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"]), "owned": bool(m["has_file"]), "monitored": bool(m["monitored"]), diff --git a/database/video_schema.sql b/database/video_schema.sql index 03ed267a..7a528d92 100644 --- a/database/video_schema.sql +++ b/database/video_schema.sql @@ -188,6 +188,33 @@ CREATE TABLE IF NOT EXISTS show_genres ( CREATE INDEX IF NOT EXISTS idx_movie_genres_genre ON movie_genres(genre_id); CREATE INDEX IF NOT EXISTS idx_show_genres_genre ON show_genres(genre_id); +-- ── People + credits (cast & crew; normalised, no blob) ───────────────────── +-- A person appears in many titles; deduped by their provider id. Each credit +-- belongs to exactly one movie OR show (separate nullable FKs + CHECK, no +-- polymorphic id), mirroring the media_files/downloads pattern. +CREATE TABLE IF NOT EXISTS people ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + tmdb_id INTEGER UNIQUE, + photo_url TEXT +); +CREATE INDEX IF NOT EXISTS idx_people_name ON people(name); + +CREATE TABLE IF NOT EXISTS credits ( + id INTEGER PRIMARY KEY, + person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE, + movie_id INTEGER REFERENCES movies(id) ON DELETE CASCADE, + show_id INTEGER REFERENCES shows(id) ON DELETE CASCADE, + department TEXT NOT NULL, -- 'cast' | 'crew' + job TEXT, -- Director | Writer | Creator (crew); 'Actor' (cast) + character TEXT, -- the role played (cast) + sort_order INTEGER NOT NULL DEFAULT 0, + CHECK ((movie_id IS NOT NULL) + (show_id IS NOT NULL) = 1) +); +CREATE INDEX IF NOT EXISTS idx_credits_movie ON credits(movie_id); +CREATE INDEX IF NOT EXISTS idx_credits_show ON credits(show_id); +CREATE INDEX IF NOT EXISTS idx_credits_person ON credits(person_id); + -- ── Content: YouTube (channels → videos) ──────────────────────────────────── CREATE TABLE IF NOT EXISTS channels ( id INTEGER PRIMARY KEY, diff --git a/tests/test_video_database.py b/tests/test_video_database.py index 9b974e07..b0ec87b5 100644 --- a/tests/test_video_database.py +++ b/tests/test_video_database.py @@ -22,7 +22,7 @@ EXPECTED_TABLES = { "meta", "root_folders", "quality_profiles", "video_settings", "movies", "shows", "seasons", "episodes", "channels", "channel_videos", "media_files", "downloads", "activity", - "genres", "movie_genres", "show_genres", + "genres", "movie_genres", "show_genres", "people", "credits", } EXPECTED_VIEWS = {"v_watchlist", "v_wishlist", "v_calendar"} @@ -543,6 +543,35 @@ def test_enrichment_backfills_genres_when_item_has_none(db): assert db.movie_detail(mid)["genres"] == ["Comedy", "Drama"] +def test_enrichment_backfills_cast_and_crew(db): + sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": []}) + db.enrichment_apply("tmdb", "show", sid, matched=True, external_id=1, metadata={ + "cast": [ + {"name": "Aidan Gillen", "tmdb_id": 39388, "character": "James Cole", + "photo_url": "https://img/ag.jpg"}, + {"name": "Amanda Schull", "tmdb_id": 84223, "character": "Cassie"}], + "crew": [{"name": "Terry Matalas", "tmdb_id": 1, "job": "Creator"}]}) + d = db.show_detail(sid) + 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"}] + # 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, + metadata={"cast": [{"name": "Aidan Gillen", "tmdb_id": 39388, "character": "X"}]}) + with db.connect() as c: + assert c.execute("SELECT COUNT(*) FROM people WHERE tmdb_id=39388").fetchone()[0] == 1 + + +def test_enrichment_does_not_clobber_existing_credits(db): + sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": []}) + db.enrichment_apply("tmdb", "show", sid, matched=True, external_id=1, + metadata={"cast": [{"name": "First", "tmdb_id": 1}]}) + db.enrichment_apply("tmdb", "show", sid, matched=True, external_id=1, + metadata={"cast": [{"name": "Second", "tmdb_id": 2}]}) + assert [c["name"] for c in db.show_detail(sid)["cast"]] == ["First"] # kept, not replaced + + def test_enrichment_backfills_season_posters_only_when_missing(db): # Season 1 has no art (server didn't provide it); season 2 has server art. sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": [ diff --git a/tests/test_video_enrichment.py b/tests/test_video_enrichment.py index 723699af..e8762110 100644 --- a/tests/test_video_enrichment.py +++ b/tests/test_video_enrichment.py @@ -199,6 +199,26 @@ def test_tmdb_season_episodes_parses(monkeypatch): assert "still_url" not in res["episodes"][1] # no still_path → omitted +def test_tmdb_parses_cast_and_crew(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": {}, "created_by": [{"id": 9, "name": "The Creator"}], + "credits": { + "cast": [{"id": 1, "name": "Lead", "character": "Hero", "profile_path": "/p.jpg"}, + {"id": 2, "name": "Support", "character": "Sidekick"}], + "crew": [{"id": 3, "name": "Dir", "job": "Director"}, + {"id": 4, "name": "Edit", "job": "Editor"}]}} # Editor filtered out + monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(detail))) + m = TMDBClient("KEY").match("show", "S", 2020, known_id=1396)["metadata"] + assert [c["name"] for c in m["cast"]] == ["Lead", "Support"] + assert m["cast"][0]["photo_url"] == "https://image.tmdb.org/t/p/w185/p.jpg" + jobs = {(c["name"], c["job"]) for c in m["crew"]} + assert ("Dir", "Director") in jobs and ("The Creator", "Creator") in jobs + assert not any(c["name"] == "Edit" for c in m["crew"]) # non-headline job dropped + + def test_tmdb_show_returns_season_posters(monkeypatch): class _Resp: def __init__(self, b): self._b = b diff --git a/tests/test_video_side_shell.py b/tests/test_video_side_shell.py index 8859887a..2a2022bb 100644 --- a/tests/test_video_side_shell.py +++ b/tests/test_video_side_shell.py @@ -338,7 +338,7 @@ def test_show_detail_subpage_present(): # Netflix billboard + episodes containers the renderer fills. for hook in ('data-vd-backdrop', 'data-vd-poster', 'data-vd-title', 'data-vd-meta', 'data-vd-overview', 'data-vd-actions', 'data-vd-view-toggle', - 'data-vd-season-nav', 'data-vd-episodes'): + 'data-vd-season-nav', 'data-vd-episodes', 'data-vd-cast', 'data-vd-crew'): assert hook in block, hook # Back button reuses the shared data-video-goto nav (no inline handler). assert 'data-video-goto="video-library"' in block diff --git a/webui/index.html b/webui/index.html index 9a184a91..0fff875c 100644 --- a/webui/index.html +++ b/webui/index.html @@ -854,6 +854,12 @@ chosen by the toggle above; all drive the same selection. -->
+ + diff --git a/webui/static/video/video-detail.js b/webui/static/video/video-detail.js index 95d8b87f..0cb42569 100644 --- a/webui/static/video/video-detail.js +++ b/webui/static/video/video-detail.js @@ -145,6 +145,38 @@ return '' + esc(gn) + ''; }).join(''); } + renderCast(d); + } + + function renderCast(d) { + var section = q('[data-vd-cast-section]'); + if (!section) return; + var cast = d.cast || [], crew = d.crew || []; + if (!cast.length && !crew.length) { section.hidden = true; return; } + section.hidden = false; + + var crewHost = q('[data-vd-crew]'); + if (crewHost) { + // Group crew by job (Creator / Director / Writer …) → "Job: A, B". + var byJob = {}; + crew.forEach(function (c) { (byJob[c.job || 'Crew'] = byJob[c.job || 'Crew'] || []).push(c.name); }); + crewHost.innerHTML = Object.keys(byJob).map(function (job) { + return '' + esc(job) + + (byJob[job].length > 1 ? 's' : '') + ' ' + esc(byJob[job].join(', ')) + ''; + }).join(''); + } + var castHost = q('[data-vd-cast]'); + if (castHost) { + castHost.innerHTML = cast.map(function (p) { + var img = p.photo + ? '' + : '' + esc((p.name || '?').charAt(0)) + ''; + return '
' + img + + '' + esc(p.name) + '' + + (p.character ? '' + esc(p.character) + '' : '') + + '
'; + }).join(''); + } } function renderActions(d) { diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 2204de40..9a9c73e3 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -681,3 +681,29 @@ body[data-side="video"] .dashboard-header-sweep { #vem-overlay .em-card--current { box-shadow: 0 0 18px rgba(var(--em-accent-rgb), 0.28); } + +/* ── Cast & crew section (detail page) ───────────────────────────────────── */ +.vd-cast-section { margin-top: 46px; } +.vd-section-h { font-size: 22px; font-weight: 800; color: #fff; margin: 0 0 16px; } +.vd-crew { display: flex; flex-wrap: wrap; gap: 8px 24px; margin-bottom: 24px; + color: rgba(255, 255, 255, 0.72); font-size: 14px; } +.vd-crew-job { color: rgba(255, 255, 255, 0.45); font-weight: 600; } +.vd-cast { display: flex; gap: 18px; overflow-x: auto; padding-bottom: 12px; scroll-snap-type: x proximity; } +.vd-cast::-webkit-scrollbar { height: 8px; } +.vd-cast::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 4px; } +.vd-cast-card { flex: 0 0 116px; width: 116px; scroll-snap-align: start; text-align: center; } +.vd-cast-photo { + width: 116px; height: 116px; border-radius: 50%; object-fit: cover; display: block; margin: 0 auto 10px; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45); border: 2px solid rgba(255, 255, 255, 0.08); + transition: transform 0.25s ease, border-color 0.25s ease; +} +.vd-cast-photo--ph { + display: flex; align-items: center; justify-content: center; font-size: 40px; font-weight: 800; + color: rgba(255, 255, 255, 0.55); + background: linear-gradient(135deg, rgba(var(--vd-accent-rgb), 0.32), rgba(var(--vd-accent-rgb), 0.1)); +} +.vd-cast-card:hover .vd-cast-photo { transform: translateY(-3px); border-color: rgba(var(--vd-accent-rgb), 0.6); } +.vd-cast-name { display: block; font-size: 13.5px; font-weight: 700; color: #fff; line-height: 1.3; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.vd-cast-char { display: block; font-size: 12px; color: rgba(255, 255, 255, 0.55); line-height: 1.3; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }