mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-17 06:03:32 +00:00
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:
co-authored by
Claude Fable 5
byrongamatos
parent
8c7cde5d5c
commit
64a499975e
@@ -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")
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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) => (
|
||||||
|
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 = 'Couldn’t 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;
|
||||||
|
})();
|
||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user