video: in-app Search + TMDB-backed (preview) detail + person pages

Search any movie / show / person (TMDB multi-search) entirely in-app. Results
that you already own link straight to the library detail; the rest open a
TMDB-backed 'preview' detail that reuses the exact same Netflix billboard UI
(direct image URLs, nothing owned/enriched). Everything resolves back into
SoulSync — no external links on un-owned titles.

- Search page (video-search.js): debounced /api/video/search, grouped
  movies/shows/people cards (reuses .library-artist-card) with owned/preview
  ribbons. People open the person page.
- Source-agnostic detail (video-detail.js): loads from /api/video/detail
  (library) or /api/video/tmdb (preview); art helpers pick proxy vs direct URLs;
  tmdb shows lazy-load episodes per season; owned-via-tmdb-url auto-redirects to
  the library detail.
- 'More Like This' now drills in-app (tmdb detail, redirects if owned); cast/crew
  link to a new in-app person page (bio + filmography, each credit owned/preview).
  Library credits now carry tmdb_id so owned-item cast is clickable too.
- Backend: TMDBClient.search/full_detail/person (+ shared _parse_extras);
  engine.search/tmdb_detail/tmdb_season/person_detail; db.library_id_for_tmdb;
  routes /search, /tmdb/<kind>/<id>, /tmdb/show/<id>/season/<n>, /person/<id>.

Isolated (one-way): video-only files, no music imports, music shell untouched.
Seam tests: search/full_detail parsing, tmdb_detail assemble+redirect, search +
person library annotation, library_id_for_tmdb, route registration, shell/JS
isolation. 234 video-suite tests pass.
This commit is contained in:
BoulderBadgeDad 2026-06-14 23:31:35 -07:00
parent b50f7c12f4
commit 288d44155d
16 changed files with 1171 additions and 35 deletions

View file

@ -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

View file

@ -67,6 +67,48 @@ def register_routes(bp):
res = {"ok": False, "reason": "error"}
return jsonify(res)
@bp.route("/tmdb/<kind>/<int:tmdb_id>", 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/<int:tv_id>/season/<int:season_number>", 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/<int:tmdb_id>", 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/<kind>/<int:item_id>/extras", methods=["GET"])
def video_detail_extras(kind, item_id):
"""Live TMDB extras (trailer / where-to-watch / similar) for the detail page."""

33
api/video/search.py Normal file
View file

@ -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})

View file

@ -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"

View file

@ -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)

View file

@ -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:

View file

@ -45,9 +45,43 @@ def test_blueprint_exposes_dashboard_route():
assert "/api/video/detail/show/<int:show_id>/refresh-art" in rules
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/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
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:

View file

@ -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"})

View file

@ -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

View file

@ -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/<kind>/<id>) 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

View file

@ -828,6 +828,45 @@
</div>
</div>
</section>
<!-- Search (in-app): movies / shows / people from TMDB, each result
linking to the owned library detail or the TMDB-backed detail.
Reuses the library card classes. Built by video/video-search.js. -->
<section class="video-subpage" data-video-subpage="video-search" hidden>
<div class="library-page-embed">
<div class="library-container">
<div class="library-header">
<div class="library-header-content">
<h2 class="library-title"><span class="vsr-title-ic" aria-hidden="true">&#128269;</span><span>Search</span></h2>
<p class="library-subtitle">Find any movie, show or person &mdash; owned or not, everything stays in-app</p>
</div>
</div>
<div class="library-controls">
<div class="library-search-container vsr-search-box">
<input type="text" class="library-search-input" data-video-search-input
placeholder="Search movies, shows, people&hellip;" autocomplete="off" spellcheck="false">
<div class="library-search-icon">&#128269;</div>
</div>
</div>
<div class="library-content">
<div class="library-loading hidden" data-video-search-loading>
<div class="loading-spinner"></div>
<div class="loading-text">Searching&hellip;</div>
</div>
<div class="vsr-results" data-video-search-results></div>
<div class="library-empty" data-video-search-hint>
<div class="empty-icon">&#127902;</div>
<div class="empty-title">Search SoulSync</div>
<div class="empty-subtitle">Movies, TV shows and people. Owned titles open your library detail; the rest open a live preview &mdash; no external links.</div>
</div>
<div class="library-empty hidden" data-video-search-empty>
<div class="empty-icon">&#129300;</div>
<div class="empty-title">No results</div>
<div class="empty-subtitle">Try another title or name.</div>
</div>
</div>
</div>
</div>
</section>
<!-- TV-show detail (drill-in from a show card; not a nav page).
Isolated: built by video/video-detail.js, styled by .vd-* in
video-side.css. Inspired by the music artist-detail vibe. -->
@ -986,6 +1025,33 @@
</div>
</div>
</section>
<!-- Person detail (drill-in from a cast/crew member; not a nav page).
Bio + filmography, every credit links back into SoulSync. Built
by video/video-person.js, styled by .vp-* in video-side.css. -->
<section class="video-subpage" data-video-subpage="video-person-detail" hidden>
<div class="vp-page" data-video-person>
<button class="vd-back" type="button" data-video-detail-back>
<span aria-hidden="true">&larr;</span> Back
</button>
<div class="vd-loading" data-vp-loading hidden>
<div class="loading-spinner"></div><div class="loading-text">Loading&hellip;</div>
</div>
<div class="vp-hero">
<div class="vp-photo-wrap"><img class="vp-photo" data-vp-photo alt="" hidden>
<span class="vp-photo-ph" data-vp-photo-ph aria-hidden="true">&#128100;</span></div>
<div class="vp-hero-info">
<h1 class="vp-name" data-vp-name></h1>
<div class="vp-meta" data-vp-meta></div>
<p class="vp-bio" data-vp-bio></p>
</div>
</div>
<div class="vp-credits-section">
<div class="vd-section-head"><h2 class="vd-section-title">Filmography</h2>
<div class="vp-credit-tabs" data-vp-tabs></div></div>
<div class="vsr-grid" data-vp-credits></div>
</div>
</div>
</section>
<div class="video-placeholder-slot" id="video-placeholder-slot" hidden></div>
</div>
@ -9206,6 +9272,10 @@
<script src="{{ url_for('static', filename='video/video-library.js', v=static_v) }}"></script>
<!-- Video detail page (isolated; show drill-in: hero + season/episode tree) -->
<script src="{{ url_for('static', filename='video/video-detail.js', v=static_v) }}"></script>
<!-- Video search (isolated; in-app multi-search → library/tmdb detail) -->
<script src="{{ url_for('static', filename='video/video-search.js', v=static_v) }}"></script>
<!-- Video person page (isolated; cast/crew drill-in → filmography) -->
<script src="{{ url_for('static', filename='video/video-person.js', v=static_v) }}"></script>
<!-- Video worker orbs (isolated copy of the music orbs; idles into the
floating-orb animation around the SoulSync logo on the video dashboard) -->
<script src="{{ url_for('static', filename='video/video-worker-orbs.js', v=static_v) }}"></script>

View file

@ -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('<span class="vd-status vd-status--preview">Preview</span>');
} else if (d.kind === 'show') {
var ownedPct = d.episode_total ? Math.round(d.episode_owned / d.episode_total * 100) : 0;
meta.push('<span class="vd-match">' + ownedPct + '% in library</span>');
} 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
? '<img class="vd-cast-photo" src="' + esc(p.photo) + '" alt="" loading="lazy" onerror="this.style.visibility=\'hidden\'">'
: '<span class="vd-cast-photo vd-cast-photo--ph">' + esc((p.name || '?').charAt(0)) + '</span>';
return '<div class="vd-cast-card">' + img +
var inner = img +
'<span class="vd-cast-name">' + esc(p.name) + '</span>' +
(p.character ? '<span class="vd-cast-char">' + esc(p.character) + '</span>' : '') +
'</div>';
(p.character ? '<span class="vd-cast-char">' + esc(p.character) + '</span>' : '');
// Clickable → in-app person page when we have a TMDB person id.
return p.tmdb_id
? '<a class="vd-cast-card vd-cast-card--link" href="/video-detail/tmdb/person/' + p.tmdb_id +
'" data-vd-person="' + p.tmdb_id + '">' + inner + '</a>'
: '<div class="vd-cast-card">' + inner + '</div>';
}).join('');
}
}
@ -236,6 +261,9 @@
html += '<button class="vd-trailer-btn" type="button" data-vd-act="trailer">' +
'<span class="vd-trailer-ic">▶</span> Trailer</button>';
}
// 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 +=
'<button class="library-artist-watchlist-btn' + (watching ? ' watching' : '') +
'" type="button" data-vd-act="watchlist">' +
@ -304,8 +332,11 @@
var poster = s.poster
? '<img class="vd-sim-poster" src="' + esc(s.poster) + '" alt="" loading="lazy">'
: '<span class="vd-sim-poster vd-sim-poster--ph">🎬</span>';
var url = 'https://www.themoviedb.org/' + (s.kind === 'movie' ? 'movie' : 'tv') + '/' + s.tmdb_id;
return '<a class="vd-sim-card" href="' + url + '" target="_blank" rel="noopener">' +
// In-app: open the TMDB-backed detail (which redirects to the
// library detail if we already own it). No external links.
var simKind = s.kind === 'movie' ? 'movie' : 'show';
return '<a class="vd-sim-card" href="/video-detail/tmdb/' + simKind + '/' + s.tmdb_id +
'" data-vd-sim="' + simKind + '" data-vd-sim-id="' + s.tmdb_id + '">' +
poster + '<span class="vd-sim-title">' + esc(s.title) + '</span></a>';
}).join('');
} else { ss.hidden = true; }
@ -411,8 +442,11 @@
var meta = [];
var rt = runtimeLabel(ep.runtime_minutes); if (rt) meta.push(rt);
if (ep.air_date) meta.push(ep.air_date);
var still = ep.has_still
? '<img class="vd-ep-still" src="/api/video/poster/episode/' + ep.id + '" alt="" loading="lazy" onerror="this.style.display=\'none\'">'
var stillSrc = (data && data.source === 'tmdb')
? (ep.still_url || '')
: (ep.has_still ? '/api/video/poster/episode/' + ep.id : '');
var still = stillSrc
? '<img class="vd-ep-still" src="' + stillSrc + '" alt="" loading="lazy" onerror="this.style.display=\'none\'">'
: '';
return '<div class="vd-ep ' + owned + '">' +
'<div class="vd-ep-index">' + (ep.episode_number != null ? ep.episode_number : '') + '</div>' +
@ -438,7 +472,37 @@
function selectSeason(n) {
selectedSeason = n; menuOpen = false;
renderSeasonNav(); renderEpisodes();
renderSeasonNav(); ensureSeasonEpisodes();
}
// tmdb (preview) shows carry season counts but load episodes lazily per season.
function ensureSeasonEpisodes() {
var season = seasonByNum(selectedSeason);
if (data && data.source === 'tmdb' && season && !season._loaded &&
!(season.episodes && season.episodes.length)) {
loadTmdbSeason(season);
} else {
renderEpisodes();
}
}
function loadTmdbSeason(season) {
var host = q('[data-vd-episodes]');
if (host) host.innerHTML = '<div class="vd-ep-empty">Loading episodes…</div>';
var sid = data.id, sn = season.season_number;
fetch(TMDB_URL + 'show/' + sid + '/season/' + sn, { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (se) {
season._loaded = true;
if (se && se.episodes) {
season.episodes = se.episodes;
season.episode_total = se.episodes.length;
}
if (currentId === sid && selectedSeason === sn) { renderSeasonNav(); renderEpisodes(); }
})
.catch(function () {
season._loaded = true;
if (currentId === sid && selectedSeason === sn) renderEpisodes();
});
}
function setView(v) {
seasonView = v; menuOpen = false;
@ -462,8 +526,8 @@
}
// ── movie detail (flat) ───────────────────────────────────────────────────
function loadMovie(id) {
currentKind = 'movie';
function loadMovie(id, source) {
currentKind = 'movie'; currentSource = source || 'library';
if (!root()) return;
if (currentId !== id) artAttemptedFor = null;
currentId = id;
@ -471,22 +535,34 @@
resetExtras();
var dh = q('[data-vd-details]'); if (dh) dh.innerHTML = '';
var r0 = root(); if (r0) r0.style.removeProperty('--vd-accent-rgb');
fetch(DETAIL_URL + 'movie/' + id, { headers: { 'Accept': 'application/json' } })
fetch(detailURL('movie', id, currentSource), { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
showLoading(false);
if (d && d.redirect) { reopen(d.redirect); return; }
if (!d || d.error) { setText('[data-vd-title]', 'Not found'); return; }
if (currentId !== id || currentKind !== 'movie') return;
data = d;
renderBillboard(d);
renderDetails(d);
var sub = document.querySelector('.video-subpage[data-video-subpage="video-movie-detail"]');
if (sub) sub.scrollTop = 0;
maybeRefreshMovie(id);
loadExtras('movie', id);
if (currentSource === 'tmdb') {
renderExtras('movie', id, d); // extras ship inside the tmdb payload
} else {
maybeRefreshMovie(id);
loadExtras('movie', id);
}
})
.catch(function () { showLoading(false); setText('[data-vd-title]', 'Could not load movie'); });
}
// An owned title reached via a tmdb URL → bounce to the real library detail.
function reopen(rd) {
document.dispatchEvent(new CustomEvent('soulsync:video-open-detail',
{ detail: { kind: rd.kind, id: rd.id, source: rd.source || 'library' } }));
}
// Lazy: backfill a movie's cast/genres/art from TMDB on view if missing.
function maybeRefreshMovie(id) {
if (artAttemptedFor === id || !data || data.id !== id) return;
@ -508,8 +584,8 @@
}).catch(function () { /* best-effort */ });
}
function loadShow(id) {
currentKind = 'show';
function loadShow(id, source) {
currentKind = 'show'; currentSource = source || 'library';
if (!root()) return;
if (currentId !== id) artAttemptedFor = null;
currentId = id;
@ -517,21 +593,27 @@
resetExtras();
['[data-vd-episodes]', '[data-vd-season-nav]'].forEach(function (s) { var n = q(s); if (n) n.innerHTML = ''; });
var r0 = root(); if (r0) r0.style.removeProperty('--vd-accent-rgb');
fetch(DETAIL_URL + 'show/' + id, { headers: { 'Accept': 'application/json' } })
fetch(detailURL('show', id, currentSource), { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
showLoading(false);
if (d && d.redirect) { reopen(d.redirect); return; }
if (!d || d.error) { setText('[data-vd-title]', 'Not found'); return; }
if (currentId !== id || currentKind !== 'show') return;
data = d; menuOpen = false; missingOnly = false;
selectedSeason = d.seasons && d.seasons.length ? d.seasons[0].season_number : null;
var mt = q('[data-vd-missing-toggle]');
if (mt) { mt.hidden = !(d.seasons && d.seasons.length); mt.classList.remove('vd-missing-toggle--on'); }
renderBillboard(d);
renderViewToggle(); renderSeasonNav(); renderEpisodes();
renderViewToggle(); renderSeasonNav(); ensureSeasonEpisodes();
var sub = document.querySelector('.video-subpage[data-video-subpage="video-show-detail"]');
if (sub) sub.scrollTop = 0;
maybeRefreshArt(id);
loadExtras('show', id);
if (currentSource === 'tmdb') {
renderExtras('show', id, d);
} else {
maybeRefreshArt(id);
loadExtras('show', id);
}
})
.catch(function () { showLoading(false); setText('[data-vd-title]', 'Could not load show'); });
}
@ -571,12 +653,37 @@
// ── events ────────────────────────────────────────────────────────────────
function onOpen(e) {
if (!e || !e.detail) return;
if (e.detail.kind === 'movie') loadMovie(e.detail.id);
else if (e.detail.kind === 'show') loadShow(e.detail.id);
var src = e.detail.source || 'library';
if (e.detail.kind === 'movie') loadMovie(e.detail.id, src);
else if (e.detail.kind === 'show') loadShow(e.detail.id, src);
// 'person' is handled by video-person.js (same event).
}
function modified(e) {
return e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey;
}
function onClick(e) {
var r = root(); if (!r) return;
// In-app drill-ins (real <a> links → modified clicks open new tabs).
var sim = e.target.closest('[data-vd-sim]');
if (sim && r.contains(sim)) {
if (modified(e)) return;
e.preventDefault();
var sid = parseInt(sim.getAttribute('data-vd-sim-id'), 10);
if (!isNaN(sid)) document.dispatchEvent(new CustomEvent('soulsync:video-open-detail',
{ detail: { kind: sim.getAttribute('data-vd-sim'), id: sid, source: 'tmdb' } }));
return;
}
var person = e.target.closest('[data-vd-person]');
if (person && r.contains(person)) {
if (modified(e)) return;
e.preventDefault();
var pid = parseInt(person.getAttribute('data-vd-person'), 10);
if (!isNaN(pid)) document.dispatchEvent(new CustomEvent('soulsync:video-open-detail',
{ detail: { kind: 'person', id: pid, source: 'tmdb' } }));
return;
}
var seasonBtn = e.target.closest('[data-vd-season]');
if (seasonBtn && r.contains(seasonBtn)) { selectSeason(parseInt(seasonBtn.getAttribute('data-vd-season'), 10)); return; }
var viewBtn = e.target.closest('[data-vd-view]');

View file

@ -0,0 +1,158 @@
/*
* SoulSync Video Person page (isolated, in-app).
*
* Drill-in for a cast/crew member (from a detail page or a search result). Shows
* bio + a filmography grid; every credit links back into SoulSync the owned
* library detail when we have it, otherwise the TMDB-backed preview detail. No
* external links.
*
* Opened by soulsync:video-open-detail {kind:'person', id, source:'tmdb'};
* video-side.js navigates to the person subpage and this loads + renders.
* Self-contained IIFE, no globals, event-delegated.
*/
(function () {
'use strict';
var PERSON_URL = '/api/video/person/';
var data = null;
var currentId = null;
var tab = 'all'; // all | movie | show
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function root() { return document.querySelector('[data-video-person]'); }
function q(sel) { var r = root(); return r ? r.querySelector(sel) : null; }
function setText(sel, t) { var n = q(sel); if (n) n.textContent = t || ''; }
function showLoading(on) { var l = q('[data-vp-loading]'); if (l) l.hidden = !on; }
function creditCard(c) {
var fallback = c.kind === 'movie' ? '🎬' : '📺';
var img = c.poster
? '<div class="library-artist-image"><img src="' + esc(c.poster) + '" alt="" loading="lazy" ' +
'onerror="this.parentNode.innerHTML=\'<div class=&quot;library-artist-image-fallback&quot;>' + fallback + '</div>\'"></div>'
: '<div class="library-artist-image"><div class="library-artist-image-fallback">' + fallback + '</div></div>';
var owned = c.library_id != null;
var ribbon = owned ? '<div class="vsr-ribbon vsr-ribbon--owned">In Library</div>'
: '<div class="vsr-ribbon vsr-ribbon--preview">Preview</div>';
var meta = [];
if (c.year) meta.push(String(c.year));
if (c.role) meta.push(c.role);
var source = owned ? 'library' : 'tmdb';
var id = owned ? c.library_id : c.tmdb_id;
var href = '/video-detail/' + source + '/' + c.kind + '/' + id;
return '<a class="library-artist-card video-card--clickable vsr-card" href="' + href + '" ' +
'data-vp-open="' + c.kind + '" data-vp-source="' + source + '" data-vp-cid="' + id + '">' +
img + ribbon +
'<div class="library-artist-info">' +
'<h3 class="library-artist-name" title="' + esc(c.title) + '">' + esc(c.title) + '</h3>' +
'<div class="library-artist-stats"><span class="library-artist-stat">' +
esc(meta.join(' · ')) + '</span></div></div></a>';
}
function renderTabs() {
var host = q('[data-vp-tabs]');
if (!host || !data) return;
var credits = data.credits || [];
var movies = credits.filter(function (c) { return c.kind === 'movie'; }).length;
var shows = credits.filter(function (c) { return c.kind === 'show'; }).length;
var defs = [['all', 'All', credits.length], ['movie', 'Movies', movies], ['show', 'TV', shows]];
host.innerHTML = defs.filter(function (d) { return d[2] > 0; }).map(function (d) {
return '<button class="vp-tab' + (d[0] === tab ? ' vp-tab--active' : '') +
'" type="button" data-vp-tab="' + d[0] + '">' + esc(d[1]) +
'<span class="vp-tab-count">' + d[2] + '</span></button>';
}).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 '<span>' + esc(x) + '</span>'; }).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();
})();

View file

@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
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
? '<div class="library-artist-image"><img src="' + esc(it.poster) + '" alt="" loading="lazy" ' +
'onerror="this.parentNode.innerHTML=\'<div class=&quot;library-artist-image-fallback&quot;>' + fallback + '</div>\'"></div>'
: '<div class="library-artist-image"><div class="library-artist-image-fallback">' + fallback + '</div></div>';
var owned = it.library_id != null;
var ribbon = owned
? '<div class="vsr-ribbon vsr-ribbon--owned">In Library</div>'
: '<div class="vsr-ribbon vsr-ribbon--preview">Preview</div>';
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 '<a class="library-artist-card video-card--clickable vsr-card" href="' + href + '" ' +
'data-vsr-open="' + it.kind + '" data-vsr-source="' + source + '" data-vsr-id="' + id + '">' +
img + ribbon +
'<div class="library-artist-info">' +
'<h3 class="library-artist-name" title="' + esc(it.title) + '">' + esc(it.title) + '</h3>' +
'<div class="library-artist-stats"><span class="library-artist-stat">' +
esc(meta.join(' · ')) + '</span></div></div></a>';
}
function personCard(it) {
var img = it.poster
? '<div class="library-artist-image vsr-person-img"><img src="' + esc(it.poster) + '" alt="" loading="lazy" ' +
'onerror="this.parentNode.innerHTML=\'<div class=&quot;library-artist-image-fallback&quot;>👤</div>\'"></div>'
: '<div class="library-artist-image vsr-person-img"><div class="library-artist-image-fallback">👤</div></div>';
var sub = it.known_for ? it.known_for : (it.department || '');
return '<a class="library-artist-card video-card--clickable vsr-card vsr-card--person" href="#" ' +
'data-vsr-open="person" data-vsr-id="' + it.tmdb_id + '">' +
img +
'<div class="library-artist-info">' +
'<h3 class="library-artist-name" title="' + esc(it.title) + '">' + esc(it.title) + '</h3>' +
'<div class="library-artist-stats"><span class="library-artist-stat">' +
esc(sub) + '</span></div></div></a>';
}
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 += '<div class="vsr-group"><h2 class="vsr-group-title">' +
'<span class="vsr-group-ic" aria-hidden="true">' + g.icon + '</span>' + g.label +
'<span class="vsr-group-count">' + items.length + '</span></h2>' +
'<div class="vsr-grid">' +
items.map(g.kind === 'person' ? personCard : titleCard).join('') +
'</div></div>';
});
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();
})();

View file

@ -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;
}

View file

@ -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);