diff --git a/api/video/discover.py b/api/video/discover.py index c32a9e42..48425e88 100644 --- a/api/video/discover.py +++ b/api/video/discover.py @@ -51,6 +51,56 @@ def register_routes(bp): logger.exception("discover taste failed") return jsonify({"movie": [], "show": []}) + @bp.route("/discover/morelike", methods=["GET"]) + def video_discover_morelike(): + """'More like ' 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"]) def video_discover_genres(): """Genre id→name maps for both kinds (powers the genre rails + filter).""" diff --git a/core/video/enrichment/clients.py b/core/video/enrichment/clients.py index 5ac12b78..4afdf505 100644 --- a/core/video/enrichment/clients.py +++ b/core/video/enrichment/clients.py @@ -544,6 +544,37 @@ class TMDBClient: return [{"id": g["id"], "name": g["name"]} 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"): """Complete detail for a TMDB title NOT in the library — shaped like the library detail payload but with direct image URLs (so the same detail UI diff --git a/core/video/enrichment/engine.py b/core/video/enrichment/engine.py index 421fa309..2385469b 100644 --- a/core/video/enrichment/engine.py +++ b/core/video/enrichment/engine.py @@ -371,6 +371,38 @@ class VideoEnrichmentEngine: return [] 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: """Full detail for a TMDB title not in the library — same shape as the library detail (source='tmdb', direct image URLs, nothing owned). If it IS diff --git a/database/video_database.py b/database/video_database.py index 96cf8a05..a5b4ffbe 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -425,6 +425,31 @@ class VideoDatabase: finally: 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: """Store IMDb / RT / Metacritic scores (from OMDb) + mark ratings_synced. Ratings are dynamic, so these overwrite (unlike gap-only metadata).""" diff --git a/tests/test_video_api.py b/tests/test_video_api.py index f0055ce2..c32e865f 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -58,6 +58,35 @@ def test_blueprint_exposes_dashboard_route(): assert "/api/video/discover/genres" in rules assert "/api/video/discover/taste" 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): diff --git a/tests/test_video_enrichment.py b/tests/test_video_enrichment.py index 6531ca77..bc558d42 100644 --- a/tests/test_video_enrichment.py +++ b/tests/test_video_enrichment.py @@ -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 +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): db.upsert_movie("plex", {"server_id": "m1", "title": "A", "tmdb_id": 1, "genres": ["Action", "Drama"], "file": {"relative_path": "a.mkv"}}) diff --git a/webui/static/video/video-discover.js b/webui/static/video/video-discover.js index ff9faa89..5b26804b 100644 --- a/webui/static/video/video-discover.js +++ b/webui/static/video/video-discover.js @@ -148,9 +148,14 @@ '
' + pills.map(function (p) { return '' + esc(p) + ''; }).join('') + '
' + (it.overview ? '

' + esc(it.overview) + '

' : '') + + '
' + ''; + 'View ' + (it.kind === 'movie' ? 'movie' : 'show') + ' →' + + '' + + '
'; } function goHero(i) { 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; } } + // ── 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 = + ''; + 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 = '
No trailer available
'; return; } + if (frame) frame.innerHTML = ''; + }) + .catch(function () { + var frame = trailerEl && trailerEl.querySelector('[data-vdsc-tr-frame]'); + if (frame) frame.innerHTML = '
Couldn’t load trailer
'; + }); + } + + // ── "More like " 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 '
' + + '
' + + '

' + esc(rl.title) + '

' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + (rl.items || []).map(card).join('') + '
' + + '
'; + }).join(''); + host.insertAdjacentHTML('afterbegin', html); + hydrateGet(host); + }) + .catch(function () { /* personalization is best-effort */ }); + } + // ── shelves (lazy rails) ────────────────────────────────────────────────── function renderShelves() { var host = $('[data-vdsc-shelves]'); if (!host) return; @@ -322,6 +402,11 @@ })); 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]'); if (seeall) { var shelf = seeall.closest('.vdsc-shelf'); @@ -386,6 +471,7 @@ if (!state.genres.movie.length && !state.genres.show.length) { showEmpty(); return; } renderGenreChips(); renderShelves(); + loadMoreLike(); // prepend personalized 'More like…' rails when ready requestAnimationFrame(positionSegs); }); } diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index e228a668..318ed406 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -2351,3 +2351,37 @@ body[data-side="video"] #soulsync-toggle { display: 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; } } + +/* ── 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; } }