Discover v2: per-category pagination, personalization, hide-owned + perf

Performance:
- Batched ownership: new db.library_ids_for_tmdb() resolves a whole rail in one
  query per kind. _stamp_owned (now also used by search + trending) groups by
  kind, so a full Discover page drops from ~500 connections to a couple per rail.

Function/data:
- 'See all' on every rail opens it as a paged grid (Load more); the filter bar's
  Browse routes through the same generic category grid with a back button + title.
- Personalized 'Because you like <Genre>' rails seeded from your most-owned
  genres (new db.top_owned_genres + /discover/taste endpoint).
- 'Hide owned' toggle drops in-library titles from every rail/grid (CSS class,
  instant).

Visual vibes:
- Ambient page-top color bleed that follows the current hero slide's hue.
- Rail edge-fade mask, gentle fade-in on load, per-title hue glow on card hover.

Tests: +4 (batched id map, server scoping, one-query-per-kind stamp, top genres).
Full video enrichment + database suites: 145 passed.
This commit is contained in:
BoulderBadgeDad 2026-06-16 14:02:44 -07:00
parent 97a2023139
commit 5c9b43a175
7 changed files with 312 additions and 147 deletions

View file

@ -34,6 +34,23 @@ def register_routes(bp):
items = []
return jsonify({"items": items})
@bp.route("/discover/taste", methods=["GET"])
def video_discover_taste():
"""The user's most-owned genres (movies + shows) → personalized rails."""
from . import get_video_db
try:
from core.video.sources import resolve_video_server
srv = resolve_video_server()
except Exception:
srv = None
db = get_video_db()
try:
return jsonify({"movie": db.top_owned_genres("movie", srv, 6),
"show": db.top_owned_genres("show", srv, 6)})
except Exception:
logger.exception("discover taste failed")
return jsonify({"movie": [], "show": []})
@bp.route("/discover/genres", methods=["GET"])
def video_discover_genres():
"""Genre id→name maps for both kinds (powers the genre rails + filter)."""

View file

@ -279,11 +279,7 @@ class VideoEnrichmentEngine:
except Exception:
logger.exception("video search failed for %r", query)
return []
srv = self._server()
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"], srv)
return results
return self._stamp_owned(results)
def trending(self) -> list:
"""Trending titles for the idle search page, annotated owned/not."""
@ -298,21 +294,26 @@ class VideoEnrichmentEngine:
except Exception:
logger.exception("video trending failed")
return []
# Re-annotate ownership fresh each call (cheap) so it tracks the library.
srv = self._server()
for r in cached:
if r.get("tmdb_id"):
r["library_id"] = self.db.library_id_for_tmdb(r["kind"], r["tmdb_id"], srv)
return cached
# Re-annotate ownership fresh each call (batched) so it tracks the library.
return self._stamp_owned(cached)
# ── discover (browse TMDB lists; owned titles annotated) ──────────────────
def _stamp_owned(self, items):
"""Annotate each movie/show with its library_id for the active server —
stamped fresh (not cached with ownership) so 'In Library' tracks scans."""
stamped fresh (not cached with ownership) so 'In Library' tracks scans.
Batched: one query per kind for the whole list, not one per item."""
srv = self._server()
by_kind: dict = {}
for r in items or []:
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"], srv)
by_kind.setdefault(r["kind"], []).append(r["tmdb_id"])
maps = {k: self.db.library_ids_for_tmdb(k, ids, srv) for k, ids in by_kind.items()}
for r in items or []:
if r.get("kind") in ("movie", "show") and r.get("tmdb_id"):
try:
r["library_id"] = maps.get(r["kind"], {}).get(int(r["tmdb_id"]))
except (TypeError, ValueError):
r["library_id"] = None
return items
def discover_curated(self, key, page=1) -> list:

View file

@ -365,6 +365,66 @@ class VideoDatabase:
finally:
conn.close()
def library_ids_for_tmdb(self, kind: str, tmdb_ids, server_source=None) -> dict:
"""{tmdb_id: library_row_id} for the owned subset of ``tmdb_ids`` on the
active server. Batched (chunked IN) so a whole Discover rail costs one
query per kind instead of one connection+query per item."""
table = {"movie": "movies", "show": "shows"}.get(kind)
out: dict = {}
if not table:
return out
ids = []
for x in (tmdb_ids or []):
try:
ids.append(int(x))
except (TypeError, ValueError):
pass
if not ids:
return out
conn = self._get_connection()
try:
for i in range(0, len(ids), 400): # stay under SQLite's variable cap
chunk = ids[i:i + 400]
ph = ",".join("?" * len(chunk))
sql = f"SELECT id, tmdb_id FROM {table} WHERE tmdb_id IN ({ph})"
args = list(chunk)
if server_source:
sql += " AND server_source=?"
args.append(server_source)
for row in conn.execute(sql, args):
out.setdefault(row["tmdb_id"], row["id"]) # first match wins
return out
except sqlite3.Error:
return out
finally:
conn.close()
def top_owned_genres(self, kind: str, server_source=None, limit: int = 6) -> list:
"""The user's most-owned genre names for movies/shows, busiest first —
drives Discover's personalized 'Because you like ' rails."""
if kind == "movie":
link, owner, tbl, owned = "movie_genres", "movie_id", "movies", "t.has_file=1"
elif kind == "show":
link, owner, tbl, owned = "show_genres", "show_id", "shows", "1=1" # any library show counts
else:
return []
sql = (f"SELECT g.name AS name, COUNT(*) AS c FROM {link} lt "
f"JOIN genres g ON g.id = lt.genre_id "
f"JOIN {tbl} t ON t.id = lt.{owner} WHERE {owned}")
args: list = []
if server_source:
sql += " AND t.server_source=?"
args.append(server_source)
sql += " GROUP BY g.name ORDER BY c DESC, g.name LIMIT ?"
args.append(int(limit))
conn = self._get_connection()
try:
return [r["name"] for r in conn.execute(sql, args)]
except sqlite3.Error:
return []
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

@ -1116,3 +1116,49 @@ def test_engine_discover_swallows_client_errors(db):
assert eng.discover_curated("popular_movies") == []
assert eng.discover_filter("movie", genre=1) == []
assert eng.genre_list("movie") == []
def test_library_ids_for_tmdb_batched(db):
m1 = db.upsert_movie("plex", {"server_id": "m1", "title": "A", "tmdb_id": 10})
m2 = db.upsert_movie("plex", {"server_id": "m2", "title": "B", "tmdb_id": 20})
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "tmdb_id": 30, "seasons": []})
assert db.library_ids_for_tmdb("movie", [10, 20, 99]) == {10: m1, 20: m2} # 99 not owned
assert db.library_ids_for_tmdb("show", [30, 31]) == {30: sid}
assert db.library_ids_for_tmdb("movie", []) == {}
assert db.library_ids_for_tmdb("bogus", [10]) == {}
def test_library_ids_for_tmdb_scopes_by_server(db):
db.upsert_movie("plex", {"server_id": "m1", "title": "A", "tmdb_id": 10})
db.upsert_movie("jellyfin", {"server_id": "j1", "title": "A", "tmdb_id": 11})
got = db.library_ids_for_tmdb("movie", [10, 11], server_source="plex")
assert 10 in got and 11 not in got # the other server's copy doesn't count
def test_stamp_owned_is_one_query_per_kind(db, monkeypatch):
# The whole rail must cost one ownership query per kind, not one per item.
db.upsert_movie("plex", {"server_id": "m1", "title": "Owned", "tmdb_id": 1})
calls = {"n": 0}
real = db.library_ids_for_tmdb
def counted(kind, ids, server_source=None):
calls["n"] += 1
return real(kind, ids, server_source)
monkeypatch.setattr(db, "library_ids_for_tmdb", counted)
eng = VideoEnrichmentEngine(db, {})
items = [{"kind": "movie", "tmdb_id": i, "title": "T%d" % i} for i in range(1, 21)]
items.append({"kind": "show", "tmdb_id": 99, "title": "Show"})
eng._stamp_owned(items)
assert calls["n"] == 2 # one movie query + one show query
assert items[0]["library_id"] is not None and items[1]["library_id"] is None
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"}})
db.upsert_movie("plex", {"server_id": "m2", "title": "B", "tmdb_id": 2,
"genres": ["Action"], "file": {"relative_path": "b.mkv"}})
db.upsert_movie("plex", {"server_id": "m3", "title": "C", "tmdb_id": 3,
"genres": ["Comedy"]}) # no file → not owned → excluded
g = db.top_owned_genres("movie", server_source="plex", limit=5)
assert g[0] == "Action" # owned twice → first
assert "Drama" in g and "Comedy" not in g # Comedy movie isn't owned

View file

@ -868,7 +868,8 @@
a filter bar that flips into a paged grid. Built by
video/video-discover.js, styled by .vdsc-* in video-side.css. -->
<section class="video-subpage" data-video-subpage="video-discover" hidden>
<div class="vdsc-page">
<div class="vdsc-page" data-vdsc-page>
<div class="vdsc-amb" data-vdsc-amb aria-hidden="true"></div>
<div class="vdsc-hero hidden" data-vdsc-hero></div>
<div class="vdsc-filterbar">
<span class="vdsc-filter-label">Browse</span>
@ -894,10 +895,17 @@
<option value="vote_count.desc">Most voted</option>
</select>
<button class="vdsc-btn" type="button" data-vdsc-apply>Browse</button>
<button class="vdsc-btn vdsc-btn--ghost" type="button" data-vdsc-clear>Clear</button>
<label class="vdsc-toggle">
<input type="checkbox" data-vdsc-hideowned>
<span>Hide owned</span>
</label>
</div>
<div class="vdsc-shelves" data-vdsc-shelves></div>
<div class="vdsc-grid-wrap hidden" data-vdsc-grid-wrap>
<div class="vdsc-grid-head">
<button class="vdsc-btn vdsc-btn--ghost" type="button" data-vdsc-clear>&larr; Discover</button>
<h2 class="vdsc-grid-title" data-vdsc-grid-title></h2>
</div>
<div class="vsr-loading hidden" data-vdsc-grid-loading>
<div class="loading-spinner"></div>
<div class="loading-text">Loading&hellip;</div>

View file

@ -2,11 +2,13 @@
* SoulSync Video Discover page (isolated, in-app).
*
* A browse-everything page for TMDB titles you don't own yet: a cross-fading
* trending hero, then a deep stack of Netflix-style rails (curated lists, every
* genre, a few decades), each lazy-loaded on scroll. A filter bar (kind / genre /
* year / sort) flips the whole page into a paged grid. Cards reuse the search
* card look + owned/preview ribbon and open the detail page via the shared
* soulsync:video-open-detail event. Self-contained IIFE, no globals.
* trending hero, personalized "because you like…" rails, then a deep stack of
* Netflix-style genre/decade/curated rails each lazy-loaded on scroll. Every
* rail has a "See all" that opens it as a paged grid (Load more). A filter bar
* (kind / genre / decade / sort) opens an arbitrary grid; a "Hide owned" toggle
* drops titles already in your library. Cards reuse the search card + owned
* ribbon and open detail via the shared soulsync:video-open-detail event.
* Self-contained IIFE, no globals.
*/
(function () {
'use strict';
@ -16,10 +18,10 @@
var state = {
loaded: false, wired: false, mode: 'shelves',
genres: { movie: [], show: [] },
io: null, // IntersectionObserver for lazy rails
genres: { movie: [], show: [] }, taste: { movie: [], show: [] },
io: null,
hero: { items: [], idx: 0, timer: null },
filter: { kind: 'movie', genre: '', decade: '', year: '', sort: 'popularity.desc', page: 1, busy: false },
cat: { title: '', q: '', page: 1, paginates: true, busy: false },
};
function $(s, r) { return (r || document).querySelector(s); }
@ -29,8 +31,9 @@
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function hueOf(s) { var h = 0, t = String(s || ''); for (var i = 0; i < t.length; i++) h = (h * 31 + t.charCodeAt(i)) >>> 0; return h % 360; }
function idMap(list) { var m = {}; (list || []).forEach(function (g) { m[(g.name || '').toLowerCase()] = g.id; }); return m; }
// ── the rail stack (curated + genre + decade) ─────────────────────────────
// ── the rail stack (personalized + curated + genre + decade) ──────────────
var CURATED = [
{ title: 'Trending This Week', q: 'key=trending' },
{ title: 'Popular Movies', q: 'key=popular_movies' },
@ -41,7 +44,6 @@
{ title: 'Top Rated Movies', q: 'key=top_movies' },
{ title: 'Top Rated Shows', q: 'key=top_shows' },
];
// Movie genres to surface as their own rails (resolved to ids once loaded).
var GENRE_RAILS = ['Action', 'Adventure', 'Comedy', 'Drama', 'Science Fiction',
'Thriller', 'Horror', 'Animation', 'Fantasy', 'Romance', 'Documentary', 'Crime'];
var DECADE_RAILS = [
@ -52,14 +54,24 @@
];
function buildShelfList() {
var shelves = CURATED.slice();
var gmap = {};
state.genres.movie.forEach(function (g) { gmap[(g.name || '').toLowerCase()] = g.id; });
GENRE_RAILS.forEach(function (name) {
var id = gmap[name.toLowerCase()];
if (id != null) shelves.push({ title: name, q: 'kind=movie&genre=' + id + '&sort=popularity.desc' });
var out = [], used = {};
var gm = idMap(state.genres.movie), gs = idMap(state.genres.show);
// personalized first — seeded from what you actually own
(state.taste.movie || []).slice(0, 3).forEach(function (name) {
var id = gm[name.toLowerCase()];
if (id != null) { out.push({ title: 'Because you like ' + name, q: 'kind=movie&genre=' + id + '&sort=popularity.desc' }); used['m:' + name.toLowerCase()] = 1; }
});
return shelves.concat(DECADE_RAILS);
(state.taste.show || []).slice(0, 2).forEach(function (name) {
var id = gs[name.toLowerCase()];
if (id != null) { out.push({ title: 'More ' + name + ' shows', q: 'kind=show&genre=' + id + '&sort=popularity.desc' }); }
});
out = out.concat(CURATED);
GENRE_RAILS.forEach(function (name) {
var id = gm[name.toLowerCase()];
if (id != null && !used['m:' + name.toLowerCase()])
out.push({ title: name, q: 'kind=movie&genre=' + id + '&sort=popularity.desc' });
});
return out.concat(DECADE_RAILS);
}
// ── card (mirrors the search title card: owned ribbon + get button) ───────
@ -81,53 +93,42 @@
var sub = [it.year, it.kind === 'movie' ? 'Movie' : 'TV'].filter(Boolean).join(' · ');
var cb = window.VideoGet ? VideoGet.cardButton({ kind: it.kind, tmdbId: it.tmdb_id,
libraryId: it.library_id, title: it.title, poster: it.poster, status: it.status, source: source }) : '';
return '<a class="vsr-card" href="' + href + '" ' +
'data-vsr-open="' + it.kind + '" data-vsr-source="' + source + '" data-vsr-id="' + id + '">' + cb +
return '<a class="vsr-card' + (owned ? ' vsr-card--owned' : '') + '" href="' + href + '" ' +
'data-vsr-open="' + it.kind + '" data-vsr-source="' + source + '" data-vsr-id="' + id +
'" style="--vgm-h:' + hueOf(it.title) + '">' + cb +
'<div class="vsr-poster">' + img + ribbon + rating +
'<span class="vsr-peek" aria-hidden="true">i</span></div>' +
'<div class="vsr-info"><span class="vsr-name" title="' + esc(it.title) + '">' + esc(it.title) +
'</span><span class="vsr-sub">' + esc(sub) + '</span></div></a>';
}
function hydrateGet(root) {
// shows already on the watchlist paint their eye as "watched"
if (window.VideoWatchlist) VideoWatchlist.hydrate(root);
}
function hydrateGet(root) { if (window.VideoWatchlist) VideoWatchlist.hydrate(root); }
// ── hero slideshow ────────────────────────────────────────────────────────
function loadHero() {
fetch('/api/video/discover/hero', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
state.hero.items = (d && d.items) || [];
renderHero();
})
.then(function (d) { state.hero.items = (d && d.items) || []; renderHero(); })
.catch(function () { /* hero is optional chrome */ });
}
function renderHero() {
var host = $('[data-vdsc-hero]'); if (!host) return;
var items = state.hero.items;
if (!items.length) { host.classList.add('hidden'); return; }
host.classList.remove('hidden');
var slides = items.map(function (it, i) {
return '<div class="vdsc-slide' + (i === 0 ? ' vdsc-slide--on' : '') + '" data-vdsc-slide="' + i + '" ' +
'style="--vgm-h:' + hueOf(it.title) + ';' + (it.backdrop ? "background-image:url('" + esc(it.backdrop) + "')" : '') + '">' +
'<div class="vdsc-slide-scrim"></div></div>';
}).join('');
var dots = items.map(function (it, i) {
return '<button class="vdsc-dot' + (i === 0 ? ' vdsc-dot--on' : '') + '" type="button" ' +
'data-vdsc-go="' + i + '" aria-label="Slide ' + (i + 1) + '"></button>';
}).join('');
host.innerHTML =
'<div class="vdsc-slides" data-vdsc-slides>' + slides + '</div>' +
'<div class="vdsc-slides">' + items.map(function (it, i) {
return '<div class="vdsc-slide' + (i === 0 ? ' vdsc-slide--on' : '') + '" data-vdsc-slide="' + i + '" ' +
'style="--vgm-h:' + hueOf(it.title) + ';' + (it.backdrop ? "background-image:url('" + esc(it.backdrop) + "')" : '') + '">' +
'<div class="vdsc-slide-scrim"></div></div>';
}).join('') + '</div>' +
'<div class="vdsc-hero-body" data-vdsc-hero-body></div>' +
'<div class="vdsc-dots">' + dots + '</div>';
'<div class="vdsc-dots">' + items.map(function (it, i) {
return '<button class="vdsc-dot' + (i === 0 ? ' vdsc-dot--on' : '') + '" type="button" data-vdsc-go="' + i + '" aria-label="Slide ' + (i + 1) + '"></button>';
}).join('') + '</div>';
state.hero.idx = 0;
paintHeroBody();
startHeroTimer();
}
function paintHeroBody() {
var body = $('[data-vdsc-hero-body]'); if (!body) return;
var it = state.hero.items[state.hero.idx]; if (!it) return;
@ -137,27 +138,26 @@
var pills = [it.kind === 'movie' ? 'Movie' : 'TV', it.year,
it.rating ? '★ ' + (Math.round(it.rating * 10) / 10) : null,
owned ? 'In Library' : null].filter(Boolean);
body.style.setProperty('--vgm-h', hueOf(it.title));
var hue = hueOf(it.title);
body.style.setProperty('--vgm-h', hue);
var pageEl = $('[data-vdsc-page]'); if (pageEl) pageEl.style.setProperty('--vdsc-amb', hue); // ambient bleed
body.innerHTML =
'<div class="vdsc-hero-eyebrow">' + (owned ? 'In your library' : 'Trending now') + '</div>' +
'<h2 class="vdsc-hero-title">' + esc(it.title) + '</h2>' +
'<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>' : '') +
'<button class="discog-submit-btn vdsc-hero-cta" type="button" data-vdsc-open ' +
'<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>';
}
function goHero(i) {
var items = state.hero.items; if (!items.length) return;
state.hero.idx = (i + items.length) % items.length;
var slides = document.querySelectorAll('[data-vdsc-slide]');
for (var s = 0; s < slides.length; s++)
slides[s].classList.toggle('vdsc-slide--on', s === state.hero.idx);
for (var s = 0; s < slides.length; s++) slides[s].classList.toggle('vdsc-slide--on', s === state.hero.idx);
var dots = document.querySelectorAll('[data-vdsc-go]');
for (var d = 0; d < dots.length; d++)
dots[d].classList.toggle('vdsc-dot--on', d === state.hero.idx);
for (var d = 0; d < dots.length; d++) dots[d].classList.toggle('vdsc-dot--on', d === state.hero.idx);
paintHeroBody();
}
function startHeroTimer() {
@ -171,88 +171,80 @@
// ── shelves (lazy rails) ──────────────────────────────────────────────────
function renderShelves() {
var host = $('[data-vdsc-shelves]'); if (!host) return;
var shelves = buildShelfList();
host.innerHTML = shelves.map(function (sh) {
return '<section class="vdsc-shelf" data-vdsc-q="' + esc(sh.q) + '">' +
host.innerHTML = buildShelfList().map(function (sh) {
return '<section class="vdsc-shelf" data-vdsc-q="' + esc(sh.q) + '" data-vdsc-title="' + esc(sh.title) + '">' +
'<div class="vdsc-shelf-head">' +
'<h3 class="vdsc-shelf-title">' + esc(sh.title) + '</h3>' +
'<div class="vdsc-shelf-nav">' +
'<button class="vdsc-seeall" type="button" data-vdsc-seeall>See all</button>' +
'<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>' +
'<div class="vdsc-skel">' + Array(7).join('<div class="vdsc-skel-card"></div>') + '</div>' +
'<div class="vdsc-skel">' + Array(8).join('<div class="vdsc-skel-card"></div>') + '</div>' +
'</div>' +
'</section>';
}).join('');
observeShelves();
}
function observeShelves() {
if (state.io) state.io.disconnect();
if (!('IntersectionObserver' in window)) { // no IO → just load all
var all = document.querySelectorAll('.vdsc-shelf');
for (var i = 0; i < all.length; i++) fillShelf(all[i]);
var shelves = document.querySelectorAll('.vdsc-shelf');
if (!('IntersectionObserver' in window)) {
for (var i = 0; i < shelves.length; i++) fillShelf(shelves[i]);
return;
}
state.io = new IntersectionObserver(function (entries) {
entries.forEach(function (en) {
if (en.isIntersecting) { state.io.unobserve(en.target); fillShelf(en.target); }
});
}, { rootMargin: '300px 0px' });
var shelves = document.querySelectorAll('.vdsc-shelf');
}, { rootMargin: '400px 0px' });
for (var j = 0; j < shelves.length; j++) state.io.observe(shelves[j]);
}
function fillShelf(shelf) {
if (!shelf || shelf.getAttribute('data-vdsc-loaded')) return;
shelf.setAttribute('data-vdsc-loaded', '1');
var rail = $('[data-vdsc-rail]', shelf);
var q = shelf.getAttribute('data-vdsc-q');
fetch(LIST_URL + '?' + q, { headers: { Accept: 'application/json' } })
fetch(LIST_URL + '?' + shelf.getAttribute('data-vdsc-q'), { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
var items = (d && d.items) || [];
if (!items.length) { shelf.remove(); return; } // drop empty shelves
if (!items.length) { shelf.remove(); return; } // drop empty shelves
if (rail) { rail.innerHTML = items.map(card).join(''); hydrateGet(rail); }
shelf.classList.add('vdsc-shelf--in'); // reveal
})
.catch(function () { shelf.remove(); });
}
// ── filter / grid mode ────────────────────────────────────────────────────
function applyFilter() {
// ── category / filter grid (paged) ────────────────────────────────────────
function openCategory(title, q) {
state.mode = 'grid';
state.filter.page = 1;
state.cat = { title: title, q: q, page: 1, paginates: !/key=trending/.test(q), busy: false };
$('[data-vdsc-shelves]').classList.add('hidden');
$('[data-vdsc-grid-wrap]').classList.remove('hidden');
var hero = $('[data-vdsc-hero]'); if (hero) hero.classList.add('hidden');
var wrap = $('[data-vdsc-grid-wrap]'); wrap.classList.remove('hidden');
var ttl = $('[data-vdsc-grid-title]'); if (ttl) ttl.textContent = title;
$('[data-vdsc-grid]').innerHTML = '';
try { wrap.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (e) { /* ignore */ }
loadGrid(true);
}
function clearFilter() {
function closeCategory() {
state.mode = 'shelves';
$('[data-vdsc-grid-wrap]').classList.add('hidden');
$('[data-vdsc-shelves]').classList.remove('hidden');
}
function gridQuery() {
var f = state.filter;
var p = ['kind=' + f.kind, 'sort=' + encodeURIComponent(f.sort), 'page=' + f.filterPage];
if (f.genre) p.push('genre=' + f.genre);
if (f.decade) p.push('decade=' + f.decade);
if (f.year) p.push('year=' + encodeURIComponent(f.year));
return p.join('&');
if (state.hero.items.length) { var h = $('[data-vdsc-hero]'); if (h) h.classList.remove('hidden'); }
}
function loadGrid(reset) {
var f = state.filter;
if (f.busy) return;
f.busy = true;
f.filterPage = f.page;
var c = state.cat;
if (c.busy) return;
c.busy = true;
var more = $('[data-vdsc-more]'); if (more) { more.disabled = true; more.textContent = 'Loading…'; }
var ld = $('[data-vdsc-grid-loading]'); if (ld && reset) ld.classList.remove('hidden');
fetch(LIST_URL + '?' + gridQuery(), { headers: { Accept: 'application/json' } })
fetch(LIST_URL + '?' + c.q + '&page=' + c.page, { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
f.busy = false;
c.busy = false;
if (ld) ld.classList.add('hidden');
var items = (d && d.items) || [];
var grid = $('[data-vdsc-grid]');
@ -262,20 +254,35 @@
if (more) {
more.textContent = 'Load more';
more.disabled = false;
more.classList.toggle('hidden', items.length < 18); // a full page → likely more
more.classList.toggle('hidden', !c.paginates || items.length < 18);
}
})
.catch(function () {
f.busy = false;
c.busy = false;
if (ld) ld.classList.add('hidden');
if (more) { more.textContent = 'Load more'; more.disabled = false; }
});
}
function applyFilter() {
var kind = ($('[data-vdsc-f-kind]') || {}).value === 'show' ? 'show' : 'movie';
var genre = ($('[data-vdsc-f-genre]') || {}).value || '';
var decade = ($('[data-vdsc-f-decade]') || {}).value || '';
var sort = ($('[data-vdsc-f-sort]') || {}).value || 'popularity.desc';
var q = ['kind=' + kind, 'sort=' + encodeURIComponent(sort)];
var bits = [kind === 'show' ? 'Shows' : 'Movies'];
if (genre) { q.push('genre=' + genre); var gn = genreName(kind, genre); if (gn) bits.push(gn); }
if (decade) { q.push('decade=' + decade); bits.push(decade + 's'); }
openCategory(bits.join(' · '), q.join('&'));
}
function genreName(kind, id) {
var list = state.genres[kind] || [];
for (var i = 0; i < list.length; i++) if (String(list[i].id) === String(id)) return list[i].name;
return '';
}
function rebuildGenreOptions() {
var sel = $('[data-vdsc-f-genre]'); if (!sel) return;
var list = state.genres[state.filter.kind] || [];
sel.innerHTML = '<option value="">All genres</option>' + list.map(function (g) {
var kind = ($('[data-vdsc-f-kind]') || {}).value === 'show' ? 'show' : 'movie';
sel.innerHTML = '<option value="">All genres</option>' + (state.genres[kind] || []).map(function (g) {
return '<option value="' + g.id + '">' + esc(g.name) + '</option>';
}).join('');
}
@ -286,85 +293,73 @@
state.wired = true;
var page = $('[data-video-subpage="' + PAGE_ID + '"]'); if (!page) return;
// one delegated click for the whole page: cards + hero CTA + arrows + dots
page.addEventListener('click', function (e) {
var open = e.target.closest('[data-vsr-open]');
if (open) {
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
e.preventDefault();
var kind = open.getAttribute('data-vsr-open');
var id = parseInt(open.getAttribute('data-vsr-id'), 10);
if (isNaN(id)) return;
document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', {
detail: { kind: kind, id: id, source: open.getAttribute('data-vsr-source') || 'tmdb' },
detail: { kind: open.getAttribute('data-vsr-open'), id: id,
source: open.getAttribute('data-vsr-source') || 'tmdb' },
}));
return;
}
var seeall = e.target.closest('[data-vdsc-seeall]');
if (seeall) {
var shelf = seeall.closest('.vdsc-shelf');
if (shelf) openCategory(shelf.getAttribute('data-vdsc-title'), shelf.getAttribute('data-vdsc-q'));
return;
}
var arrow = e.target.closest('[data-vdsc-scroll]');
if (arrow) {
var rail = $('[data-vdsc-rail]', arrow.closest('.vdsc-shelf'));
if (rail) rail.scrollBy({ left: parseInt(arrow.getAttribute('data-vdsc-scroll'), 10) * rail.clientWidth * 0.8, behavior: 'smooth' });
if (rail) rail.scrollBy({ left: parseInt(arrow.getAttribute('data-vdsc-scroll'), 10) * rail.clientWidth * 0.85, behavior: 'smooth' });
return;
}
var dot = e.target.closest('[data-vdsc-go]');
if (dot) { goHero(parseInt(dot.getAttribute('data-vdsc-go'), 10)); startHeroTimer(); return; }
if (dot) { goHero(parseInt(dot.getAttribute('data-vdsc-go'), 10)); startHeroTimer(); }
});
// hero auto-advance pauses while hovered
var hero = $('[data-vdsc-hero]');
if (hero) {
hero.addEventListener('mouseenter', stopHeroTimer);
hero.addEventListener('mouseleave', startHeroTimer);
}
if (hero) { hero.addEventListener('mouseenter', stopHeroTimer); hero.addEventListener('mouseleave', startHeroTimer); }
var kind = $('[data-vdsc-f-kind]');
if (kind) kind.addEventListener('change', function () {
state.filter.kind = kind.value === 'show' ? 'show' : 'movie';
rebuildGenreOptions();
});
var genre = $('[data-vdsc-f-genre]');
if (genre) genre.addEventListener('change', function () { state.filter.genre = genre.value; });
var decade = $('[data-vdsc-f-decade]');
if (decade) decade.addEventListener('change', function () { state.filter.decade = decade.value; });
var sort = $('[data-vdsc-f-sort]');
if (sort) sort.addEventListener('change', function () { state.filter.sort = sort.value; });
var kindSel = $('[data-vdsc-f-kind]');
if (kindSel) kindSel.addEventListener('change', rebuildGenreOptions);
var apply = $('[data-vdsc-apply]'); if (apply) apply.addEventListener('click', applyFilter);
var clear = $('[data-vdsc-clear]'); if (clear) clear.addEventListener('click', closeCategory);
var more = $('[data-vdsc-more]'); if (more) more.addEventListener('click', function () { state.cat.page++; loadGrid(false); });
var apply = $('[data-vdsc-apply]');
if (apply) apply.addEventListener('click', applyFilter);
var clear = $('[data-vdsc-clear]');
if (clear) clear.addEventListener('click', clearFilter);
var more = $('[data-vdsc-more]');
if (more) more.addEventListener('click', function () { state.filter.page++; loadGrid(false); });
var hide = $('[data-vdsc-hideowned]');
if (hide) hide.addEventListener('change', function () { page.classList.toggle('vdsc-hide-owned', hide.checked); });
}
function loadGenres() {
fetch('/api/video/discover/genres', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
state.genres = { movie: (d && d.movie) || [], show: (d && d.show) || [] };
function loadMeta() {
var jget = function (u) { return fetch(u, { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }); };
Promise.all([jget('/api/video/discover/genres'), jget('/api/video/discover/taste')])
.then(function (res) {
var g = res[0] || {}, t = res[1] || {};
state.genres = { movie: g.movie || [], show: g.show || [] };
state.taste = { movie: t.movie || [], show: t.show || [] };
rebuildGenreOptions();
renderShelves(); // genre rails need the id map
})
.catch(function () { renderShelves(); });
renderShelves();
});
}
function load() {
if (state.loaded) return;
state.loaded = true;
loadHero();
loadGenres();
loadMeta();
}
function onShown(e) { if (e && e.detail === PAGE_ID) { wire(); load(); startHeroTimer(); } }
function onHidden() { stopHeroTimer(); }
function init() {
document.addEventListener('soulsync:video-page-shown', onShown);
// pause the hero when leaving the page
document.addEventListener('soulsync:video-page-shown', function (e) {
if (e && e.detail !== PAGE_ID) onHidden();
});
function onShown(e) {
if (!e) return;
if (e.detail === PAGE_ID) { wire(); load(); startHeroTimer(); }
else stopHeroTimer(); // left the page → stop the slideshow
}
function init() { document.addEventListener('soulsync:video-page-shown', onShown); }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();

View file

@ -2258,3 +2258,41 @@ body[data-side="video"] #soulsync-toggle { display: none; }
.vdsc-hero-body { animation: none; }
.vdsc-skel-card { animation: none; }
}
/* ── Discover v2: ambient glow + see-all + grid head + hide-owned + polish ─── */
.vdsc-page { position: relative; }
.vdsc-hero, .vdsc-filterbar, .vdsc-shelves, .vdsc-grid-wrap { position: relative; z-index: 1; }
/* a faint page-top color bleed that follows the current hero slide's hue */
.vdsc-amb { position: absolute; top: -40px; left: 50%; transform: translateX(-50%); z-index: 0;
width: 130%; height: 580px; pointer-events: none; opacity: 0.5; transition: opacity 0.7s ease;
background: radial-gradient(58% 80% at 50% 0%, hsla(var(--vdsc-amb, 230), 82%, 55%, 0.22), transparent 70%); }
/* "See all" → opens the rail as a paged grid */
.vdsc-seeall { font-size: 12.5px; font-weight: 700; color: rgba(255, 255, 255, 0.5); background: none;
border: none; cursor: pointer; padding: 4px 9px; border-radius: 8px; margin-right: 4px;
transition: color 0.15s ease, background 0.15s ease; }
.vdsc-seeall:hover { color: #fff; background: rgba(255, 255, 255, 0.07); }
/* grid (category) header — back button + title */
.vdsc-grid-head { display: flex; align-items: center; gap: 16px; margin: 2px 0 22px; }
.vdsc-grid-title { font-size: 23px; font-weight: 800; letter-spacing: -0.01em; color: #fff; margin: 0; }
/* hide-owned toggle (sits at the far right of the filter bar) */
.vdsc-toggle { display: inline-flex; align-items: center; gap: 7px; margin-left: auto;
font-size: 13px; font-weight: 600; color: rgba(255, 255, 255, 0.7); cursor: pointer; user-select: none; }
.vdsc-toggle input { accent-color: rgb(var(--accent-rgb, 88 101 242)); width: 16px; height: 16px; cursor: pointer; }
.vdsc-hide-owned .vsr-card--owned { display: none; }
/* rails: soft edge fade + a gentle fade-in when filled + per-title hue on hover */
.vdsc-rail { -webkit-mask-image: linear-gradient(90deg, transparent, #000 18px, #000 calc(100% - 18px), transparent);
mask-image: linear-gradient(90deg, transparent, #000 18px, #000 calc(100% - 18px), transparent); }
.vdsc-shelf--in .vdsc-rail { animation: vdscFade 0.45s ease both; }
@keyframes vdscFade { from { opacity: 0.35; } to { opacity: 1; } }
.vdsc-rail .vsr-card, .vdsc-grid .vsr-card { transition: transform 0.2s ease, box-shadow 0.2s ease; }
.vdsc-rail .vsr-card:hover, .vdsc-grid .vsr-card:hover {
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.5), 0 0 26px -6px hsla(var(--vgm-h, 230), 80%, 58%, 0.5); }
@media (prefers-reduced-motion: reduce) {
.vdsc-shelf--in .vdsc-rail { animation: none; }
.vdsc-amb { transition: none; }
}