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 @@ '
' + SKELETON_TILE + SKELETON_TILE + SKELETON_TILE + '
' + '
Fetching covers… the source is rate-limited.
' + '' + + // 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(); }