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:
parent
c5e30530c7
commit
8bbdd712f9
4 changed files with 68 additions and 1 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
|
|
|||
Loading…
Reference in a new issue