diff --git a/api/video/__init__.py b/api/video/__init__.py index f68b614e..6d30bb5a 100644 --- a/api/video/__init__.py +++ b/api/video/__init__.py @@ -44,6 +44,7 @@ def create_video_blueprint() -> Blueprint: from .poster import register_routes as reg_poster from .enrichment import register_routes as reg_enrichment from .detail import register_routes as reg_detail + from .search import register_routes as reg_search reg_dashboard(bp) reg_scan(bp) reg_library(bp) @@ -51,5 +52,6 @@ def create_video_blueprint() -> Blueprint: reg_poster(bp) reg_enrichment(bp) reg_detail(bp) + reg_search(bp) return bp diff --git a/api/video/detail.py b/api/video/detail.py index 30b85e8c..a16419f3 100644 --- a/api/video/detail.py +++ b/api/video/detail.py @@ -67,6 +67,48 @@ def register_routes(bp): res = {"ok": False, "reason": "error"} return jsonify(res) + @bp.route("/tmdb//", methods=["GET"]) + def video_tmdb_detail(kind, tmdb_id): + """Full detail for a TMDB title not in the library (the search → detail + view). May return {redirect:{source,kind,id}} if it's actually owned.""" + if kind not in ("movie", "show"): + return jsonify({"error": "bad kind"}), 400 + try: + from core.video.enrichment.engine import get_video_enrichment_engine + d = get_video_enrichment_engine().tmdb_detail(kind, tmdb_id) + except Exception: + logger.exception("tmdb detail failed for %s %s", kind, tmdb_id) + d = None + if not d: + return jsonify({"error": "not found"}), 404 + return jsonify(d) + + @bp.route("/tmdb/show//season/", methods=["GET"]) + def video_tmdb_season(tv_id, season_number): + """Lazy per-season episodes for a TMDB (un-owned) show detail.""" + try: + from core.video.enrichment.engine import get_video_enrichment_engine + d = get_video_enrichment_engine().tmdb_season(tv_id, season_number) + except Exception: + logger.exception("tmdb season failed for %s S%s", tv_id, season_number) + d = None + if not d: + return jsonify({"error": "not found"}), 404 + return jsonify(d) + + @bp.route("/person/", methods=["GET"]) + def video_person_detail(tmdb_id): + """In-app person page: bio + filmography (each credit annotated owned/not).""" + try: + from core.video.enrichment.engine import get_video_enrichment_engine + d = get_video_enrichment_engine().person_detail(tmdb_id) + except Exception: + logger.exception("person detail failed for %s", tmdb_id) + d = None + if not d: + return jsonify({"error": "not found"}), 404 + return jsonify(d) + @bp.route("/detail///extras", methods=["GET"]) def video_detail_extras(kind, item_id): """Live TMDB extras (trailer / where-to-watch / similar) for the detail page.""" diff --git a/api/video/search.py b/api/video/search.py new file mode 100644 index 00000000..673a320e --- /dev/null +++ b/api/video/search.py @@ -0,0 +1,33 @@ +"""Video search API (in-app, isolated). + + GET /api/video/search?q=... → TMDB multi-search (movies / shows / people), + movie/show results annotated with library_id + if already owned. + +Everything resolves back into SoulSync — results link to the library detail +(owned) or the TMDB-backed detail (not owned); people open the in-app person +page. Reads only the video engine + video.db. +""" + +from __future__ import annotations + +from flask import jsonify, request + +from utils.logging_config import get_logger + +logger = get_logger("video_api.search") + + +def register_routes(bp): + @bp.route("/search", methods=["GET"]) + def video_search(): + q = (request.args.get("q") or "").strip() + if not q: + return jsonify({"results": [], "query": ""}) + try: + from core.video.enrichment.engine import get_video_enrichment_engine + results = get_video_enrichment_engine().search(q) + except Exception: + logger.exception("video search failed for %r", q) + results = [] + return jsonify({"results": results, "query": q}) diff --git a/core/video/enrichment/clients.py b/core/video/enrichment/clients.py index ae5a7b2e..0702a0be 100644 --- a/core/video/enrichment/clients.py +++ b/core/video/enrichment/clients.py @@ -170,7 +170,11 @@ class TMDBClient: r = requests.get(self.BASE + path, params={ "api_key": self.api_key, "append_to_response": "videos,watch/providers,similar"}, timeout=15) r.raise_for_status() - d = r.json() or {} + return self._parse_extras(kind, r.json() or {}, region) + + def _parse_extras(self, kind, d, region="US"): + """Pull trailer / where-to-watch / similar out of a TMDB detail body. Shared + by extras() and full_detail() so the search detail can render them too.""" out = {} # Trailer — prefer a YouTube "Trailer", fall back to a teaser. @@ -235,6 +239,142 @@ class TMDBClient: "poster_url": (self.IMG + data["poster_path"]) if data.get("poster_path") else None, "episodes": out} + def search(self, query): + """Multi-search (movies / TV / people) for the in-app search page. Returns + a flat list of {kind, tmdb_id, title, year, poster, ...} — no external IDs, + everything resolves back into SoulSync.""" + if not self.api_key or not (query or "").strip(): + return [] + import requests + r = requests.get(self.BASE + "/search/multi", params={ + "api_key": self.api_key, "query": query, "include_adult": "false"}, timeout=15) + r.raise_for_status() + out = [] + for it in ((r.json() or {}).get("results") or [])[:32]: + mt, tid = it.get("media_type"), it.get("id") + if not tid: + continue + if mt == "movie": + out.append({"kind": "movie", "tmdb_id": tid, "title": it.get("title"), + "year": (it.get("release_date") or "")[:4] or None, + "overview": it.get("overview"), "rating": it.get("vote_average") or None, + "poster": (self.POSTER_W + it["poster_path"]) if it.get("poster_path") else None}) + elif mt == "tv": + out.append({"kind": "show", "tmdb_id": tid, "title": it.get("name"), + "year": (it.get("first_air_date") or "")[:4] or None, + "overview": it.get("overview"), "rating": it.get("vote_average") or None, + "poster": (self.POSTER_W + it["poster_path"]) if it.get("poster_path") else None}) + elif mt == "person": + known = [k.get("title") or k.get("name") for k in (it.get("known_for") or [])] + out.append({"kind": "person", "tmdb_id": tid, "title": it.get("name"), + "known_for": ", ".join([k for k in known if k][:3]) or None, + "department": it.get("known_for_department"), + "poster": (self.PROFILE + it["profile_path"]) if it.get("profile_path") else None}) + return out + + 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 + renders it). Seasons carry counts; episodes load lazily per season.""" + if not self.api_key or tmdb_id is None: + return None + import requests + path = ("/movie/" if kind == "movie" else "/tv/") + str(tmdb_id) + r = requests.get(self.BASE + path, params={ + "api_key": self.api_key, + "append_to_response": "external_ids,credits,images,videos,watch/providers,similar", + "include_image_language": "en,null"}, timeout=15) + r.raise_for_status() + dr = r.json() or {} + if not dr.get("id"): + return None + ext = dr.get("external_ids") or {} + logo = self._pick_logo((dr.get("images") or {}).get("logos") or []) + cmeta = {} + self._add_credits(cmeta, dr.get("credits") or {}, dr.get("created_by") or []) + out = { + "kind": kind, "tmdb_id": tmdb_id, + "title": dr.get("title") or dr.get("name"), + "overview": dr.get("overview"), "tagline": dr.get("tagline") or None, + "status": dr.get("status"), "rating": dr.get("vote_average") or None, + "imdb_id": ext.get("imdb_id") or dr.get("imdb_id"), + "poster_url": (self.IMG + dr["poster_path"]) if dr.get("poster_path") else None, + "backdrop_url": (self.IMG + dr["backdrop_path"]) if dr.get("backdrop_path") else None, + "logo": (self.LOGO + logo) if logo else None, + "genres": [g.get("name") for g in (dr.get("genres") or []) if g.get("name")], + "cast": [{"name": p["name"], "character": p.get("character"), + "photo": p.get("photo_url"), "tmdb_id": p.get("tmdb_id")} + for p in cmeta.get("cast") or []], + "crew": [{"name": p["name"], "job": p.get("job"), "tmdb_id": p.get("tmdb_id")} + for p in cmeta.get("crew") or []], + "_extras": self._parse_extras(kind, dr), + } + if kind == "movie": + out["year"] = (dr.get("release_date") or "")[:4] or None + out["release_date"] = dr.get("release_date") or None + out["runtime_minutes"] = dr.get("runtime") + out["studio"] = next((c.get("name") for c in (dr.get("production_companies") or [])), None) + else: + out["year"] = (dr.get("first_air_date") or "")[:4] or None + out["first_air_date"] = dr.get("first_air_date") or None + out["last_air_date"] = dr.get("last_air_date") or None + ert = dr.get("episode_run_time") or [] + out["runtime_minutes"] = ert[0] if ert else None + out["network"] = next((n.get("name") for n in (dr.get("networks") or [])), None) + out["tvdb_id"] = _int(ext.get("tvdb_id")) + seasons = [] + for s in (dr.get("seasons") or []): + num = s.get("season_number") + if num is None: + continue + seasons.append({ + "season_number": num, + "title": s.get("name") or ("Specials" if num == 0 else "Season %d" % num), + "poster_url": (self.POSTER_W + s["poster_path"]) if s.get("poster_path") else None, + "episode_count": s.get("episode_count") or 0}) + out["_seasons"] = sorted(seasons, key=lambda s: s["season_number"]) + return out + + def person(self, tmdb_id): + """Person detail + their filmography (cast + crew credits) for the in-app + person page. Everything points back to TMDB ids we resolve in SoulSync.""" + if not self.api_key or tmdb_id is None: + return None + import requests + r = requests.get(self.BASE + "/person/" + str(tmdb_id), params={ + "api_key": self.api_key, "append_to_response": "combined_credits,external_ids"}, timeout=15) + r.raise_for_status() + d = r.json() or {} + if not d.get("id"): + return None + cc = d.get("combined_credits") or {} + seen, credits = set(), [] + for c in (cc.get("cast") or []) + (cc.get("crew") or []): + mt, tid = c.get("media_type"), c.get("id") + if not tid or mt not in ("movie", "tv"): + continue + kind = "movie" if mt == "movie" else "show" + key = (kind, tid) + if key in seen: + continue + 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, + "popularity": c.get("popularity") or 0, + "poster": (self.POSTER_W + c["poster_path"]) if c.get("poster_path") else None}) + credits.sort(key=lambda x: x["popularity"], reverse=True) + return { + "tmdb_id": d.get("id"), "name": d.get("name"), + "biography": d.get("biography") or None, + "known_for": d.get("known_for_department") or None, + "birthday": d.get("birthday") or None, "deathday": d.get("deathday") or None, + "place_of_birth": d.get("place_of_birth") or None, + "photo": (self.PROFILE + d["profile_path"]) if d.get("profile_path") else None, + "credits": credits} + class TVDBClient: BASE = "https://api4.thetvdb.com/v4" diff --git a/core/video/enrichment/engine.py b/core/video/enrichment/engine.py index 02d8c878..9c6deb45 100644 --- a/core/video/enrichment/engine.py +++ b/core/video/enrichment/engine.py @@ -164,6 +164,113 @@ class VideoEnrichmentEngine: logger.exception("item_extras failed for %s %s", kind, item_id) return {} + # ── in-app search + TMDB-backed (un-owned) detail ───────────────────────── + def search(self, query) -> list: + """Multi-search via TMDB, each movie/show annotated with the library row id + if it's already owned (so the UI links to the owned detail, not the tmdb + view).""" + w = self.workers.get("tmdb") + if not w or not w.enabled: + return [] + try: + results = w.client.search(query) or [] + except Exception: + logger.exception("video search failed for %r", query) + return [] + for r in results: + if r.get("kind") in ("movie", "show") and 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 + in the library, returns a redirect to the owned detail instead.""" + w = self.workers.get("tmdb") + if not w or not w.enabled: + return None + lib_id = self.db.library_id_for_tmdb(kind, tmdb_id) + if lib_id: + return {"redirect": {"source": "library", "kind": kind, "id": lib_id}} + try: + d = w.client.full_detail(kind, tmdb_id) + except Exception: + logger.exception("tmdb_detail failed for %s %s", kind, tmdb_id) + return None + if not d: + return None + d.update({"source": "tmdb", "id": tmdb_id, "owned": False, "monitored": False, + "has_poster": bool(d.get("poster_url")), "has_backdrop": bool(d.get("backdrop_url"))}) + ex = d.pop("_extras", {}) or {} + d.update({"trailer": ex.get("trailer"), "providers": ex.get("providers") or [], + "providers_link": ex.get("providers_link"), "similar": ex.get("similar") or []}) + if kind == "show": + seasons = d.pop("_seasons", []) or [] + for s in seasons: + s["has_poster"] = bool(s.get("poster_url")) + s["episode_total"] = s.pop("episode_count", 0) or 0 + s["episode_owned"] = 0 + s["episodes"] = [] # loaded lazily per season (tmdb_season) + d["seasons"] = seasons + d["season_count"] = len(seasons) + d["episode_total"] = sum(s["episode_total"] for s in seasons) + d["episode_owned"] = 0 + self._fill_tmdb_ratings(d) + return d + + def _fill_tmdb_ratings(self, d) -> None: + imdb_id = d.get("imdb_id") + ow = self.workers.get("omdb") + if not imdb_id or not ow or not getattr(ow.client, "enabled", False): + return + try: + r = ow.client.ratings(imdb_id) or {} + for k in ("imdb_rating", "rt_rating", "metacritic"): + if r.get(k) is not None: + d[k] = r[k] + except Exception: + logger.exception("tmdb_detail ratings failed for %s", imdb_id) + + def tmdb_season(self, tv_id, season_number) -> dict | None: + """One season's episodes for a TMDB (un-owned) show — lazy-loaded when the + season is selected on the search detail page. Nothing is owned.""" + w = self.workers.get("tmdb") + if not w or not w.enabled: + return None + try: + se = w.client.season_episodes(tv_id, season_number) + except Exception: + logger.exception("tmdb_season failed for %s S%s", tv_id, season_number) + return None + if not se: + return None + eps = [{"episode_number": e.get("episode_number"), "title": e.get("title"), + "overview": e.get("overview"), "air_date": e.get("air_date"), + "runtime_minutes": e.get("runtime_minutes"), "rating": e.get("rating"), + "still_url": e.get("still_url"), "has_still": bool(e.get("still_url")), + "owned": False} + for e in (se.get("episodes") or []) if e.get("episode_number") is not None] + return {"season_number": season_number, "overview": se.get("overview"), + "poster_url": se.get("poster_url"), "episodes": eps} + + def person_detail(self, tmdb_id) -> dict | None: + """A person (actor/director) page — bio + filmography, each credit + annotated with the library id if owned. Keeps cast clicks in-app.""" + w = self.workers.get("tmdb") + if not w or not w.enabled: + return None + try: + p = w.client.person(tmdb_id) + except Exception: + logger.exception("person_detail failed for %s", tmdb_id) + return None + if not p: + return None + for c in p.get("credits") or []: + if c.get("tmdb_id"): + c["library_id"] = self.db.library_id_for_tmdb(c["kind"], c["tmdb_id"]) + return p + def worker(self, service): return self.workers.get(service) diff --git a/database/video_database.py b/database/video_database.py index 5ff004e0..6758df3b 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -336,6 +336,27 @@ class VideoDatabase: finally: conn.close() + def library_id_for_tmdb(self, kind: str, tmdb_id) -> int | None: + """The library row id for a TMDB id if it's already owned, else None. Lets + the search → detail flow link owned titles to their real library detail + (instead of the live TMDB view).""" + table = {"movie": "movies", "show": "shows"}.get(kind) + if not table or tmdb_id is None: + return None + try: + tmdb_id = int(tmdb_id) + except (TypeError, ValueError): + return None + conn = self._get_connection() + try: + row = conn.execute( + f"SELECT id FROM {table} WHERE tmdb_id=? LIMIT 1", (tmdb_id,)).fetchone() + return row["id"] if row else None + except sqlite3.Error: + return None + finally: + conn.close() + def apply_ratings(self, kind: str, item_id: int, ratings: dict) -> None: """Store IMDb / RT / Metacritic scores (from OMDb) + mark ratings_synced. Ratings are dynamic, so these overwrite (unlike gap-only metadata).""" @@ -782,12 +803,14 @@ class VideoDatabase: @staticmethod def _credits_for(conn, owner_col: str, owner_id: int, cast_limit: int = 18) -> dict: rows = conn.execute( - "SELECT p.name, p.photo_url, c.department, c.job, c.character " + "SELECT p.name, p.photo_url, p.tmdb_id, c.department, c.job, c.character " f"FROM credits c JOIN people p ON p.id = c.person_id WHERE c.{owner_col}=? " "ORDER BY c.department, c.sort_order", (owner_id,)).fetchall() - cast = [{"name": r["name"], "character": r["character"], "photo": r["photo_url"]} + cast = [{"name": r["name"], "character": r["character"], "photo": r["photo_url"], + "tmdb_id": r["tmdb_id"]} for r in rows if r["department"] == "cast"][:cast_limit] - crew = [{"name": r["name"], "job": r["job"]} for r in rows if r["department"] == "crew"] + crew = [{"name": r["name"], "job": r["job"], "tmdb_id": r["tmdb_id"]} + for r in rows if r["department"] == "crew"] return {"cast": cast, "crew": crew} def upsert_movie(self, server_source: str, item: dict) -> int: diff --git a/tests/test_video_api.py b/tests/test_video_api.py index 64a39af0..fbfbac4a 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -45,9 +45,43 @@ def test_blueprint_exposes_dashboard_route(): assert "/api/video/detail/show//refresh-art" in rules 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/tmdb//" in rules + assert "/api/video/tmdb/show//season/" in rules + assert "/api/video/person/" in rules assert any(r.startswith("/api/video/backdrop/") for r in rules) +def test_search_endpoint_empty_query(tmp_path): + client, _ = _make_client(tmp_path) + resp = client.get("/api/video/search?q=") + assert resp.status_code == 200 + assert resp.get_json() == {"results": [], "query": ""} + + +def test_search_endpoint_uses_engine(tmp_path, monkeypatch): + client, _ = _make_client(tmp_path) + + class FakeEngine: + def search(self, q): return [{"kind": "movie", "tmdb_id": 1, "title": "Dune", "library_id": None}] + monkeypatch.setattr("core.video.enrichment.engine.get_video_enrichment_engine", + lambda: FakeEngine()) + body = client.get("/api/video/search?q=dune").get_json() + assert body["query"] == "dune" and body["results"][0]["title"] == "Dune" + + +def test_tmdb_detail_endpoint(tmp_path, monkeypatch): + client, _ = _make_client(tmp_path) + + class FakeEngine: + def tmdb_detail(self, kind, tid): return {"source": "tmdb", "kind": kind, "id": tid, "title": "X"} + monkeypatch.setattr("core.video.enrichment.engine.get_video_enrichment_engine", + lambda: FakeEngine()) + resp = client.get("/api/video/tmdb/movie/438631") + assert resp.status_code == 200 and resp.get_json()["source"] == "tmdb" + assert client.get("/api/video/tmdb/bogus/1").status_code == 400 + + def test_show_detail_endpoint(tmp_path): client, videoapi = _make_client(tmp_path) try: diff --git a/tests/test_video_database.py b/tests/test_video_database.py index c515a314..ae48fb7e 100644 --- a/tests/test_video_database.py +++ b/tests/test_video_database.py @@ -554,7 +554,7 @@ def test_enrichment_backfills_cast_and_crew(db): d = db.show_detail(sid) assert [c["name"] for c in d["cast"]] == ["Aidan Gillen", "Amanda Schull"] # billing order assert d["cast"][0]["character"] == "James Cole" and d["cast"][0]["photo"] == "https://img/ag.jpg" - assert d["crew"] == [{"name": "Terry Matalas", "job": "Creator"}] + assert d["crew"] == [{"name": "Terry Matalas", "job": "Creator", "tmdb_id": 1}] # Clearlogo backfills like the other art (gap-only) and rides in the payload. db.enrichment_apply("tmdb", "show", sid, matched=True, external_id=1, metadata={"logo_url": "https://img/logo.png"}) diff --git a/tests/test_video_enrichment.py b/tests/test_video_enrichment.py index 552ce9c9..bd6c3e20 100644 --- a/tests/test_video_enrichment.py +++ b/tests/test_video_enrichment.py @@ -167,6 +167,120 @@ def test_show_match_info(db): assert db.show_match_info(999999) is None +class _Resp: + def __init__(self, b): self._b = b + def raise_for_status(self): pass + def json(self): return self._b + + +def test_tmdb_search_parses_multi(monkeypatch): + body = {"results": [ + {"media_type": "movie", "id": 1, "title": "Dune", "release_date": "2021-10-22", + "poster_path": "/d.jpg", "vote_average": 8.0}, + {"media_type": "tv", "id": 2, "name": "Loki", "first_air_date": "2021-06-09", "poster_path": "/l.jpg"}, + {"media_type": "person", "id": 3, "name": "Zendaya", "profile_path": "/z.jpg", + "known_for_department": "Acting", + "known_for": [{"title": "Dune"}, {"name": "Euphoria"}]}, + {"media_type": "company", "id": 9, "name": "ignore me"}]} + monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(body))) + res = TMDBClient("KEY").search("d") + kinds = [r["kind"] for r in res] + assert kinds == ["movie", "show", "person"] # company dropped + assert res[0] == {"kind": "movie", "tmdb_id": 1, "title": "Dune", "year": "2021", + "overview": None, "rating": 8.0, + "poster": "https://image.tmdb.org/t/p/w300/d.jpg"} + assert res[2]["known_for"] == "Dune, Euphoria" + + +def test_tmdb_full_detail_movie(monkeypatch): + detail = {"id": 1, "title": "Dune", "overview": "O", "release_date": "2021-10-22", + "runtime": 155, "vote_average": 8.0, "tagline": "Fear is the mind-killer", + "poster_path": "/p.jpg", "backdrop_path": "/b.jpg", + "genres": [{"name": "Sci-Fi"}], "production_companies": [{"name": "Legendary"}], + "external_ids": {"imdb_id": "tt1160419"}, + "credits": {"cast": [{"name": "Timothée", "id": 11, "character": "Paul", "profile_path": "/t.jpg"}], + "crew": [{"name": "Denis", "id": 12, "job": "Director"}]}, + "images": {"logos": [{"iso_639_1": "en", "file_path": "/logo.png"}]}} + monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(detail))) + d = TMDBClient("KEY").full_detail("movie", 1) + assert d["title"] == "Dune" and d["year"] == "2021" and d["studio"] == "Legendary" + assert d["poster_url"] == "https://image.tmdb.org/t/p/original/p.jpg" + assert d["cast"][0] == {"name": "Timothée", "character": "Paul", + "photo": "https://image.tmdb.org/t/p/w185/t.jpg", "tmdb_id": 11} + assert d["imdb_id"] == "tt1160419" and d["logo"].endswith("/logo.png") + assert d["_extras"] == {} # no videos/providers/similar here + + +def test_engine_tmdb_detail_redirects_when_owned(db): + mid = db.upsert_movie("plex", {"server_id": "m1", "title": "Owned", "tmdb_id": 77}) + + class Tmdb: + enabled = True + def full_detail(self, kind, tid): raise AssertionError("must not fetch an owned title") + eng = VideoEnrichmentEngine(db, {"tmdb": Tmdb()}) + assert eng.tmdb_detail("movie", 77) == {"redirect": {"source": "library", "kind": "movie", "id": mid}} + + +def test_engine_tmdb_detail_assembles_show(db): + class Tmdb: + enabled = True + def full_detail(self, kind, tid): + return {"kind": "show", "tmdb_id": tid, "title": "Loki", "imdb_id": None, + "poster_url": "http://p", "backdrop_url": None, "cast": [], "crew": [], + "_extras": {"similar": [{"title": "X", "tmdb_id": 5, "kind": "show"}]}, + "_seasons": [{"season_number": 1, "title": "Season 1", + "poster_url": "http://s1", "episode_count": 6}]} + eng = VideoEnrichmentEngine(db, {"tmdb": Tmdb()}) + d = eng.tmdb_detail("show", 84958) + assert d["source"] == "tmdb" and d["owned"] is False and d["id"] == 84958 + assert d["has_poster"] is True and d["has_backdrop"] is False + assert d["similar"][0]["tmdb_id"] == 5 + s = d["seasons"][0] + assert s["episode_total"] == 6 and s["episode_owned"] == 0 and s["episodes"] == [] + assert d["season_count"] == 1 and d["episode_total"] == 6 + + +def test_engine_search_annotates_library(db): + mid = db.upsert_movie("plex", {"server_id": "m1", "title": "Owned", "tmdb_id": 1}) + + class Tmdb: + enabled = True + def search(self, q): + return [{"kind": "movie", "tmdb_id": 1, "title": "Owned"}, + {"kind": "movie", "tmdb_id": 2, "title": "Not owned"}, + {"kind": "person", "tmdb_id": 3, "title": "Someone"}] + eng = VideoEnrichmentEngine(db, {"tmdb": Tmdb()}) + res = eng.search("own") + assert res[0]["library_id"] == mid + assert res[1]["library_id"] is None + assert "library_id" not in res[2] # people aren't library-matched + + +def test_engine_person_detail_annotates_credits(db): + mid = db.upsert_movie("plex", {"server_id": "m1", "title": "Owned", "tmdb_id": 1}) + + class Tmdb: + enabled = True + def person(self, tid): + return {"tmdb_id": tid, "name": "P", "credits": [ + {"kind": "movie", "tmdb_id": 1, "title": "Owned"}, + {"kind": "show", "tmdb_id": 9, "title": "Other"}]} + eng = VideoEnrichmentEngine(db, {"tmdb": Tmdb()}) + p = eng.person_detail(55) + assert p["credits"][0]["library_id"] == mid + assert p["credits"][1]["library_id"] is None + + +def test_library_id_for_tmdb(db): + mid = db.upsert_movie("plex", {"server_id": "m1", "title": "M", "tmdb_id": 500}) + sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "tmdb_id": 600, "seasons": []}) + assert db.library_id_for_tmdb("movie", 500) == mid + assert db.library_id_for_tmdb("show", 600) == sid + assert db.library_id_for_tmdb("movie", 999) is None + assert db.library_id_for_tmdb("bogus", 500) is None + assert db.library_id_for_tmdb("movie", None) is None + + def test_omdb_worker_processes_ratings_queue(db): db.upsert_movie("plex", {"server_id": "m1", "title": "A", "imdb_id": "tt1"}) db.upsert_movie("plex", {"server_id": "m2", "title": "B"}) # no imdb_id → skipped diff --git a/tests/test_video_side_shell.py b/tests/test_video_side_shell.py index 466c9453..edfc4ff9 100644 --- a/tests/test_video_side_shell.py +++ b/tests/test_video_side_shell.py @@ -386,6 +386,41 @@ def test_video_detail_module_referenced_and_isolated(): assert "soulsync:video-open-detail" in src # opened via the shared event +def test_search_subpage_and_module(): + assert 'data-video-subpage="video-search"' in _INDEX + assert "data-video-search-input" in _INDEX and "data-video-search-results" in _INDEX + assert "video/video-search.js" in _INDEX + src = (_ROOT / "webui" / "static" / "video" / "video-search.js").read_text(encoding="utf-8") + assert "(function" in src and "})();" in src + assert "window." not in src # no globals + assert "/api/video/search" in src + 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 + + +def test_person_subpage_and_module_isolated(): + assert 'data-video-subpage="video-person-detail"' in _INDEX + assert "data-video-person" in _INDEX + assert "video/video-person.js" in _INDEX + src = (_ROOT / "webui" / "static" / "video" / "video-person.js").read_text(encoding="utf-8") + assert "(function" in src and "})();" in src + assert "window." not in src + assert "/api/video/person/" in src + assert "themoviedb.org" not in src and "imdb.com" not in src + # The person page is a registered detail route (reload / back / new-tab work). + assert "video-person-detail" in _JS + + +def test_detail_keeps_preview_items_in_app(): + src = (_ROOT / "webui" / "static" / "video" / "video-detail.js").read_text(encoding="utf-8") + # 'More like this' and cast now drill in via the shared event, not external links. + assert "data-vd-sim" in src and "data-vd-person" in src + assert "/video-detail/tmdb/" in src # similar/cast link in-app + # The old external 'similar' link (themoviedb.org//) is gone — the + # only remaining themoviedb.org ref is the TMDB badge logo asset for owned items. + assert "www.themoviedb.org/' + (s.kind" not in src + + def test_library_cards_open_detail(): src = _LIB_JS assert "soulsync:video-open-detail" in src # show cards drill in diff --git a/webui/index.html b/webui/index.html index 1ee197fd..3804f800 100644 --- a/webui/index.html +++ b/webui/index.html @@ -828,6 +828,45 @@ + + @@ -986,6 +1025,33 @@ + + @@ -9206,6 +9272,10 @@ + + + + diff --git a/webui/static/video/video-detail.js b/webui/static/video/video-detail.js index dd21a364..768e434f 100644 --- a/webui/static/video/video-detail.js +++ b/webui/static/video/video-detail.js @@ -32,8 +32,14 @@ var missingOnly = false; var currentId = null; var currentKind = 'show'; + var currentSource = 'library'; // 'library' (video.db) or 'tmdb' (live preview) var artAttemptedFor = null; // lazy art refresh runs once per detail view + var TMDB_URL = '/api/video/tmdb/'; + function detailURL(kind, id, source) { + return source === 'tmdb' ? TMDB_URL + kind + '/' + id : DETAIL_URL + kind + '/' + id; + } + try { var sv = localStorage.getItem(VIEW_KEY); if (sv) seasonView = sv; } catch (e) { /* ignore */ } function esc(s) { @@ -59,9 +65,21 @@ return null; } function seasonArt(s) { + if (data && data.source === 'tmdb') return s.poster_url || data.poster_url || ''; return (s.has_poster && s.id != null) ? '/api/video/poster/season/' + s.id : (data && data.has_poster ? '/api/video/poster/show/' + data.id : ''); } + // Source-aware billboard art: library items proxy through /api/video; tmdb + // (preview) items use the direct image URLs in the payload. + function bbBackdrop(d) { + if (d.source === 'tmdb') return d.backdrop_url || d.poster_url || ''; + var art = '/' + d.kind + '/' + d.id; + return d.has_backdrop ? '/api/video/backdrop' + art : (d.has_poster ? '/api/video/poster' + art : ''); + } + function bbPoster(d) { + if (d.source === 'tmdb') return d.poster_url || ''; + return d.has_poster ? '/api/video/poster/' + d.kind + '/' + d.id : ''; + } function pct(s) { return s.episode_total ? Math.round(s.episode_owned / s.episode_total * 100) : 0; } function badge(logo, fallback, title, url) { @@ -113,26 +131,27 @@ } } - var art = '/' + d.kind + '/' + d.id; var bg = q('[data-vd-backdrop]'); if (bg) { - var url = d.has_backdrop ? '/api/video/backdrop' + art - : (d.has_poster ? '/api/video/poster' + art : ''); + var url = bbBackdrop(d); bg.style.backgroundImage = url ? "url('" + url + "')" : ''; bg.classList.toggle('vd-bb-bg--poster', !d.has_backdrop && !!d.has_poster); bg.classList.toggle('vd-bb-bg--empty', !d.has_backdrop && !d.has_poster); } var poster = q('[data-vd-poster]'); - if (poster && d.has_poster) { + var posterUrl = bbPoster(d); + if (poster && posterUrl) { poster.onload = function () { applyAccent(poster); }; - poster.src = '/api/video/poster' + art; + poster.src = posterUrl; } var tl = q('[data-vd-tagline]'); if (tl) { tl.textContent = d.tagline || ''; tl.hidden = !d.tagline; } var meta = []; - if (d.kind === 'show') { + if (d.source === 'tmdb') { + meta.push('Preview'); + } else if (d.kind === 'show') { var ownedPct = d.episode_total ? Math.round(d.episode_owned / d.episode_total * 100) : 0; meta.push('' + ownedPct + '% in library'); } else { @@ -156,7 +175,9 @@ renderActions(d); var l = q('[data-vd-links]'); - if (l) { + if (l && d.source === 'tmdb') { + l.innerHTML = ''; // preview items keep everything in-app + } else if (l) { var badges = []; if (d.imdb_id) badges.push(badge('', 'IMDb', 'IMDb', 'https://www.imdb.com/title/' + d.imdb_id + '/')); if (d.tmdb_id) badges.push(badge(TMDB_LOGO, 'TMDB', 'TMDB', @@ -219,10 +240,14 @@ var img = p.photo ? '' : '' + esc((p.name || '?').charAt(0)) + ''; - return '
' + img + + var inner = img + '' + esc(p.name) + '' + - (p.character ? '' + esc(p.character) + '' : '') + - '
'; + (p.character ? '' + esc(p.character) + '' : ''); + // Clickable → in-app person page when we have a TMDB person id. + return p.tmdb_id + ? '' + inner + '' + : '
' + inner + '
'; }).join(''); } } @@ -236,6 +261,9 @@ html += ''; } + // Preview (tmdb, un-owned) items have no library row to monitor — acquisition + // (add-to-watchlist / get-missing) lands with the downloads phase. + if (d.source === 'tmdb') { a.innerHTML = html; return; } html += ''; + }).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; + }); + host.innerHTML = credits.map(creditCard).join(''); + } + + function lifespan(d) { + if (!d.birthday && !d.deathday) return ''; + var by = (d.birthday || '').slice(0, 4); + var dy = (d.deathday || '').slice(0, 4); + return dy ? (by + ' – ' + dy) : (by ? 'Born ' + by : ''); + } + + function render(d) { + data = d; tab = 'all'; + var photo = q('[data-vp-photo]'), ph = q('[data-vp-photo-ph]'); + if (photo) { + if (d.photo) { + photo.src = d.photo; photo.hidden = false; if (ph) ph.hidden = true; + photo.onerror = function () { photo.hidden = true; if (ph) ph.hidden = false; }; + } else { photo.hidden = true; if (ph) ph.hidden = false; } + } + setText('[data-vp-name]', d.name); + var meta = []; + if (d.known_for) meta.push(d.known_for); + var ls = lifespan(d); if (ls) meta.push(ls); + if (d.place_of_birth) meta.push(d.place_of_birth); + var m = q('[data-vp-meta]'); + if (m) m.innerHTML = meta.map(function (x) { return '' + esc(x) + ''; }).join(''); + var bio = q('[data-vp-bio]'); + if (bio) { bio.textContent = d.biography || ''; bio.hidden = !d.biography; } + renderTabs(); renderCredits(); + var sub = document.querySelector('.video-subpage[data-video-subpage="video-person-detail"]'); + if (sub) sub.scrollTop = 0; + } + + function load(id) { + if (!root()) return; + currentId = id; + showLoading(true); + setText('[data-vp-name]', ''); + 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 = ''; + fetch(PERSON_URL + id, { headers: { 'Accept': 'application/json' } }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { + showLoading(false); + if (currentId !== id) return; + if (!d || d.error) { setText('[data-vp-name]', 'Not found'); return; } + render(d); + }) + .catch(function () { showLoading(false); setText('[data-vp-name]', 'Could not load'); }); + } + + function onOpen(e) { + if (!e || !e.detail || e.detail.kind !== 'person') return; + load(e.detail.id); + } + + function onClick(e) { + var r = root(); if (!r) return; + var tabBtn = e.target.closest('[data-vp-tab]'); + if (tabBtn && r.contains(tabBtn)) { + tab = tabBtn.getAttribute('data-vp-tab'); renderTabs(); renderCredits(); return; + } + var card = e.target.closest('[data-vp-open]'); + if (card && r.contains(card)) { + if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; + e.preventDefault(); + var id = parseInt(card.getAttribute('data-vp-cid'), 10); + if (isNaN(id)) return; + document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', { + detail: { kind: card.getAttribute('data-vp-open'), id: id, + source: card.getAttribute('data-vp-source') || 'tmdb' }, + })); + } + } + + function init() { + document.addEventListener('soulsync:video-open-detail', onOpen); + document.addEventListener('click', onClick); + } + + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); + else init(); +})(); diff --git a/webui/static/video/video-search.js b/webui/static/video/video-search.js new file mode 100644 index 00000000..d4e06d04 --- /dev/null +++ b/webui/static/video/video-search.js @@ -0,0 +1,176 @@ +/* + * SoulSync — Video Search page (isolated, in-app). + * + * Debounced multi-search via /api/video/search (movies / shows / people from + * TMDB). Movie/show results link to the OWNED library detail when we already + * have them (library_id), otherwise to the TMDB-backed detail. People open the + * in-app person page. Everything stays inside SoulSync — no external links. + * + * Reuses the library card classes (.library-artist-card). Self-contained IIFE, + * no globals, event-delegated, no inline handlers. Talks only to /api/video/*. + */ +(function () { + 'use strict'; + + var PAGE_ID = 'video-search'; + var SEARCH_URL = '/api/video/search'; + var GROUPS = [ + { kind: 'movie', label: 'Movies', icon: '🎬' }, + { kind: 'show', label: 'TV Shows', icon: '📺' }, + { kind: 'person', label: 'People', icon: '👤' }, + ]; + + var lastQuery = ''; + var reqSeq = 0; // guards against out-of-order responses + var timer = null; + var wired = false; + + function $(sel) { return document.querySelector(sel); } + function esc(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + } + function show(sel, on) { var n = $(sel); if (n) n.classList.toggle('hidden', !on); } + + // movie/show card — mirrors the library card, plus an owned/preview ribbon. + function titleCard(it) { + var fallback = it.kind === 'movie' ? '🎬' : '📺'; + var img = it.poster + ? '
' + : '
' + fallback + '
'; + var owned = it.library_id != null; + var ribbon = owned + ? '
In Library
' + : '
Preview
'; + var meta = []; + if (it.year) meta.push(String(it.year)); + if (it.rating) meta.push('★ ' + (Math.round(it.rating * 10) / 10)); + // Owned → real library detail; otherwise the TMDB-backed (preview) detail. + var source = owned ? 'library' : 'tmdb'; + var id = owned ? it.library_id : it.tmdb_id; + var href = '/video-detail/' + source + '/' + it.kind + '/' + id; + return '' + + img + ribbon + + '
' + + '

' + esc(it.title) + '

' + + '
' + + esc(meta.join(' · ')) + '
'; + } + + function personCard(it) { + var img = it.poster + ? '
' + : '
👤
'; + var sub = it.known_for ? it.known_for : (it.department || ''); + return '' + + img + + '
' + + '

' + esc(it.title) + '

' + + '
' + + esc(sub) + '
'; + } + + function render(results) { + var host = $('[data-video-search-results]'); + if (!host) return; + var any = results && results.length; + show('[data-video-search-hint]', false); + show('[data-video-search-empty]', !any); + if (!any) { host.innerHTML = ''; return; } + + var html = ''; + GROUPS.forEach(function (g) { + var items = results.filter(function (r) { return r.kind === g.kind; }); + if (!items.length) return; + html += '

' + + '' + g.label + + '' + items.length + '

' + + '
' + + items.map(g.kind === 'person' ? personCard : titleCard).join('') + + '
'; + }); + host.innerHTML = html; + } + + function runSearch(q) { + var seq = ++reqSeq; + show('[data-video-search-loading]', true); + fetch(SEARCH_URL + '?q=' + encodeURIComponent(q), { headers: { 'Accept': 'application/json' } }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { + if (seq !== reqSeq) return; // a newer query superseded this one + show('[data-video-search-loading]', false); + render(d && d.results ? d.results : []); + }) + .catch(function () { + if (seq !== reqSeq) return; + show('[data-video-search-loading]', false); + render([]); + }); + } + + function onInput(val) { + var q = (val || '').trim(); + lastQuery = q; + if (timer) clearTimeout(timer); + 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 = ''; + return; + } + timer = setTimeout(function () { runSearch(q); }, 320); + } + + function openCard(card) { + var kind = card.getAttribute('data-vsr-open'); + var id = parseInt(card.getAttribute('data-vsr-id'), 10); + if (isNaN(id)) return; + if (kind === 'person') { + document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', + { detail: { kind: 'person', id: id, source: 'tmdb' } })); + } else { + document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', + { detail: { kind: kind, id: id, source: card.getAttribute('data-vsr-source') || 'tmdb' } })); + } + } + + function wire() { + if (wired) return; + wired = true; + var input = $('[data-video-search-input]'); + if (input) input.addEventListener('input', function () { onInput(input.value); }); + + var results = $('[data-video-search-results]'); + if (results) { + results.addEventListener('click', function (e) { + if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; + var card = e.target.closest('[data-vsr-open]'); + if (!card || !results.contains(card)) return; + e.preventDefault(); + openCard(card); + }); + } + } + + function onPageShown(e) { + if (!e || e.detail !== PAGE_ID) return; + wire(); + var input = $('[data-video-search-input]'); + if (input) { try { input.focus(); } catch (err) { /* ignore */ } } + } + + function init() { + document.addEventListener('soulsync:video-page-shown', onPageShown); + } + + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); + else init(); +})(); diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 2c1da0c7..0fcfcdbd 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -804,3 +804,96 @@ body[data-side="video"] .dashboard-header-sweep { } .video-enrich-button:hover .video-enrich-glyph { transform: scale(1.1); } #vem-overlay .vem-glyph { font-size: 20px; line-height: 1; } + +/* ══════════════════════════════════════════════════════════════════════════ + In-app SEARCH results + PERSON page (isolated, video side only). + Reuses the library card shell; adds owned/preview ribbons, grouped sections, + a person billboard, and a filmography grid. + ════════════════════════════════════════════════════════════════════════ */ + +.vsr-title-ic { font-size: 22px; margin-right: 10px; } +.vsr-search-box { max-width: 640px; } +.vsr-search-box .library-search-input { font-size: 16px; } + +.vsr-results { display: flex; flex-direction: column; gap: 30px; } +.vsr-group { animation: vsr-fade 0.35s ease both; } +@keyframes vsr-fade { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } +.vsr-group-title { + display: flex; align-items: center; gap: 10px; margin: 0 0 14px; + font-size: 18px; font-weight: 700; color: #fff; letter-spacing: 0.2px; +} +.vsr-group-ic { font-size: 18px; } +.vsr-group-count { + font-size: 12px; font-weight: 600; color: rgba(255, 255, 255, 0.55); + background: rgba(255, 255, 255, 0.08); border-radius: 10px; padding: 2px 9px; +} +.vsr-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 18px; +} + +/* Owned vs preview ribbon on a result card. */ +.vsr-card { position: relative; } +.vsr-ribbon { + position: absolute; top: 10px; left: 10px; z-index: 2; + font-size: 10.5px; font-weight: 700; letter-spacing: 0.4px; text-transform: uppercase; + padding: 4px 9px; border-radius: 7px; color: #fff; + backdrop-filter: blur(6px); box-shadow: 0 3px 10px rgba(0, 0, 0, 0.45); +} +.vsr-ribbon--owned { background: rgba(34, 197, 94, 0.92); } +.vsr-ribbon--preview { background: rgba(99, 102, 241, 0.88); } +.vsr-card--person .library-artist-image, +.vsr-person-img { border-radius: 50% !important; overflow: hidden; aspect-ratio: 1 / 1; } +.vsr-card--person { text-align: center; } + +/* ── person page ─────────────────────────────────────────────────────────── */ +.vp-page { padding: 0 0 60px; position: relative; min-height: 70vh; } +.vp-hero { + display: flex; gap: 28px; align-items: flex-start; + padding: 10px 4px 26px; margin-bottom: 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} +.vp-photo-wrap { + flex: 0 0 auto; width: 168px; height: 168px; border-radius: 50%; overflow: hidden; + background: rgba(255, 255, 255, 0.05); display: flex; align-items: center; justify-content: center; + box-shadow: 0 10px 34px rgba(0, 0, 0, 0.5), 0 0 0 4px rgba(var(--vd-accent-rgb, 99, 102, 241), 0.25); +} +.vp-photo { width: 100%; height: 100%; object-fit: cover; } +.vp-photo-ph { font-size: 62px; opacity: 0.5; } +.vp-hero-info { flex: 1; min-width: 0; } +.vp-name { margin: 4px 0 10px; font-size: 34px; font-weight: 800; color: #fff; line-height: 1.05; } +.vp-meta { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-bottom: 14px; } +.vp-meta span { font-size: 13px; color: rgba(255, 255, 255, 0.62); } +.vp-meta span + span::before { content: ""; } +.vp-bio { + font-size: 14px; line-height: 1.6; color: rgba(255, 255, 255, 0.78); + max-width: 880px; max-height: 9.6em; overflow-y: auto; margin: 0; white-space: pre-line; +} +.vp-credit-tabs { display: flex; gap: 8px; } +.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: 20px; padding: 6px 14px; cursor: pointer; + font-size: 13px; font-weight: 600; display: inline-flex; align-items: center; gap: 7px; + transition: all 0.18s ease; +} +.vp-tab:hover { background: rgba(255, 255, 255, 0.12); color: #fff; } +.vp-tab--active { + background: rgba(var(--vd-accent-rgb, 99, 102, 241), 0.9); border-color: transparent; color: #fff; +} +.vp-tab-count { font-size: 11px; opacity: 0.8; } + +/* Cast card becomes a real link when a person page exists for it. */ +.vd-cast-card--link { cursor: pointer; text-decoration: none; transition: transform 0.18s ease; } +.vd-cast-card--link:hover { transform: translateY(-4px); } +.vd-cast-card--link:hover .vd-cast-name { color: #fff; } + +@media (max-width: 640px) { + .vp-hero { flex-direction: column; align-items: center; text-align: center; } + .vp-name { font-size: 26px; } + .vsr-grid { grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: 14px; } +} + +.vd-status--preview { + background: rgba(99, 102, 241, 0.9) !important; color: #fff !important; + border-color: transparent !important; +} diff --git a/webui/static/video/video-side.js b/webui/static/video/video-side.js index d858a33c..404730cd 100644 --- a/webui/static/video/video-side.js +++ b/webui/static/video/video-side.js @@ -31,7 +31,7 @@ // reload / Back / Forward / open-in-new-tab all work. ``source`` is 'library' // (a video.db id) today; 'tmdb' (a search result not yet in the library) later. var DETAIL_BASE = '/video-detail/'; - var DETAIL_PAGES = { 'video-show-detail': 1, 'video-movie-detail': 1 }; + var DETAIL_PAGES = { 'video-show-detail': 1, 'video-movie-detail': 1, 'video-person-detail': 1 }; function buildDetailPath(source, kind, id) { return DETAIL_BASE + encodeURIComponent(source || 'library') + '/' + kind + '/' + id; @@ -41,7 +41,7 @@ var p = pathname.slice(DETAIL_BASE.length).split('/').filter(Boolean); if (p.length < 3) return null; var kind = p[1], id = parseInt(p[2], 10); - if ((kind !== 'movie' && kind !== 'show') || isNaN(id)) return null; + if ((kind !== 'movie' && kind !== 'show' && kind !== 'person') || isNaN(id)) return null; return { source: decodeURIComponent(p[0]), kind: kind, id: id }; } // Restore a detail from the URL (popstate / initial load) WITHOUT re-pushing. @@ -81,6 +81,7 @@ // Drill-in detail pages — reachable from cards, not the sidebar nav. { id: 'video-show-detail', label: 'Show' }, { id: 'video-movie-detail', label: 'Movie' }, + { id: 'video-person-detail', label: 'Person' }, ]; // "Shared" video pages reuse the REAL music page (shown identically on the @@ -243,6 +244,7 @@ var d = e && e.detail; if (!d) return; 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) { var path = buildDetailPath(d.source, d.kind, d.id);