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. -->
+ +