mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-23 21:32:33 +00:00
* library: cover-art layer — CAA auto-fetch + user overrides, GIF local-only (R3/P9)
Third slice of the enrichment series (stacked on the matcher): covers.
- Serve chain for GET /api/song/{fn}/art: USER OVERRIDE -> PACK ART ->
COVER ART ARCHIVE cache -> 404. Behaviour change, deliberate: a
user-uploaded cover now OVERRIDES pack art (previously the upload only
filled the no-art gap, which made custom art look broken on any song
that already shipped a cover).
- GIF is allowed as an override and kept VERBATIM (animation intact) —
a local-only bonus. Everything else normalizes to RGB PNG as before.
One override per song (saving either kind removes the other), and
nothing ever writes art INTO a pack file — test-pinned: the pack's
cover.jpg is byte-identical after a GIF upload.
- Art by URL: POST /api/song/{fn}/art/url fetches server-side (http(s)
only, 10 MB cap enforced while streaming) into the same override slot.
DELETE /api/art/{fn}/override drops it — under /api/art because the
greedy DELETE /api/song/{path} catch-all shadows anything beneath it
(the same dodge the chart split/unsplit routes use).
- Cover Art Archive fetch as phase 3 of the enrichment pass: matched
songs that LACK pack art get their release's front cover, throttled +
identified + offline-guarded exactly like the MusicBrainz client
(pytest can never reach the network; a transport error pauses the
pass without burning the row). The cache is keyed by RELEASE MBID —
ten charts of one album cost one fetch — and every outcome writes an
art_state (pack/user/caa/none/error) so a row is evaluated once.
- LRU cap (200 MB) on the CAA side of the cache only; user overrides
are never evicted, and evicted rows reset so a later pass may
re-fetch. Deleting a song removes its override files (CAA files stay
— they may be shared by other charts of the release).
No frontend changes: the grid, the review modal, and the player pick
the new art up through the same route they already use. The
upload/paste-a-link surfaces in the Details drawer land with the
context-menu slice once the drawer PR merges.
13 new tests (tests/test_art_layer.py) + demo-mode routes; full-suite
failure set byte-identical with the change stashed vs applied.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN
* library: harden cover-art layer — SSRF guard, demo/size caps, override-delete state reset
Follow-up hardening on the R3 cover-art layer:
- remove_song_art_override: reset the enrichment row (set_enrichment_art(fn,
None, None)) when an override is deleted, so a row previously settled as
'user' re-queues and the CAA fallback resumes. Previously a removed override
stranded the row (enrichment_art_pending only re-queues art_state IS NULL),
leaving the song with no art at all.
- Base64 art upload: block it in demo mode (was open — a write/disk-fill vector,
worse now that GIFs are stored verbatim), validate the filename resolves to a
real song (mirrors the url route), and cap the decoded payload at 10 MB.
- Art-by-URL: reject hosts that resolve to loopback/private/link-local/reserved/
multicast/unspecified addresses (SSRF, e.g. cloud metadata) and stop following
redirects (allow_redirects=False) so a redirect can't smuggle the request to an
internal target. Fails closed on unresolvable/unparseable hosts.
- _caa_http_get: stream with a per-file 10 MB cap (bounds any one response
independently of the aggregate LRU); guard release_id against a conservative
token before interpolating it into a cache-file path (no separators/dots).
Tests: delete-override→CAA-fallback, upload unknown-song/oversize rejection,
SSRF internal-host guard, and a demo-mode block assertion for art/upload.
Note: art_state='error' rows are intentionally not auto-retried — there is no
per-row attempt counter on the art side, so an unbounded retry could storm CAA
for permanently-bad rows; a bounded retry would need extra state, left out here.
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>
319 lines
14 KiB
Python
319 lines
14 KiB
Python
"""Tests for the R3 art layer: the user>pack>CAA serve chain, user overrides
|
|
(upload / URL / delete, with GIF kept animated and LOCAL-ONLY), and the
|
|
enrichment art worker's Cover Art Archive fetch + LRU cache.
|
|
|
|
Both network seams (`_caa_http_get`, `_fetch_art_url`) are faked — nothing
|
|
here opens a socket, and the offline default is itself asserted.
|
|
"""
|
|
|
|
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 gif_bytes():
|
|
"""A tiny 2-frame animated GIF."""
|
|
buf = _io.BytesIO()
|
|
frames = [Image.new("P", (4, 4), i) for i in (0, 255)]
|
|
frames[0].save(buf, "GIF", save_all=True, append_images=frames[1:], duration=100)
|
|
return buf.getvalue()
|
|
|
|
|
|
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 b64(data):
|
|
import base64
|
|
return base64.b64encode(data).decode()
|
|
|
|
|
|
# ── the serve chain: user > pack > CAA ────────────────────────────────────────
|
|
|
|
def test_user_override_beats_pack_art(server, client):
|
|
make_sloppak(server, "a.sloppak", with_cover=True)
|
|
r = client.get("/api/song/a.sloppak/art")
|
|
assert r.status_code == 200 # pack art serves
|
|
assert client.post("/api/song/a.sloppak/art/upload",
|
|
json={"image": b64(png_bytes((1, 2, 3)))}).json()["ok"]
|
|
r = client.get("/api/song/a.sloppak/art")
|
|
assert r.status_code == 200
|
|
assert r.headers["content-type"] == "image/png" # the override, not the pack jpeg
|
|
# Removing the override falls back to pack art. (The route lives under
|
|
# /api/art — the DELETE /api/song/{path} catch-all would shadow it.)
|
|
assert client.delete("/api/art/a.sloppak/override").json()["removed"]
|
|
r = client.get("/api/song/a.sloppak/art")
|
|
assert r.status_code == 200
|
|
assert r.headers["content-type"] == "image/jpeg"
|
|
|
|
|
|
def test_gif_override_kept_animated_and_local_only(server, client):
|
|
make_sloppak(server, "a.sloppak", with_cover=True)
|
|
raw = gif_bytes()
|
|
body = client.post("/api/song/a.sloppak/art/upload", json={"image": b64(raw)}).json()
|
|
assert body == {"ok": True, "kind": "gif"}
|
|
r = client.get("/api/song/a.sloppak/art")
|
|
assert r.headers["content-type"] == "image/gif"
|
|
assert r.content == raw # VERBATIM — animation intact
|
|
# …and the pack file was never touched (GIF never reaches the feedpak).
|
|
assert (server.DLC_DIR / "a.sloppak" / "cover.jpg").read_bytes() == png_bytes((10, 200, 10))
|
|
assert not (server.DLC_DIR / "a.sloppak" / "cover.gif").exists()
|
|
# A later PNG upload replaces the GIF (one override per song).
|
|
client.post("/api/song/a.sloppak/art/upload", json={"image": b64(png_bytes())})
|
|
assert client.get("/api/song/a.sloppak/art").headers["content-type"] == "image/png"
|
|
assert len(server._art_override_paths("a.sloppak")) == 1
|
|
|
|
|
|
def test_bad_upload_rejected(server, client):
|
|
make_sloppak(server, "a.sloppak")
|
|
assert "error" in client.post("/api/song/a.sloppak/art/upload",
|
|
json={"image": b64(b"GIF89a not really a gif")}).json()
|
|
assert "error" in client.post("/api/song/a.sloppak/art/upload",
|
|
json={"image": b64(b"plain text")}).json()
|
|
|
|
|
|
# ── art by URL ────────────────────────────────────────────────────────────────
|
|
|
|
def test_art_url_fetches_and_overrides(server, client, monkeypatch):
|
|
make_sloppak(server, "a.sloppak", with_cover=True)
|
|
monkeypatch.setattr(server, "_fetch_art_url", lambda url: png_bytes((9, 9, 9)))
|
|
body = client.post("/api/song/a.sloppak/art/url",
|
|
json={"url": "https://example.com/cover.png"}).json()
|
|
assert body == {"ok": True, "kind": "png"}
|
|
assert client.get("/api/song/a.sloppak/art").headers["content-type"] == "image/png"
|
|
|
|
|
|
def test_art_url_validation(server, client, monkeypatch):
|
|
make_sloppak(server, "a.sloppak")
|
|
assert client.post("/api/song/a.sloppak/art/url",
|
|
json={"url": "ftp://example.com/x.png"}).status_code == 400
|
|
assert client.post("/api/song/a.sloppak/art/url", json={}).status_code == 400
|
|
assert client.post("/api/song/ghost.sloppak/art/url",
|
|
json={"url": "https://example.com/x.png"}).status_code == 404
|
|
# Oversize → 400 (the seam raises ValueError at the cap).
|
|
def _huge(url):
|
|
raise ValueError("image larger than 10 MB")
|
|
monkeypatch.setattr(server, "_fetch_art_url", _huge)
|
|
assert client.post("/api/song/a.sloppak/art/url",
|
|
json={"url": "https://example.com/x.png"}).status_code == 400
|
|
|
|
|
|
def test_art_url_offline_by_default(server, client):
|
|
"""The real fetch seam refuses under the test env — pytest can never
|
|
reach the network even when a test forgets to fake it."""
|
|
make_sloppak(server, "a.sloppak")
|
|
r = client.post("/api/song/a.sloppak/art/url",
|
|
json={"url": "https://example.com/x.png"})
|
|
assert r.status_code == 502
|
|
|
|
|
|
# ── the enrichment art worker (Cover Art Archive) ─────────────────────────────
|
|
|
|
def _match_row(server, fn, release_id="rel-1"):
|
|
"""Seed a matched enrichment row with a release id (as the P8 matcher
|
|
would have written) so the art worker picks it up."""
|
|
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, "matched", source="text", score=1.0,
|
|
cand={"recording_id": "rec-1", "release_id": release_id,
|
|
"title": song["title"], "artist": song["artist"]})
|
|
|
|
|
|
@pytest.fixture()
|
|
def caa(server, monkeypatch):
|
|
"""Fake CAA transport + network flag on (mirrors the P8 test fixture)."""
|
|
calls = []
|
|
art = {"rel-1": png_bytes((60, 60, 200))}
|
|
|
|
def fake(release_id):
|
|
calls.append(release_id)
|
|
return art.get(release_id)
|
|
fake.calls, fake.art = calls, art
|
|
monkeypatch.setattr(server, "_caa_http_get", fake)
|
|
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
|
return fake
|
|
|
|
|
|
def test_caa_fetch_fills_missing_art(server, client, caa):
|
|
make_sloppak(server, "a.sloppak") # no pack art
|
|
_match_row(server, "a.sloppak")
|
|
server._background_enrich()
|
|
row = server.meta_db.get_enrichment("a.sloppak")
|
|
assert row["art_state"] == "caa"
|
|
assert row["art_cache_path"] and row["art_cache_path"].endswith("caa_rel-1.jpg")
|
|
r = client.get("/api/song/a.sloppak/art")
|
|
assert r.status_code == 200
|
|
assert r.headers["content-type"] == "image/jpeg"
|
|
# Settled: the next pass never re-fetches.
|
|
n = len(caa.calls)
|
|
server._background_enrich()
|
|
assert len(caa.calls) == n
|
|
|
|
|
|
def test_caa_skips_pack_art_and_dedupes_by_release(server, caa):
|
|
make_sloppak(server, "haspack.sloppak", with_cover=True, title="One")
|
|
make_sloppak(server, "b.sloppak", title="Two")
|
|
make_sloppak(server, "c.sloppak", title="Three")
|
|
_match_row(server, "haspack.sloppak")
|
|
_match_row(server, "b.sloppak") # same release as c
|
|
_match_row(server, "c.sloppak")
|
|
server._background_enrich()
|
|
assert server.meta_db.get_enrichment("haspack.sloppak")["art_state"] == "pack"
|
|
assert server.meta_db.get_enrichment("b.sloppak")["art_state"] == "caa"
|
|
assert server.meta_db.get_enrichment("c.sloppak")["art_state"] == "caa"
|
|
assert len(caa.calls) == 1 # one release → ONE fetch
|
|
|
|
|
|
def test_caa_404_marks_none(server, caa):
|
|
make_sloppak(server, "a.sloppak")
|
|
_match_row(server, "a.sloppak", release_id="rel-missing")
|
|
server._background_enrich()
|
|
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "none"
|
|
n = len(caa.calls)
|
|
server._background_enrich()
|
|
assert len(caa.calls) == n # never re-hammered
|
|
|
|
|
|
def test_caa_transport_error_leaves_row_unevaluated(server, caa, monkeypatch):
|
|
make_sloppak(server, "a.sloppak")
|
|
_match_row(server, "a.sloppak")
|
|
|
|
def _down(release_id):
|
|
raise server.EnrichTransportError("down")
|
|
monkeypatch.setattr(server, "_caa_http_get", _down)
|
|
server._background_enrich()
|
|
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] is None
|
|
# Network back → next pass completes it.
|
|
monkeypatch.setattr(server, "_caa_http_get", caa)
|
|
server._background_enrich()
|
|
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "caa"
|
|
|
|
|
|
def test_offline_default_skips_art_worker(server, monkeypatch):
|
|
"""Under the plain test env the whole art phase is skipped with the rest
|
|
of the network work."""
|
|
calls = []
|
|
monkeypatch.setattr(server, "_caa_http_get", lambda rid: calls.append(rid))
|
|
make_sloppak(server, "a.sloppak")
|
|
_match_row(server, "a.sloppak")
|
|
server._background_enrich()
|
|
assert calls == []
|
|
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] is None
|
|
|
|
|
|
def test_lru_prune_evicts_oldest_and_resets_rows(server, caa, monkeypatch):
|
|
monkeypatch.setattr(server, "_CAA_CACHE_CAP_BYTES", 1) # everything over cap
|
|
make_sloppak(server, "a.sloppak", title="One")
|
|
make_sloppak(server, "b.sloppak", title="Two")
|
|
caa.art["rel-2"] = png_bytes((1, 1, 1))
|
|
_match_row(server, "a.sloppak", release_id="rel-1")
|
|
_match_row(server, "b.sloppak", release_id="rel-2")
|
|
server._background_enrich()
|
|
# With a 1-byte cap every fetch immediately evicts — the rows that pointed
|
|
# at evicted files were reset to unevaluated.
|
|
caa_files = list(server.ART_CACHE_DIR.glob("caa_*.jpg"))
|
|
assert len(caa_files) <= 1
|
|
states = {fn: server.meta_db.get_enrichment(fn)["art_state"]
|
|
for fn in ("a.sloppak", "b.sloppak")}
|
|
assert None in states.values() or list(states.values()).count("caa") <= 1
|
|
|
|
|
|
def test_delete_song_removes_override(server, client):
|
|
make_sloppak(server, "a.sloppak")
|
|
client.post("/api/song/a.sloppak/art/upload", json={"image": b64(png_bytes())})
|
|
assert server._art_override_paths("a.sloppak")
|
|
r = client.delete("/api/song/a.sloppak")
|
|
assert r.status_code == 200
|
|
assert server._art_override_paths("a.sloppak") == []
|
|
|
|
|
|
def test_delete_override_restores_caa_fallback(server, client, caa):
|
|
"""Removing a user override that had settled the row as 'user' must reset
|
|
the enrichment state so the CAA fallback is fetched and served again —
|
|
otherwise the song is stranded with no art at all."""
|
|
make_sloppak(server, "a.sloppak") # no pack art
|
|
_match_row(server, "a.sloppak")
|
|
# Pin an override BEFORE the art worker runs → the pass stamps art_state='user'.
|
|
client.post("/api/song/a.sloppak/art/upload", json={"image": b64(png_bytes())})
|
|
server._background_enrich()
|
|
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "user"
|
|
# Remove it → the row resets to unevaluated…
|
|
assert client.delete("/api/art/a.sloppak/override").json()["removed"]
|
|
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] is None
|
|
# …and the next pass fetches + serves the release's front cover.
|
|
server._background_enrich()
|
|
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "caa"
|
|
r = client.get("/api/song/a.sloppak/art")
|
|
assert r.status_code == 200
|
|
assert r.headers["content-type"] == "image/jpeg"
|
|
|
|
|
|
def test_upload_rejects_unknown_song_and_oversize(server, client):
|
|
# Unknown filename → 404 (no stray override file written).
|
|
assert client.post("/api/song/ghost.sloppak/art/upload",
|
|
json={"image": b64(png_bytes())}).status_code == 404
|
|
assert server._art_override_paths("ghost.sloppak") == []
|
|
# Oversize decoded payload → 400 (bounds the base64 upload path).
|
|
make_sloppak(server, "a.sloppak")
|
|
huge = b64(b"\x00" * (server._ART_URL_MAX_BYTES + 1))
|
|
assert client.post("/api/song/a.sloppak/art/upload",
|
|
json={"image": huge}).status_code == 400
|
|
|
|
|
|
def test_fetch_art_url_blocks_internal_hosts(server):
|
|
"""The SSRF guard refuses loopback / link-local / private targets before
|
|
any request is made (the real seam, not the faked one)."""
|
|
assert server._url_host_is_internal("http://127.0.0.1/x.png")
|
|
assert server._url_host_is_internal("http://localhost/x.png")
|
|
assert server._url_host_is_internal("http://169.254.169.254/latest/meta-data")
|
|
assert server._url_host_is_internal("http://10.0.0.5/x.png")
|
|
assert server._url_host_is_internal("http://[::1]/x.png")
|
|
assert server._url_host_is_internal("http://nonexistent.invalid/x.png") # unresolvable → closed
|
|
assert not server._url_host_is_internal("http://93.184.216.34/x.png") # public literal
|