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 "",
"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 "")

View file

@ -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":

View file

@ -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:

View file

@ -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

View file

@ -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)

View file

@ -5205,6 +5205,30 @@
<div class="callback-info">
<div class="callback-help">Plays a muted trailer behind the hero after a moment (uses more bandwidth).</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>

View file

@ -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]');

View file

@ -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++) {

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-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; }