video: per-title accent on preview + person pages (same-origin image proxy)

Owned detail pages sample the poster for the per-title glow, but preview/person
pages fell back to the theme accent because their TMDB images are cross-origin
(canvas taint). Added /api/video/img — a same-origin proxy restricted to
image.tmdb.org (SSRF-safe) — so:
- preview (tmdb) detail samples its poster via the proxy → real accent;
- the person page samples the portrait → per-person accent on the ring/glow/role.
Tests: route registered + proxy rejects non-tmdb URLs.
This commit is contained in:
BoulderBadgeDad 2026-06-15 11:46:49 -07:00
parent c5e30530c7
commit 8bbdd712f9
4 changed files with 68 additions and 1 deletions

View file

@ -71,3 +71,26 @@ def register_routes(bp):
@bp.route("/backdrop/<kind>/<int:item_id>", methods=["GET"])
def video_backdrop(kind, item_id):
return _stream_art(kind, item_id, "backdrop")
@bp.route("/img", methods=["GET"])
def video_img_proxy():
"""Same-origin proxy for TMDB images (image.tmdb.org ONLY — SSRF-safe).
Lets the detail/person pages canvas-sample a poster/portrait for the
per-title accent colour, which a direct cross-origin image taints."""
from flask import request
url = request.args.get("u", "")
if not url.startswith("https://image.tmdb.org/"):
abort(404)
try:
import requests
upstream = requests.get(url, timeout=15, stream=True)
if upstream.status_code != 200:
abort(404)
resp = Response(upstream.iter_content(8192),
content_type=upstream.headers.get("Content-Type", "image/jpeg"))
resp.headers["Cache-Control"] = "public, max-age=604800"
resp.headers["Access-Control-Allow-Origin"] = "*"
return resp
except Exception:
logger.exception("video image proxy failed for %s", url)
abort(404)

View file

@ -51,6 +51,13 @@ def test_blueprint_exposes_dashboard_route():
assert "/api/video/tmdb/show/<int:tv_id>/season/<int:season_number>" in rules
assert "/api/video/person/<int:tmdb_id>" in rules
assert any(r.startswith("/api/video/backdrop/") for r in rules)
assert "/api/video/img" in rules
def test_img_proxy_rejects_non_tmdb(tmp_path):
client, _ = _make_client(tmp_path)
assert client.get("/api/video/img?u=https://evil.example.com/x.jpg").status_code == 404
assert client.get("/api/video/img").status_code == 404
def test_search_endpoint_empty_query(tmp_path):

View file

@ -83,9 +83,15 @@
return d.has_backdrop ? '/api/video/backdrop' + art : (d.has_poster ? '/api/video/poster' + art : '');
}
function bbPoster(d) {
if (d.source === 'tmdb') return d.poster_url || '';
// The offscreen poster is canvas-sampled for the accent — must be
// same-origin, so tmdb (preview) posters go through our image proxy.
if (d.source === 'tmdb') return d.poster_url ? proxied(d.poster_url) : '';
return d.has_poster ? '/api/video/poster/' + d.kind + '/' + d.id : '';
}
function proxied(url) {
return /^https:\/\/image\.tmdb\.org\//.test(url || '')
? '/api/video/img?u=' + encodeURIComponent(url) : (url || '');
}
function pct(s) { return s.episode_total ? Math.round(s.episode_owned / s.episode_total * 100) : 0; }
function badge(logo, fallback, title, url) {

View file

@ -161,6 +161,35 @@
return dy ? (by + ' ' + dy) : (by ? 'Born ' + by : '');
}
// Per-person accent: sample the portrait's dominant vibrant colour (via the
// same-origin image proxy, so the cross-origin canvas isn't tainted).
function applyAccent(photoUrl) {
var page = root();
if (!page || !photoUrl) return;
var img = new Image();
img.crossOrigin = 'anonymous';
img.onload = function () {
try {
var w = 24, h = 24, c = document.createElement('canvas'); c.width = w; c.height = h;
var ctx = c.getContext('2d'); ctx.drawImage(img, 0, 0, w, h);
var px = ctx.getImageData(0, 0, w, h).data;
var best = null, bestScore = -1, fr = 0, fg = 0, fb = 0, n = 0;
for (var i = 0; i < px.length; i += 4) {
var r = px[i], g = px[i + 1], b = px[i + 2], a = px[i + 3];
if (a < 128) continue;
var mx = Math.max(r, g, b), mn = Math.min(r, g, b), light = (mx + mn) / 2;
fr += r; fg += g; fb += b; n++;
if (light < 35 || light > 225) continue;
var sat = mx === 0 ? 0 : (mx - mn) / mx, score = sat * (mx / 255);
if (score > bestScore) { bestScore = score; best = [r, g, b]; }
}
if (!best && n) best = [Math.round(fr / n), Math.round(fg / n), Math.round(fb / n)];
if (best && root()) root().style.setProperty('--vd-accent-rgb', best[0] + ', ' + best[1] + ', ' + best[2]);
} catch (e) { /* tainted / no image — keep theme accent */ }
};
img.src = '/api/video/img?u=' + encodeURIComponent(photoUrl);
}
function computeAge(birthday, deathday) {
if (!birthday) return null;
var b = new Date(birthday), end = deathday ? new Date(deathday) : new Date();
@ -185,6 +214,7 @@
var page = root(), amb = q('[data-vp-ambient]');
if (page) page.setAttribute('data-has-bg', d.photo ? '1' : '0');
if (amb) amb.style.setProperty('--vp-bg', d.photo ? "url('" + d.photo + "')" : 'none');
applyAccent(d.photo);
setText('[data-vp-name]', d.name);
@ -223,6 +253,7 @@
function load(id) {
if (!root()) return;
currentId = id;
var pg = root(); if (pg) pg.style.removeProperty('--vd-accent-rgb'); // reset per-person accent
showLoading(true);
setText('[data-vp-name]', '');
var m = q('[data-vp-meta]'); if (m) m.innerHTML = '';