From a275cbfb7ad62f3ffd6bb1ea0d24266849d41ca8 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Tue, 23 Jun 2026 23:55:30 -0700 Subject: [PATCH] Video Discover: Top 10 ranked rail + New/Mood/Studios sections + NEW badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Best-in-class discovery pass (Netflix/Disney-class): - iconic ranked 'Top 10 today' rail (daily trending, big outlined rank numerals, true global chart via lang=any, single un-paged fetch) - 'New & noteworthy' (date-windowed new releases + theatrical pipeline) - 'By mood' (genre AND-combos w/ vote floors: feel-good, mind-bending…) - 'From the studios' (Pixar/Ghibli/A24/Marvel/DreamWorks via company ids) - 'Something different' (quick watches / epics / family) merged with gems - 'NEW' flag on un-owned current-year cards Preserves async-loader group ids (foryou/collection/taste). Empty rails self-prune; wrong studio ids just drop their rail. --- api/video/discover.py | 4 +- webui/static/video/video-discover.js | 69 ++++++++++++++++++++++++---- webui/static/video/video-side.css | 24 ++++++++++ 3 files changed, 87 insertions(+), 10 deletions(-) diff --git a/api/video/discover.py b/api/video/discover.py index 36334860..d941a527 100644 --- a/api/video/discover.py +++ b/api/video/discover.py @@ -360,8 +360,8 @@ def register_routes(bp): # thread-safe) so digging 20 pages deep costs ~4 page-latencies, not 20. need_fill = hide_owned or bool(prefer_langs) target = 20 if need_fill else 0 - if key == "trending": - consume(fetch(page)) + if key in ("trending", "trending_today"): + consume(fetch(page)) # a single fixed chart; never paged elif not need_fill: for offset in range(pages): # no filtering: respect requested page count if not consume(fetch(page + offset)): diff --git a/webui/static/video/video-discover.js b/webui/static/video/video-discover.js index f53db34b..2213b747 100644 --- a/webui/static/video/video-discover.js +++ b/webui/static/video/video-discover.js @@ -15,6 +15,7 @@ var PAGE_ID = 'video-discover'; var LIST_URL = '/api/video/discover/list'; + var NOW_YEAR = new Date().getFullYear(); // for the 'NEW' (just-released) card flag var state = { loaded: false, wired: false, mode: 'shelves', @@ -48,16 +49,46 @@ } // ── the rail stack (personalized + curated + genre + decade) ────────────── + // The iconic ranked "Top 10 today" row — daily TMDB trending, rendered with rank numbers. + var TOP10 = { title: 'Top 10 Today', q: 'key=trending_today&lang=any', ranked: true }; var CURATED = [ { title: 'Trending This Week', q: 'key=trending' }, { title: 'Popular Movies', q: 'key=popular_movies' }, { title: 'Popular Shows', q: 'key=popular_shows' }, - { title: 'In Theaters Now', q: 'key=now_playing' }, - { title: 'Coming Soon', q: 'key=upcoming_movies' }, { title: 'On The Air', q: 'key=on_the_air' }, { title: 'Top Rated Movies', q: 'key=top_movies' }, { title: 'Top Rated Shows', q: 'key=top_shows' }, ]; + // New & noteworthy — date-windowed "new" + the theatrical pipeline. + var NEW_RAILS = [ + { title: 'New Movies This Month', q: 'kind=movie&release_window=last_30&sort=popularity.desc' }, + { title: 'Fresh on TV', q: 'kind=show&release_window=last_90&sort=popularity.desc' }, + { title: 'In Theaters Now', q: 'key=now_playing' }, + { title: 'Coming Soon', q: 'key=upcoming_movies' }, + ]; + // Mood rails — genre AND-combos + a vote floor so they read as a vibe, not a dump. + var MOOD_RAILS = [ + { title: 'Feel-Good Favorites', q: 'kind=movie&genre=35,10751&sort=popularity.desc' }, // Comedy + Family + { title: 'Edge of Your Seat', q: 'kind=movie&genre=53&sort=popularity.desc&vote_count_min=300' }, // Thriller + { title: 'Mind-Bending', q: 'kind=movie&genre=878,9648&sort=vote_average.desc&vote_count_min=300' }, // Sci-Fi + Mystery + { title: 'Date Night', q: 'kind=movie&genre=10749,35&sort=popularity.desc' }, // Romance + Comedy + { title: 'Tearjerkers', q: 'kind=movie&genre=18&sort=vote_average.desc&vote_count_min=500' }, // Drama + { title: 'Laugh Out Loud', q: 'kind=movie&genre=35&sort=vote_average.desc&vote_count_min=400' }, // Comedy + ]; + // From the studios — TMDB company ids (stable). A wrong/empty one just drops its rail. + var STUDIO_RAILS = [ + { title: 'Pixar', q: 'kind=movie&companies=3&sort=primary_release_date.desc' }, + { title: 'Studio Ghibli', q: 'kind=movie&companies=10342&sort=vote_average.desc' }, + { title: 'A24', q: 'kind=movie&companies=41077&sort=primary_release_date.desc' }, + { title: 'Marvel Studios', q: 'kind=movie&companies=420&sort=primary_release_date.desc' }, + { title: 'DreamWorks Animation', q: 'kind=movie&companies=521&sort=popularity.desc' }, + ]; + // Something different — runtime + family slices. + var DIFFERENT_RAILS = [ + { title: 'Quick Watches (under 90 min)', q: 'kind=movie&max_runtime=90&sort=popularity.desc&vote_count_min=200' }, + { title: 'Epics (3 hrs+)', q: 'kind=movie&min_runtime=180&sort=vote_average.desc&vote_count_min=200' }, + { title: 'Family Movie Night', q: 'kind=movie&genre=10751&sort=popularity.desc' }, + ]; var GENRE_RAILS = ['Action', 'Adventure', 'Comedy', 'Drama', 'Science Fiction', 'Thriller', 'Horror', 'Animation', 'Fantasy', 'Romance', 'Documentary', 'Crime']; // A thematic colour per genre so the chips read intentional, not random. @@ -120,14 +151,19 @@ if (id != null && !used['m:' + name.toLowerCase()]) genre.push({ title: name, q: 'kind=movie&genre=' + id + '&sort=popularity.desc' }); }); - // foryou/collection/taste are also fed by async loaders (loadForYou/loadGaps/loadMoreLike). + // foryou/collection/taste are also fed by async loaders (loadForYou/loadGaps/loadMoreLike) — + // keep those group ids. Order of this array IS the on-screen order. return [ { id: 'foryou', label: 'For you', rails: foryou }, + { id: 'topten', label: 'Top 10 today', rails: [TOP10] }, { id: 'collection', label: 'Finish your collection', rails: [] }, { id: 'taste', label: 'More of what you like', rails: taste }, + { id: 'new', label: 'New & noteworthy', rails: NEW_RAILS }, { id: 'popular', label: 'Trending & popular', rails: CURATED }, + { id: 'mood', label: 'By mood', rails: MOOD_RAILS }, + { id: 'studios', label: 'From the studios', rails: STUDIO_RAILS }, { id: 'genre', label: 'Browse by genre', rails: genre }, - { id: 'corners', label: 'Hidden gems & more', rails: GEM_RAILS.concat(DECADE_RAILS).concat(FOREIGN_RAILS) }, + { id: 'different', label: 'Something different', rails: DIFFERENT_RAILS.concat(GEM_RAILS).concat(DECADE_RAILS).concat(FOREIGN_RAILS) }, ]; } @@ -144,6 +180,9 @@ : 'Preview'; var rating = it.rating ? '★ ' + (Math.round(it.rating * 10) / 10) + '' : ''; + // 'NEW' flag for un-owned current-year releases — a quick "just out" signal. + var fresh = (!owned && it.year && Number(it.year) >= NOW_YEAR) + ? 'NEW' : ''; var source = owned ? 'library' : 'tmdb'; var id = owned ? it.library_id : it.tmdb_id; var href = '/video-detail/' + source + '/' + it.kind + '/' + id; @@ -158,11 +197,15 @@ return '' + cb + notInt + - '
' + img + ribbon + rating + + '
' + img + ribbon + rating + fresh + '
' + '
' + esc(it.title) + '' + esc(sub) + '
'; } + // A ranked card (Top 10): a big outlined rank numeral beside the normal poster. + function rankedCard(it, rank) { + return '
' + card(it) + '
'; + } function hydrateGet(root) { if (window.VideoWatchlist) VideoWatchlist.hydrate(root); } // ── 'Not interested' + ignore-list management ───────────────────────────── @@ -482,7 +525,9 @@ } // A lazy rail (skeleton until scrolled into view, then filled by fillShelf). function lazyShelfHtml(sh) { - return '
' + + var rankCls = sh.ranked ? ' vdsc-shelf--ranked' : ''; + var rankAttr = sh.ranked ? ' data-vdsc-ranked="1"' : ''; + return '
' + '

' + esc(sh.title) + '

' + shelfNav(true) + '
' + '
' + '
' + Array(8).join('
') + '
' + @@ -557,14 +602,22 @@ shelf.setAttribute('data-vdsc-loaded', '1'); var rail = $('[data-vdsc-rail]', shelf); var grp = shelf.closest('.vdsc-group'); + var ranked = shelf.getAttribute('data-vdsc-ranked') === '1'; // Normal: 2 pages (~40 items). Hiding owned: let the backend page DEEPER and drop // owned server-side, so a huge library's rail still fills instead of CSS-hiding to ~nothing. - var q = shelf.getAttribute('data-vdsc-q') + (isHideOwned() ? '&hide_owned=1' : '&pages=2'); + // Ranked (Top 10): a single fixed chart — never paged, capped at 10. + var q = shelf.getAttribute('data-vdsc-q') + (ranked ? '' : (isHideOwned() ? '&hide_owned=1' : '&pages=2')); cachedFetch(LIST_URL + '?' + q) .then(function (d) { var items = (d && d.items) || []; + if (ranked) items = items.slice(0, 10); if (!items.length) { shelf.remove(); pruneGroup(grp); return; } // drop empty shelves - if (rail) { rail.innerHTML = items.map(card).join(''); stagger(rail); hydrateGet(rail); } + if (rail) { + rail.innerHTML = ranked + ? items.map(function (it, i) { return rankedCard(it, i + 1); }).join('') + : items.map(card).join(''); + stagger(rail); hydrateGet(rail); + } shelf.classList.add('vdsc-shelf--in'); // reveal (cards cascade via --i) }) .catch(function () { shelf.remove(); pruneGroup(grp); }); diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index c924eaf5..69f107c8 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -922,6 +922,15 @@ a.vd-prov:hover img, a.vd-prov:hover .vd-prov-ph { border-color: rgba(var(--vd-a padding: 3px 8px; border-radius: 7px; background: rgba(0, 0, 0, 0.62); backdrop-filter: blur(6px); } +/* 'NEW' flag — un-owned current-year releases (bottom-left, clear of ribbon + rating) */ +.vsr-new { + position: absolute; bottom: 10px; left: 10px; z-index: 2; + font-size: 9.5px; font-weight: 900; letter-spacing: 0.6px; + padding: 3px 7px; border-radius: 6px; color: #07110a; + background: linear-gradient(135deg, #34d399, #10b981); + box-shadow: 0 3px 10px rgba(16, 185, 129, 0.45); +} + .vsr-info { padding: 11px 4px 0; min-width: 0; } .vsr-info--center { text-align: center; } .vsr-name { @@ -2313,6 +2322,19 @@ body[data-side="video"] #soulsync-toggle { display: none; } .vdsc-rail::-webkit-scrollbar-track { background: transparent; } .vdsc-rail .vsr-card { flex: 0 0 162px; width: 162px; scroll-snap-align: start; } +/* ── Top 10 ranked rail — giant outlined numeral hugging each poster ──────────── */ +.vdsc-shelf--ranked .vdsc-rail { gap: 4px; align-items: flex-end; } +.vsr-ranked { display: flex; align-items: flex-end; flex: 0 0 auto; scroll-snap-align: start; } +.vsr-rank { + font-family: "Arial Black", "Helvetica Neue", system-ui, sans-serif; + font-size: 150px; font-weight: 900; line-height: 0.66; letter-spacing: -0.07em; + color: #0a0c14; -webkit-text-stroke: 3px rgba(255, 255, 255, 0.5); + text-shadow: 0 6px 22px rgba(0, 0, 0, 0.55); + margin: 0 -22px 0 0; user-select: none; flex: 0 0 auto; align-self: flex-end; +} +.vsr-ranked .vsr-card { flex: 0 0 150px; width: 150px; } +.vsr-ranked .vsr-card .vsr-info { padding-left: 0; } + /* skeleton shimmer while a rail loads */ .vdsc-skel { display: flex; gap: 16px; } .vdsc-skel-card { flex: 0 0 162px; height: 280px; border-radius: 12px; @@ -2331,6 +2353,8 @@ body[data-side="video"] #soulsync-toggle { display: none; } .vdsc-hero-body { left: 22px; bottom: 22px; right: 22px; } .vdsc-hero-title { font-size: 27px; } .vdsc-rail .vsr-card, .vdsc-skel-card { flex-basis: 132px; width: 132px; } + .vsr-ranked .vsr-card { flex-basis: 120px; width: 120px; } + .vsr-rank { font-size: 104px; margin-right: -16px; } } @media (prefers-reduced-motion: reduce) { .vdsc-slide, .vdsc-slide--on { animation: none; transition: opacity 0.4s ease; transform: none; }