video: person page — sort, department filter, age (best-in-class filmography)

- Sort dropdown: Newest / Oldest / Most popular.
- Department filter (Acting / Directing / Writing / …) for multi-hyphenates —
  only appears when a person has 2+ departments. Composes with the existing
  kind + ownership filters; every chip shows a CONTEXTUAL count (what you'd get
  if you clicked it, given the other active filters).
- Age in the hero meta ('47 years old', or lifespan + 'aged N' for the deceased).
- Backend: each person credit now carries its department (cast=Acting,
  crew=its TMDB department). Seam test added. 249 video-suite tests pass.
This commit is contained in:
BoulderBadgeDad 2026-06-15 09:57:39 -07:00
parent 6eed3d7775
commit 8441ece6f0
6 changed files with 132 additions and 35 deletions

View file

@ -436,22 +436,29 @@ class TMDBClient:
return None
cc = d.get("combined_credits") or {}
seen, credits = set(), []
for c in (cc.get("cast") or []) + (cc.get("crew") or []):
def add(c, department, role):
mt, tid = c.get("media_type"), c.get("id")
if not tid or mt not in ("movie", "tv"):
continue
return
kind = "movie" if mt == "movie" else "show"
key = (kind, tid)
if key in seen:
continue
if key in seen: # same title in two roles → keep the first
return
seen.add(key)
date = c.get("release_date") or c.get("first_air_date") or ""
credits.append({
"kind": kind, "tmdb_id": tid, "title": c.get("title") or c.get("name"),
"year": (date or "")[:4] or None, "date": date or None,
"role": c.get("character") or c.get("job") or None,
"department": department, "role": role,
"popularity": c.get("popularity") or 0,
"poster": (self.POSTER_W + c["poster_path"]) if c.get("poster_path") else None})
# Cast first (so an actor-director title files under Acting), then crew.
for c in (cc.get("cast") or []):
add(c, "Acting", c.get("character") or None)
for c in (cc.get("crew") or []):
add(c, c.get("department") or "Crew", c.get("job") or None)
credits.sort(key=lambda x: x["popularity"], reverse=True)
return {
"tmdb_id": d.get("id"), "name": d.get("name"),

View file

@ -280,6 +280,20 @@ def test_tmdb_trending_parses(monkeypatch):
assert res[0]["poster"] == "https://image.tmdb.org/t/p/w300/a.jpg"
def test_tmdb_person_credits_carry_department(monkeypatch):
body = {"id": 5, "name": "X", "known_for_department": "Acting",
"combined_credits": {
"cast": [{"id": 1, "media_type": "movie", "title": "M", "character": "Hero",
"release_date": "2020-01-01", "popularity": 9}],
"crew": [{"id": 2, "media_type": "movie", "title": "D", "job": "Director",
"department": "Directing", "release_date": "2019-01-01", "popularity": 3}]}}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(body)))
p = TMDBClient("KEY").person(5)
by = {c["title"]: c for c in p["credits"]}
assert by["M"]["department"] == "Acting" and by["M"]["role"] == "Hero"
assert by["D"]["department"] == "Directing" and by["D"]["role"] == "Director"
def test_engine_person_detail_annotates_credits(db):
mid = db.upsert_movie("plex", {"server_id": "m1", "title": "Owned", "tmdb_id": 1})

View file

@ -413,6 +413,9 @@ def test_person_subpage_and_module_isolated():
# Cinematic hero bits: ambient backdrop, rotating accent ring, role tagline.
assert "data-vp-ambient" in _INDEX and "vp-photo-ring" in _INDEX
assert "data-vp-role" in _INDEX
# Filmography controls: sort + department filter (multi-hyphenate), age.
assert "data-vp-sort" in _INDEX and "data-vp-dept" in _INDEX
assert "renderDept" in src and "computeAge" in src
def test_detail_keeps_preview_items_in_app():

View file

@ -1066,11 +1066,18 @@
<div class="vp-known-rail" data-vp-known></div>
</div>
<div class="vp-credits-section">
<div class="vp-credits-head"><h2 class="vp-credits-title">Filmography</h2>
<div class="vp-credit-filters">
<div class="vp-credit-tabs" data-vp-tabs></div>
<div class="vp-credit-tabs vp-own-tabs" data-vp-own></div>
</div>
<div class="vp-credits-head">
<h2 class="vp-credits-title">Filmography</h2>
<select class="vp-sort" data-vp-sort aria-label="Sort filmography">
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="popularity">Most popular</option>
</select>
</div>
<div class="vp-credit-filters">
<div class="vp-credit-tabs vp-dept-tabs" data-vp-dept></div>
<div class="vp-credit-tabs" data-vp-tabs></div>
<div class="vp-credit-tabs vp-own-tabs" data-vp-own></div>
</div>
<div class="vsr-grid" data-vp-credits></div>
<div class="vp-credits-empty" data-vp-credits-empty hidden></div>

View file

@ -18,6 +18,8 @@
var currentId = null;
var tab = 'all'; // kind filter: all | movie | show
var own = 'all'; // ownership filter: all | owned | missing
var dept = 'all'; // department filter: all | Acting | Directing | …
var sortBy = 'newest'; // newest | oldest | popularity
var ROLE_NOUNS = {
Acting: 'Actor', Directing: 'Director', Writing: 'Writer', Production: 'Producer',
Sound: 'Composer', Camera: 'Cinematographer', Editing: 'Editor', Creator: 'Creator',
@ -60,43 +62,64 @@
'<span class="vp-tab-count">' + count + '</span></button>';
}
function renderDept() {
var host = q('[data-vp-dept]');
if (!host || !data) return;
var counts = {};
(data.credits || []).forEach(function (c) {
var dp = c.department || 'Other'; counts[dp] = (counts[dp] || 0) + 1;
});
var depts = Object.keys(counts);
// Only worth a row for multi-hyphenates (actor-directors etc.).
if (depts.length < 2) { host.innerHTML = ''; host.hidden = true; return; }
host.hidden = false;
depts.sort(function (a, b) { return counts[b] - counts[a]; });
var html = tabBtn('data-vp-dept', 'all', dept === 'all', 'All', countWith({ dept: 'all' }));
html += depts.map(function (dp) {
return tabBtn('data-vp-dept', dp, dp === dept, dp, countWith({ dept: dp }));
}).join('');
host.innerHTML = html;
}
function renderTabs() {
var host = q('[data-vp-tabs]');
if (!host || !data) return;
// Counts reflect the CURRENT ownership filter (so the numbers match what
// you'd actually see).
var base = (data.credits || []).filter(function (c) { return matchOwn(c, own); });
var movies = base.filter(function (c) { return c.kind === 'movie'; }).length;
var shows = base.filter(function (c) { return c.kind === 'show'; }).length;
var defs = [['all', 'All', base.length], ['movie', 'Movies', movies], ['show', 'TV', shows]];
host.innerHTML = defs.filter(function (d) { return d[2] > 0 || d[0] === 'all'; }).map(function (d) {
return tabBtn('data-vp-tab', d[0], d[0] === tab, d[1], d[2]);
}).join('');
var defs = [['all', 'All'], ['movie', 'Movies'], ['show', 'TV']];
host.innerHTML = defs.map(function (d) { return [d[0], d[1], countWith({ tab: d[0] })]; })
.filter(function (d) { return d[2] > 0 || d[0] === 'all'; })
.map(function (d) { return tabBtn('data-vp-tab', d[0], d[0] === tab, d[1], d[2]); }).join('');
}
function renderOwn() {
var host = q('[data-vp-own]');
if (!host || !data) return;
// Counts reflect the current KIND filter.
var base = (data.credits || []).filter(function (c) { return matchKind(c, tab); });
var owned = base.filter(isOwned).length;
var defs = [['all', 'All', base.length], ['owned', 'In Library', owned],
['missing', 'Missing', base.length - owned]];
var defs = [['all', 'All'], ['owned', 'In Library'], ['missing', 'Missing']];
host.innerHTML = defs.map(function (d) {
return tabBtn('data-vp-own', d[0], d[0] === own, d[1], d[2]);
return tabBtn('data-vp-own', d[0], d[0] === own, d[1], countWith({ own: d[0] }));
}).join('');
}
function applyFilters() {
renderTabs(); renderOwn(); renderKnownFor(); renderCredits();
renderDept(); renderTabs(); renderOwn(); renderKnownFor(); renderCredits();
}
// ── filters (kind + ownership) ────────────────────────────────────────────
// ── filters (department + kind + ownership) ───────────────────────────────
function matchKind(c, k) { return k === 'all' || c.kind === k; }
function isOwned(c) { return c.library_id != null; }
function matchOwn(c, o) { return o === 'all' || (o === 'owned' ? isOwned(c) : !isOwned(c)); }
function matchDept(c, dp) { return dp === 'all' || (c.department || '') === dp; }
function filtered() {
return (data.credits || []).filter(function (c) { return matchKind(c, tab) && matchOwn(c, own); });
return (data.credits || []).filter(function (c) {
return matchDept(c, dept) && matchKind(c, tab) && matchOwn(c, own);
});
}
// Count credits under the active filters, overriding one dimension — so every
// chip's count reflects what you'd actually get if you clicked it.
function countWith(o) {
var dp = 'dept' in o ? o.dept : dept, k = 'tab' in o ? o.tab : tab, ow = 'own' in o ? o.own : own;
return (data.credits || []).filter(function (c) {
return matchDept(c, dp) && matchKind(c, k) && matchOwn(c, ow);
}).length;
}
function renderKnownFor() {
@ -113,9 +136,13 @@
var host = q('[data-vp-credits]'), empty = q('[data-vp-credits-empty]');
if (!host || !data) return;
var credits = filtered();
// Full filmography reads best chronologically (newest first); Known For
// already covers the popular ones.
credits.sort(function (a, b) { return (b.date || '').localeCompare(a.date || ''); });
if (sortBy === 'popularity') {
credits.sort(function (a, b) { return (b.popularity || 0) - (a.popularity || 0); });
} else if (sortBy === 'oldest') {
credits.sort(function (a, b) { return (a.date || '9999').localeCompare(b.date || '9999'); });
} else {
credits.sort(function (a, b) { return (b.date || '').localeCompare(a.date || ''); });
}
host.innerHTML = credits.map(creditCard).join('');
if (empty) {
empty.hidden = credits.length > 0;
@ -134,8 +161,19 @@
return dy ? (by + ' ' + dy) : (by ? 'Born ' + by : '');
}
function computeAge(birthday, deathday) {
if (!birthday) return null;
var b = new Date(birthday), end = deathday ? new Date(deathday) : new Date();
if (isNaN(b.getTime()) || isNaN(end.getTime())) return null;
var age = end.getFullYear() - b.getFullYear();
var mo = end.getMonth() - b.getMonth();
if (mo < 0 || (mo === 0 && end.getDate() < b.getDate())) age--;
return (age >= 0 && age < 130) ? age : null;
}
function render(d) {
data = d; tab = 'all'; own = 'all';
data = d; tab = 'all'; own = 'all'; dept = 'all'; sortBy = 'newest';
var ss = document.querySelector('[data-vp-sort]'); if (ss) ss.value = 'newest';
var photo = q('[data-vp-photo]'), ph = q('[data-vp-photo-ph]');
if (photo) {
if (d.photo) {
@ -158,7 +196,15 @@
}
var meta = [];
var ls = lifespan(d); if (ls) meta.push(ls);
var ls = lifespan(d), age = computeAge(d.birthday, d.deathday);
if (d.deathday) {
if (ls) meta.push(ls);
if (age != null) meta.push('aged ' + age);
} else if (age != null) {
meta.push(age + ' years old');
} else if (ls) {
meta.push(ls);
}
if (d.place_of_birth) meta.push(d.place_of_birth);
var n = (d.credits || []).length;
if (n) meta.push(n + (n === 1 ? ' credit' : ' credits'));
@ -183,6 +229,7 @@
var c = q('[data-vp-credits]'); if (c) c.innerHTML = '';
var t = q('[data-vp-tabs]'); if (t) t.innerHTML = '';
var o = q('[data-vp-own]'); if (o) o.innerHTML = '';
var dp = q('[data-vp-dept]'); if (dp) dp.innerHTML = '';
var ce = q('[data-vp-credits-empty]'); if (ce) ce.hidden = true;
var ks = q('[data-vp-known-section]'); if (ks) ks.hidden = true;
var k = q('[data-vp-known]'); if (k) k.innerHTML = '';
@ -212,6 +259,10 @@
if (ownBtn && r.contains(ownBtn)) {
own = ownBtn.getAttribute('data-vp-own'); applyFilters(); return;
}
var deptBtn = e.target.closest('[data-vp-dept]');
if (deptBtn && r.contains(deptBtn)) {
dept = deptBtn.getAttribute('data-vp-dept'); applyFilters(); return;
}
var moreBtn = e.target.closest('[data-vp-bio-more]');
if (moreBtn && r.contains(moreBtn)) {
var bio = q('[data-vp-bio]');
@ -237,6 +288,10 @@
function init() {
document.addEventListener('soulsync:video-open-detail', onOpen);
document.addEventListener('click', onClick);
var sortSel = document.querySelector('[data-vp-sort]');
if (sortSel) sortSel.addEventListener('change', function () {
sortBy = sortSel.value; renderCredits();
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);

View file

@ -1111,10 +1111,10 @@ a.vd-prov:hover img, a.vd-prov:hover .vd-prov-ph { border-color: rgba(var(--vd-a
.vp-credits-section { position: relative; z-index: 2; }
.vp-credits-head {
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 14px; margin-bottom: 20px;
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 14px; margin-bottom: 16px;
}
.vp-credits-title { font-size: 22px; font-weight: 800; color: #fff; margin: 0; }
.vp-credit-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; }
.vp-credit-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; margin-bottom: 20px; }
.vp-credit-tabs { display: flex; gap: 8px; }
/* The ownership group sits after a faint divider so it reads as a second axis. */
.vp-own-tabs { position: relative; padding-left: 14px; }
@ -1129,6 +1129,17 @@ a.vd-prov:hover img, a.vd-prov:hover .vd-prov-ph { border-color: rgba(var(--vd-a
.vp-credits-empty {
padding: 44px 20px; text-align: center; font-size: 14px; color: rgba(255, 255, 255, 0.55);
}
.vp-dept-tabs [data-vp-dept].vp-tab--active {
background: rgba(var(--vd-accent-rgb, 99, 102, 241), 0.92); border-color: transparent; color: #fff;
}
.vp-dept-tabs:empty { display: none; }
.vp-sort {
background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.14);
color: rgba(255, 255, 255, 0.85); border-radius: 9px; padding: 8px 12px; font-size: 13px;
font-weight: 600; cursor: pointer; outline: none;
}
.vp-sort:hover { background: rgba(255, 255, 255, 0.1); }
.vp-sort option { background: #16161c; color: #fff; }
.vp-tab {
background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.7); border-radius: 999px; padding: 7px 16px; cursor: pointer;