From d6d0dc537ceebf3aabb938fdf776d0df043bec83 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Mon, 15 Jun 2026 08:30:17 -0700 Subject: [PATCH] video: smart multi-layer back button + next-level search/person MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ' — 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. --- api/video/search.py | 10 ++++ core/video/enrichment/clients.py | 25 +++++++++ core/video/enrichment/engine.py | 15 +++++ tests/test_video_api.py | 1 + tests/test_video_enrichment.py | 24 ++++++++ tests/test_video_side_shell.py | 13 +++++ webui/index.html | 10 +++- webui/static/video/video-person.js | 16 +++++- webui/static/video/video-search.js | 34 +++++++++++- webui/static/video/video-side.css | 14 +++++ webui/static/video/video-side.js | 88 ++++++++++++++++++++++++++---- 11 files changed, 231 insertions(+), 19 deletions(-) diff --git a/api/video/search.py b/api/video/search.py index 673a320e..71cab7c7 100644 --- a/api/video/search.py +++ b/api/video/search.py @@ -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}) diff --git a/core/video/enrichment/clients.py b/core/video/enrichment/clients.py index 0702a0be..1fb13471 100644 --- a/core/video/enrichment/clients.py +++ b/core/video/enrichment/clients.py @@ -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 diff --git a/core/video/enrichment/engine.py b/core/video/enrichment/engine.py index 9c6deb45..d5219fb7 100644 --- a/core/video/enrichment/engine.py +++ b/core/video/enrichment/engine.py @@ -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 diff --git a/tests/test_video_api.py b/tests/test_video_api.py index fbfbac4a..a3221f59 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -46,6 +46,7 @@ def test_blueprint_exposes_dashboard_route(): assert "/api/video/detail/movie//refresh-art" in rules assert "/api/video/detail///extras" in rules assert "/api/video/search" in rules + assert "/api/video/trending" in rules assert "/api/video/tmdb//" in rules assert "/api/video/tmdb/show//season/" in rules assert "/api/video/person/" in rules diff --git a/tests/test_video_enrichment.py b/tests/test_video_enrichment.py index bd6c3e20..88c37115 100644 --- a/tests/test_video_enrichment.py +++ b/tests/test_video_enrichment.py @@ -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}) diff --git a/tests/test_video_side_shell.py b/tests/test_video_side_shell.py index edfc4ff9..416894aa 100644 --- a/tests/test_video_side_shell.py +++ b/tests/test_video_side_shell.py @@ -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). diff --git a/webui/index.html b/webui/index.html index 1a9b4441..2f690a27 100644 --- a/webui/index.html +++ b/webui/index.html @@ -871,7 +871,7 @@ at load for a per-show glow. Built by video/video-detail.js. -->
@@ -928,7 +928,7 @@