video 'capture everything': cast & crew (people + credits) + cast row UI

Last capture piece, schema v3 (new people + credits tables; CREATE IF NOT EXISTS
migrates existing DBs on restart, no wipe):
- people deduped by tmdb_id; credits link to exactly one movie OR show (separate
  nullable FKs + CHECK, no polymorphic id) with department/job/character/order.
- TMDB client appends credits to the detail call (free) and parses cast (name,
  character, photo, billing order) + headline crew (directors/writers/creators).
- enrichment_apply backfills cast/crew gap-only (never clobbers); show/movie
  detail return cast + crew. Populates on view via the existing lazy refresh-art.
- Cast & Crew section on the detail page: grouped crew line + a horizontal
  cast row with circular TMDB headshots, names, characters (accent hover).

Seam tests: TMDB credit parse (+ job filtering, created_by), backfill + people
dedup across titles, gap-only no-clobber, payload shape.
This commit is contained in:
BoulderBadgeDad 2026-06-14 21:20:08 -07:00
parent 8002f93220
commit b9b3b9eed3
9 changed files with 226 additions and 5 deletions

View file

@ -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

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 = 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"]),

View file

@ -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,

View file

@ -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": [

View file

@ -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

View file

@ -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

View file

@ -854,6 +854,12 @@
chosen by the toggle above; all drive the same selection. -->
<div class="vd-season-nav" data-vd-season-nav></div>
<div class="vd-episodes" data-vd-episodes></div>
<!-- Cast & crew (TMDB) — populated by video-detail.js. -->
<div class="vd-cast-section" data-vd-cast-section hidden>
<h2 class="vd-section-h">Cast &amp; Crew</h2>
<div class="vd-crew" data-vd-crew></div>
<div class="vd-cast" data-vd-cast></div>
</div>
</div>
</div>
</section>

View file

@ -145,6 +145,38 @@
return '<span class="vd-genre">' + esc(gn) + '</span>';
}).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 '<span class="vd-crew-item"><span class="vd-crew-job">' + esc(job) +
(byJob[job].length > 1 ? 's' : '') + '</span> ' + esc(byJob[job].join(', ')) + '</span>';
}).join('');
}
var castHost = q('[data-vd-cast]');
if (castHost) {
castHost.innerHTML = cast.map(function (p) {
var img = p.photo
? '<img class="vd-cast-photo" src="' + esc(p.photo) + '" alt="" loading="lazy" onerror="this.style.visibility=\'hidden\'">'
: '<span class="vd-cast-photo vd-cast-photo--ph">' + esc((p.name || '?').charAt(0)) + '</span>';
return '<div class="vd-cast-card">' + img +
'<span class="vd-cast-name">' + esc(p.name) + '</span>' +
(p.character ? '<span class="vd-cast-char">' + esc(p.character) + '</span>' : '') +
'</div>';
}).join('');
}
}
function renderActions(d) {

View file

@ -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; }