From b2adc63a6a75ea2538b7d9916ff8174fbae01056 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Mon, 15 Jun 2026 13:28:17 -0700 Subject: [PATCH] video: where-to-watch region (no longer hardcoded US) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A saved 'Where-to-watch region' picker in Settings → Detail Pages (19 common regions, default US). The engine reads it for the providers in extras + tmdb_detail (region in the cache key), and the detail page labels the section 'Where to Watch · ' so you know which market you're seeing. --- api/video/enrichment.py | 9 ++++++++- core/video/enrichment/clients.py | 4 ++-- core/video/enrichment/engine.py | 18 +++++++++++++----- tests/test_video_api.py | 11 +++++++---- tests/test_video_enrichment.py | 6 +++--- webui/index.html | 24 ++++++++++++++++++++++++ webui/static/video/video-detail.js | 6 ++++++ webui/static/video/video-settings.js | 14 +++++++++++--- webui/static/video/video-side.css | 7 +++++++ 9 files changed, 81 insertions(+), 18 deletions(-) diff --git a/api/video/enrichment.py b/api/video/enrichment.py index 59f6b8bd..615006f1 100644 --- a/api/video/enrichment.py +++ b/api/video/enrichment.py @@ -47,6 +47,7 @@ def register_routes(bp): "tvdb_api_key": db.get_setting("tvdb_api_key") or "", "omdb_api_key": db.get_setting("omdb_api_key") or "", "billboard_autoplay": (db.get_setting("billboard_autoplay") or "1") == "1", + "watch_region": (db.get_setting("watch_region") or "US").upper(), }) @bp.route("/prefs", methods=["GET"]) @@ -54,7 +55,10 @@ def register_routes(bp): # Lightweight UI prefs for the detail page (no API keys). from . import get_video_db db = get_video_db() - return jsonify({"billboard_autoplay": (db.get_setting("billboard_autoplay") or "1") == "1"}) + return jsonify({ + "billboard_autoplay": (db.get_setting("billboard_autoplay") or "1") == "1", + "watch_region": (db.get_setting("watch_region") or "US").upper(), + }) @bp.route("/enrichment/config", methods=["POST"]) def video_enrichment_config_save(): @@ -67,6 +71,9 @@ def register_routes(bp): db.set_setting("tvdb_api_key", body.get("tvdb_api_key") or "") if "billboard_autoplay" in body: db.set_setting("billboard_autoplay", "1" if body.get("billboard_autoplay") else "0") + if "watch_region" in body: + region = (body.get("watch_region") or "US").strip().upper()[:2] or "US" + db.set_setting("watch_region", region) if "omdb_api_key" in body: new_key = body.get("omdb_api_key") or "" changed = new_key != (db.get_setting("omdb_api_key") or "") diff --git a/core/video/enrichment/clients.py b/core/video/enrichment/clients.py index 1278cca5..fc565f10 100644 --- a/core/video/enrichment/clients.py +++ b/core/video/enrichment/clients.py @@ -466,7 +466,7 @@ class TMDBClient: "poster": (self.POSTER_W + it["poster_path"]) if it.get("poster_path") else None}) return out[:20] - def full_detail(self, kind, tmdb_id): + 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 renders it). Seasons carry counts; episodes load lazily per season.""" @@ -503,7 +503,7 @@ class TMDBClient: for p in cmeta.get("cast") or []], "crew": [{"name": p["name"], "job": p.get("job"), "tmdb_id": p.get("tmdb_id")} for p in cmeta.get("crew") or []], - "_extras": self._parse_extras(kind, dr), + "_extras": self._parse_extras(kind, dr, region), } self._fill_collection(out["_extras"]) if kind == "movie": diff --git a/core/video/enrichment/engine.py b/core/video/enrichment/engine.py index dd60a5ec..d91c98e0 100644 --- a/core/video/enrichment/engine.py +++ b/core/video/enrichment/engine.py @@ -38,6 +38,12 @@ class VideoEnrichmentEngine: for w in self.workers.values(): w.restore_paused() + def _region(self): + try: + return (self.db.get_setting("watch_region") or "US").upper() + except Exception: + return "US" + def _cache_get(self, key): return self._cache.get(key) @@ -171,11 +177,12 @@ class VideoEnrichmentEngine: info = (self.db.movie_match_info(item_id) if kind == "movie" else self.db.show_match_info(item_id)) if info and info.get("tmdb_id"): - key = ("extras", kind, info["tmdb_id"]) + region = self._region() + key = ("extras", kind, info["tmdb_id"], region) cached = self._cache_get(key) if cached is None: try: - cached = w.client.extras(kind, info["tmdb_id"]) or {} + cached = w.client.extras(kind, info["tmdb_id"], region=region) or {} self._cache_put(key, cached) except Exception: logger.exception("item_extras failed for %s %s", kind, item_id) @@ -304,11 +311,12 @@ class VideoEnrichmentEngine: lib_id = self.db.library_id_for_tmdb(kind, tmdb_id) if lib_id: return {"redirect": {"source": "library", "kind": kind, "id": lib_id}} - cached = self._cache_get(("detail", kind, tmdb_id)) + region = self._region() + cached = self._cache_get(("detail", kind, tmdb_id, region)) if cached is not None: return dict(cached) try: - d = w.client.full_detail(kind, tmdb_id) + d = w.client.full_detail(kind, tmdb_id, region=region) except Exception: logger.exception("tmdb_detail failed for %s %s", kind, tmdb_id) return None @@ -336,7 +344,7 @@ class VideoEnrichmentEngine: d["episode_total"] = sum(s["episode_total"] for s in seasons) d["episode_owned"] = 0 self._fill_tmdb_ratings(d) - self._cache_put(("detail", kind, tmdb_id), d) + self._cache_put(("detail", kind, tmdb_id, region), d) return d def _fill_tmdb_ratings(self, d) -> None: diff --git a/tests/test_video_api.py b/tests/test_video_api.py index 2ce69ed6..d1ca6218 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -268,14 +268,17 @@ def test_enrichment_config_save_load(tmp_path, monkeypatch): client = app.test_client() try: assert client.get("/api/video/enrichment/config").get_json() == { - "tmdb_api_key": "", "tvdb_api_key": "", "omdb_api_key": "", "billboard_autoplay": True} + "tmdb_api_key": "", "tvdb_api_key": "", "omdb_api_key": "", + "billboard_autoplay": True, "watch_region": "US"} client.post("/api/video/enrichment/config", json={"tmdb_api_key": "abc", "tvdb_api_key": "xyz", "omdb_api_key": "om", - "billboard_autoplay": False}) + "billboard_autoplay": False, "watch_region": "gb"}) assert client.get("/api/video/enrichment/config").get_json() == { - "tmdb_api_key": "abc", "tvdb_api_key": "xyz", "omdb_api_key": "om", "billboard_autoplay": False} + "tmdb_api_key": "abc", "tvdb_api_key": "xyz", "omdb_api_key": "om", + "billboard_autoplay": False, "watch_region": "GB"} assert db.get_setting("tmdb_api_key") == "abc" and db.get_setting("omdb_api_key") == "om" - assert client.get("/api/video/prefs").get_json() == {"billboard_autoplay": False} + assert client.get("/api/video/prefs").get_json() == { + "billboard_autoplay": False, "watch_region": "GB"} finally: videoapi._video_db = None diff --git a/tests/test_video_enrichment.py b/tests/test_video_enrichment.py index a48c6477..6955f3d3 100644 --- a/tests/test_video_enrichment.py +++ b/tests/test_video_enrichment.py @@ -217,7 +217,7 @@ def test_engine_tmdb_detail_redirects_when_owned(db): class Tmdb: enabled = True - def full_detail(self, kind, tid): raise AssertionError("must not fetch an owned title") + def full_detail(self, kind, tid, region="US"): raise AssertionError("must not fetch an owned title") eng = VideoEnrichmentEngine(db, {"tmdb": Tmdb()}) assert eng.tmdb_detail("movie", 77) == {"redirect": {"source": "library", "kind": "movie", "id": mid}} @@ -225,7 +225,7 @@ def test_engine_tmdb_detail_redirects_when_owned(db): def test_engine_tmdb_detail_assembles_show(db): class Tmdb: enabled = True - def full_detail(self, kind, tid): + def full_detail(self, kind, tid, region="US"): return {"kind": "show", "tmdb_id": tid, "title": "Loki", "imdb_id": None, "poster_url": "http://p", "backdrop_url": None, "cast": [], "crew": [], "_extras": {"similar": [{"title": "X", "tmdb_id": 5, "kind": "show"}]}, @@ -643,7 +643,7 @@ def test_item_extras_caches_tmdb_call(db, monkeypatch): calls = [] class Tmdb: enabled = True - def extras(self, kind, tid): calls.append(tid); return {"keywords": ["x"]} + def extras(self, kind, tid, region="US"): calls.append(tid); return {"keywords": ["x"]} eng = VideoEnrichmentEngine(db, {"tmdb": Tmdb()}) a = eng.item_extras("movie", mid) b = eng.item_extras("movie", mid) diff --git a/webui/index.html b/webui/index.html index 76b458e2..db72e38f 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5205,6 +5205,30 @@
Plays a muted trailer behind the hero after a moment (uses more bandwidth).
+ diff --git a/webui/static/video/video-detail.js b/webui/static/video/video-detail.js index a9d64d87..d429510e 100644 --- a/webui/static/video/video-detail.js +++ b/webui/static/video/video-detail.js @@ -501,6 +501,12 @@ } ps.hidden = !html; ph.innerHTML = html; + if (!ps.hidden) { + loadPrefs(function (p) { + var h = ps.querySelector('.vd-section-h'); + if (h) h.textContent = 'Where to Watch' + (p && p.watch_region ? ' · ' + p.watch_region : ''); + }); + } } // Franchise / collection (movies) — the other films in the set. var cs = q('[data-vd-collection-section]'), ch = q('[data-vd-collection]'), ct = q('[data-vd-collection-title]'); diff --git a/webui/static/video/video-settings.js b/webui/static/video/video-settings.js index 9883ec20..7df8ec67 100644 --- a/webui/static/video/video-settings.js +++ b/webui/static/video/video-settings.js @@ -77,15 +77,21 @@ if (o && d.omdb_api_key != null) o.value = d.omdb_api_key; var ap = document.getElementById('video-billboard-autoplay'); if (ap && d.billboard_autoplay != null) ap.checked = !!d.billboard_autoplay; + var wr = document.getElementById('video-watch-region'); + if (wr && d.watch_region) wr.value = d.watch_region; }) .catch(function () { /* ignore */ }); } - function saveAutoplay() { + function savePrefs() { var ap = document.getElementById('video-billboard-autoplay'); + var wr = document.getElementById('video-watch-region'); fetch(CONFIG_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, - body: JSON.stringify({ billboard_autoplay: ap ? ap.checked : true }) + body: JSON.stringify({ + billboard_autoplay: ap ? ap.checked : true, + watch_region: wr ? wr.value : 'US', + }) }).catch(function () { /* ignore */ }); } @@ -140,7 +146,9 @@ if (el) el.addEventListener('change', saveKeys); }); var autoplay = document.getElementById('video-billboard-autoplay'); - if (autoplay) autoplay.addEventListener('change', saveAutoplay); + if (autoplay) autoplay.addEventListener('change', savePrefs); + var region = document.getElementById('video-watch-region'); + if (region) region.addEventListener('change', savePrefs); // Per-connection Test buttons (same behaviour as music's testConnection). var testBtns = document.querySelectorAll('[data-video-test-service]'); for (var k = 0; k < testBtns.length; k++) { diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 98cce568..602dbf5b 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -1458,3 +1458,10 @@ a.vd-guest:hover .vd-guest-name { color: rgb(var(--vd-accent-rgb)); } .vd-ep-extra { flex-direction: column; padding-left: 14px; } .vd-ep-extra-still { width: 100%; } } + +.vid-pref-row--select { justify-content: space-between; cursor: default; } +.vid-pref-row--select select { + background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.14); + color: var(--text-primary, #fff); border-radius: 8px; padding: 7px 10px; font-size: 13px; cursor: pointer; +} +.vid-pref-row--select select option { background: #16161c; color: #fff; }