diff --git a/api/video/discover.py b/api/video/discover.py
index fab426a4..f068685d 100644
--- a/api/video/discover.py
+++ b/api/video/discover.py
@@ -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")
diff --git a/webui/index.html b/webui/index.html
index 4f97106a..d1e935b6 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -1063,6 +1063,17 @@
+
+ 📺
+
+
+
+
+
+
+
+
+
diff --git a/webui/static/video/video-discover.js b/webui/static/video/video-discover.js
index 84a20d45..9aba8ac6 100644
--- a/webui/static/video/video-discover.js
+++ b/webui/static/video/video-discover.js
@@ -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();