diff --git a/server.py b/server.py
index 0165823..ecc38da 100644
--- a/server.py
+++ b/server.py
@@ -6547,6 +6547,36 @@ def _mb_search_recordings(artist, title, limit: int = 12) -> list[dict]:
return cands
+def _mb_search_release_groups(query: str, limit: int = 8) -> list[dict]:
+ """Text search /release-group for the Change-cover picker: albums matching a
+ free query, each mapped to its Cover Art Archive front thumb. One request;
+ tiles whose CAA art is missing self-hide client-side (front-250 404s). Lets a
+ cover be found even for a song with no metadata match (the city-pop pile)."""
+ q = (query or "").strip()
+ if not q:
+ return []
+ body = _mb_http_get("release-group", {"query": q, "limit": limit})
+ out: list[dict] = []
+ for rg in ((body or {}).get("release-groups") or []):
+ rid = rg.get("id")
+ if not rid:
+ continue
+ # artist-credit is a list of {name, joinphrase, artist} (joinphrase glues
+ # collaborations) — reconstruct the credited name.
+ artist = "".join(
+ (c.get("name", "") + c.get("joinphrase", "")) if isinstance(c, dict) else str(c)
+ for c in (rg.get("artist-credit") or [])
+ ).strip()
+ title = rg.get("title") or ""
+ year = (rg.get("first-release-date") or "")[:4]
+ out.append({
+ "id": rid,
+ "label": " · ".join(x for x in (title, artist, year) if x) or title or "Cover",
+ "thumb_url": f"https://coverartarchive.org/release-group/{rid}/front-250",
+ })
+ return out
+
+
# ── AcoustID audio fingerprinting (content-based identification) ──────────────
# Optional path: requires the Chromaprint `fpcalc` binary AND an AcoustID API
# key ($ACOUSTID_API_KEY). Both absent ⇒ graceful no-op; the text matcher runs.
@@ -12249,6 +12279,26 @@ async def get_song_art(filename: str, request: Request = None, source: str = "")
_ART_PICKER_MAX_CAA = 12
+@app.get("/api/song/{filename:path}/art/cover-search")
+def api_art_cover_search(filename: str, q: str = ""):
+ """Search Cover Art Archive (via MusicBrainz release-groups) for album covers
+ — powers the Change-cover picker's search box, so a cover can be found even
+ for a song with no metadata match (the unmatched city-pop pile, where
+ /art/candidates is empty). `q` defaults to the song's own artist + album/
+ title (romaji fallback applied). Read-only; the picker renders the thumbs and
+ applies a pick through the existing /art/url route."""
+ query = (q or "").strip()
+ if not query:
+ pack = meta_db.pack_fields(meta_db._canonical_song_filename(filename))
+ query = " ".join(x for x in (pack.get("artist"), pack.get("album") or pack.get("title")) if x).strip()
+ if not query:
+ return {"query": "", "covers": []}
+ try:
+ return {"query": query, "covers": _mb_search_release_groups(query, limit=8)}
+ except EnrichTransportError:
+ return {"query": query, "covers": [], "error": "unavailable"}
+
+
@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
diff --git a/static/v3/image-picker.js b/static/v3/image-picker.js
index 47e14cb..c13182e 100644
--- a/static/v3/image-picker.js
+++ b/static/v3/image-picker.js
@@ -138,6 +138,17 @@
'
' +
'' +
+ // Search Cover Art Archive — find an album cover even when the song has
+ // no match (the auto candidates above are empty then). Pre-filled from
+ // the song's artist + album/title; the source is rate-limited.
+ '
' +
+ '
Search covers
' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ '' +
+ '
' +
'' +
'' +
'';
@@ -185,9 +196,47 @@
}
});
});
+ const searchInput = panel.querySelector('[data-ip-search-input]');
+ const runSearch = () => coverSearch(panel, (searchInput && searchInput.value) || '');
+ panel.querySelector('[data-ip-search-go]')?.addEventListener('click', runSearch);
+ searchInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); runSearch(); } });
panel.querySelector('[data-ip-close]')?.focus();
}
+ // Search Cover Art Archive (via the song-scoped cover-search endpoint) and
+ // render the album covers as pickable tiles — the same apply('url') path as
+ // the auto candidates. Covers with no CAA art self-hide (img onerror).
+ async function coverSearch(panel, query) {
+ const out = panel.querySelector('[data-ip-search-results]');
+ const fn = _cur && _cur.filename;
+ if (!out || !fn) return;
+ out.innerHTML = '
' + SKELETON_TILE + SKELETON_TILE + '
';
+ let body = null;
+ try {
+ const r = await fetch('/api/song/' + enc(fn) + '/art/cover-search?q=' + enc(String(query).trim()));
+ if (r.ok) body = await r.json();
+ } catch (_) { /* falls through to the empty state */ }
+ if (!_cur || _cur.filename !== fn) return; // closed / changed song while searching
+ const covers = (body && body.covers) || [];
+ if (!covers.length) {
+ out.innerHTML = '
' +
+ ((body && body.error) ? 'Cover search is unavailable right now.' : 'No covers found — try a different search.') +
+ '
';
+ return;
+ }
+ out.innerHTML = covers.map((c, i) =>
+ tileHtml('data-ip-cover="' + i + '"', imgFace(c.thumb_url), c.label || 'Cover')).join('');
+ out.querySelectorAll('[data-ip-cover]').forEach((btn) => {
+ const img = btn.querySelector('img');
+ if (img) img.onerror = () => btn.classList.add('hidden'); // no CAA art for this album → hide
+ btn.addEventListener('click', () => {
+ if (_busy) return;
+ const c = covers[Number(btn.getAttribute('data-ip-cover'))];
+ if (c) apply('url', c.thumb_url);
+ });
+ });
+ }
+
// 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.
@@ -281,7 +330,13 @@
const filename = opts && opts.filename;
if (!filename) return;
_lastFocus = document.activeElement;
- _cur = { filename: filename, title: (opts && opts.title) || filename };
+ const title = (opts && opts.title) || filename;
+ const artist = (opts && opts.artist) || '';
+ const album = (opts && opts.album) || '';
+ // Pre-fill the cover search: "artist album" when the album is known, else
+ // just the artist, else the title — the server default backs it up.
+ const query = [artist, album].filter(Boolean).join(' ').trim() || title;
+ _cur = { filename: filename, title: title, query: query };
_busy = false;
const m = ensureModal();
const panel = document.getElementById('v3-imgpick-panel');
diff --git a/static/v3/match-review.js b/static/v3/match-review.js
index 2337778..a33340c 100644
--- a/static/v3/match-review.js
+++ b/static/v3/match-review.js
@@ -696,7 +696,7 @@
'';
body.querySelector('[data-cover-open]')?.addEventListener('click', () => {
if (window.__fbOpenImagePicker) {
- window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename });
+ window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename, artist: song.artist, album: song.album });
}
});
}
diff --git a/static/v3/songs.js b/static/v3/songs.js
index 1c5490f..bb21c18 100644
--- a/static/v3/songs.js
+++ b/static/v3/songs.js
@@ -1007,7 +1007,7 @@
// 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 === '__cover') {
- if (window.__fbOpenImagePicker) window.__fbOpenImagePicker({ filename: playTarget.filename, title: playTarget.title || playTarget.filename });
+ if (window.__fbOpenImagePicker) window.__fbOpenImagePicker({ filename: playTarget.filename, title: playTarget.title || playTarget.filename, artist: playTarget.artist, album: playTarget.album });
return;
}
if (id === '__refreshmeta') {
@@ -3126,7 +3126,7 @@
// when image-picker.js isn't loaded.
artWrap.addEventListener('click', () => {
if (window.__fbOpenImagePicker) {
- window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename });
+ window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename, artist: song.artist, album: song.album });
} else {
artFile.click();
}