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:
parent
43ed30b4d2
commit
036faff8b1
7 changed files with 326 additions and 0 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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/<uuid> 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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue