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'
|
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):
|
def _search_service(service, entity_type, query):
|
||||||
"""Search a service and return normalized results."""
|
"""Search a service and return normalized results."""
|
||||||
import requests as req_lib
|
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 service == 'spotify':
|
||||||
if not spotify_enrichment_worker or not spotify_enrichment_worker.client:
|
if not spotify_enrichment_worker or not spotify_enrichment_worker.client:
|
||||||
raise ValueError("Spotify worker not initialized")
|
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');
|
const searchInput = document.createElement('input');
|
||||||
searchInput.type = 'text';
|
searchInput.type = 'text';
|
||||||
searchInput.className = 'enhanced-match-search-input';
|
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;
|
searchInput.value = defaultQuery;
|
||||||
searchRow.appendChild(searchInput);
|
searchRow.appendChild(searchInput);
|
||||||
const searchBtn = document.createElement('button');
|
const searchBtn = document.createElement('button');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue