diff --git a/core/library/direct_id.py b/core/library/direct_id.py new file mode 100644 index 00000000..3932db26 --- /dev/null +++ b/core/library/direct_id.py @@ -0,0 +1,53 @@ +"""Direct-ID detection for manual matching (Ashh's request). + +The manual-match modal fuzzy-searches a service and shows the top 8 hits. +When the right release isn't in those 8 (common name like "Idols"), the user +is stuck. But they often already KNOW the exact ID — so let them paste it +and match directly instead of fighting the search ranking. + +This module is the pure detector: given a service + the text the user typed, +return the canonical ID if the text *is* an ID (bare or pasted as a URL/URI), +else None. No network, no I/O — the caller decides whether to do a direct +lookup or fall through to the normal fuzzy search. + +Conservative by design: only return an ID when the text matches that +service's ID shape unambiguously. Anything else returns None so a normal +text search still runs (pasting "Idols" never looks like an ID). +""" + +from __future__ import annotations + +import re +from typing import Optional + +# MusicBrainz MBIDs are UUIDs (8-4-4-4-12 hex). Accept a bare UUID or one +# embedded in a musicbrainz.org URL (/artist/, /release/, ...). +_UUID_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, +) + + +def extract_direct_id(service: str, entity_type: str, query: str) -> Optional[str]: + """Return the canonical service ID if ``query`` is one, else None. + + ``entity_type`` is accepted for future per-type shapes (e.g. Spotify + track vs album URLs); MusicBrainz UUIDs are type-agnostic so it's unused + there today.""" + if not query: + return None + text = query.strip() + if not text: + return None + + service = (service or "").strip().lower() + + if service == "musicbrainz": + # Bare UUID, or a MB URL — but ONLY a UUID. A search like "Idols" + # can't match, so normal search is never hijacked. + m = _UUID_RE.search(text) + if m and (text == m.group(1) or "musicbrainz.org" in text.lower()): + return m.group(1).lower() + return None + + return None diff --git a/core/library/service_search.py b/core/library/service_search.py index 38fd450e..d431b41a 100644 --- a/core/library/service_search.py +++ b/core/library/service_search.py @@ -59,10 +59,65 @@ def _detect_provider(items, client): return 'spotify' +def _mb_direct_lookup(entity_type, mbid): + """Confirm a pasted MusicBrainz MBID by fetching that exact entity. + Returns a one-item result list (same shape as the search path) so the + modal shows it for confirmation, or [] if the ID doesn't resolve.""" + if not mb_worker or not mb_worker.mb_service: + raise ValueError("MusicBrainz worker not initialized") + mb_client = mb_worker.mb_service.mb_client + + if entity_type == 'artist': + a = mb_client.get_artist(mbid) + if not a: + return [] + extra = a.get('disambiguation') or a.get('country') or a.get('type') or '' + return [{'id': a['id'], 'name': a.get('name', ''), 'image': None, + 'extra': f"Direct ID match{' · ' + extra if extra else ''}"}] + + if entity_type == 'album': + # A pasted album ID may be a release OR a release-group — try release + # first (what the matcher stores), fall back to release-group. + r = mb_client.get_release(mbid) or mb_client.get_release_group(mbid) + if not r: + return [] + artists = ', '.join(ac.get('name', '') for ac in r.get('artist-credit', []) if isinstance(ac, dict)) + cover_url = f"https://coverartarchive.org/release/{r['id']}/front-250" if r.get('id') else None + bits = ' · '.join(b for b in (artists, r.get('date', '')) if b) + return [{'id': r['id'], 'name': r.get('title', ''), 'image': cover_url, + 'extra': f"Direct ID match{' · ' + bits if bits else ''}"}] + + if entity_type == 'track': + rec = mb_client.get_recording(mbid) + if not rec: + return [] + artists = ', '.join(ac.get('name', '') for ac in rec.get('artist-credit', []) if isinstance(ac, dict)) + return [{'id': rec['id'], 'name': rec.get('title', ''), 'image': None, + 'extra': f"Direct ID match{' · ' + artists if artists else ''}"}] + return [] + + def _search_service(service, entity_type, query): """Search a service and return normalized results.""" import requests as req_lib + # Direct-ID fast path (Ashh): if the user pasted an exact service ID + # (e.g. a MusicBrainz MBID for a release the top-8 fuzzy search missed), + # confirm it by direct lookup and return just that entity. A failed + # lookup falls through to the normal search, so a paste that merely + # LOOKS like an ID can't dead-end the modal. + from core.library.direct_id import extract_direct_id + direct_id = extract_direct_id(service, entity_type, query) + if direct_id: + try: + if service == 'musicbrainz': + hit = _mb_direct_lookup(entity_type, direct_id) + if hit: + return hit + except Exception as e: + logger.debug("Direct-ID lookup failed for %s %s: %s", service, direct_id, e) + # fall through to fuzzy search + if service == 'spotify': if not spotify_enrichment_worker or not spotify_enrichment_worker.client: raise ValueError("Spotify worker not initialized") diff --git a/tests/library/test_direct_id_match.py b/tests/library/test_direct_id_match.py new file mode 100644 index 00000000..50ca48fb --- /dev/null +++ b/tests/library/test_direct_id_match.py @@ -0,0 +1,113 @@ +"""Direct-ID manual matching (Ashh: 'just slap MB ID in that search'). + +When the right release isn't in the top-8 fuzzy results, the user pastes the +exact ID. extract_direct_id detects it (pure); _search_service confirms it +via a direct lookup and returns just that entity, falling back to fuzzy +search if the paste only looks ID-ish. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock + +from core.library.direct_id import extract_direct_id + +MBID = "1af02ea7-3f00-40ca-804b-41e2dca7e4a9" + + +# ── pure detector ──────────────────────────────────────────────────────────── + +def test_bare_mbid_detected(): + assert extract_direct_id("musicbrainz", "album", MBID) == MBID + assert extract_direct_id("musicbrainz", "artist", MBID.upper()) == MBID # normalized + + +def test_mbid_in_url_detected(): + for url in ( + f"https://musicbrainz.org/release/{MBID}", + f"https://musicbrainz.org/release/{MBID}/cover-art", + f" https://beta.musicbrainz.org/artist/{MBID} ", + ): + assert extract_direct_id("musicbrainz", "album", url) == MBID + + +def test_plain_text_query_is_not_an_id(): + assert extract_direct_id("musicbrainz", "album", "Idols") is None + assert extract_direct_id("musicbrainz", "album", "Yungblud Idols") is None + assert extract_direct_id("musicbrainz", "album", "") is None + assert extract_direct_id("musicbrainz", "album", " ") is None + + +def test_loose_uuid_without_url_context_is_rejected(): + # A UUID embedded in free text (not the whole query, no MB URL) is NOT + # treated as a direct ID — avoids hijacking a genuine search. + assert extract_direct_id("musicbrainz", "album", f"album {MBID} deluxe") is None + + +def test_non_musicbrainz_services_have_no_direct_id_yet(): + assert extract_direct_id("spotify", "album", MBID) is None + assert extract_direct_id("deezer", "track", "12345") is None + + +# ── _search_service direct dispatch ────────────────────────────────────────── + +def _wire_mb(monkeypatch, **methods): + import core.library.service_search as ss + mb_client = MagicMock(**methods) + worker = SimpleNamespace(mb_service=SimpleNamespace(mb_client=mb_client)) + monkeypatch.setattr(ss, "mb_worker", worker) + return ss, mb_client + + +def test_pasted_mbid_returns_single_confirmed_release(monkeypatch): + ss, mb_client = _wire_mb(monkeypatch) + mb_client.get_release.return_value = { + "id": MBID, "title": "Idols", "date": "2025-06-20", + "artist-credit": [{"name": "Yungblud"}], + } + results = ss._search_service("musicbrainz", "album", MBID) + + assert len(results) == 1 + assert results[0]["id"] == MBID + assert results[0]["name"] == "Idols" + assert "Direct ID match" in results[0]["extra"] + assert "Yungblud" in results[0]["extra"] + mb_client.get_release.assert_called_once_with(MBID) + mb_client.search_release.assert_not_called() # never fuzzy-searched + + +def test_album_falls_back_to_release_group(monkeypatch): + ss, mb_client = _wire_mb( + monkeypatch, + get_release=lambda mbid: None, + get_release_group=lambda mbid: {"id": MBID, "title": "Idols", "artist-credit": []}, + ) + results = ss._search_service("musicbrainz", "album", MBID) + assert len(results) == 1 and results[0]["name"] == "Idols" + + +def test_unresolvable_mbid_falls_through_to_fuzzy(monkeypatch): + # ID-shaped but doesn't resolve → don't dead-end; run the normal search. + ss, mb_client = _wire_mb( + monkeypatch, + get_release=lambda mbid: None, + get_release_group=lambda mbid: None, + search_release=lambda q, limit=8, strict=False: [ + {"id": "other", "title": "Idols (fuzzy)", "artist-credit": [], "date": "", "score": 90}, + ], + ) + results = ss._search_service("musicbrainz", "album", MBID) + assert len(results) == 1 and results[0]["id"] == "other" # fuzzy result + + +def test_plain_query_skips_direct_lookup(monkeypatch): + ss, mb_client = _wire_mb( + monkeypatch, + search_release=lambda q, limit=8, strict=False: [ + {"id": "r1", "title": "Idols", "artist-credit": [], "date": "", "score": 100}, + ], + ) + results = ss._search_service("musicbrainz", "album", "Idols") + assert results[0]["id"] == "r1" + mb_client.get_release.assert_not_called() # no wasted direct lookup diff --git a/webui/static/library.js b/webui/static/library.js index e9850567..922cb82e 100644 --- a/webui/static/library.js +++ b/webui/static/library.js @@ -6065,7 +6065,9 @@ function openManualMatchModal(entityType, entityId, service, defaultQuery, artis const searchInput = document.createElement('input'); searchInput.type = 'text'; searchInput.className = 'enhanced-match-search-input'; - searchInput.placeholder = `Search ${serviceLabels[service] || service}...`; + searchInput.placeholder = service === 'musicbrainz' + ? `Search ${serviceLabels[service]}… or paste a MusicBrainz ID/URL` + : `Search ${serviceLabels[service] || service}...`; searchInput.value = defaultQuery; searchRow.appendChild(searchInput); const searchBtn = document.createElement('button');