diff --git a/api/video/poster.py b/api/video/poster.py
index 02e8a3cc..90f14f97 100644
--- a/api/video/poster.py
+++ b/api/video/poster.py
@@ -117,12 +117,19 @@ def register_routes(bp):
@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."""
+ """Same-origin image proxy (SSRF-safe allowlist). TMDB for accent-sampling,
+ and YouTube CDN (avatars/banners/thumbnails) so channel art loads reliably
+ regardless of hotlink/CORS policy."""
from flask import request
+ from urllib.parse import urlparse
url = request.args.get("u", "")
- if not url.startswith("https://image.tmdb.org/"):
+ if not url.startswith("https://"):
+ abort(404)
+ host = (urlparse(url).hostname or "").lower()
+ # image.tmdb.org + any YouTube image host (yt3/yt4.ggpht, googleusercontent, *.ytimg)
+ ok = host == "image.tmdb.org" or any(
+ host == s or host.endswith("." + s) for s in ("ytimg.com", "ggpht.com", "googleusercontent.com"))
+ if not ok:
abort(404)
try:
import requests
diff --git a/tests/test_video_api.py b/tests/test_video_api.py
index 3b274aa3..0ac355b3 100644
--- a/tests/test_video_api.py
+++ b/tests/test_video_api.py
@@ -667,3 +667,20 @@ def test_youtube_video_detail_404(tmp_path, monkeypatch):
import core.video.youtube as ytmod
monkeypatch.setattr(ytmod, "video_detail", lambda vid: None)
assert client.get("/api/video/youtube/video/nope").status_code == 404
+
+
+def test_img_proxy_allows_youtube_cdn_only(tmp_path, monkeypatch):
+ client, _ = _make_client(tmp_path)
+ import requests
+
+ class FakeResp:
+ status_code = 200
+ headers = {"Content-Type": "image/jpeg"}
+ def iter_content(self, n): yield b"x"
+ monkeypatch.setattr(requests, "get", lambda *a, **k: FakeResp())
+ assert client.get("/api/video/img?u=https://yt3.googleusercontent.com/abc=s900").status_code == 200
+ assert client.get("/api/video/img?u=https://i.ytimg.com/vi/x/hq.jpg").status_code == 200
+ assert client.get("/api/video/img?u=https://image.tmdb.org/t/p/w500/x.jpg").status_code == 200
+ # still SSRF-safe: arbitrary hosts rejected
+ assert client.get("/api/video/img?u=https://evil.example.com/x.jpg").status_code == 404
+ assert client.get("/api/video/img?u=http://yt3.googleusercontent.com/x").status_code == 404 # http
diff --git a/webui/static/video/video-channel.js b/webui/static/video/video-channel.js
index 9fe6d686..db04280e 100644
--- a/webui/static/video/video-channel.js
+++ b/webui/static/video/video-channel.js
@@ -24,7 +24,7 @@
function videoCard(v) {
var dur = YT().fmtDuration(v.duration_seconds);
var thumb = v.thumbnail_url
- ? ''
: '';
var bits = [];
@@ -52,12 +52,12 @@
(ch.videos || []).forEach(function (v) { state.videos[v.youtube_id] = v; });
var banner = $('[data-vc-banner]');
- if (banner) banner.style.backgroundImage = ch.banner_url ? "url('" + ch.banner_url + "')" : '';
+ if (banner) banner.style.backgroundImage = ch.banner_url ? "url('" + YT().img(ch.banner_url) + "')" : '';
var page = $('[data-video-channel]'); if (page) page.setAttribute('data-has-banner', ch.banner_url ? '1' : '0');
var av = $('[data-vc-avatar]'), avph = $('[data-vc-avatar-ph]');
if (av) {
- if (ch.avatar_url) { av.src = ch.avatar_url; show(av, true); if (avph) avph.hidden = true; }
+ if (ch.avatar_url) { av.src = YT().img(ch.avatar_url); show(av, true); if (avph) avph.hidden = true; }
else { show(av, false); if (avph) avph.hidden = false; }
}
var name = $('[data-vc-name]'); if (name) name.textContent = ch.title || 'Channel';
diff --git a/webui/static/video/video-wishlist.js b/webui/static/video/video-wishlist.js
index 89efad89..238029ae 100644
--- a/webui/static/video/video-wishlist.js
+++ b/webui/static/video/video-wishlist.js
@@ -75,6 +75,8 @@
// 'youtube') opens the in-app channel page. YEAR is the "season", video the
// "episode" — the data is already shaped that way, so the same render runs.
var yt = sh.source === 'youtube';
+ // YouTube art goes through our image proxy (reliable load); tmdb passes through.
+ var pimg = function (u) { return (yt && window.VideoYoutube) ? VideoYoutube.img(u) : u; };
var src = sh.library_id != null ? 'library' : 'tmdb';
var openId = sh.library_id != null ? sh.library_id : sh.tmdb_id;
var openAttrs = yt
@@ -86,7 +88,7 @@
// fall back to the newest video's thumbnail so the orb is never blank.
var poster = sh.poster_url || (yt && (sh.seasons || [])[0] ? sh.seasons[0].poster_url : null);
var img = poster
- ? '
' + esc(initials(sh.title)) + '\'">'
: '