Discover: 'More like…' rails + in-app hero trailer
More-like rails (real personalization beyond genre): - db.random_owned_titles() seeds from a few random owned titles (with tmdb_id); client.recommendations() + engine.recommendations() (cached + owned-annotated) fetch TMDB recs. New /discover/morelike interleaves movie/show seeds (max 3 rails) and the page prepends them, pre-filled, above the rail stack with a gradient 'for you' title. Hero trailer (cheap big visual win): - client.video_trailer() (light /videos call, Trailer over Teaser) + engine .trailer() (day-cached) + /discover/trailer. Hero gets a 'Trailer' button that opens an in-app YouTube lightbox (autoplay, Esc/backdrop close, pauses the slideshow) — nothing leaves SoulSync. Tests: +11 (recs parse/annotate/cache, trailer pick+fallback+cache, random owned seeds owned-with-tmdb only, /discover/trailer + /discover/morelike endpoints). Enrichment + API suites: 114 passed.
This commit is contained in:
parent
5aa792d399
commit
a9ec025706
8 changed files with 363 additions and 1 deletions
|
|
@ -51,6 +51,56 @@ def register_routes(bp):
|
||||||
logger.exception("discover taste failed")
|
logger.exception("discover taste failed")
|
||||||
return jsonify({"movie": [], "show": []})
|
return jsonify({"movie": [], "show": []})
|
||||||
|
|
||||||
|
@bp.route("/discover/morelike", methods=["GET"])
|
||||||
|
def video_discover_morelike():
|
||||||
|
"""'More like <owned title>' rails — TMDB recommendations seeded from a few
|
||||||
|
random titles you already own (interleaved movie/show, max 3 rails)."""
|
||||||
|
from . import get_video_db
|
||||||
|
from core.video.enrichment.engine import get_video_enrichment_engine
|
||||||
|
try:
|
||||||
|
from core.video.sources import resolve_video_server
|
||||||
|
srv = resolve_video_server()
|
||||||
|
except Exception:
|
||||||
|
srv = None
|
||||||
|
db = get_video_db()
|
||||||
|
eng = get_video_enrichment_engine()
|
||||||
|
try:
|
||||||
|
seeds = db.random_owned_titles(2, srv)
|
||||||
|
movies = [s for s in seeds if s["kind"] == "movie"]
|
||||||
|
shows = [s for s in seeds if s["kind"] == "show"]
|
||||||
|
ordered = []
|
||||||
|
while (movies or shows) and len(ordered) < 3:
|
||||||
|
if movies:
|
||||||
|
ordered.append(movies.pop(0))
|
||||||
|
if shows and len(ordered) < 3:
|
||||||
|
ordered.append(shows.pop(0))
|
||||||
|
rails = []
|
||||||
|
for s in ordered:
|
||||||
|
items = [it for it in eng.recommendations(s["kind"], s["tmdb_id"])
|
||||||
|
if it.get("tmdb_id") != s["tmdb_id"]]
|
||||||
|
if len(items) >= 4:
|
||||||
|
rails.append({"title": "More like " + s["title"], "items": items[:30]})
|
||||||
|
return jsonify({"rails": rails})
|
||||||
|
except Exception:
|
||||||
|
logger.exception("discover morelike failed")
|
||||||
|
return jsonify({"rails": []})
|
||||||
|
|
||||||
|
@bp.route("/discover/trailer", methods=["GET"])
|
||||||
|
def video_discover_trailer():
|
||||||
|
"""Best YouTube trailer {key,name} for a tmdb title (hero 'Trailer' button)."""
|
||||||
|
from core.video.enrichment.engine import get_video_enrichment_engine
|
||||||
|
kind = request.args.get("kind", "movie")
|
||||||
|
try:
|
||||||
|
tmdb_id = int(request.args.get("tmdb_id"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({"trailer": None})
|
||||||
|
try:
|
||||||
|
tr = get_video_enrichment_engine().trailer(kind, tmdb_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("discover trailer failed")
|
||||||
|
tr = None
|
||||||
|
return jsonify({"trailer": tr or None})
|
||||||
|
|
||||||
@bp.route("/discover/genres", methods=["GET"])
|
@bp.route("/discover/genres", methods=["GET"])
|
||||||
def video_discover_genres():
|
def video_discover_genres():
|
||||||
"""Genre id→name maps for both kinds (powers the genre rails + filter)."""
|
"""Genre id→name maps for both kinds (powers the genre rails + filter)."""
|
||||||
|
|
|
||||||
|
|
@ -544,6 +544,37 @@ class TMDBClient:
|
||||||
return [{"id": g["id"], "name": g["name"]}
|
return [{"id": g["id"], "name": g["name"]}
|
||||||
for g in (r.json() or {}).get("genres") or [] if g.get("id")]
|
for g in (r.json() or {}).get("genres") or [] if g.get("id")]
|
||||||
|
|
||||||
|
def recommendations(self, kind, tmdb_id, page=1):
|
||||||
|
"""TMDB 'recommended' titles for a movie/show — powers 'More like …' rails."""
|
||||||
|
if not self.api_key or tmdb_id is None:
|
||||||
|
return []
|
||||||
|
import requests
|
||||||
|
path = ("/movie/" if kind == "movie" else "/tv/") + str(tmdb_id) + "/recommendations"
|
||||||
|
r = requests.get(self.BASE + path,
|
||||||
|
params={"api_key": self.api_key, "page": page}, timeout=15)
|
||||||
|
r.raise_for_status()
|
||||||
|
return self._disc_map((r.json() or {}).get("results"), kind)
|
||||||
|
|
||||||
|
def video_trailer(self, kind, tmdb_id):
|
||||||
|
"""The best YouTube trailer key for a title (official Trailer over Teaser).
|
||||||
|
Light — just the /videos endpoint, not the whole detail append."""
|
||||||
|
if not self.api_key or tmdb_id is None:
|
||||||
|
return None
|
||||||
|
import requests
|
||||||
|
path = ("/movie/" if kind == "movie" else "/tv/") + str(tmdb_id) + "/videos"
|
||||||
|
r = requests.get(self.BASE + path, params={"api_key": self.api_key}, timeout=15)
|
||||||
|
r.raise_for_status()
|
||||||
|
teaser = None
|
||||||
|
for v in ((r.json() or {}).get("results") or []):
|
||||||
|
if v.get("site") != "YouTube" or not v.get("key"):
|
||||||
|
continue
|
||||||
|
t = v.get("type") or ""
|
||||||
|
if t == "Trailer":
|
||||||
|
return {"key": v["key"], "name": v.get("name")}
|
||||||
|
if t == "Teaser" and teaser is None:
|
||||||
|
teaser = {"key": v["key"], "name": v.get("name")}
|
||||||
|
return teaser
|
||||||
|
|
||||||
def full_detail(self, kind, tmdb_id, region="US"):
|
def full_detail(self, kind, tmdb_id, region="US"):
|
||||||
"""Complete detail for a TMDB title NOT in the library — shaped like the
|
"""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
|
library detail payload but with direct image URLs (so the same detail UI
|
||||||
|
|
|
||||||
|
|
@ -371,6 +371,38 @@ class VideoEnrichmentEngine:
|
||||||
return []
|
return []
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
def recommendations(self, kind, tmdb_id, page=1) -> list:
|
||||||
|
"""'More like this' titles for a tmdb id — cached + owned-annotated."""
|
||||||
|
w = self.workers.get("tmdb")
|
||||||
|
if not w or not w.enabled or not tmdb_id:
|
||||||
|
return []
|
||||||
|
ck = ("recs", kind, tmdb_id, page)
|
||||||
|
items = self._cache_get(ck)
|
||||||
|
if items is None:
|
||||||
|
try:
|
||||||
|
items = w.client.recommendations(kind, tmdb_id, page=page) or []
|
||||||
|
self._cache_put(ck, items, ttl=3600)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("recommendations failed (%s %s)", kind, tmdb_id)
|
||||||
|
return []
|
||||||
|
return self._stamp_owned(items)
|
||||||
|
|
||||||
|
def trailer(self, kind, tmdb_id) -> dict | None:
|
||||||
|
"""Best YouTube trailer for a title (cached a day — trailers don't move)."""
|
||||||
|
w = self.workers.get("tmdb")
|
||||||
|
if not w or not w.enabled or not tmdb_id:
|
||||||
|
return None
|
||||||
|
ck = ("trailer", kind, tmdb_id)
|
||||||
|
cached = self._cache_get(ck)
|
||||||
|
if cached is None:
|
||||||
|
try:
|
||||||
|
cached = w.client.video_trailer(kind, tmdb_id) or {}
|
||||||
|
self._cache_put(ck, cached, ttl=86400)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("trailer failed (%s %s)", kind, tmdb_id)
|
||||||
|
return None
|
||||||
|
return cached or None
|
||||||
|
|
||||||
def tmdb_detail(self, kind, tmdb_id) -> dict | None:
|
def tmdb_detail(self, kind, tmdb_id) -> dict | None:
|
||||||
"""Full detail for a TMDB title not in the library — same shape as the
|
"""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
|
library detail (source='tmdb', direct image URLs, nothing owned). If it IS
|
||||||
|
|
|
||||||
|
|
@ -425,6 +425,31 @@ class VideoDatabase:
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
def random_owned_titles(self, limit: int = 2, server_source=None) -> list:
|
||||||
|
"""A few random owned titles (with a tmdb_id) to seed 'More like …' rails —
|
||||||
|
up to ``limit`` movies and ``limit`` shows."""
|
||||||
|
out = []
|
||||||
|
conn = self._get_connection()
|
||||||
|
try:
|
||||||
|
for kind, tbl, alias, owned in (("movie", "movies", "m", "m.has_file=1"),
|
||||||
|
("show", "shows", "s", "1=1")):
|
||||||
|
sql = (f"SELECT {alias}.id AS id, {alias}.tmdb_id AS tmdb_id, {alias}.title AS title "
|
||||||
|
f"FROM {tbl} {alias} WHERE {alias}.tmdb_id IS NOT NULL AND {owned}")
|
||||||
|
args: list = []
|
||||||
|
if server_source:
|
||||||
|
sql += f" AND {alias}.server_source=?"
|
||||||
|
args.append(server_source)
|
||||||
|
sql += " ORDER BY RANDOM() LIMIT ?"
|
||||||
|
args.append(int(limit))
|
||||||
|
for r in conn.execute(sql, args):
|
||||||
|
out.append({"kind": kind, "tmdb_id": r["tmdb_id"],
|
||||||
|
"title": r["title"], "library_id": r["id"]})
|
||||||
|
return out
|
||||||
|
except sqlite3.Error:
|
||||||
|
return out
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def apply_ratings(self, kind: str, item_id: int, ratings: dict) -> None:
|
def apply_ratings(self, kind: str, item_id: int, ratings: dict) -> None:
|
||||||
"""Store IMDb / RT / Metacritic scores (from OMDb) + mark ratings_synced.
|
"""Store IMDb / RT / Metacritic scores (from OMDb) + mark ratings_synced.
|
||||||
Ratings are dynamic, so these overwrite (unlike gap-only metadata)."""
|
Ratings are dynamic, so these overwrite (unlike gap-only metadata)."""
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,35 @@ def test_blueprint_exposes_dashboard_route():
|
||||||
assert "/api/video/discover/genres" in rules
|
assert "/api/video/discover/genres" in rules
|
||||||
assert "/api/video/discover/taste" in rules
|
assert "/api/video/discover/taste" in rules
|
||||||
assert "/api/video/discover/list" in rules
|
assert "/api/video/discover/list" in rules
|
||||||
|
assert "/api/video/discover/morelike" in rules
|
||||||
|
assert "/api/video/discover/trailer" in rules
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_trailer_returns_key(tmp_path, monkeypatch):
|
||||||
|
client, _ = _make_client(tmp_path)
|
||||||
|
import core.video.enrichment.engine as eng_mod
|
||||||
|
|
||||||
|
class FakeEng:
|
||||||
|
def trailer(self, kind, tmdb_id): return {"key": "abc123", "name": "Official"}
|
||||||
|
monkeypatch.setattr(eng_mod, "get_video_enrichment_engine", lambda: FakeEng())
|
||||||
|
assert client.get("/api/video/discover/trailer?kind=movie&tmdb_id=5").get_json()["trailer"]["key"] == "abc123"
|
||||||
|
# a non-numeric id is rejected without touching the engine
|
||||||
|
assert client.get("/api/video/discover/trailer?kind=movie").get_json() == {"trailer": None}
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_morelike_builds_seeded_rails(tmp_path, monkeypatch):
|
||||||
|
client, vapi = _make_client(tmp_path)
|
||||||
|
db = vapi._video_db
|
||||||
|
db.upsert_movie("plex", {"server_id": "m1", "title": "Dune", "tmdb_id": 1, "file": {"relative_path": "a.mkv"}})
|
||||||
|
import core.video.enrichment.engine as eng_mod
|
||||||
|
|
||||||
|
class FakeEng:
|
||||||
|
def recommendations(self, kind, tmdb_id, page=1):
|
||||||
|
return [{"kind": "movie", "tmdb_id": 100 + i} for i in range(6)]
|
||||||
|
monkeypatch.setattr(eng_mod, "get_video_enrichment_engine", lambda: FakeEng())
|
||||||
|
rails = client.get("/api/video/discover/morelike").get_json()["rails"]
|
||||||
|
assert rails and rails[0]["title"] == "More like Dune"
|
||||||
|
assert len(rails[0]["items"]) == 6
|
||||||
|
|
||||||
|
|
||||||
def test_discover_list_pages_concatenates_and_dedupes(tmp_path, monkeypatch):
|
def test_discover_list_pages_concatenates_and_dedupes(tmp_path, monkeypatch):
|
||||||
|
|
|
||||||
|
|
@ -1152,6 +1152,81 @@ def test_stamp_owned_is_one_query_per_kind(db, monkeypatch):
|
||||||
assert items[0]["library_id"] is not None and items[1]["library_id"] is None
|
assert items[0]["library_id"] is not None and items[1]["library_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_tmdb_recommendations_parses_with_forced_kind(monkeypatch):
|
||||||
|
body = {"results": [
|
||||||
|
{"id": 5, "title": "Rec", "release_date": "2019-01-01", "poster_path": "/r.jpg",
|
||||||
|
"backdrop_path": "/b.jpg", "vote_average": 7.0, "overview": "O"}]}
|
||||||
|
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(body)))
|
||||||
|
res = TMDBClient("KEY").recommendations("movie", 1)
|
||||||
|
assert res[0]["kind"] == "movie" and res[0]["tmdb_id"] == 5 and res[0]["year"] == "2019"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tmdb_video_trailer_prefers_trailer_over_teaser(monkeypatch):
|
||||||
|
body = {"results": [
|
||||||
|
{"site": "YouTube", "type": "Teaser", "key": "teasekey", "name": "Teaser"},
|
||||||
|
{"site": "Vimeo", "type": "Trailer", "key": "nope"}, # wrong site, ignored
|
||||||
|
{"site": "YouTube", "type": "Trailer", "key": "trailkey", "name": "Official"}]}
|
||||||
|
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(body)))
|
||||||
|
assert TMDBClient("KEY").video_trailer("movie", 1) == {"key": "trailkey", "name": "Official"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_tmdb_video_trailer_falls_back_to_teaser(monkeypatch):
|
||||||
|
body = {"results": [{"site": "YouTube", "type": "Teaser", "key": "teasekey", "name": "T"}]}
|
||||||
|
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(body)))
|
||||||
|
assert TMDBClient("KEY").video_trailer("show", 1) == {"key": "teasekey", "name": "T"}
|
||||||
|
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp({"results": []})))
|
||||||
|
assert TMDBClient("KEY").video_trailer("movie", 1) is None # nothing → None
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_recommendations_annotates_and_caches(db):
|
||||||
|
mid = db.upsert_movie("plex", {"server_id": "m1", "title": "Owned", "tmdb_id": 1})
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class Tmdb:
|
||||||
|
enabled = True
|
||||||
|
def recommendations(self, kind, tmdb_id, page=1):
|
||||||
|
calls.append((kind, tmdb_id, page))
|
||||||
|
return [{"kind": "movie", "tmdb_id": 1, "title": "Owned"},
|
||||||
|
{"kind": "movie", "tmdb_id": 2, "title": "Other"}]
|
||||||
|
eng = VideoEnrichmentEngine(db, {"tmdb": Tmdb()})
|
||||||
|
res = eng.recommendations("movie", 99)
|
||||||
|
assert res[0]["library_id"] == mid and res[1]["library_id"] is None
|
||||||
|
eng.recommendations("movie", 99)
|
||||||
|
assert calls == [("movie", 99, 1)] # cached on repeat
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_trailer_caches(db):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class Tmdb:
|
||||||
|
enabled = True
|
||||||
|
def video_trailer(self, kind, tmdb_id):
|
||||||
|
calls.append((kind, tmdb_id)); return {"key": "abc", "name": "T"}
|
||||||
|
eng = VideoEnrichmentEngine(db, {"tmdb": Tmdb()})
|
||||||
|
assert eng.trailer("movie", 5)["key"] == "abc"
|
||||||
|
eng.trailer("movie", 5)
|
||||||
|
assert calls == [("movie", 5)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_engine_trailer_none_when_no_video(db):
|
||||||
|
class Tmdb:
|
||||||
|
enabled = True
|
||||||
|
def video_trailer(self, kind, tmdb_id): return None
|
||||||
|
assert VideoEnrichmentEngine(db, {"tmdb": Tmdb()}).trailer("movie", 5) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_random_owned_titles_only_owned_with_tmdb(db):
|
||||||
|
db.upsert_movie("plex", {"server_id": "m1", "title": "Owned", "tmdb_id": 1, "file": {"relative_path": "a.mkv"}})
|
||||||
|
db.upsert_movie("plex", {"server_id": "m2", "title": "NoFile", "tmdb_id": 2}) # not owned
|
||||||
|
db.upsert_movie("plex", {"server_id": "m3", "title": "NoTmdb", "file": {"relative_path": "c.mkv"}}) # no tmdb
|
||||||
|
db.upsert_show_tree("plex", {"server_id": "s1", "title": "Show", "tmdb_id": 9, "seasons": []})
|
||||||
|
seeds = db.random_owned_titles(5, server_source="plex")
|
||||||
|
by_title = {s["title"]: s for s in seeds}
|
||||||
|
assert "Owned" in by_title and by_title["Owned"]["tmdb_id"] == 1
|
||||||
|
assert "Show" in by_title # library shows seed too
|
||||||
|
assert "NoFile" not in by_title and "NoTmdb" not in by_title
|
||||||
|
|
||||||
|
|
||||||
def test_top_owned_genres_orders_by_count(db):
|
def test_top_owned_genres_orders_by_count(db):
|
||||||
db.upsert_movie("plex", {"server_id": "m1", "title": "A", "tmdb_id": 1,
|
db.upsert_movie("plex", {"server_id": "m1", "title": "A", "tmdb_id": 1,
|
||||||
"genres": ["Action", "Drama"], "file": {"relative_path": "a.mkv"}})
|
"genres": ["Action", "Drama"], "file": {"relative_path": "a.mkv"}})
|
||||||
|
|
|
||||||
|
|
@ -148,9 +148,14 @@
|
||||||
'<div class="vdsc-hero-pills">' + pills.map(function (p) {
|
'<div class="vdsc-hero-pills">' + pills.map(function (p) {
|
||||||
return '<span class="vdsc-hero-pill">' + esc(p) + '</span>'; }).join('') + '</div>' +
|
return '<span class="vdsc-hero-pill">' + esc(p) + '</span>'; }).join('') + '</div>' +
|
||||||
(it.overview ? '<p class="vdsc-hero-ov">' + esc(it.overview) + '</p>' : '') +
|
(it.overview ? '<p class="vdsc-hero-ov">' + esc(it.overview) + '</p>' : '') +
|
||||||
|
'<div class="vdsc-hero-actions">' +
|
||||||
'<button class="discog-submit-btn vdsc-hero-cta" type="button" ' +
|
'<button class="discog-submit-btn vdsc-hero-cta" type="button" ' +
|
||||||
'data-vsr-open="' + it.kind + '" data-vsr-source="' + source + '" data-vsr-id="' + id + '">' +
|
'data-vsr-open="' + it.kind + '" data-vsr-source="' + source + '" data-vsr-id="' + id + '">' +
|
||||||
'<span class="discog-submit-text">View ' + (it.kind === 'movie' ? 'movie' : 'show') + ' →</span></button>';
|
'<span class="discog-submit-text">View ' + (it.kind === 'movie' ? 'movie' : 'show') + ' →</span></button>' +
|
||||||
|
'<button class="vdsc-hero-trailer" type="button" data-vdsc-trailer ' +
|
||||||
|
'data-kind="' + it.kind + '" data-tmdb="' + it.tmdb_id + '" data-title="' + esc(it.title) + '">' +
|
||||||
|
'<span class="vdsc-tr-ic" aria-hidden="true">▶</span> Trailer</button>' +
|
||||||
|
'</div>';
|
||||||
}
|
}
|
||||||
function goHero(i) {
|
function goHero(i) {
|
||||||
var items = state.hero.items; if (!items.length) return;
|
var items = state.hero.items; if (!items.length) return;
|
||||||
|
|
@ -169,6 +174,81 @@
|
||||||
}
|
}
|
||||||
function stopHeroTimer() { if (state.hero.timer) { clearInterval(state.hero.timer); state.hero.timer = null; } }
|
function stopHeroTimer() { if (state.hero.timer) { clearInterval(state.hero.timer); state.hero.timer = null; } }
|
||||||
|
|
||||||
|
// ── trailer lightbox (in-app YouTube embed) ───────────────────────────────
|
||||||
|
var trailerEl = null, trKey = null;
|
||||||
|
function closeTrailer() {
|
||||||
|
if (!trailerEl) return;
|
||||||
|
var el = trailerEl; trailerEl = null;
|
||||||
|
el.classList.remove('vdsc-tr-open');
|
||||||
|
document.body.style.removeProperty('overflow');
|
||||||
|
if (trKey) { document.removeEventListener('keydown', trKey); trKey = null; }
|
||||||
|
setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 200);
|
||||||
|
startHeroTimer();
|
||||||
|
}
|
||||||
|
function openTrailer(kind, tmdbId, title) {
|
||||||
|
closeTrailer();
|
||||||
|
stopHeroTimer();
|
||||||
|
var ov = document.createElement('div');
|
||||||
|
ov.className = 'vdsc-tr-overlay';
|
||||||
|
ov.innerHTML =
|
||||||
|
'<div class="vdsc-tr-box" role="dialog" aria-modal="true">' +
|
||||||
|
'<button class="vdsc-tr-close" type="button" data-vdsc-tr-close aria-label="Close">×</button>' +
|
||||||
|
'<div class="vdsc-tr-frame" data-vdsc-tr-frame><div class="loading-spinner"></div></div>' +
|
||||||
|
'<div class="vdsc-tr-title">' + esc(title || '') + '</div>' +
|
||||||
|
'</div>';
|
||||||
|
document.body.appendChild(ov);
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
trailerEl = ov;
|
||||||
|
requestAnimationFrame(function () { ov.classList.add('vdsc-tr-open'); });
|
||||||
|
ov.addEventListener('click', function (e) {
|
||||||
|
if (e.target === ov || e.target.closest('[data-vdsc-tr-close]')) closeTrailer();
|
||||||
|
});
|
||||||
|
trKey = function (e) { if (e.key === 'Escape') closeTrailer(); };
|
||||||
|
document.addEventListener('keydown', trKey);
|
||||||
|
fetch('/api/video/discover/trailer?kind=' + encodeURIComponent(kind) + '&tmdb_id=' + encodeURIComponent(tmdbId),
|
||||||
|
{ headers: { Accept: 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (d) {
|
||||||
|
if (!trailerEl) return;
|
||||||
|
var frame = trailerEl.querySelector('[data-vdsc-tr-frame]');
|
||||||
|
var key = d && d.trailer && d.trailer.key;
|
||||||
|
if (!key) { if (frame) frame.innerHTML = '<div class="vdsc-tr-none">No trailer available</div>'; return; }
|
||||||
|
if (frame) frame.innerHTML = '<iframe src="https://www.youtube.com/embed/' + encodeURIComponent(key) +
|
||||||
|
'?autoplay=1&rel=0" title="Trailer" frameborder="0" ' +
|
||||||
|
'allow="autoplay; encrypted-media; picture-in-picture" allowfullscreen></iframe>';
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
var frame = trailerEl && trailerEl.querySelector('[data-vdsc-tr-frame]');
|
||||||
|
if (frame) frame.innerHTML = '<div class="vdsc-tr-none">Couldn’t load trailer</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── "More like <owned title>" rails (pre-filled, prepended above the stack) ─
|
||||||
|
function loadMoreLike() {
|
||||||
|
fetch('/api/video/discover/morelike', { headers: { Accept: 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (d) {
|
||||||
|
var rails = (d && d.rails) || [];
|
||||||
|
var host = $('[data-vdsc-shelves]');
|
||||||
|
if (!rails.length || !host) return;
|
||||||
|
var html = rails.map(function (rl) {
|
||||||
|
return '<section class="vdsc-shelf vdsc-shelf--in vdsc-shelf--ml" data-vdsc-loaded="1">' +
|
||||||
|
'<div class="vdsc-shelf-head">' +
|
||||||
|
'<h3 class="vdsc-shelf-title">' + esc(rl.title) + '</h3>' +
|
||||||
|
'<div class="vdsc-shelf-nav">' +
|
||||||
|
'<button class="vdsc-arrow" type="button" data-vdsc-scroll="-1" aria-label="Scroll left">‹</button>' +
|
||||||
|
'<button class="vdsc-arrow" type="button" data-vdsc-scroll="1" aria-label="Scroll right">›</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="vdsc-rail" data-vdsc-rail>' + (rl.items || []).map(card).join('') + '</div>' +
|
||||||
|
'</section>';
|
||||||
|
}).join('');
|
||||||
|
host.insertAdjacentHTML('afterbegin', html);
|
||||||
|
hydrateGet(host);
|
||||||
|
})
|
||||||
|
.catch(function () { /* personalization is best-effort */ });
|
||||||
|
}
|
||||||
|
|
||||||
// ── shelves (lazy rails) ──────────────────────────────────────────────────
|
// ── shelves (lazy rails) ──────────────────────────────────────────────────
|
||||||
function renderShelves() {
|
function renderShelves() {
|
||||||
var host = $('[data-vdsc-shelves]'); if (!host) return;
|
var host = $('[data-vdsc-shelves]'); if (!host) return;
|
||||||
|
|
@ -322,6 +402,11 @@
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
var trbtn = e.target.closest('[data-vdsc-trailer]');
|
||||||
|
if (trbtn) {
|
||||||
|
openTrailer(trbtn.getAttribute('data-kind'), trbtn.getAttribute('data-tmdb'), trbtn.getAttribute('data-title'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
var seeall = e.target.closest('[data-vdsc-seeall]');
|
var seeall = e.target.closest('[data-vdsc-seeall]');
|
||||||
if (seeall) {
|
if (seeall) {
|
||||||
var shelf = seeall.closest('.vdsc-shelf');
|
var shelf = seeall.closest('.vdsc-shelf');
|
||||||
|
|
@ -386,6 +471,7 @@
|
||||||
if (!state.genres.movie.length && !state.genres.show.length) { showEmpty(); return; }
|
if (!state.genres.movie.length && !state.genres.show.length) { showEmpty(); return; }
|
||||||
renderGenreChips();
|
renderGenreChips();
|
||||||
renderShelves();
|
renderShelves();
|
||||||
|
loadMoreLike(); // prepend personalized 'More like…' rails when ready
|
||||||
requestAnimationFrame(positionSegs);
|
requestAnimationFrame(positionSegs);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2351,3 +2351,37 @@ body[data-side="video"] #soulsync-toggle { display: none; }
|
||||||
.vdsc-seg::before { transition: none; }
|
.vdsc-seg::before { transition: none; }
|
||||||
.vdsc-chip, .vdsc-chip:hover, .vdsc-chip--on { transition: color 0.15s ease, background 0.15s ease; transform: none; }
|
.vdsc-chip, .vdsc-chip:hover, .vdsc-chip--on { transition: color 0.15s ease, background 0.15s ease; transform: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── hero actions: View CTA + Trailer button ──────────────────────────────── */
|
||||||
|
.vdsc-hero-actions { display: flex; align-items: center; gap: 12px; margin-top: 18px; flex-wrap: wrap; }
|
||||||
|
.vdsc-hero-actions .vdsc-hero-cta { margin-top: 0; }
|
||||||
|
.vdsc-hero-trailer { display: inline-flex; align-items: center; gap: 8px; padding: 11px 19px; border-radius: 12px;
|
||||||
|
font-size: 13px; font-weight: 800; cursor: pointer; color: #fff; backdrop-filter: blur(6px);
|
||||||
|
background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.22);
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease; }
|
||||||
|
.vdsc-hero-trailer:hover { background: rgba(255, 255, 255, 0.18); border-color: rgba(255, 255, 255, 0.42); transform: translateY(-1px); }
|
||||||
|
.vdsc-tr-ic { font-size: 10px; }
|
||||||
|
|
||||||
|
/* "More like…" rails get a gradient title as a personalized cue */
|
||||||
|
.vdsc-shelf--ml .vdsc-shelf-title { color: #fff;
|
||||||
|
background: linear-gradient(90deg, #fff 35%, hsl(265 85% 78%) 100%);
|
||||||
|
-webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
|
||||||
|
|
||||||
|
/* ── trailer lightbox (in-app YouTube embed) ──────────────────────────────── */
|
||||||
|
.vdsc-tr-overlay { position: fixed; inset: 0; z-index: 9200; display: flex; align-items: center; justify-content: center;
|
||||||
|
padding: 24px; background: rgba(3, 3, 6, 0.86); backdrop-filter: blur(10px); opacity: 0; transition: opacity 0.2s ease; }
|
||||||
|
.vdsc-tr-overlay.vdsc-tr-open { opacity: 1; }
|
||||||
|
.vdsc-tr-box { position: relative; width: min(960px, 100%); }
|
||||||
|
.vdsc-tr-close { position: absolute; top: -46px; right: 0; width: 38px; height: 38px; border-radius: 50%;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2); background: rgba(0, 0, 0, 0.5); color: #fff; font-size: 22px; line-height: 1;
|
||||||
|
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: background 0.15s ease, transform 0.25s ease; }
|
||||||
|
.vdsc-tr-close:hover { background: rgba(0, 0, 0, 0.82); transform: rotate(90deg); }
|
||||||
|
.vdsc-tr-frame { position: relative; aspect-ratio: 16 / 9; border-radius: 14px; overflow: hidden; background: #000;
|
||||||
|
box-shadow: 0 40px 120px rgba(0, 0, 0, 0.7); display: flex; align-items: center; justify-content: center;
|
||||||
|
transform: scale(0.97); transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1); }
|
||||||
|
.vdsc-tr-open .vdsc-tr-frame { transform: none; }
|
||||||
|
.vdsc-tr-frame iframe { width: 100%; height: 100%; border: 0; }
|
||||||
|
.vdsc-tr-none { color: rgba(255, 255, 255, 0.6); font-size: 15px; font-weight: 600; }
|
||||||
|
.vdsc-tr-title { margin-top: 14px; text-align: center; font-size: 15px; font-weight: 700; color: rgba(255, 255, 255, 0.85); }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .vdsc-tr-frame { transition: none; } }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue