From 8441ece6f078be08a6bb42f5a6edf4c2655d31e8 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Mon, 15 Jun 2026 09:57:39 -0700 Subject: [PATCH] =?UTF-8?q?video:=20person=20page=20=E2=80=94=20sort,=20de?= =?UTF-8?q?partment=20filter,=20age=20(best-in-class=20filmography)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- core/video/enrichment/clients.py | 17 +++-- tests/test_video_enrichment.py | 14 ++++ tests/test_video_side_shell.py | 3 + webui/index.html | 17 +++-- webui/static/video/video-person.js | 101 ++++++++++++++++++++++------- webui/static/video/video-side.css | 15 ++++- 6 files changed, 132 insertions(+), 35 deletions(-) diff --git a/core/video/enrichment/clients.py b/core/video/enrichment/clients.py index 9989a5a9..5ba18f5c 100644 --- a/core/video/enrichment/clients.py +++ b/core/video/enrichment/clients.py @@ -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"), diff --git a/tests/test_video_enrichment.py b/tests/test_video_enrichment.py index 716bf0c7..ecf1d81e 100644 --- a/tests/test_video_enrichment.py +++ b/tests/test_video_enrichment.py @@ -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}) diff --git a/tests/test_video_side_shell.py b/tests/test_video_side_shell.py index 80472f75..39ad2080 100644 --- a/tests/test_video_side_shell.py +++ b/tests/test_video_side_shell.py @@ -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(): diff --git a/webui/index.html b/webui/index.html index a5dae16d..a380af1d 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1066,11 +1066,18 @@
-

Filmography

-
-
-
-
+
+

Filmography

+ +
+
+
+
+
diff --git a/webui/static/video/video-person.js b/webui/static/video/video-person.js index 2e8f0b22..bfa7558c 100644 --- a/webui/static/video/video-person.js +++ b/webui/static/video/video-person.js @@ -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 @@ '' + count + ''; } + 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); diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 636bef21..46c6c6db 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -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;