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:
parent
b50f7c12f4
commit
288d44155d
16 changed files with 1171 additions and 35 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
33
api/video/search.py
Normal 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})
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">🔍</span><span>Search</span></h2>
|
||||
<p class="library-subtitle">Find any movie, show or person — 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…" autocomplete="off" spellcheck="false">
|
||||
<div class="library-search-icon">🔍</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…</div>
|
||||
</div>
|
||||
<div class="vsr-results" data-video-search-results></div>
|
||||
<div class="library-empty" data-video-search-hint>
|
||||
<div class="empty-icon">🎞</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 — no external links.</div>
|
||||
</div>
|
||||
<div class="library-empty hidden" data-video-search-empty>
|
||||
<div class="empty-icon">🤔</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">←</span> Back
|
||||
</button>
|
||||
<div class="vd-loading" data-vp-loading hidden>
|
||||
<div class="loading-spinner"></div><div class="loading-text">Loading…</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">👤</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>
|
||||
|
|
|
|||
|
|
@ -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]');
|
||||
|
|
|
|||
158
webui/static/video/video-person.js
Normal file
158
webui/static/video/video-person.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
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="library-artist-image-fallback">' + 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();
|
||||
})();
|
||||
176
webui/static/video/video-search.js
Normal file
176
webui/static/video/video-search.js
Normal 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, '&').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
|
||||
? '<div class="library-artist-image"><img src="' + esc(it.poster) + '" alt="" loading="lazy" ' +
|
||||
'onerror="this.parentNode.innerHTML=\'<div class="library-artist-image-fallback">' + 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="library-artist-image-fallback">👤</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();
|
||||
})();
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue