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:
BoulderBadgeDad 2026-06-16 14:44:44 -07:00
parent 5aa792d399
commit a9ec025706
8 changed files with 363 additions and 1 deletions

View file

@ -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 <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"])
def video_discover_genres():
"""Genre id→name maps for both kinds (powers the genre rails + filter)."""

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -148,9 +148,14 @@
'<div class="vdsc-hero-pills">' + pills.map(function (p) {
return '<span class="vdsc-hero-pill">' + esc(p) + '</span>'; }).join('') + '</div>' +
(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" ' +
'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) {
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 =
'<div class="vdsc-tr-box" role="dialog" aria-modal="true">' +
'<button class="vdsc-tr-close" type="button" data-vdsc-tr-close aria-label="Close">&times;</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">Couldnt 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) ──────────────────────────────────────────────────
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);
});
}

View file

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