diff --git a/api/video/poster.py b/api/video/poster.py index a9a1bb11..6358b121 100644 --- a/api/video/poster.py +++ b/api/video/poster.py @@ -71,3 +71,26 @@ def register_routes(bp): @bp.route("/backdrop//", 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) diff --git a/tests/test_video_api.py b/tests/test_video_api.py index 9501e0b9..1d8504b6 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -51,6 +51,13 @@ def test_blueprint_exposes_dashboard_route(): assert "/api/video/tmdb/show//season/" in rules assert "/api/video/person/" 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): diff --git a/webui/static/video/video-detail.js b/webui/static/video/video-detail.js index b7ec0dd0..82c338eb 100644 --- a/webui/static/video/video-detail.js +++ b/webui/static/video/video-detail.js @@ -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) { diff --git a/webui/static/video/video-person.js b/webui/static/video/video-person.js index 1cdca811..0194324a 100644 --- a/webui/static/video/video-person.js +++ b/webui/static/video/video-person.js @@ -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 = '';