feat(fix-popup): paste MusicBrainz URL/MBID to match directly

Power-user escape hatch on the Discovery Fix Track Match modal — when
fuzzy auto-search ranks the wrong recording among many same-title
versions (10 remasters, live cuts, alt sessions), paste the MusicBrainz
recording URL or bare UUID into the new field and resolve straight to
that record.

Layout:

- Shape adapter `get_recording_flat(mbid)` lives in
  `core/musicbrainz_search.py` next to existing `get_track_details`.
  Returns the flat Fix-popup track shape (artists as `string[]`,
  album as string, single `image_url`) — distinct from the
  Spotify-shaped nested dict `get_track_details` returns.

- New route `GET /api/musicbrainz/recording/<mbid>` is a thin wrapper:
  validates MBID format with an anchored UUID regex, calls the adapter,
  returns 400 / 404 / 200 with no inline shape massaging.

- Frontend `parseMusicBrainzMbid()` lives in `shared-helpers.js` —
  pure URL/UUID parser, reusable from other surfaces (failed-MB cache,
  manual match) without duplication.

- Fix modal HTML gets one new input row + button; existing search row
  and result render pipeline are untouched. New `lookupDiscoveryFixByMbid()`
  fetches the endpoint and feeds the single result through the existing
  `renderDiscoveryFixResults` -> confirm-dialog -> match pipeline, so MB-
  paste matches go through the exact same selection flow as auto-search
  results.

- Enter-key bound on the MBID input via a separate handler ref so its
  lifecycle matches the search-input handlers without conflating the
  two submit targets.

7 unit tests cover the adapter: happy path, empty/None MBID, MB returns
None, recording-without-release (empty album), multi-artist credits,
includes-list contract, and client-error swallow.

Out of scope: the Fix popup's fuzzy cascade is still hardcoded to
spotify/deezer/itunes regardless of which primary source the user has
configured. Adding MB to that cascade (when MB is the active primary)
is a separate concern.
This commit is contained in:
Broque Thomas 2026-05-19 17:03:19 -07:00
parent 43ed30b4d2
commit 036faff8b1
7 changed files with 326 additions and 0 deletions

View file

@ -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/<mbid>` 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)

View file

@ -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

View file

@ -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/<mbid>', 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."""

View file

@ -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/<uuid>` 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' },

View file

@ -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;
}

View file

@ -7414,6 +7414,19 @@ function openYouTubeDiscoveryModal(urlHash) {
🔍 Search
</button>
</div>
<!-- MBID escape hatch: paste a MusicBrainz recording URL or UUID
to bypass fuzzy search and resolve directly to that record. -->
<div class="search-input-group" style="margin-top: 8px;">
<input type="text"
id="fix-modal-mbid-input"
placeholder="Or paste MusicBrainz recording URL / MBID"
class="fix-modal-input"
style="flex: 1;">
<button class="search-btn" onclick="lookupDiscoveryFixByMbid()">
🔗 Look up
</button>
</div>
</div>
<!-- Search results -->

View file

@ -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/<uuid>`) 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 = '<div class="error-message">❌ Not a valid MusicBrainz recording URL or MBID. Paste a URL like https://musicbrainz.org/recording/&lt;uuid&gt; or the bare UUID.</div>';
}
showToast('Invalid MusicBrainz MBID', 'error');
return;
}
if (resultsContainer) {
resultsContainer.innerHTML = '<div class="loading">🔗 Looking up MusicBrainz recording...</div>';
}
try {
const response = await fetch(`/api/musicbrainz/recording/${encodeURIComponent(mbid)}`);
if (response.status === 404) {
if (resultsContainer) {
resultsContainer.innerHTML = '<div class="no-results">Recording not found on MusicBrainz. Double-check the MBID.</div>';
}
return;
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const track = await response.json();
if (!track || !track.id) {
if (resultsContainer) {
resultsContainer.innerHTML = '<div class="no-results">Recording not found on MusicBrainz.</div>';
}
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 = '<div class="error-message">❌ MBID lookup failed. Try again.</div>';
}
}
}
/**
* Render search results as clickable cards
*/