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:
parent
6eed3d7775
commit
8441ece6f0
6 changed files with 132 additions and 35 deletions
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue