video discover phase 3: 'On your streaming services' rail

A saved streaming-services preference drives a personalized 'On your streaming services' rail:
- GET/POST /discover/providers-pref (TMDB provider ids); /discover/list OR-joins multiple
  providers (comma->pipe) into with_watch_providers.
- A 📺 multi-select in the toolbar (Netflix/Prime/Disney+/Max/Apple TV+/Hulu/Paramount+/Peacock);
  selecting services saves the preference and rebuilds the rails.
- The rail (high priority, after taste) appears once you've picked services, showing what's
  streaming on yours.
This commit is contained in:
BoulderBadgeDad 2026-06-23 00:33:35 -07:00
parent 6734b6ab22
commit 5906576a13
3 changed files with 69 additions and 1 deletions

View file

@ -223,6 +223,28 @@ def register_routes(bp):
logger.exception("discover languages get/set failed")
return jsonify({"languages": ["en"]})
@bp.route("/discover/providers-pref", methods=["GET", "POST"])
def video_discover_providers_pref():
"""The user's subscribed streaming services (TMDB provider ids) — drives the
'On your streaming services' rail. POST {providers:[8,9]} (or '8,9'); GET returns them."""
from . import get_video_db
db = get_video_db()
try:
if request.method == "POST":
body = request.get_json(silent=True) or {}
p = body.get("providers")
if isinstance(p, list):
val = ",".join(str(c).strip() for c in p if str(c).strip())
else:
val = ",".join(c.strip() for c in str(p or "").split(",") if c.strip())
db.set_setting("discover_providers", val)
return jsonify({"success": True, "providers": [c for c in val.split(",") if c]})
raw = db.get_setting("discover_providers", "") or ""
return jsonify({"providers": [c.strip() for c in raw.split(",") if c.strip()]})
except Exception:
logger.exception("discover providers-pref failed")
return jsonify({"providers": []})
@bp.route("/discover/genres", methods=["GET"])
def video_discover_genres():
"""Genre id→name maps for both kinds (powers the genre rails + filter)."""
@ -257,6 +279,8 @@ def register_routes(bp):
year = request.args.get("year") or None
decade = request.args.get("decade") or None
providers = request.args.get("providers") or None
if providers and "," in providers:
providers = providers.replace(",", "|") # TMDB with_watch_providers OR-join
sort = request.args.get("sort") or "popularity.desc"
lang = (request.args.get("lang") or "").strip() or None # explicit (foreign rail)
hide_owned = (request.args.get("hide_owned") or "") in ("1", "true", "yes")

View file

@ -1063,6 +1063,17 @@
<button class="vdsc-lang" type="button" data-lang="de">DE</button>
<button class="vdsc-lang" type="button" data-lang="it">IT</button>
</div>
<div class="vdsc-langs" data-vdsc-myprov aria-label="My streaming services" title="Your streaming services power the 'On your streaming services' rail">
<span class="vdsc-langs-label">📺</span>
<button class="vdsc-lang" type="button" data-prov="8">Netflix</button>
<button class="vdsc-lang" type="button" data-prov="9">Prime</button>
<button class="vdsc-lang" type="button" data-prov="337">Disney+</button>
<button class="vdsc-lang" type="button" data-prov="1899">Max</button>
<button class="vdsc-lang" type="button" data-prov="350">Apple TV+</button>
<button class="vdsc-lang" type="button" data-prov="15">Hulu</button>
<button class="vdsc-lang" type="button" data-prov="531">Paramount+</button>
<button class="vdsc-lang" type="button" data-prov="386">Peacock</button>
</div>
</div>
<div class="vdsc-chips" data-vdsc-chipset="genre" aria-label="Genre">
<button class="vdsc-chip vdsc-chip--on" type="button" data-val="">All genres</button>

View file

@ -19,6 +19,7 @@
var state = {
loaded: false, wired: false, mode: 'shelves',
genres: { movie: [], show: [] }, taste: { movie: [], show: [] },
myProviders: [], // saved streaming services -> 'On your streaming services' rail
io: null,
hero: { items: [], idx: 0, timer: null },
cat: { title: '', q: '', page: 1, paginates: true, busy: false, hasMore: false },
@ -105,6 +106,11 @@
var id = gs[name.toLowerCase()];
if (id != null) { out.push({ title: 'More ' + name + ' shows', q: 'kind=show&genre=' + id + '&sort=popularity.desc' }); }
});
// 'On your streaming services' — only when the user has set their subscriptions.
if (state.myProviders && state.myProviders.length) {
out.push({ title: 'On your streaming services',
q: 'kind=movie&providers=' + state.myProviders.join(',') + '&sort=popularity.desc' });
}
out = out.concat(CURATED);
GENRE_RAILS.forEach(function (name) {
var id = gm[name.toLowerCase()];
@ -701,6 +707,31 @@
}).then(function () { reloadRails(); }).catch(function () { /* ignore */ });
});
}
// 'My streaming services' preference — multi-select; drives the 'On your services' rail.
var provWrap = $('[data-vdsc-myprov]');
if (provWrap && !provWrap._wired) {
provWrap._wired = 1;
fetch('/api/video/discover/providers-pref', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
var on = {}; ((d && d.providers) || []).forEach(function (c) { on[String(c)] = 1; });
state.myProviders = (d && d.providers) || [];
provWrap.querySelectorAll('.vdsc-lang').forEach(function (b) {
b.classList.toggle('vdsc-lang--on', !!on[b.getAttribute('data-prov')]);
});
}).catch(function () { /* chips stay off */ });
provWrap.addEventListener('click', function (e) {
var btn = e.target.closest('.vdsc-lang'); if (!btn) return;
btn.classList.toggle('vdsc-lang--on');
var provs = Array.prototype.map.call(provWrap.querySelectorAll('.vdsc-lang--on'),
function (b) { return b.getAttribute('data-prov'); });
state.myProviders = provs;
fetch('/api/video/discover/providers-pref', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ providers: provs }),
}).then(function () { reloadRails(); }).catch(function () { /* ignore */ });
});
}
// Infinite scroll: a sentinel near the grid bottom pulls the next page.
var sentinel = $('[data-vdsc-sentinel]');
if (sentinel && AUTO) {
@ -726,11 +757,13 @@
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')])
Promise.all([jget('/api/video/discover/genres'), jget('/api/video/discover/taste'),
jget('/api/video/discover/providers-pref')])
.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 || [] };
state.myProviders = (res[2] && res[2].providers) || [];
// Genres are a static TMDB endpoint — empty means TMDB isn't set up.
if (!state.genres.movie.length && !state.genres.show.length) { showEmpty(); return; }
renderGenreChips();