Manual match: paste a MusicBrainz ID/URL to match directly (Ashh)
Ashh: the manual-match modal fuzzy-searches a service and shows the top 8. When the right release isn't in those 8 (common title — their example was "Idols", which returns 8 unrelated releases and not Yungblud's), there was no way through. But the user usually already knows the exact MBID. Now the modal's search box doubles as a direct-ID box. Paste a MusicBrainz MBID (bare UUID or a musicbrainz.org URL) and SoulSync looks that exact entity up and shows it as the single result to confirm + Match — no fighting the search ranking. - core/library/direct_id.py: pure detector, returns the canonical ID only when the text unambiguously IS one (whole-query UUID, or a UUID inside a musicbrainz.org URL). "Idols", "Yungblud Idols", a UUID buried in free text → None, so normal search is never hijacked. - _search_service: direct-ID fast path before the fuzzy search — get_release (→ get_release_group fallback for albums) / get_artist / get_recording. A pasted-but-unresolvable ID falls THROUGH to fuzzy search, so a typo can't dead-end the modal. - UI: MusicBrainz placeholder now says "…or paste a MusicBrainz ID/URL". Detector is service-keyed so Spotify/iTunes/etc. direct IDs can be added later; today only MusicBrainz has a confirmable direct lookup, matching the reporter's ask + screenshot. 9 tests: detector truth table (bare/URL/plain/ buried/other-service) + dispatch (confirmed release, release-group fallback, unresolvable→fuzzy, plain query skips direct lookup).
This commit is contained in:
parent
46f03827a2
commit
8b7609cdb2
4 changed files with 224 additions and 1 deletions
53
core/library/direct_id.py
Normal file
53
core/library/direct_id.py
Normal file
|
|
@ -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/<id>, /release/<id>, ...).
|
||||
_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
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
113
tests/library/test_direct_id_match.py
Normal file
113
tests/library/test_direct_id_match.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue