diff --git a/core/musicbrainz_search.py b/core/musicbrainz_search.py index 4ce82e87..ff05be13 100644 --- a/core/musicbrainz_search.py +++ b/core/musicbrainz_search.py @@ -725,6 +725,57 @@ class MusicBrainzSearchClient: logger.error(f'get_track_details({track_id}) error: {e}') return None + def get_recording_flat(self, mbid: str) -> Optional[Dict[str, Any]]: + """Return a Fix-popup-compatible flat track dict by recording MBID. + + Distinct from `get_track_details` which returns a Spotify-shaped + nested dict (artists as objects, album as nested object with + images array). The Discovery Fix popup expects the flat shape that + the spotify/deezer/itunes search endpoints produce — artists as a + list of strings, album as a string, single image_url field. + + Used by `GET /api/musicbrainz/recording/` to support the + MBID-paste lookup field — power-user escape hatch when fuzzy auto- + search ranks the wrong recording among many same-title versions. + + Returns None when the MBID is missing or MB returns no recording. + Recording-without-release is valid (album = '', image_url = ''). + """ + if not mbid: + return None + try: + rec = self._client.get_recording( + mbid, includes=['releases', 'artist-credits', 'release-groups'] + ) + if not rec: + return None + + releases = rec.get('releases', []) or [] + releases.sort(key=self._release_preference_key) + first_rel = releases[0] if releases else {} + rg = first_rel.get('release-group', {}) or {} + release_id = first_rel.get('id', '') + rg_id = rg.get('id', '') + + artists = _extract_artist_credit(rec.get('artist-credit', [])) + album_name = first_rel.get('title', '') or '' + image_url = self._cached_art(release_id, rg_id) if (release_id or rg_id) else None + + return { + 'id': rec.get('id', '') or mbid, + 'name': rec.get('title', '') or '', + 'artists': artists if artists else [], + 'album': album_name, + 'duration_ms': rec.get('length') or 0, + 'image_url': image_url or '', + 'external_urls': { + 'musicbrainz': f'https://musicbrainz.org/recording/{mbid}' + }, + } + except Exception as e: + logger.error(f'get_recording_flat({mbid}) error: {e}') + return None + def get_album_tracks(self, album_mbid: str) -> Optional[Dict[str, Any]]: """Return {items: [...], total: N} track listing for a release/release-group MBID.""" album = self.get_album(album_mbid, include_tracks=True) diff --git a/tests/metadata/test_musicbrainz_search.py b/tests/metadata/test_musicbrainz_search.py index 719f02ae..350a52a4 100644 --- a/tests/metadata/test_musicbrainz_search.py +++ b/tests/metadata/test_musicbrainz_search.py @@ -765,3 +765,125 @@ def test_search_tracks_text_path_filters_by_score(): titles = [t.name for t in tracks] assert 'Good' in titles assert 'Bad' not in titles + + +# --------------------------------------------------------------------------- +# get_recording_flat — Fix-popup MBID paste adapter +# --------------------------------------------------------------------------- + +def test_get_recording_flat_happy_path(): + """Recording with a release returns flat shape with album + image.""" + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.get_recording.return_value = { + 'id': 'rec-abc', + 'title': 'Army of Me', + 'length': 234000, + 'artist-credit': [{'artist': {'name': 'Björk'}}], + 'releases': [{ + 'id': 'rel-xyz', + 'title': 'Post', + 'date': '1995-06-13', + 'status': 'Official', + 'media': [{'track-count': 11}], + 'release-group': {'id': 'rg-post', 'primary-type': 'Album', 'secondary-types': []}, + }], + } + + track = client.get_recording_flat('rec-abc') + + assert track is not None + assert track['id'] == 'rec-abc' + assert track['name'] == 'Army of Me' + assert track['artists'] == ['Björk'] # flat list of strings, not Spotify-shaped objects + assert track['album'] == 'Post' # flat string, not nested dict + assert track['duration_ms'] == 234000 + assert track['image_url'] # CAA URL present + assert 'musicbrainz.org/recording/rec-abc' in track['external_urls']['musicbrainz'] + + +def test_get_recording_flat_missing_mbid_returns_none(): + """No MBID → no API call, returns None.""" + client = MusicBrainzSearchClient() + client._client = MagicMock() + + assert client.get_recording_flat('') is None + assert client.get_recording_flat(None) is None + client._client.get_recording.assert_not_called() + + +def test_get_recording_flat_mb_returns_no_recording(): + """MB returns None (404 / missing) → adapter returns None.""" + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.get_recording.return_value = None + + assert client.get_recording_flat('rec-missing') is None + + +def test_get_recording_flat_recording_without_release(): + """Standalone recording (no releases) — album stays empty, + image_url empty, but the rest of the shape is intact.""" + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.get_recording.return_value = { + 'id': 'rec-standalone', + 'title': 'Untitled Demo', + 'length': 120000, + 'artist-credit': [{'artist': {'name': 'Unknown'}}], + 'releases': [], + } + + track = client.get_recording_flat('rec-standalone') + + assert track is not None + assert track['name'] == 'Untitled Demo' + assert track['album'] == '' + assert track['image_url'] == '' + assert track['artists'] == ['Unknown'] + assert track['duration_ms'] == 120000 + + +def test_get_recording_flat_multi_artist_credit(): + """Recording with multiple credited artists — all flatten to list of strings.""" + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.get_recording.return_value = { + 'id': 'rec-collab', + 'title': 'Collab Track', + 'length': 180000, + 'artist-credit': [ + {'artist': {'name': 'Artist A'}}, + {'artist': {'name': 'Artist B'}}, + ], + 'releases': [], + } + + track = client.get_recording_flat('rec-collab') + + assert track['artists'] == ['Artist A', 'Artist B'] + + +def test_get_recording_flat_includes_match_get_track_details(): + """Sanity: passes the same includes list so the API call is cacheable + against the same key as get_track_details (one network request can + serve both surfaces if MB ever adds response caching upstream).""" + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.get_recording.return_value = None + + client.get_recording_flat('rec-x') + + client._client.get_recording.assert_called_once_with( + 'rec-x', includes=['releases', 'artist-credits', 'release-groups'] + ) + + +def test_get_recording_flat_swallows_client_errors(): + """MB client raising must not propagate to the route handler — return + None so the endpoint can render a friendly 404 instead of 500.""" + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.get_recording.side_effect = RuntimeError('boom') + + assert client.get_recording_flat('rec-err') is None diff --git a/web_server.py b/web_server.py index 46bdd39c..347e938c 100644 --- a/web_server.py +++ b/web_server.py @@ -15789,6 +15789,33 @@ def musicbrainz_search_api(): return jsonify({"error": str(e)}), 500 +# Recording MBID format: standard UUID, 8-4-4-4-12 hex. +_MB_RECORDING_MBID_RE = re.compile( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.IGNORECASE +) + + +@app.route('/api/musicbrainz/recording/', methods=['GET']) +def musicbrainz_recording_lookup_api(mbid): + """Look up a single MusicBrainz recording by MBID and return it in the + Fix-popup-compatible flat track shape. Powers the MBID-paste field — + user-facing escape hatch when fuzzy auto-search ranks the wrong + recording among many same-title versions.""" + mbid = (mbid or '').strip().lower() + if not _MB_RECORDING_MBID_RE.match(mbid): + return jsonify({"error": "Invalid MusicBrainz recording MBID"}), 400 + try: + from core.musicbrainz_search import MusicBrainzSearchClient + mb_search = MusicBrainzSearchClient() + track = mb_search.get_recording_flat(mbid) + if not track: + return jsonify({"error": "Recording not found on MusicBrainz"}), 404 + return jsonify(track) + except Exception as e: + logger.error(f"Error looking up MB recording {mbid}: {e}") + return jsonify({"error": str(e)}), 500 + + @app.route('/api/metadata-cache/mb-match', methods=['POST']) def metadata_cache_save_mb_match(): """Save a manual MusicBrainz match for a failed lookup.""" diff --git a/webui/static/helper.js b/webui/static/helper.js index 40c74509..0b581183 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3421,6 +3421,7 @@ const WHATS_NEW = { { title: 'Fix: Amazon search albums/artists missing, album downloads all track 01', desc: 't2tunes proxies Amazon Music and uses 400 to signal transient failures — first API call in a session hit this consistently, so album/artist searches always failed while track search (called 0.5s later) scraped through. added up to 3 retries with backoff on t2tunes-specific 400s. also: all search methods were using types=track,album but t2tunes album-type queries are broken — switched everything to types=track and derive albums/artists from track metadata instead. track numbers from album downloads were also always 1 — added index-based fallback when t2tunes tags omit trackNumber.' }, { title: 'Fix: MusicBrainz manual search missing results', desc: 'the Fix popup and manual library service search were using strict Lucene phrase-match queries against the `recording` / `release` / `artist` fields — diacritics ("Bjork" vs canonical "Björk"), bracketed suffixes like "(Live)", and any AND-clause mismatch all killed recall. switched user-facing manual lookups to bare queries that hit MB\'s alias / sortname indexes with diacritic folding. enrichment workers stay strict for precision.' }, { title: 'Fix: MusicBrainz album clicks 404ing in enhanced search', desc: 'every click on a MusicBrainz album result was silently 404-ing — the /release fetch was passing `cover-art-archive` as an `inc` param, which MB rejects with 400 (that field is returned on every release response by default, no include needed). dropped the bad include; album detail now loads correctly.' }, + { title: 'Fix popup: paste a MusicBrainz URL or MBID to match directly', desc: 'new escape hatch on the Fix Track Match modal (the 🔧 Fix button on mirrored / YouTube / Tidal / Deezer / Beatport / ListenBrainz / Spotify-public discovery rows). when fuzzy search keeps ranking the wrong recording among many same-title versions, paste the MusicBrainz recording URL like `https://musicbrainz.org/recording/` or the bare UUID into the new field and hit "Look up". skips all fuzzy logic, resolves straight to that record, and runs it through the same confirm + match pipeline.' }, ], '2.5.5': [ { date: 'May 17, 2026 — 2.5.5 release' }, diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index e5aeb0bc..9840fea3 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -3968,3 +3968,31 @@ function showCompletionError() { overlay.title = 'Failed to check completion status'; }); } + + +// ---------------------------------------------------------------------------- +// MusicBrainz MBID parsing — accept full URLs or bare UUIDs. +// Used by the Discovery Fix popup's MBID-paste lookup; reusable anywhere the +// user can paste a MusicBrainz identifier (failed-MB cache, manual match, +// future surfaces). Returns the bare lowercase UUID when valid, or null. +// ---------------------------------------------------------------------------- + +const _MB_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function parseMusicBrainzMbid(input) { + if (!input || typeof input !== 'string') return null; + let candidate = input.trim(); + if (!candidate) return null; + + // Strip a musicbrainz.org URL down to the final UUID segment. Accepts + // /recording/, /release/, /release-group/, /artist/ — the caller decides + // which entity type is appropriate for its endpoint. + const urlMatch = candidate.match(/musicbrainz\.org\/[a-z-]+\/([0-9a-f-]+)/i); + if (urlMatch) candidate = urlMatch[1]; + + // Strip trailing query / fragment that survived a copy-paste from a URL + // (e.g. "?utm=...", "#tab"). + candidate = candidate.split(/[?#]/)[0].trim().toLowerCase(); + + return _MB_UUID_RE.test(candidate) ? candidate : null; +} diff --git a/webui/static/sync-services.js b/webui/static/sync-services.js index 8eeececb..11b50933 100644 --- a/webui/static/sync-services.js +++ b/webui/static/sync-services.js @@ -7414,6 +7414,19 @@ function openYouTubeDiscoveryModal(urlHash) { 🔍 Search + + +
+ + +
diff --git a/webui/static/wishlist-tools.js b/webui/static/wishlist-tools.js index ee11148f..ad705785 100644 --- a/webui/static/wishlist-tools.js +++ b/webui/static/wishlist-tools.js @@ -12,6 +12,9 @@ let currentDiscoveryFix = { // Store event handler reference to allow proper removal let discoveryFixEnterHandler = null; +// Separate handler for the MBID-paste input — targets lookup-by-MBID +// instead of fuzzy search so Enter does the obvious right thing per field. +let discoveryFixMbidEnterHandler = null; /** * Open discovery fix modal for a specific track @@ -113,11 +116,20 @@ function openDiscoveryFixModal(platform, identifier, trackIndex) { artist: artistInput.value }); + // MBID input — separate ref because its Enter binding targets a + // different function (direct lookup vs fuzzy search). Optional: older + // modal markup that doesn't have the MBID row will get null here. + const mbidInput = fixModalOverlay.querySelector('#fix-modal-mbid-input'); + if (mbidInput) mbidInput.value = ''; + // Remove old enter key handler if exists if (discoveryFixEnterHandler) { trackInput.removeEventListener('keypress', discoveryFixEnterHandler); artistInput.removeEventListener('keypress', discoveryFixEnterHandler); } + if (discoveryFixMbidEnterHandler && mbidInput) { + mbidInput.removeEventListener('keypress', discoveryFixMbidEnterHandler); + } // Add new enter key handler discoveryFixEnterHandler = function (e) { @@ -126,6 +138,13 @@ function openDiscoveryFixModal(platform, identifier, trackIndex) { trackInput.addEventListener('keypress', discoveryFixEnterHandler); artistInput.addEventListener('keypress', discoveryFixEnterHandler); + if (mbidInput) { + discoveryFixMbidEnterHandler = function (e) { + if (e.key === 'Enter') lookupDiscoveryFixByMbid(); + }; + mbidInput.addEventListener('keypress', discoveryFixMbidEnterHandler); + } + // Show modal BEFORE auto-search so elements are visible fixModalOverlay.classList.remove('hidden'); console.log('✅ Fix modal opened, starting auto-search...'); @@ -238,6 +257,71 @@ async function searchDiscoveryFix() { } } +/** + * Look up a track directly by MusicBrainz recording MBID — bypasses fuzzy + * search entirely. Escape hatch for cases where the user knows the exact + * record (e.g. there are 10 same-title recordings from different sessions + * and auto-search keeps ranking the wrong one). Accepts full URLs + * (`https://musicbrainz.org/recording/`) or bare UUIDs. + */ +async function lookupDiscoveryFixByMbid() { + if (!currentDiscoveryFix.identifier) { + console.error('No active fix modal context'); + return; + } + + const discoveryModal = document.getElementById(`youtube-discovery-modal-${currentDiscoveryFix.identifier}`); + if (!discoveryModal) return; + const fixModalOverlay = discoveryModal.querySelector('.discovery-fix-modal-overlay'); + if (!fixModalOverlay) return; + + const mbidInput = fixModalOverlay.querySelector('#fix-modal-mbid-input'); + if (!mbidInput) return; + + const mbid = parseMusicBrainzMbid(mbidInput.value); + const resultsContainer = fixModalOverlay.querySelector('#fix-modal-results'); + + if (!mbid) { + if (resultsContainer) { + resultsContainer.innerHTML = '
❌ Not a valid MusicBrainz recording URL or MBID. Paste a URL like https://musicbrainz.org/recording/<uuid> or the bare UUID.
'; + } + showToast('Invalid MusicBrainz MBID', 'error'); + return; + } + + if (resultsContainer) { + resultsContainer.innerHTML = '
🔗 Looking up MusicBrainz recording...
'; + } + + try { + const response = await fetch(`/api/musicbrainz/recording/${encodeURIComponent(mbid)}`); + if (response.status === 404) { + if (resultsContainer) { + resultsContainer.innerHTML = '
Recording not found on MusicBrainz. Double-check the MBID.
'; + } + return; + } + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const track = await response.json(); + if (!track || !track.id) { + if (resultsContainer) { + resultsContainer.innerHTML = '
Recording not found on MusicBrainz.
'; + } + return; + } + // Render as a single-result list — user still clicks to confirm, + // matching the existing search-result flow exactly. + renderDiscoveryFixResults([track], fixModalOverlay); + } catch (error) { + console.error('MBID lookup error:', error); + if (resultsContainer) { + resultsContainer.innerHTML = '
❌ MBID lookup failed. Try again.
'; + } + } +} + /** * Render search results as clickable cards */