video: where-to-watch region (no longer hardcoded US)

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 · <region>' so you know which market you're seeing.
This commit is contained in:
BoulderBadgeDad 2026-06-15 13:28:17 -07:00
parent b8de46d2ad
commit b2adc63a6a
9 changed files with 81 additions and 18 deletions

View file

@ -47,6 +47,7 @@ def register_routes(bp):
"tvdb_api_key": db.get_setting("tvdb_api_key") or "", "tvdb_api_key": db.get_setting("tvdb_api_key") or "",
"omdb_api_key": db.get_setting("omdb_api_key") or "", "omdb_api_key": db.get_setting("omdb_api_key") or "",
"billboard_autoplay": (db.get_setting("billboard_autoplay") or "1") == "1", "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"]) @bp.route("/prefs", methods=["GET"])
@ -54,7 +55,10 @@ def register_routes(bp):
# Lightweight UI prefs for the detail page (no API keys). # Lightweight UI prefs for the detail page (no API keys).
from . import get_video_db from . import get_video_db
db = 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"]) @bp.route("/enrichment/config", methods=["POST"])
def video_enrichment_config_save(): 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 "") db.set_setting("tvdb_api_key", body.get("tvdb_api_key") or "")
if "billboard_autoplay" in body: if "billboard_autoplay" in body:
db.set_setting("billboard_autoplay", "1" if body.get("billboard_autoplay") else "0") 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: if "omdb_api_key" in body:
new_key = body.get("omdb_api_key") or "" new_key = body.get("omdb_api_key") or ""
changed = new_key != (db.get_setting("omdb_api_key") or "") changed = new_key != (db.get_setting("omdb_api_key") or "")

View file

@ -466,7 +466,7 @@ class TMDBClient:
"poster": (self.POSTER_W + it["poster_path"]) if it.get("poster_path") else None}) "poster": (self.POSTER_W + it["poster_path"]) if it.get("poster_path") else None})
return out[:20] 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 """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 library detail payload but with direct image URLs (so the same detail UI
renders it). Seasons carry counts; episodes load lazily per season.""" renders it). Seasons carry counts; episodes load lazily per season."""
@ -503,7 +503,7 @@ class TMDBClient:
for p in cmeta.get("cast") or []], for p in cmeta.get("cast") or []],
"crew": [{"name": p["name"], "job": p.get("job"), "tmdb_id": p.get("tmdb_id")} "crew": [{"name": p["name"], "job": p.get("job"), "tmdb_id": p.get("tmdb_id")}
for p in cmeta.get("crew") or []], 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"]) self._fill_collection(out["_extras"])
if kind == "movie": if kind == "movie":

View file

@ -38,6 +38,12 @@ class VideoEnrichmentEngine:
for w in self.workers.values(): for w in self.workers.values():
w.restore_paused() 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): def _cache_get(self, key):
return self._cache.get(key) return self._cache.get(key)
@ -171,11 +177,12 @@ class VideoEnrichmentEngine:
info = (self.db.movie_match_info(item_id) if kind == "movie" info = (self.db.movie_match_info(item_id) if kind == "movie"
else self.db.show_match_info(item_id)) else self.db.show_match_info(item_id))
if info and info.get("tmdb_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) cached = self._cache_get(key)
if cached is None: if cached is None:
try: 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) self._cache_put(key, cached)
except Exception: except Exception:
logger.exception("item_extras failed for %s %s", kind, item_id) 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) lib_id = self.db.library_id_for_tmdb(kind, tmdb_id)
if lib_id: if lib_id:
return {"redirect": {"source": "library", "kind": kind, "id": 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: if cached is not None:
return dict(cached) return dict(cached)
try: try:
d = w.client.full_detail(kind, tmdb_id) d = w.client.full_detail(kind, tmdb_id, region=region)
except Exception: except Exception:
logger.exception("tmdb_detail failed for %s %s", kind, tmdb_id) logger.exception("tmdb_detail failed for %s %s", kind, tmdb_id)
return None return None
@ -336,7 +344,7 @@ class VideoEnrichmentEngine:
d["episode_total"] = sum(s["episode_total"] for s in seasons) d["episode_total"] = sum(s["episode_total"] for s in seasons)
d["episode_owned"] = 0 d["episode_owned"] = 0
self._fill_tmdb_ratings(d) self._fill_tmdb_ratings(d)
self._cache_put(("detail", kind, tmdb_id), d) self._cache_put(("detail", kind, tmdb_id, region), d)
return d return d
def _fill_tmdb_ratings(self, d) -> None: def _fill_tmdb_ratings(self, d) -> None:

View file

@ -268,14 +268,17 @@ def test_enrichment_config_save_load(tmp_path, monkeypatch):
client = app.test_client() client = app.test_client()
try: try:
assert client.get("/api/video/enrichment/config").get_json() == { 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", client.post("/api/video/enrichment/config",
json={"tmdb_api_key": "abc", "tvdb_api_key": "xyz", "omdb_api_key": "om", 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() == { 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 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: finally:
videoapi._video_db = None videoapi._video_db = None

View file

@ -217,7 +217,7 @@ def test_engine_tmdb_detail_redirects_when_owned(db):
class Tmdb: class Tmdb:
enabled = True 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()}) eng = VideoEnrichmentEngine(db, {"tmdb": Tmdb()})
assert eng.tmdb_detail("movie", 77) == {"redirect": {"source": "library", "kind": "movie", "id": mid}} 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): def test_engine_tmdb_detail_assembles_show(db):
class Tmdb: class Tmdb:
enabled = True 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, return {"kind": "show", "tmdb_id": tid, "title": "Loki", "imdb_id": None,
"poster_url": "http://p", "backdrop_url": None, "cast": [], "crew": [], "poster_url": "http://p", "backdrop_url": None, "cast": [], "crew": [],
"_extras": {"similar": [{"title": "X", "tmdb_id": 5, "kind": "show"}]}, "_extras": {"similar": [{"title": "X", "tmdb_id": 5, "kind": "show"}]},
@ -643,7 +643,7 @@ def test_item_extras_caches_tmdb_call(db, monkeypatch):
calls = [] calls = []
class Tmdb: class Tmdb:
enabled = True 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()}) eng = VideoEnrichmentEngine(db, {"tmdb": Tmdb()})
a = eng.item_extras("movie", mid) a = eng.item_extras("movie", mid)
b = eng.item_extras("movie", mid) b = eng.item_extras("movie", mid)

View file

@ -5205,6 +5205,30 @@
<div class="callback-info"> <div class="callback-info">
<div class="callback-help">Plays a muted trailer behind the hero after a moment (uses more bandwidth).</div> <div class="callback-help">Plays a muted trailer behind the hero after a moment (uses more bandwidth).</div>
</div> </div>
<label class="vid-pref-row vid-pref-row--select">
<span>Where-to-watch region</span>
<select id="video-watch-region" data-video-pref="watch_region">
<option value="US">United States</option>
<option value="GB">United Kingdom</option>
<option value="CA">Canada</option>
<option value="AU">Australia</option>
<option value="IE">Ireland</option>
<option value="NZ">New Zealand</option>
<option value="DE">Germany</option>
<option value="FR">France</option>
<option value="ES">Spain</option>
<option value="IT">Italy</option>
<option value="NL">Netherlands</option>
<option value="SE">Sweden</option>
<option value="NO">Norway</option>
<option value="DK">Denmark</option>
<option value="BR">Brazil</option>
<option value="MX">Mexico</option>
<option value="IN">India</option>
<option value="JP">Japan</option>
<option value="KR">South Korea</option>
</select>
</label>
</div> </div>
</div> </div>
</div> </div>

View file

@ -501,6 +501,12 @@
} }
ps.hidden = !html; ps.hidden = !html;
ph.innerHTML = 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. // 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]'); var cs = q('[data-vd-collection-section]'), ch = q('[data-vd-collection]'), ct = q('[data-vd-collection-title]');

View file

@ -77,15 +77,21 @@
if (o && d.omdb_api_key != null) o.value = d.omdb_api_key; if (o && d.omdb_api_key != null) o.value = d.omdb_api_key;
var ap = document.getElementById('video-billboard-autoplay'); var ap = document.getElementById('video-billboard-autoplay');
if (ap && d.billboard_autoplay != null) ap.checked = !!d.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 */ }); .catch(function () { /* ignore */ });
} }
function saveAutoplay() { function savePrefs() {
var ap = document.getElementById('video-billboard-autoplay'); var ap = document.getElementById('video-billboard-autoplay');
var wr = document.getElementById('video-watch-region');
fetch(CONFIG_URL, { fetch(CONFIG_URL, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, 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 */ }); }).catch(function () { /* ignore */ });
} }
@ -140,7 +146,9 @@
if (el) el.addEventListener('change', saveKeys); if (el) el.addEventListener('change', saveKeys);
}); });
var autoplay = document.getElementById('video-billboard-autoplay'); 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). // Per-connection Test buttons (same behaviour as music's testConnection).
var testBtns = document.querySelectorAll('[data-video-test-service]'); var testBtns = document.querySelectorAll('[data-video-test-service]');
for (var k = 0; k < testBtns.length; k++) { for (var k = 0; k < testBtns.length; k++) {

View file

@ -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 { flex-direction: column; padding-left: 14px; }
.vd-ep-extra-still { width: 100%; } .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; }