video: smart multi-layer back button + next-level search/person

Smart back (mirrors music's artist-detail): the top-left back button now
remembers where you actually came from, many layers deep. It keeps an origin
stack ({page} or {detail title}) and stamps each history entry with its layer
depth, so:
- the label is dynamic — '← Back to Search' / '← Back to The Bear' / '← Back to
  <person>' — instead of a hardcoded 'Library'/'Back';
- backing out of the first layer returns to the page you started from (Search,
  Watchlist, wherever), not always the Library;
- browser Back and our button both unwind the chain one layer at a time, in sync.
Fixes: search → person → back → movie used to mislabel as 'Library' and dump you
in the library.

Next level:
- Search isn't a blank box when idle — a 'Trending this week' rail (TMDB
  trending, owned/preview annotated). Returns when you clear the query.
- Person page gets a 'Known For' hero rail (top titles by popularity) above a
  full filmography now sorted chronologically (newest first).

Backend: TMDBClient.trending + engine.trending (+library annotation), route
/api/video/trending. Isolated; 237 video-suite tests pass.
video
BoulderBadgeDad 2 weeks ago
parent 87f414c8c7
commit d6d0dc537c

@ -31,3 +31,13 @@ def register_routes(bp):
logger.exception("video search failed for %r", q)
results = []
return jsonify({"results": results, "query": q})
@bp.route("/trending", methods=["GET"])
def video_trending():
try:
from core.video.enrichment.engine import get_video_enrichment_engine
results = get_video_enrichment_engine().trending()
except Exception:
logger.exception("video trending failed")
results = []
return jsonify({"results": results})

@ -272,6 +272,31 @@ class TMDBClient:
"poster": (self.PROFILE + it["profile_path"]) if it.get("profile_path") else None})
return out
def trending(self, window="week"):
"""Trending movies + shows this week — fills the search page when idle."""
if not self.api_key:
return []
import requests
r = requests.get(self.BASE + "/trending/all/" + window,
params={"api_key": self.api_key}, timeout=15)
r.raise_for_status()
out = []
for it in ((r.json() or {}).get("results") or []):
mt, tid = it.get("media_type"), it.get("id")
if not tid or mt not in ("movie", "tv"):
continue
if mt == "movie":
out.append({"kind": "movie", "tmdb_id": tid, "title": it.get("title"),
"year": (it.get("release_date") or "")[:4] or None,
"rating": it.get("vote_average") or None,
"poster": (self.POSTER_W + it["poster_path"]) if it.get("poster_path") else None})
else:
out.append({"kind": "show", "tmdb_id": tid, "title": it.get("name"),
"year": (it.get("first_air_date") or "")[:4] or None,
"rating": it.get("vote_average") or None,
"poster": (self.POSTER_W + it["poster_path"]) if it.get("poster_path") else None})
return out[:20]
def full_detail(self, kind, tmdb_id):
"""Complete detail for a TMDB title NOT in the library — shaped like the
library detail payload but with direct image URLs (so the same detail UI

@ -182,6 +182,21 @@ class VideoEnrichmentEngine:
r["library_id"] = self.db.library_id_for_tmdb(r["kind"], r["tmdb_id"])
return results
def trending(self) -> list:
"""Trending titles for the idle search page, annotated owned/not."""
w = self.workers.get("tmdb")
if not w or not w.enabled:
return []
try:
results = w.client.trending() or []
except Exception:
logger.exception("video trending failed")
return []
for r in results:
if r.get("tmdb_id"):
r["library_id"] = self.db.library_id_for_tmdb(r["kind"], r["tmdb_id"])
return results
def tmdb_detail(self, kind, tmdb_id) -> dict | None:
"""Full detail for a TMDB title not in the library — same shape as the
library detail (source='tmdb', direct image URLs, nothing owned). If it IS

@ -46,6 +46,7 @@ def test_blueprint_exposes_dashboard_route():
assert "/api/video/detail/movie/<int:movie_id>/refresh-art" in rules
assert "/api/video/detail/<kind>/<int:item_id>/extras" in rules
assert "/api/video/search" in rules
assert "/api/video/trending" in rules
assert "/api/video/tmdb/<kind>/<int:tmdb_id>" in rules
assert "/api/video/tmdb/show/<int:tv_id>/season/<int:season_number>" in rules
assert "/api/video/person/<int:tmdb_id>" in rules

@ -256,6 +256,30 @@ def test_engine_search_annotates_library(db):
assert "library_id" not in res[2] # people aren't library-matched
def test_engine_trending_annotates_library(db):
mid = db.upsert_movie("plex", {"server_id": "m1", "title": "Owned", "tmdb_id": 1})
class Tmdb:
enabled = True
def trending(self):
return [{"kind": "movie", "tmdb_id": 1, "title": "Owned"},
{"kind": "show", "tmdb_id": 2, "title": "Hot show"}]
eng = VideoEnrichmentEngine(db, {"tmdb": Tmdb()})
res = eng.trending()
assert res[0]["library_id"] == mid and res[1]["library_id"] is None
def test_tmdb_trending_parses(monkeypatch):
body = {"results": [
{"media_type": "movie", "id": 1, "title": "A", "release_date": "2024-01-01", "poster_path": "/a.jpg"},
{"media_type": "tv", "id": 2, "name": "B", "first_air_date": "2023-05-05"},
{"media_type": "person", "id": 3, "name": "C"}]} # people excluded from the rail
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(body)))
res = TMDBClient("KEY").trending()
assert [r["kind"] for r in res] == ["movie", "show"]
assert res[0]["poster"] == "https://image.tmdb.org/t/p/w300/a.jpg"
def test_engine_person_detail_annotates_credits(db):
mid = db.upsert_movie("plex", {"server_id": "m1", "title": "Owned", "tmdb_id": 1})

@ -394,6 +394,7 @@ def test_search_subpage_and_module():
assert "(function" in src and "})();" in src
assert "window." not in src # no globals
assert "/api/video/search" in src
assert "/api/video/trending" in src # idle page shows a trending rail
assert "soulsync:video-open-detail" in src # results drill in via the shared event
assert "themoviedb.org" not in src and "imdb.com" not in src # stays in-app
@ -432,6 +433,18 @@ def test_video_side_registers_detail_pages_and_open_event():
assert "soulsync:video-open-detail" in _JS # navigates on the event
def test_smart_back_button_remembers_origin():
# The back button label is dynamic (a span we rewrite), and the JS keeps an
# origin stack + layer-stamped history so back returns to where you came from
# (not always the library).
assert _INDEX.count("data-vd-back-label") >= 3 # all three detail back buttons
assert "_backStack" in _JS and "currentOrigin" in _JS
assert "updateBackLabels" in _JS
assert "layer: _backStack.length" in _JS # depth stamped on history state
# Fallback must use the recorded first origin, not a hardcoded library jump.
assert "_backStack[0]" in _JS
def test_music_worker_orbs_untouched_by_video():
# The video orbs are a separate file; the music orbs must not learn about
# the video side (one-way isolation — music never depends on video).

@ -871,7 +871,7 @@
at load for a per-show glow. Built by video/video-detail.js. -->
<div class="vd-page" data-video-detail="show">
<button class="vd-back" type="button" data-video-detail-back>
<span aria-hidden="true">&larr;</span> Library
<span aria-hidden="true">&larr;</span> <span data-vd-back-label>Back</span>
</button>
<!-- Billboard: full-bleed backdrop, content anchored bottom-left. -->
<div class="vd-billboard">
@ -928,7 +928,7 @@
<section class="video-subpage" data-video-subpage="video-movie-detail" hidden>
<div class="vd-page" data-video-detail="movie">
<button class="vd-back" type="button" data-video-detail-back>
<span aria-hidden="true">&larr;</span> Library
<span aria-hidden="true">&larr;</span> <span data-vd-back-label>Back</span>
</button>
<div class="vd-billboard">
<div class="vd-bb-bg" data-vd-backdrop aria-hidden="true"></div>
@ -1027,7 +1027,7 @@
<div class="vp-page" data-video-person data-has-bg="0">
<div class="vp-ambient" data-vp-ambient aria-hidden="true"></div>
<button class="vd-back" type="button" data-video-detail-back>
<span aria-hidden="true">&larr;</span> Back
<span aria-hidden="true">&larr;</span> <span data-vd-back-label>Back</span>
</button>
<div class="vd-loading" data-vp-loading hidden>
<div class="loading-spinner"></div><div class="loading-text">Loading&hellip;</div>
@ -1043,6 +1043,10 @@
<div class="vp-body">
<p class="vp-bio" data-vp-bio></p>
<button class="vp-bio-more" type="button" data-vp-bio-more hidden>Read more</button>
<div class="vp-known-section" data-vp-known-section hidden>
<h2 class="vp-credits-title vp-known-title">Known For</h2>
<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-tabs" data-vp-tabs></div></div>

@ -63,12 +63,24 @@
}).join('');
}
function renderKnownFor() {
var section = q('[data-vp-known-section]'), host = q('[data-vp-known]');
if (!section || !host || !data) return;
// The credits arrive popularity-sorted → the top few are the "known for".
var top = (data.credits || []).slice(0, 10);
section.hidden = top.length < 3; // only worth a rail if there are a few
host.innerHTML = top.map(creditCard).join('');
}
function renderCredits() {
var host = q('[data-vp-credits]');
if (!host || !data) return;
var credits = (data.credits || []).filter(function (c) {
return tab === 'all' || c.kind === tab;
});
// 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 || ''); });
host.innerHTML = credits.map(creditCard).join('');
}
@ -105,7 +117,7 @@
if (bio) { bio.textContent = d.biography || ''; bio.hidden = !d.biography; bio.classList.remove('vp-bio--open'); }
if (more) { more.hidden = !((d.biography || '').length > 320); more.textContent = 'Read more'; }
renderTabs(); renderCredits();
renderKnownFor(); renderTabs(); renderCredits();
var sub = document.querySelector('.video-subpage[data-video-subpage="video-person-detail"]');
if (sub) sub.scrollTop = 0;
}
@ -118,6 +130,8 @@
var m = q('[data-vp-meta]'); if (m) m.innerHTML = '';
var c = q('[data-vp-credits]'); if (c) c.innerHTML = '';
var t = q('[data-vp-tabs]'); if (t) t.innerHTML = '';
var ks = q('[data-vp-known-section]'); if (ks) ks.hidden = true;
var k = q('[data-vp-known]'); if (k) k.innerHTML = '';
fetch(PERSON_URL + id, { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {

@ -24,6 +24,7 @@
var reqSeq = 0; // guards against out-of-order responses
var timer = null;
var wired = false;
var trendingCache = null; // null = not fetched; [] = fetched/empty
function $(sel) { return document.querySelector(sel); }
function esc(s) {
@ -94,6 +95,34 @@
host.innerHTML = html;
}
// Idle state: a "Trending this week" rail so the page isn't a blank box.
function renderTrending() {
var host = $('[data-video-search-results]');
if (!host || !trendingCache || !trendingCache.length) return;
show('[data-video-search-hint]', false);
show('[data-video-search-empty]', false);
host.innerHTML = '<div class="vsr-group"><h2 class="vsr-group-title">' +
'<span class="vsr-group-ic" aria-hidden="true">🔥</span>Trending this week</h2>' +
'<div class="vsr-grid">' + trendingCache.map(titleCard).join('') + '</div></div>';
}
function loadTrending() {
if (trendingCache !== null) { if (!lastQuery) renderTrending(); return; }
fetch('/api/video/trending', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
trendingCache = (d && d.results) ? d.results : [];
if (!lastQuery) renderTrending();
})
.catch(function () { trendingCache = []; });
}
function showIdle() {
if (trendingCache && trendingCache.length) { renderTrending(); return; }
show('[data-video-search-empty]', false);
show('[data-video-search-hint]', true);
var host = $('[data-video-search-results]'); if (host) host.innerHTML = '';
loadTrending();
}
function runSearch(q) {
var seq = ++reqSeq;
show('[data-video-search-loading]', true);
@ -118,9 +147,7 @@
if (!q) {
reqSeq++; // cancel any in-flight render
show('[data-video-search-loading]', false);
show('[data-video-search-empty]', false);
show('[data-video-search-hint]', true);
var host = $('[data-video-search-results]'); if (host) host.innerHTML = '';
showIdle(); // back to the trending rail
return;
}
timer = setTimeout(function () { runSearch(q); }, 320);
@ -162,6 +189,7 @@
wire();
var input = $('[data-video-search-input]');
if (input) { try { input.focus(); } catch (err) { /* ignore */ } }
if (!lastQuery) loadTrending(); // fill the idle page
}
function init() {

@ -454,6 +454,10 @@ body[data-side="video"] .dashboard-header-sweep {
backdrop-filter: blur(10px); transition: all 0.2s ease;
}
.vd-back:hover { background: rgba(0,0,0,0.7); transform: translateX(-2px); }
.vd-back [data-vd-back-label] {
display: inline-block; max-width: 230px; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; vertical-align: bottom;
}
/* ── Billboard ─────────────────────────────────────────────────────────────── */
.vd-billboard { position: relative; width: 100%; min-height: 76vh; display: flex; align-items: flex-end; overflow: hidden; }
@ -1023,6 +1027,16 @@ body[data-side="video"] .dashboard-header-sweep {
}
.vp-bio-more[hidden] { display: none; }
/* Known For — a horizontal hero rail of the person's best-known titles */
.vp-known-section { position: relative; z-index: 2; margin-bottom: 38px; }
.vp-known-title { margin: 0 0 16px; }
.vp-known-rail {
display: flex; gap: 18px; overflow-x: auto; padding: 4px 2px 14px; scroll-snap-type: x proximity;
}
.vp-known-rail::-webkit-scrollbar { height: 8px; }
.vp-known-rail::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 4px; }
.vp-known-rail .vsr-card { flex: 0 0 152px; width: 152px; scroll-snap-align: start; }
.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;

@ -50,14 +50,68 @@
document.dispatchEvent(new CustomEvent('soulsync:video-open-detail',
{ detail: { kind: r.kind, id: r.id, source: r.source, _restore: true } }));
}
// ── Smart back button (mirrors music's artist-detail) ─────────────────────
// Browser history does the real (multi-layer) navigation; this stack only
// tracks WHERE EACH detail layer was opened FROM, so the button can label
// itself ("← Back to Search" / "← Back to The Bear") and so backing out of
// the first layer returns to the right page — not always the library. Each
// entry: {type:'page', pageId} or {type:'detail', label}. The pushed history
// state carries its layer depth so browser Back stays in sync too.
var _backStack = [];
function detailTitleOf(pageId) {
if (pageId === 'video-person-detail') {
var n = document.querySelector('[data-video-person] [data-vp-name]');
return n ? (n.textContent || '').trim() : '';
}
var host = pageId === 'video-movie-detail' ? '[data-video-detail="movie"]' : '[data-video-detail="show"]';
var t = document.querySelector(host + ' [data-vd-title]');
return t ? (t.textContent || '').trim() : '';
}
function currentOrigin() {
var page = document.body.getAttribute('data-video-page');
if (DETAIL_PAGES[page]) return { type: 'detail', label: detailTitleOf(page) };
return { type: 'page', pageId: page };
}
function backLabelText() {
var top = _backStack[_backStack.length - 1];
if (!top) return 'Back';
if (top.type === 'detail') return top.label ? ('Back to ' + top.label) : 'Back';
return 'Back to ' + pageMeta(top.pageId).label;
}
function updateBackLabels() {
var text = backLabelText();
var labels = document.querySelectorAll('[data-vd-back-label]');
for (var i = 0; i < labels.length; i++) labels[i].textContent = text;
}
function backFallback() {
// No browser history to pop (deep link) — go to the recorded first origin.
var dest = (_backStack[0] && _backStack[0].type === 'page') ? _backStack[0].pageId : 'video-library';
_backStack = [];
navigate(dest);
updateBackLabels();
}
function onPopState() {
var r = parseDetailPath(window.location.pathname);
if (r) { restoreDetail(r); return; }
// Left the detail URL — if we're still on the video side showing a detail,
// fall back to the library (Back out of the detail).
if (r) {
// Synced to the layer depth stamped on the history entry (handles both
// our back button and the browser's Back).
var st = window.history.state;
var layer = (st && st.videoDetail && st.videoDetail.layer) || _backStack.length;
if (_backStack.length > layer) _backStack.length = layer;
restoreDetail(r);
updateBackLabels();
return;
}
// Left the detail URL — return to where the chain started, not always library.
if (document.body.getAttribute('data-side') === 'video' &&
DETAIL_PAGES[document.body.getAttribute('data-video-page')]) {
navigate('video-library');
var dest = (_backStack[0] && _backStack[0].type === 'page') ? _backStack[0].pageId : 'video-library';
_backStack = [];
navigate(dest);
updateBackLabels();
}
}
@ -220,6 +274,7 @@
(function (btn) {
btn.addEventListener('click', function (e) {
e.preventDefault();
_backStack = []; // sidebar nav is a fresh entry point
navigate(btn.getAttribute('data-video-page'));
});
})(navButtons[j]);
@ -232,6 +287,7 @@
(function (el) {
el.addEventListener('click', function (e) {
e.preventDefault();
_backStack = []; // in-page jump is a fresh entry point
navigate(el.getAttribute('data-video-goto'));
});
})(gotos[k]);
@ -242,27 +298,35 @@
// and push a real URL — unless we're restoring from the URL (_restore).
document.addEventListener('soulsync:video-open-detail', function (e) {
var d = e && e.detail; if (!d) return;
// Capture the origin BEFORE navigating away from the current page.
var origin = d._restore ? null : currentOrigin();
if (d.kind === 'movie') navigate('video-movie-detail');
else if (d.kind === 'show') navigate('video-show-detail');
else if (d.kind === 'person') navigate('video-person-detail');
else return;
if (!d._restore) {
_backStack.push(origin);
var state = { videoDetail: { kind: d.kind, id: d.id, source: d.source || 'library',
layer: _backStack.length } };
var path = buildDetailPath(d.source, d.kind, d.id);
if (window.location.pathname !== path) {
history.pushState({ videoDetail: { kind: d.kind, id: d.id, source: d.source || 'library' } },
'', path);
}
if (window.location.pathname !== path) history.pushState(state, '', path);
else history.replaceState(state, '', path);
}
updateBackLabels();
});
// The detail "← Library" back button is real Back (returns to wherever you
// drilled in from); falls back to the library if there's no in-app history.
// The detail back button is real browser Back (so it unwinds the whole
// drill-in chain, layer by layer); only when there's no in-app history to
// pop does it fall back to the recorded first origin.
document.addEventListener('click', function (e) {
var back = e.target.closest('[data-video-detail-back]');
if (!back) return;
e.preventDefault();
if (history.state && history.state.videoDetail) history.back();
else navigate('video-library');
if (window.history.length > 1 && window.history.state && window.history.state.videoDetail) {
window.history.back();
} else {
backFallback();
}
});
window.addEventListener('popstate', onPopState);

Loading…
Cancel
Save