v3 library: multi-candidate cover picker — select from a populated list (#732)

* v3 library: multi-candidate cover picker — select from list, media-server style

Auto-best already ships; this adds the "pick from a populated list" surface
Christian asked for, as ONE reusable component (song covers now; album/artist
art reuse the same picker later).

Server:
- GET /api/song/{filename}/art/candidates — assembles WITHOUT hoarding: the
  Current image + its provenance, Pack art when present, and Cover Art Archive
  candidates for the matched release (and any release ids stored on a review
  row's candidates). New _caa_release_index() fetches the CAA release INDEX
  json (image list + types + thumb sizes) through the existing throttle +
  offline gate, cached as caa_index_{id}.json beside the covers (indexes are
  stable). Capped at 12; fetched on demand. Demo-blocked (it spends the rate
  budget) and offline → instant tiles only, no error.

Frontend: new static/v3/image-picker.js — window.__fbOpenImagePicker({filename,
title}), a body-appended singleton modal (match-review anatomy: overlay, focus
trap, Esc). Current image + provenance badge on the left; a tile grid on the
right whose instant tiles — Current, Pack original, Upload, Paste URL — work
immediately even offline, while CAA candidates load behind ONE /art/candidates
fetch with skeleton tiles + a "the source is rate-limited" caption. The fetch
is tied to an AbortController and cancelled when the modal closes.

Applying a pick reuses EXISTING routes so there's no new write path and the
design's key trick holds: a chosen cover POSTs to …/art/url (the override
lane — never evicted by the art-cache LRU, survives a re-match); "Pack
original" DELETEs the override; Upload POSTs …/art/upload (GIF stays
upload-only + local-only). Silent-on-success; the drawer/card art refreshes
via the existing cache-buster.

Entry points: the Details drawer art click (the old direct file dialog is now
the Upload tile) and a card ⋮ "Change cover…" action.

Tests: tests/test_art_candidates.py (matched row lists index images; review
row pulls in candidate releases; unmatched/offline → instant tiles only;
index cached, no second fetch; demo blocked) over a fake index seam.
30 pass with test_art_layer green (same seams). node --check clean; tailwind
rebuilt for the new file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* fix(v3 cover picker): uiPrompt over window.prompt, visible-only focus trap, gate CAA to matched rows, index-cache lock, abort-on-reopen; +traversal tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
ChrisBeWithYou
2026-07-03 08:49:44 +02:00
committed by GitHub
co-authored by Claude Fable 5 byrongamatos
parent 8c7cde5d5c
commit 64a499975e
6 changed files with 954 additions and 37 deletions
+268 -35
View File
@@ -252,6 +252,10 @@ _DEMO_BLOCKED: list[tuple[str, re.Pattern]] = [
("POST", re.compile(r"^/api/song/.+/art/upload$")), ("POST", re.compile(r"^/api/song/.+/art/upload$")),
("POST", re.compile(r"^/api/song/.+/art/url$")), ("POST", re.compile(r"^/api/song/.+/art/url$")),
("DELETE", re.compile(r"^/api/art/.+/override$")), ("DELETE", re.compile(r"^/api/art/.+/override$")),
# Cover picker (PR-C): read-only, but a cache-miss open spends 1-3
# throttled Cover Art Archive calls — anonymous demo visitors don't get
# to spend the shared rate budget (same rule as enrichment search/kick).
("GET", re.compile(r"^/api/song/.+/art/candidates$")),
] ]
@@ -5922,6 +5926,87 @@ def _caa_http_get(release_id: str) -> bytes | None:
raise EnrichTransportError(str(e)) from e raise EnrichTransportError(str(e)) from e
def _caa_release_index(release_id: str) -> dict | None:
"""Fetch a release's Cover Art Archive INDEX (json — image METADATA, not
image bytes): the cover picker's one network seam (tests fake exactly
this). Same etiquette as _caa_http_get: throttled, identified,
offline-guarded. Returns the parsed index dict, None when the archive
has no art for the release (404), and raises EnrichTransportError for
anything network-shaped."""
if not _enrich_network_enabled():
raise EnrichTransportError("enrichment network disabled")
import requests
_enrich_throttle()
try:
resp = requests.get(
f"https://coverartarchive.org/release/{release_id}",
headers={"User-Agent": _enrich_user_agent(),
"Accept": "application/json"},
timeout=15, allow_redirects=True)
if resp.status_code == 404:
return None
if resp.status_code != 200:
raise EnrichTransportError(f"cover art archive HTTP {resp.status_code}")
body = resp.json()
return body if isinstance(body, dict) else None
except requests.RequestException as e:
raise EnrichTransportError(str(e)) from e
except ValueError as e:
# Non-JSON body — treat as a transport blip (nothing gets cached, a
# later picker-open retries) rather than caching an empty index.
raise EnrichTransportError(f"cover art archive returned non-JSON: {e}") from e
# Per-release lock so two concurrent /art/candidates opens for the SAME
# release serialise their read→fetch→write (the "index cached, no second
# fetch" invariant). Different releases still fetch in parallel; the guard
# lock only protects the tiny registry lookup.
_caa_index_locks: dict[str, threading.Lock] = {}
_caa_index_locks_guard = threading.Lock()
def _caa_index_lock(release_id: str) -> threading.Lock:
with _caa_index_locks_guard:
lock = _caa_index_locks.get(release_id)
if lock is None:
lock = _caa_index_locks[release_id] = threading.Lock()
return lock
def _caa_index_cached(release_id: str) -> list[dict]:
"""A release's CAA index images through a TTL-less on-disk cache
(`caa_index_{id}.json` beside the cover files indexes are stable, and
a 404 is cached as an empty index so a coverless release is never
re-asked). Outside the network seam on purpose: tests fake
_caa_release_index and still exercise this cache. Raises
EnrichTransportError on a cache-miss network failure (the caller stops
asking for further releases); malformed ids/bodies yield []."""
if not _CAA_ID_RE.match(str(release_id or "")):
return []
cache_file = _enrichment_art_dir() / f"caa_index_{release_id}.json"
# Hold the per-id lock across the check→fetch→write so a concurrent open
# for the same release finds the freshly-written cache instead of racing a
# second fetch. (The network fetch sleeps in _enrich_throttle under a
# different lock — no deadlock; a different release is never blocked.)
with _caa_index_lock(str(release_id)):
if cache_file.is_file():
try:
body = json.loads(cache_file.read_text(encoding="utf-8"))
imgs = body.get("images") if isinstance(body, dict) else None
if isinstance(imgs, list):
return imgs
except (OSError, ValueError):
pass # unreadable/corrupt cache → refetch below
body = _caa_release_index(release_id)
if body is None or not isinstance(body.get("images"), list):
body = {"images": []}
try:
cache_file.write_text(json.dumps(body), encoding="utf-8")
except OSError:
pass # cache is best-effort; the response still serves
return body["images"]
def _art_safe_name(filename: str) -> str: def _art_safe_name(filename: str) -> str:
"""The flattened cache-file stem the art routes key user overrides on """The flattened cache-file stem the art routes key user overrides on
(matches the legacy /art/upload naming, so old uploads keep working).""" (matches the legacy /art/upload naming, so old uploads keep working)."""
@@ -10504,7 +10589,7 @@ def _file_art_response(path: Path, media_type: str, request: Request | None):
@app.get("/api/song/{filename:path}/art") @app.get("/api/song/{filename:path}/art")
async def get_song_art(filename: str, request: Request = None): async def get_song_art(filename: str, request: Request = None, source: str = ""):
"""Serve album art for a song, walking the R3 override chain: """Serve album art for a song, walking the R3 override chain:
1. USER OVERRIDE (upload / URL-fetch, {safe_name}.gif|.png in the art 1. USER OVERRIDE (upload / URL-fetch, {safe_name}.gif|.png in the art
@@ -10516,6 +10601,11 @@ async def get_song_art(filename: str, request: Request = None):
the loose folder's discovered image. the loose folder's discovered image.
3. COVER ART ARCHIVE cache fetched by the enrichment art worker for 3. COVER ART ARCHIVE cache fetched by the enrichment art worker for
matched songs that lack pack art, keyed by release MBID. matched songs that lack pack art, keyed by release MBID.
`?source=pack` narrows the chain to step 2 only (no override, no CAA):
the cover picker's "Pack original" tile must show the pack's own art
even while a user override is what the plain route serves. 404 when the
song ships no art of its own.
""" """
dlc = _get_dlc_dir() dlc = _get_dlc_dir()
if not dlc: if not dlc:
@@ -10527,10 +10617,13 @@ async def get_song_art(filename: str, request: Request = None):
if not song_path.exists(): if not song_path.exists():
return JSONResponse({"error": "not found"}, 404) return JSONResponse({"error": "not found"}, 404)
pack_only = source == "pack"
# 1. User override — GIF first (it wins over a stale PNG override). # 1. User override — GIF first (it wins over a stale PNG override).
for cached in _art_override_paths(filename): if not pack_only:
mt = "image/gif" if cached.suffix == ".gif" else "image/png" for cached in _art_override_paths(filename):
return _file_art_response(cached, mt, request) mt = "image/gif" if cached.suffix == ".gif" else "image/png"
return _file_art_response(cached, mt, request)
# 2a. Sloppak: read the cover (manifest-declared or default) straight from # 2a. Sloppak: read the cover (manifest-declared or default) straight from
# the package. For a zip-form sloppak this opens just the cover member — # the package. For a zip-form sloppak this opens just the cover member —
@@ -10576,15 +10669,130 @@ async def get_song_art(filename: str, request: Request = None):
return _file_art_response(art_resolved, mt, request) return _file_art_response(art_resolved, mt, request)
# 3. Cover Art Archive cache (the enrichment art worker's fetch). # 3. Cover Art Archive cache (the enrichment art worker's fetch).
row = meta_db.get_enrichment(filename) if not pack_only:
if row and row.get("art_state") == "caa" and row.get("art_cache_path"): row = meta_db.get_enrichment(filename)
caa = Path(row["art_cache_path"]) if row and row.get("art_state") == "caa" and row.get("art_cache_path"):
if caa.is_file(): caa = Path(row["art_cache_path"])
return _file_art_response(caa, "image/jpeg", request) if caa.is_file():
return _file_art_response(caa, "image/jpeg", request)
return JSONResponse({"error": "no art"}, 404) return JSONResponse({"error": "no art"}, 404)
# ── Cover picker (PR-C): candidate assembly ───────────────────────────────────
# Enumerated ON OPEN, never at scan time (charrette §8), and NO image bytes
# are fetched here — Cover Art Archive release INDEX jsons only (1-3 throttled
# calls on a cache miss); the tiles' thumbnails load straight from the archive
# in the client. Applying a pick never grows a new write path: the client
# POSTs the chosen thumb URL to the EXISTING …/art/url route (the override
# lane — never evicted, survives a re-match), "Pack original" DELETEs the
# override, uploads keep the existing upload route.
_ART_PICKER_MAX_CAA = 12
@app.get("/api/song/{filename:path}/art/candidates")
def get_song_art_candidates(filename: str):
"""Everything the cover picker can offer for one song, without fetching a
single image: the current cover (with its provenance), the pack original
when the song ships art, and CAA candidates for the matched/manual
release plus any distinct releases among the stored review candidates.
Sync route on purpose (the CAA index fetch sleeps in the shared
throttle FastAPI runs `def` routes in the threadpool). One response,
`pending` always False the client shows a spinner for the request's own
latency; offline / CAA-down just means an empty caa tail (the instant
tiles keep working), never an error."""
from urllib.parse import quote
dlc = _get_dlc_dir()
song_path = _resolve_dlc_path(dlc, filename) if dlc else None
if song_path is None or not song_path.exists():
raise HTTPException(status_code=404, detail="unknown song")
row = meta_db.get_enrichment(filename) or {}
has_pack = _song_pack_art_exists(filename)
art_url = f"/api/song/{quote(filename)}/art"
# What the plain art route would serve right now — the serve chain's
# order (override > pack > CAA cache) restated as provenance.
if _art_override_paths(filename):
provenance = "yours"
elif has_pack:
provenance = "pack"
elif row.get("art_state") == "caa" and row.get("art_cache_path"):
provenance = "matched"
else:
provenance = "none"
candidates: list[dict] = [{
"id": "current", "kind": "current", "label": "Current",
"thumb_url": art_url, "provenance": provenance,
}]
if has_pack:
candidates.append({
"id": "pack", "kind": "pack", "label": "Pack original",
"thumb_url": art_url + "?source=pack", "provenance": "pack",
})
# Releases worth asking the archive about: the matched/manual release
# first (it seeds the best candidates), then any distinct release among
# the stored review candidates (a review row has no mb_release_id of its
# own — its releases live in the candidates JSON).
# Only spend the shared CAA rate budget on rows whose match warrants it:
# a matched/manual release seeds the best candidates, and a review row's
# stored candidates are still live proposals. A failed/rejected (or
# unscanned) row has no accepted match — asking would burn the budget and
# surface releases already rejected as non-matches. The Current + Pack
# tiles above serve regardless, so those songs still get a picker.
rids: list[str] = []
if row.get("match_state") in ("matched", "manual", "review"):
if row.get("match_state") in ("matched", "manual") and row.get("mb_release_id"):
rids.append(str(row["mb_release_id"]))
for cand in (row.get("candidates") or []):
rid = str(cand.get("release_id") or "") if isinstance(cand, dict) else ""
if rid and rid not in rids:
rids.append(rid)
caa_entries: list[dict] = []
for rid in rids:
if len(caa_entries) >= _ART_PICKER_MAX_CAA:
break
try:
imgs = _caa_index_cached(rid)
except EnrichTransportError:
# Offline / archive down — stop asking (each further miss would
# only burn a timeout). The instant tiles still serve; a later
# picker-open retries naturally (failures are never cached).
break
# Front covers first, approved before pending, otherwise index order
# (the picker grammar is a RANKED list — §7/§9).
def _rank(img):
types = img.get("types") or []
is_front = bool(img.get("front")) or "Front" in types
return (not is_front, not bool(img.get("approved")))
for img in sorted((i for i in imgs if isinstance(i, dict)), key=_rank):
if len(caa_entries) >= _ART_PICKER_MAX_CAA:
break
thumbs = img.get("thumbnails") or {}
if not isinstance(thumbs, dict):
continue
thumb = (thumbs.get("500") or thumbs.get("large")
or thumbs.get("250") or thumbs.get("small"))
if not thumb:
continue
types = [str(t) for t in (img.get("types") or []) if isinstance(t, str)]
caa_entries.append({
"id": f"caa-{rid}-{img.get('id', '')}",
"kind": "caa",
"label": ", ".join(types) or "Cover",
"thumb_url": str(thumb),
"provenance": "matched",
"types": types,
"approved": bool(img.get("approved")),
"release_id": rid,
})
return {"candidates": candidates + caa_entries, "pending": False}
@app.post("/api/song/{filename:path}/meta") @app.post("/api/song/{filename:path}/meta")
def update_song_meta(filename: str, data: dict): def update_song_meta(filename: str, data: dict):
"""Update song metadata, persisting it back into the underlying file. """Update song metadata, persisting it back into the underlying file.
@@ -10914,40 +11122,65 @@ def _url_host_is_internal(url: str) -> bool:
return False return False
# Art-by-URL redirect budget. Cover hosts commonly answer with a redirect —
# the Cover Art Archive (whose thumbs the cover picker applies through this
# very route) 307s every image to archive.org — so redirects must work; 5
# hops is generous for any real CDN chain while still bounding the walk.
_ART_URL_MAX_REDIRECTS = 5
def _fetch_art_url(url: str) -> bytes: def _fetch_art_url(url: str) -> bytes:
"""The one place art-by-URL touches the network (tests fake this seam). """The one place art-by-URL touches the network (tests fake this seam).
User-initiated, so not throttled like the background workers but the User-initiated, so not throttled like the background workers but the
same offline guard applies (pytest can never fetch), the host is checked same offline guard applies (pytest can never fetch), the host is checked
against internal/reserved ranges (SSRF), redirects are NOT followed (a against internal/reserved ranges (SSRF), redirects are followed MANUALLY
redirect can't smuggle the request to an internal target), and the size with the scheme + internal-host guard re-applied to every hop (so a
cap is enforced while streaming so a huge response never fully downloads. redirect can't smuggle the request to an internal target — a blanket
no-redirect rule would break every Cover Art Archive pick, which always
redirects to archive.org), and the size cap is enforced while streaming
so a huge response never fully downloads.
Residual, accepted: the host is resolved here and again by requests, so a Residual, accepted: each hop's host is resolved here and again by
rebinding DNS name is a theoretical TOCTOU. Not closed with an IP-pinned requests, so a rebinding DNS name is a theoretical TOCTOU. Not closed
connection because (a) this is a single-user, no-auth app (constitution with an IP-pinned connection because (a) this is a single-user, no-auth
§I) and the route is demo-blocked, so there is no untrusted submission app (constitution §I) and the route is demo-blocked, so there is no
path, and (b) no other in-tree client (MusicBrainz, CAA) pins either a untrusted submission path, and (b) no other in-tree client (MusicBrainz,
bespoke pinned+SNI adapter here would be inconsistent and disproportionate. CAA) pins either a bespoke pinned+SNI adapter here would be
The cheap guards above still stop the realistic vectors (direct internal inconsistent and disproportionate. The cheap guards above still stop the
URL, redirect-to-internal).""" realistic vectors (direct internal URL, redirect-to-internal)."""
if not _enrich_network_enabled(): if not _enrich_network_enabled():
raise EnrichTransportError("art fetch disabled (offline)") raise EnrichTransportError("art fetch disabled (offline)")
if _url_host_is_internal(url):
raise ValueError("url host is not allowed")
import requests import requests
try: from urllib.parse import urljoin, urlparse
with requests.get(url, timeout=15, stream=True, allow_redirects=False, for _hop in range(_ART_URL_MAX_REDIRECTS + 1):
headers={"User-Agent": _enrich_user_agent()}) as resp: # Re-validate EVERY hop, not just the user's original URL: the whole
if resp.status_code != 200: # point of handling redirects ourselves is that each target gets the
raise EnrichTransportError(f"HTTP {resp.status_code}") # same scheme + SSRF gate before any request is made.
data = b"" if urlparse(url).scheme not in ("http", "https"):
for chunk in resp.iter_content(65536): raise ValueError("url must be http(s)")
data += chunk if _url_host_is_internal(url):
if len(data) > _ART_URL_MAX_BYTES: raise ValueError("url host is not allowed")
raise ValueError("image larger than 10 MB") try:
return data with requests.get(url, timeout=15, stream=True, allow_redirects=False,
except requests.RequestException as e: headers={"User-Agent": _enrich_user_agent()}) as resp:
raise EnrichTransportError(str(e)) from e if resp.status_code in (301, 302, 303, 307, 308):
loc = resp.headers.get("Location") or ""
if not loc:
raise EnrichTransportError(
f"HTTP {resp.status_code} without a Location")
url = urljoin(url, loc)
continue
if resp.status_code != 200:
raise EnrichTransportError(f"HTTP {resp.status_code}")
data = b""
for chunk in resp.iter_content(65536):
data += chunk
if len(data) > _ART_URL_MAX_BYTES:
raise ValueError("image larger than 10 MB")
return data
except requests.RequestException as e:
raise EnrichTransportError(str(e)) from e
raise EnrichTransportError("too many redirects")
@app.post("/api/song/{filename:path}/art/url") @app.post("/api/song/{filename:path}/art/url")
+1 -1
View File
File diff suppressed because one or more lines are too long
+295
View File
@@ -0,0 +1,295 @@
// Cover-art picker (PR-C — multi-candidate "change cover", media-server
// style). ONE component: window.__fbOpenImagePicker({filename, title}),
// reached from the Details drawer's art click and the card ⋮ "Change cover…".
//
// Anatomy mirrors match-review.js (body-appended singleton: overlay +
// centred panel, light focus trap, Esc closes, overlay click closes) but
// layers at z-[200] — the songs.js centered-modal tier — because one of its
// openers is the details drawer (z-[61]), which sits above match-review's
// z-40/50 pair.
//
// The design's key trick (§7-§9/§11 of the launch charrette): a pick never
// grows a new write path. Choosing a CAA candidate POSTs its thumb URL to
// the EXISTING …/art/url route (the override lane: never evicted, survives
// a re-match); "Pack original" DELETEs the override; Upload POSTs the
// existing …/art/upload (GIF stays upload-only + local-only; the server's
// 10MB / http(s) guards apply to URLs). Success is silent (hearing-safe,
// like the match layer): the modal just closes and the art refreshes.
(function () {
'use strict';
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const enc = encodeURIComponent;
// Provenance badge text — same vocabulary as the match layer.
const PROV_LABEL = { yours: 'Yours', pack: 'Pack', matched: 'Matched' };
let _cur = null; // {filename, title} while the picker is open
let _abort = null; // in-flight candidates fetch — cancelled on close
let _busy = false; // an apply is running — ignore further tile clicks
let _lastFocus = null;
const artBase = (fn) => '/api/song/' + enc(fn) + '/art';
// Post-apply refresh — the grid's cache-buster idiom (`?v=`): re-src
// every rendered <img> pointing at this song's art with a fresh v so the
// new pick paints everywhere it's currently shown (grid card, drawer
// preview, list row) without a full reload.
function refreshArt(fn) {
const base = artBase(fn);
document.querySelectorAll('img').forEach((img) => {
const src = img.getAttribute('src') || '';
if (src.split('?')[0] === base) {
img.src = base + '?v=' + Date.now();
img.style.visibility = 'visible';
}
});
}
function ensureModal() {
let m = document.getElementById('v3-imgpick-modal');
if (m) return m;
const overlay = document.createElement('div');
overlay.id = 'v3-imgpick-overlay';
overlay.className = 'fixed inset-0 bg-black/60 z-[200] hidden';
overlay.addEventListener('click', close);
document.body.appendChild(overlay);
m = document.createElement('div');
m.id = 'v3-imgpick-modal';
// Appended after the overlay: same z tier, DOM order paints it above.
m.className = 'fixed inset-0 z-[200] hidden flex items-center justify-center p-4 pointer-events-none';
m.innerHTML = '<div id="v3-imgpick-panel" class="pointer-events-auto w-full max-w-2xl max-h-[85vh] bg-fb-sidebar border border-fb-border/50 rounded-xl shadow-2xl flex flex-col" role="dialog" aria-label="Change cover"></div>';
m.addEventListener('keydown', onKeydown);
document.body.appendChild(m);
return m;
}
function onKeydown(e) {
if (e.key === 'Escape') { e.stopPropagation(); close(); return; }
if (e.key !== 'Tab') return;
// Light focus trap: cycle within the panel (mirrors match-review).
const panel = document.getElementById('v3-imgpick-panel');
if (!panel) return;
// Only trap VISIBLE focusables: hidden tiles (?source=pack 404 →
// onerror .hidden, unloadable candidates, .hidden buttons) must never
// catch a Tab. offsetParent is null for display:none / .hidden.
const foci = Array.from(
panel.querySelectorAll('button:not(.hidden), input:not(.hidden), [tabindex="0"]'),
).filter((el) => el.offsetParent !== null);
if (!foci.length) return;
const first = foci[0], last = foci[foci.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
function close() {
if (_abort) { try { _abort.abort(); } catch (_) { /* already done */ } _abort = null; }
document.getElementById('v3-imgpick-modal')?.classList.add('hidden');
document.getElementById('v3-imgpick-overlay')?.classList.add('hidden');
_cur = null;
_busy = false;
if (_lastFocus && _lastFocus.isConnected) { try { _lastFocus.focus(); } catch (_) { /* */ } }
_lastFocus = null;
}
// One tile: a 6rem square art/icon face + a caption underneath.
function tileHtml(attrs, face, label, hidden) {
return '<button ' + attrs + ' class="group w-24 shrink-0 text-center' + (hidden ? ' hidden' : '') + '">' +
'<span class="w-24 h-24 rounded-lg overflow-hidden bg-fb-card border border-fb-border/50 hover:border-fb-primary/60 flex items-center justify-center">' + face + '</span>' +
'<span class="block text-xs text-fb-textDim group-hover:text-fb-text truncate pt-1">' + esc(label) + '</span></button>';
}
const imgFace = (src) => '<img src="' + esc(src) + '" alt="" loading="lazy" class="w-full h-full object-cover">';
const iconFace = (glyph) => '<span class="text-2xl text-fb-textDim">' + glyph + '</span>';
const SKELETON_TILE = '<span class="w-24 h-24 rounded-lg bg-fb-card animate-pulse shrink-0"></span>';
function render(panel) {
const fn = _cur.filename;
// Fresh ?v so a reopened picker never shows a stale "current".
const curSrc = artBase(fn) + '?v=' + Date.now();
panel.innerHTML =
'<div class="flex items-center justify-between gap-3 p-5 pb-3 border-b border-fb-border/40 shrink-0">' +
'<div class="min-w-0"><h3 class="text-lg font-semibold text-fb-text">Change cover</h3>' +
'<div class="text-xs text-fb-textDim truncate">' + esc(_cur.title || fn) + '</div></div>' +
'<button data-ip-close class="text-fb-textDim hover:text-fb-text" aria-label="Close">✕</button></div>' +
'<div class="p-5 flex flex-col sm:flex-row items-start gap-5 overflow-y-auto v3-scroll">' +
// Left: the current cover + its provenance.
'<div class="shrink-0">' +
'<img data-ip-current src="' + esc(curSrc) + '" alt="" class="w-24 h-24 rounded-lg object-cover bg-fb-card" onerror="this.style.visibility=\'hidden\'">' +
'<div class="pt-1 flex items-center gap-1.5">' +
'<span class="text-xs text-fb-textDim">Current</span>' +
'<span data-ip-prov class="hidden text-[0.625rem] px-1.5 py-0.5 rounded-full bg-gray-800/70 text-fb-textDim border border-gray-700"></span>' +
'</div></div>' +
// Right: the candidate tiles. First row acts instantly; CAA
// candidates land behind the one /art/candidates fetch.
'<div class="min-w-0 flex-1 space-y-3">' +
'<div class="flex flex-wrap gap-3">' +
tileHtml('data-ip-act="keep"', imgFace(curSrc), 'Current') +
// Pack tile renders instantly and self-hides when the song ships
// no art of its own (?source=pack 404s → img onerror); the
// candidates response reconciles it either way.
tileHtml('data-ip-act="pack"', imgFace(artBase(fn) + '?source=pack'), 'Pack original') +
tileHtml('data-ip-act="upload"', iconFace('⤒'), 'Upload') +
tileHtml('data-ip-act="url"', iconFace('🔗'), 'Paste URL') +
'</div>' +
'<div data-ip-caa>' +
'<div class="flex flex-wrap gap-3">' + SKELETON_TILE + SKELETON_TILE + SKELETON_TILE + '</div>' +
'<div class="text-xs text-fb-textDim pt-2">Fetching covers… the source is rate-limited.</div>' +
'</div>' +
'<div data-ip-status class="hidden text-xs text-fb-accent"></div>' +
'</div></div>' +
'<input type="file" accept="image/*" data-ip-file class="hidden">';
wire(panel);
}
function wire(panel) {
panel.querySelector('[data-ip-close]')?.addEventListener('click', close);
// The pack tile self-hides when there is no pack art to show.
const packTile = panel.querySelector('[data-ip-act="pack"]');
const packImg = packTile ? packTile.querySelector('img') : null;
if (packImg) packImg.onerror = () => packTile.classList.add('hidden');
const file = panel.querySelector('[data-ip-file]');
file?.addEventListener('change', () => {
const f = file.files && file.files[0];
if (!f) return;
const rd = new FileReader();
rd.onload = (e) => apply('upload', e.target.result);
rd.readAsDataURL(f);
});
panel.querySelectorAll('[data-ip-act]').forEach((btn) => {
btn.addEventListener('click', async () => {
if (_busy) return;
const act = btn.getAttribute('data-ip-act');
if (act === 'keep') { close(); return; }
if (act === 'pack') { apply('pack'); return; }
if (act === 'upload') { file?.click(); return; }
if (act === 'url') {
// window.prompt is a silent no-op in Electron — use the
// project's injection-safe async modal; fall back to prompt
// only if it isn't loaded (mirrors other v3 callers' guard).
const ask = (typeof window.uiPrompt === 'function')
? window.uiPrompt({
title: 'Paste URL',
label: 'Paste an image link (http or https)',
okLabel: 'Set cover',
placeholder: 'https://…',
})
: Promise.resolve(window.prompt('Paste an image link (http or https)'));
const u = String((await ask) || '').trim();
if (u) apply('url', u);
}
});
});
panel.querySelector('[data-ip-close]')?.focus();
}
// The one candidates fetch, cancelled if the modal closes first. Failure
// (offline, demo mode, aborted) is silent: the skeletons just clear and
// the instant tiles remain — never an error wall.
function loadCandidates(panel) {
const fn = _cur.filename;
// Reopening without an intervening close() can leave a prior fetch in
// flight — cancel it so only the newest request settles the tiles.
if (_abort) { try { _abort.abort(); } catch (_) { /* already done */ } }
_abort = new AbortController();
fetch('/api/song/' + enc(fn) + '/art/candidates', { signal: _abort.signal })
.then((r) => (r.ok ? r.json() : null))
.then((body) => { if (_cur && _cur.filename === fn) patchCandidates(panel, body); })
.catch(() => { if (_cur && _cur.filename === fn) patchCandidates(panel, null); });
}
function patchCandidates(panel, body) {
const wrap = panel.querySelector('[data-ip-caa]');
if (!wrap) return;
const list = (body && body.candidates) || [];
// Reconcile the instant tiles with what the server actually knows.
const cur = list.find((c) => c.kind === 'current');
const badge = panel.querySelector('[data-ip-prov]');
if (badge && cur && PROV_LABEL[cur.provenance]) {
badge.textContent = PROV_LABEL[cur.provenance];
badge.classList.remove('hidden');
}
const packTile = panel.querySelector('[data-ip-act="pack"]');
if (packTile) packTile.classList.toggle('hidden', !list.some((c) => c.kind === 'pack'));
const caa = list.filter((c) => c.kind === 'caa' && c.thumb_url);
if (!caa.length) { wrap.innerHTML = ''; return; }
wrap.innerHTML = '<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim pb-2">Online covers</div>' +
'<div class="flex flex-wrap gap-3">' +
caa.map((c, i) => tileHtml(
'data-ip-cand="' + i + '"',
imgFace(c.thumb_url),
c.label || 'Cover')).join('') +
'</div>';
wrap.querySelectorAll('[data-ip-cand]').forEach((btn) => {
// A candidate whose thumb can't load isn't offerable — hide it
// rather than let a click apply an image nobody saw.
const img = btn.querySelector('img');
if (img) img.onerror = () => btn.classList.add('hidden');
btn.addEventListener('click', () => {
if (_busy) return;
const c = caa[Number(btn.getAttribute('data-ip-cand'))];
if (c) apply('url', c.thumb_url);
});
});
}
// Apply a pick through the EXISTING routes; silent on success (close +
// cache-busted refresh), inline note on failure (the modal stays open so
// another tile can be tried).
async function apply(kind, arg) {
const fn = _cur && _cur.filename;
if (!fn || _busy) return;
_busy = true;
let ok = false;
try {
let r = null;
if (kind === 'url') {
r = await fetch('/api/song/' + enc(fn) + '/art/url', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: arg }),
});
} else if (kind === 'upload') {
r = await fetch('/api/song/' + enc(fn) + '/art/upload', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: arg }),
});
} else if (kind === 'pack') {
r = await fetch('/api/art/' + enc(fn) + '/override', { method: 'DELETE' });
}
if (r && r.ok) {
// The art routes report soft failures as {error} bodies.
const body = await r.json().catch(() => ({}));
ok = !body.error;
}
} catch (_) { ok = false; }
_busy = false;
if (ok) { close(); refreshArt(fn); return; }
const status = document.querySelector('#v3-imgpick-panel [data-ip-status]');
if (status) {
status.textContent = 'Couldnt set that cover — try another image.';
status.classList.remove('hidden');
}
}
function openImagePicker(opts) {
const filename = opts && opts.filename;
if (!filename) return;
_lastFocus = document.activeElement;
_cur = { filename: filename, title: (opts && opts.title) || filename };
_busy = false;
const m = ensureModal();
const panel = document.getElementById('v3-imgpick-panel');
render(panel);
m.classList.remove('hidden');
document.getElementById('v3-imgpick-overlay')?.classList.remove('hidden');
loadCandidates(panel);
}
window.__fbOpenImagePicker = openImagePicker;
})();
+3
View File
@@ -1225,6 +1225,9 @@
<!-- Before songs.js: the songs toolbar calls the match-review chip hook <!-- Before songs.js: the songs toolbar calls the match-review chip hook
on build, so the module must already be registered. --> on build, so the module must already be registered. -->
<script src="/static/v3/match-review.js"></script> <script src="/static/v3/match-review.js"></script>
<!-- Before songs.js: the drawer art click + card ⋮ "Change cover…" open
the cover picker (window.__fbOpenImagePicker). -->
<script src="/static/v3/image-picker.js"></script>
<script src="/static/v3/songs.js"></script> <script src="/static/v3/songs.js"></script>
<script src="/static/v3/lessons.js"></script> <script src="/static/v3/lessons.js"></script>
<script src="/static/v3/dashboard.js"></script> <script src="/static/v3/dashboard.js"></script>
+17 -1
View File
@@ -894,6 +894,7 @@
// right-click) share this list, so parity is structural. // right-click) share this list, so parity is structural.
...(state.provider === 'local' && song.filename ? [ ...(state.provider === 'local' && song.filename ? [
{ id: '__fixmatch', label: 'Fix match…' }, { id: '__fixmatch', label: 'Fix match…' },
{ id: '__cover', label: 'Change cover…' },
{ id: '__refreshmeta', label: 'Refresh metadata' }, { id: '__refreshmeta', label: 'Refresh metadata' },
{ id: '__getinfo', label: 'Get info…' }, { id: '__getinfo', label: 'Get info…' },
{ id: '__remove', label: 'Remove from library', destructive: true }, { id: '__remove', label: 'Remove from library', destructive: true },
@@ -941,6 +942,10 @@
// not the group representative. (__remove stays on `song`: it needs // not the group representative. (__remove stays on `song`: it needs
// the group's work_key/chart_count and pre-ticks the shown chart.) // the group's work_key/chart_count and pre-ticks the shown chart.)
if (id === '__fixmatch') { if (window.__fbFixMatch) window.__fbFixMatch(playTarget); return; } if (id === '__fixmatch') { if (window.__fbFixMatch) window.__fbFixMatch(playTarget); return; }
if (id === '__cover') {
if (window.__fbOpenImagePicker) window.__fbOpenImagePicker({ filename: playTarget.filename, title: playTarget.title || playTarget.filename });
return;
}
if (id === '__refreshmeta') { if (id === '__refreshmeta') {
// Silent on success (hearing-safe, like the rest of the match // Silent on success (hearing-safe, like the rest of the match
// layer) — the re-match trickles in through the normal pass. // layer) — the re-match trickles in through the normal pass.
@@ -2670,7 +2675,18 @@
const artWrap = $('[data-det-art]'); const artFile = $('#det-art-file'); const artWrap = $('[data-det-art]'); const artFile = $('#det-art-file');
if (artWrap && artFile) { if (artWrap && artFile) {
artWrap.addEventListener('click', () => artFile.click()); // Art click opens the cover PICKER (PR-C) — the old direct file
// dialog lives on inside it as the Upload tile. The picker applies
// immediately (its own routes + refresh), so it bypasses the
// drawer's Save; the file-input path below stays as the fallback
// when image-picker.js isn't loaded.
artWrap.addEventListener('click', () => {
if (window.__fbOpenImagePicker) {
window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename });
} else {
artFile.click();
}
});
artFile.addEventListener('change', () => { artFile.addEventListener('change', () => {
const f = artFile.files && artFile.files[0]; if (!f) return; const f = artFile.files && artFile.files[0]; if (!f) return;
const rd = new FileReader(); const rd = new FileReader();
+370
View File
@@ -0,0 +1,370 @@
"""Tests for the PR-C cover picker's server side: the /art/candidates
assembly (current + pack + Cover Art Archive index candidates), the
`caa_index_{id}.json` TTL-less cache around the new `_caa_release_index`
seam, the `?source=pack` art-route variant, and the redirect-following
art-by-URL fetch that lets a CAA pick apply through the existing
override lane.
Both network seams (`_caa_release_index`, `requests.get` under
`_fetch_art_url`) are faked nothing here opens a socket, and the
offline default is itself asserted. Fixture patterns mirror
tests/test_art_layer.py.
"""
import importlib
import io as _io
import sys
import pytest
from fastapi.testclient import TestClient
from PIL import Image
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
dlc = tmp_path / "dlc"
dlc.mkdir()
monkeypatch.setenv("DLC_DIR", str(dlc))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
srv = importlib.import_module("server")
try:
yield srv
finally:
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
if conn is not None:
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
def png_bytes(color=(200, 30, 30)):
buf = _io.BytesIO()
Image.new("RGB", (4, 4), color).save(buf, "PNG")
return buf.getvalue()
def b64(data):
import base64
return base64.b64encode(data).decode()
def make_sloppak(server, name, with_cover=False, title="Song", artist="Artist"):
d = server.DLC_DIR / name
d.mkdir(parents=True)
(d / "manifest.yaml").write_text(
f"title: {title}\nartist: {artist}\nduration: 100\n"
"arrangements: []\nstems: []\n", encoding="utf-8")
if with_cover:
(d / "cover.jpg").write_bytes(png_bytes((10, 200, 10)))
server.meta_db.put(name, 0, 0, {
"title": title, "artist": artist, "album": "", "year": "",
"duration": 100, "arrangements": [{"name": "Lead", "index": 0}],
})
return d
def _match_row(server, fn, release_id="rel-1", state="matched"):
"""Seed a matched/manual enrichment row with a release id (as the P8
matcher would have written)."""
song = server.meta_db.enrichment_song_row(fn)
h = server.meta_db.enrichment_content_hash(
song["artist"], song["title"], song["album"], song["duration"])
server.meta_db.apply_enrichment_match(
fn, h, state, source="text", score=1.0,
cand={"recording_id": "rec-1", "release_id": release_id,
"title": song["title"], "artist": song["artist"]})
def _review_row(server, fn, candidates):
"""Seed a review-tier row: no canonical release of its own, releases
live only in the stored candidates JSON."""
song = server.meta_db.enrichment_song_row(fn)
h = server.meta_db.enrichment_content_hash(
song["artist"], song["title"], song["album"], song["duration"])
server.meta_db.apply_enrichment_match(
fn, h, "review", source="text", score=0.75, candidates=candidates)
def _img(img_id, *, front=False, approved=True, sizes=("500",)):
"""One CAA index image dict, with thumbnails for the given size keys."""
return {
"id": img_id,
"front": front,
"approved": approved,
"types": ["Front"] if front else ["Back"],
"image": f"https://caa.example/full/{img_id}.jpg",
"thumbnails": {s: f"https://caa.example/{img_id}-{s}.jpg" for s in sizes},
}
@pytest.fixture()
def caa_index(server, monkeypatch):
"""Fake CAA index transport + network flag on (mirrors the art-layer
`caa` fixture; this is the picker's own seam)."""
calls = []
indexes = {
"rel-1": {"images": [_img(101, front=True),
_img(102, approved=False, sizes=("250",))]},
"rel-2": {"images": [_img(201, front=True)]},
}
def fake(release_id):
calls.append(release_id)
return indexes.get(release_id) # unknown release → None (a CAA 404)
fake.calls, fake.indexes = calls, indexes
monkeypatch.setattr(server, "_caa_release_index", fake)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
return fake
def _get(client, fn="a.sloppak"):
r = client.get(f"/api/song/{fn}/art/candidates")
assert r.status_code == 200
return r.json()
def _caa(body):
return [c for c in body["candidates"] if c["kind"] == "caa"]
def _current(body):
return next(c for c in body["candidates"] if c["kind"] == "current")
# ── candidate assembly ────────────────────────────────────────────────────────
def test_matched_row_lists_index_images(server, client, caa_index):
make_sloppak(server, "a.sloppak") # no pack art
_match_row(server, "a.sloppak", release_id="rel-1")
body = _get(client)
assert body["pending"] is False
cur = _current(body)
assert cur["provenance"] == "none" # nothing served yet
assert not any(c["kind"] == "pack" for c in body["candidates"])
caa = _caa(body)
assert [c["thumb_url"] for c in caa] == [
"https://caa.example/101-500.jpg", # front, 500px
"https://caa.example/102-250.jpg", # 250 fallback
]
assert caa[0]["provenance"] == "matched"
assert caa[0]["approved"] is True and caa[1]["approved"] is False
assert caa[0]["release_id"] == "rel-1"
assert caa_index.calls == ["rel-1"] # one index fetch
def test_review_row_includes_candidate_releases(server, client, caa_index):
make_sloppak(server, "a.sloppak")
_review_row(server, "a.sloppak", [
{"recording_id": "rec-1", "title": "Song", "release_id": "rel-1"},
{"recording_id": "rec-2", "title": "Song", "release_id": "rel-2"},
{"recording_id": "rec-3", "title": "Song", "release_id": "rel-1"}, # dupe
{"recording_id": "rec-4", "title": "Song"}, # no release — skipped
])
body = _get(client)
assert caa_index.calls == ["rel-1", "rel-2"] # deduped, in order
assert {c["release_id"] for c in _caa(body)} == {"rel-1", "rel-2"}
assert len(_caa(body)) == 3
def test_rejected_row_skips_caa_fetch(server, client, caa_index):
"""A row the user rejected (failed/rejected) has no accepted match, so the
picker must not spend the shared CAA budget on its stale candidates. The
Current tile still serves; the index seam is never asked."""
make_sloppak(server, "a.sloppak")
_review_row(server, "a.sloppak", [
{"recording_id": "rec-1", "title": "Song", "release_id": "rel-1"}])
assert server.meta_db.set_enrichment_rejected("a.sloppak")
body = _get(client)
assert _caa(body) == []
assert caa_index.calls == []
assert _current(body)["kind"] == "current"
def test_unmatched_instant_tiles_only(server, client, caa_index):
"""No enrichment row at all → current (+ pack when it exists), empty
caa list, and the index seam is never asked."""
make_sloppak(server, "a.sloppak", with_cover=True)
body = _get(client)
kinds = [c["kind"] for c in body["candidates"]]
assert kinds == ["current", "pack"]
assert _current(body)["provenance"] == "pack"
pack = body["candidates"][1]
assert pack["thumb_url"].endswith("?source=pack")
assert caa_index.calls == []
def test_override_provenance_is_yours(server, client, caa_index):
make_sloppak(server, "a.sloppak", with_cover=True)
assert client.post("/api/song/a.sloppak/art/upload",
json={"image": b64(png_bytes((1, 2, 3)))}).json()["ok"]
body = _get(client)
assert _current(body)["provenance"] == "yours"
# Pack original stays offered even while the override is what serves.
assert any(c["kind"] == "pack" for c in body["candidates"])
def test_offline_empty_caa_list_no_error(server, client):
"""Under the plain test env the REAL index seam refuses (offline guard);
the endpoint still answers 200 with the instant tiles and caches
nothing (a later open retries)."""
make_sloppak(server, "a.sloppak")
_match_row(server, "a.sloppak", release_id="rel-1")
body = _get(client)
assert _caa(body) == []
assert _current(body)["kind"] == "current"
assert list(server.ART_CACHE_DIR.glob("caa_index_*.json")) == []
def test_index_cached_second_call_no_refetch(server, client, caa_index):
make_sloppak(server, "a.sloppak")
_match_row(server, "a.sloppak", release_id="rel-1")
first = _get(client)
assert len(caa_index.calls) == 1
cache = server.ART_CACHE_DIR / "caa_index_rel-1.json"
assert cache.is_file() # TTL-less on-disk cache
# Even a changed upstream index is not re-asked — indexes are stable.
caa_index.indexes["rel-1"] = {"images": []}
second = _get(client)
assert len(caa_index.calls) == 1 # no refetch
assert _caa(second) == _caa(first)
def test_404_release_cached_as_empty(server, client, caa_index):
"""A coverless release (CAA 404 → seam returns None) yields no tiles and
is never re-asked either."""
make_sloppak(server, "a.sloppak")
_match_row(server, "a.sloppak", release_id="rel-missing")
assert _caa(_get(client)) == []
assert _caa(_get(client)) == []
assert caa_index.calls == ["rel-missing"]
def test_caa_candidates_capped_at_12(server, client, caa_index):
make_sloppak(server, "a.sloppak")
caa_index.indexes["rel-big"] = {
"images": [_img(300 + i, front=(i == 0)) for i in range(20)]}
_match_row(server, "a.sloppak", release_id="rel-big")
assert len(_caa(_get(client))) == server._ART_PICKER_MAX_CAA == 12
def test_demo_mode_blocks_candidates(server, client, monkeypatch):
"""Read-only, but it spends the shared CAA rate budget — blocked in demo
like enrichment search/kick."""
make_sloppak(server, "a.sloppak")
monkeypatch.setenv("FEEDBACK_DEMO_MODE", "1")
r = client.get("/api/song/a.sloppak/art/candidates")
assert r.status_code == 403
assert r.json() == {"error": "demo mode: read-only"}
def test_unknown_song_404(server, client):
assert client.get("/api/song/ghost.sloppak/art/candidates").status_code == 404
# ── traversal / injection hardening ───────────────────────────────────────────
def test_malicious_release_id_rejected_no_fetch_no_write(server, caa_index):
"""A crafted release id (path traversal) never matches _CAA_ID_RE, so it
yields no images, opens no socket, and writes no cache file inside the
art dir or anywhere else."""
art_dir = server._enrichment_art_dir()
before = set(art_dir.glob("*"))
assert not server._CAA_ID_RE.match("../../etc/x")
assert server._caa_index_cached("../../etc/x") == []
assert caa_index.calls == [] # the seam was never asked
assert set(art_dir.glob("*")) == before # nothing written
# And nothing landed at the traversal target beside the cache dir either.
assert not (art_dir.parent / "etc").exists()
def test_candidates_route_rejects_traversal_filename(server, client, caa_index):
"""A traversal filename resolves outside DLC_DIR → _resolve_dlc_path
refuses it, the route 404s, and the CAA seam is never touched."""
for path in ("..%2F..%2Fsecret", "%2e%2e%2f%2e%2e%2fsecret", "../../secret"):
r = client.get(f"/api/song/{path}/art/candidates")
assert r.status_code == 404, path
assert caa_index.calls == []
# ── the ?source=pack serve variant ────────────────────────────────────────────
def test_pack_source_serves_pack_under_override(server, client):
"""The Pack-original tile's thumb must show the pack's own art even while
an override is what the plain route serves and 404 when the song ships
no art of its own."""
make_sloppak(server, "a.sloppak", with_cover=True)
assert client.post("/api/song/a.sloppak/art/upload",
json={"image": b64(png_bytes((1, 2, 3)))}).json()["ok"]
assert client.get("/api/song/a.sloppak/art").headers["content-type"] == "image/png"
r = client.get("/api/song/a.sloppak/art?source=pack")
assert r.status_code == 200
assert r.headers["content-type"] == "image/jpeg" # the pack cover, not the override
make_sloppak(server, "bare.sloppak")
assert client.get("/api/song/bare.sloppak/art?source=pack").status_code == 404
# ── art-by-URL redirect handling (what makes a CAA pick applyable) ────────────
class _FakeResp:
def __init__(self, status, headers=None, chunks=()):
self.status_code = status
self.headers = headers or {}
self._chunks = chunks
def iter_content(self, _size):
return iter(self._chunks)
def __enter__(self):
return self
def __exit__(self, *a):
return False
def test_fetch_art_url_follows_redirects_validating_each_hop(server, monkeypatch):
import requests
fetched, checked = [], []
def fake_get(url, **kw):
fetched.append(url)
assert kw.get("allow_redirects") is False # hops stay manual
if "coverartarchive.example" in url:
return _FakeResp(307, {"Location": "https://archive.example/img.png"})
return _FakeResp(200, chunks=[b"IMGDATA"])
monkeypatch.setattr(requests, "get", fake_get)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(server, "_url_host_is_internal",
lambda u: (checked.append(u), False)[1])
data = server._fetch_art_url("https://coverartarchive.example/release/x/front-500")
assert data == b"IMGDATA"
assert fetched == ["https://coverartarchive.example/release/x/front-500",
"https://archive.example/img.png"]
assert checked == fetched # every hop was gated
def test_fetch_art_url_blocks_redirect_to_internal(server, monkeypatch):
import requests
monkeypatch.setattr(requests, "get", lambda url, **kw: _FakeResp(
302, {"Location": "http://internal.example/x.png"}))
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(server, "_url_host_is_internal",
lambda u: "internal" in u)
with pytest.raises(ValueError):
server._fetch_art_url("https://public.example/x.png")
def test_fetch_art_url_redirect_budget(server, monkeypatch):
import requests
monkeypatch.setattr(requests, "get", lambda url, **kw: _FakeResp(
307, {"Location": "https://public.example/next.png"}))
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(server, "_url_host_is_internal", lambda u: False)
with pytest.raises(server.EnrichTransportError):
server._fetch_art_url("https://public.example/x.png")