Add #775: resolve a pasted metadata link/ID instead of searching

New 'Link / ID' input on the Search page: paste a Spotify / Apple Music /
MusicBrainz / Deezer URL (or a bare ID) and it's looked up directly on the
owning source — no fuzzy search, no scoring.

- core/search/by_id.py: source-agnostic parser (URL domain/path or bare-ID
  format -> source,kind,id; numeric IDs fan out, first hit wins) + per-source
  get-by-id dispatch + adapters projecting each provider's dict onto the
  standard album/track card shape.
- /api/enhanced-search/by-id: thin additive route over resolve_identifier.
- Frontend: dedicated input that adopts the resolved source as active and
  renders through the existing dropdown + download/import flow.

Purely additive — existing files are insertion-only; the resolver runs only
behind the new route. 29 seam tests cover parsing, shaping, fan-out, and
not-found.
This commit is contained in:
BoulderBadgeDad 2026-06-05 06:32:45 -07:00
parent 1590330171
commit 9772d5313c
6 changed files with 914 additions and 0 deletions

364
core/search/by_id.py Normal file
View file

@ -0,0 +1,364 @@
"""Resolve a pasted metadata link or ID to a single album/track result.
This backs the Search page's "Link / ID" mode (#775): instead of a fuzzy
name search, the user pastes a provider URL (or a bare ID) and we look the
entity up *directly* on the owning source no scoring, no guessing.
Design notes
------------
- **Source-agnostic input.** A full URL carries its source in the domain
(``open.spotify.com`` spotify, ``musicbrainz.org`` musicbrainz, )
and its kind in the path (``/album/`` vs ``/track/``), so it resolves to
exactly one lookup. A *bare* ID is disambiguated by format where we can
(UUID MusicBrainz, 22-char base62 Spotify) and otherwise fanned out
across the numeric-ID sources (Deezer, iTunes); the first source that
returns a hit wins. Paste the full link to remove that ambiguity.
- **Reuses existing per-source get-by-id.** Spotify/iTunes/MusicBrainz all
expose ``get_album``; Deezer exposes ``get_album_metadata``; all four
expose ``get_track_details``. Those already normalize to a common
"Spotify-shaped" dict, so a single adapter projects them onto the same
card shape the enhanced-search dropdown renders (see
``core/search/sources.py``).
- **Purely additive.** Nothing here mutates existing search behavior; the
route layer calls :func:`resolve_identifier` only for the new mode.
The module is import-safe and side-effect free: clients are resolved through
an injected ``client_resolver`` (defaulting to the orchestrator's
``resolve_client``) so the seam is unit-testable with fakes.
"""
from __future__ import annotations
import logging
import re
from typing import Any, Callable, NamedTuple, Optional
from urllib.parse import parse_qs, urlparse
logger = logging.getLogger(__name__)
# Sources we can resolve a link/ID against. These are exactly the metadata
# providers whose public links a user would paste AND whose get-by-id returns
# the common Spotify-shaped dict. Streaming download backends (Tidal/Qobuz)
# return raw API shapes and aren't metadata-link sources, so they're omitted.
SUPPORTED_SOURCES = ('spotify', 'itunes', 'musicbrainz', 'deezer')
_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.I
)
_SPOTIFY_ID_RE = re.compile(r'^[0-9A-Za-z]{22}$')
_NUMERIC_RE = re.compile(r'^\d+$')
# Numeric-ID sources, tried in this order when a bare number is pasted.
_NUMERIC_SOURCES = ('deezer', 'itunes')
# Domains we recognize — used to detect a pasted URL even when the user
# omitted the scheme (e.g. "open.spotify.com/album/…").
_KNOWN_HOSTS = (
'open.spotify.com', 'music.apple.com', 'itunes.apple.com',
'musicbrainz.org', 'deezer.com',
)
class LookupTarget(NamedTuple):
"""One (source, kind, id) lookup to attempt.
``kind`` is ``'album'`` or ``'track'`` when the input pins it (a URL path
or URI type), or ``None`` when unknown the resolver then tries album
first, then track.
"""
source: str
kind: Optional[str]
id: str
# --------------------------------------------------------------------------
# Parsing
# --------------------------------------------------------------------------
def _kind_from_keyword(keyword: str) -> Optional[str]:
"""Map a URL/URI path keyword to a lookup kind."""
if keyword in ('album', 'release', 'release-group'):
return 'album'
if keyword in ('track', 'recording', 'song'):
return 'track'
return None
def _parse_spotify_uri(raw: str) -> Optional[LookupTarget]:
"""``spotify:album:ID`` / ``spotify:track:ID``."""
parts = raw.split(':')
if len(parts) >= 3 and parts[0] == 'spotify':
kind = _kind_from_keyword(parts[1])
if kind:
return LookupTarget('spotify', kind, parts[-1])
return None
def _parse_url(raw: str) -> list[LookupTarget]:
"""Parse a provider URL into lookup targets (empty if unrecognized)."""
parsed = urlparse(raw)
host = (parsed.netloc or '').lower()
segs = [s for s in (parsed.path or '').split('/') if s]
def _by_keyword(source: str) -> list[LookupTarget]:
"""Find the first album/track-style keyword and take the next seg as id."""
for i, seg in enumerate(segs):
kind = _kind_from_keyword(seg.lower())
if kind and i + 1 < len(segs):
return [LookupTarget(source, kind, segs[i + 1])]
return []
if 'open.spotify.com' in host:
return _by_keyword('spotify')
if 'music.apple.com' in host or 'itunes.apple.com' in host:
# Apple track links are an album URL with ?i=<track-id>; otherwise the
# trailing path segment is the album/song id.
qs = parse_qs(parsed.query or '')
track_id = (qs.get('i') or [None])[0]
if track_id:
return [LookupTarget('itunes', 'track', track_id)]
for i, seg in enumerate(segs):
kind = _kind_from_keyword(seg.lower())
if kind and i + 1 < len(segs):
# Apple's id is the last segment, not necessarily i+1.
return [LookupTarget('itunes', kind, segs[-1])]
return []
if 'musicbrainz.org' in host:
return _by_keyword('musicbrainz')
if 'deezer.com' in host:
# link.deezer.com short links can't be resolved without a network
# redirect; only handle canonical /album/ /track/ paths.
return _by_keyword('deezer')
return []
def parse_metadata_identifier(
raw: str, preferred_source: Optional[str] = None
) -> list[LookupTarget]:
"""Parse a pasted link/ID into an ordered list of lookup targets.
Returns the candidates to try in order; the first that resolves wins.
``preferred_source`` (the user's active source) only reorders the
fan-out for ambiguous *bare numeric* IDs.
"""
raw = (raw or '').strip()
if not raw:
return []
if raw.lower().startswith('spotify:'):
uri = _parse_spotify_uri(raw)
return [uri] if uri else []
lowered = raw.lower()
looks_like_url = (
'://' in raw
or lowered.startswith('www.')
or any(host in lowered for host in _KNOWN_HOSTS)
)
if looks_like_url:
url = raw if '://' in raw else f'https://{raw}'
targets = _parse_url(url)
if targets:
return targets
# Fall through: maybe a bare id slipped in with a stray scheme.
# Bare identifier — disambiguate by format.
if _UUID_RE.match(raw):
# MusicBrainz MBID: could be release / release-group (album) or
# recording (track). Try album first, then track.
return [LookupTarget('musicbrainz', 'album', raw),
LookupTarget('musicbrainz', 'track', raw)]
if _NUMERIC_RE.match(raw):
# Numeric IDs collide across Deezer/iTunes — fan out, first hit wins.
order = list(_NUMERIC_SOURCES)
if preferred_source in order:
order.remove(preferred_source)
order.insert(0, preferred_source)
targets: list[LookupTarget] = []
for src in order:
targets.append(LookupTarget(src, 'album', raw))
targets.append(LookupTarget(src, 'track', raw))
return targets
if _SPOTIFY_ID_RE.match(raw):
return [LookupTarget('spotify', 'album', raw),
LookupTarget('spotify', 'track', raw)]
return []
# --------------------------------------------------------------------------
# Shaping — project a get-by-id dict onto the dropdown's card shape
# --------------------------------------------------------------------------
def _join_artists(artists: Any) -> str:
"""Normalize an artists field (list of str OR list of {'name': ...}) to a
display string."""
names: list[str] = []
for a in artists or []:
if isinstance(a, dict):
n = a.get('name')
else:
n = a
if n:
names.append(str(n))
return ', '.join(names) if names else 'Unknown Artist'
def _first_image(d: dict) -> str:
"""Pull the first image URL from a Spotify-shaped images list."""
imgs = d.get('images') or []
if imgs and isinstance(imgs[0], dict):
return imgs[0].get('url', '') or ''
return d.get('image_url', '') or ''
def album_dict_to_card(d: dict) -> dict:
"""Project a get_album / get_album_metadata dict onto the album card shape
(mirrors ``core/search/sources.py`` ``search_kind('albums')``)."""
return {
'id': str(d.get('id', '')),
'name': d.get('name', ''),
'artist': _join_artists(d.get('artists')),
'image_url': _first_image(d),
'release_date': d.get('release_date', ''),
'total_tracks': d.get('total_tracks', 0),
'album_type': d.get('album_type', 'album'),
'format': d.get('format'),
'country': d.get('country'),
'status': d.get('status'),
'label': d.get('label'),
'disambiguation': d.get('disambiguation'),
'release_group_id': d.get('release_group_id'),
'external_urls': d.get('external_urls') or {},
}
def track_dict_to_card(d: dict) -> dict:
"""Project a get_track_details dict onto the track card shape (mirrors
``core/search/sources.py`` ``search_kind('tracks')``)."""
album = d.get('album')
if isinstance(album, dict):
album_name = album.get('name', '')
image_url = _first_image(album)
release_date = album.get('release_date', '')
else:
album_name = album or ''
image_url = _first_image(d)
release_date = d.get('release_date', '')
return {
'id': str(d.get('id', '')),
'name': d.get('name', ''),
'artist': _join_artists(d.get('artists')),
'album': album_name,
'duration_ms': d.get('duration_ms', 0),
'image_url': image_url or _first_image(d),
'release_date': release_date,
'external_urls': d.get('external_urls') or {},
}
# --------------------------------------------------------------------------
# Fetch dispatch — per-source method names differ slightly
# --------------------------------------------------------------------------
def _fetch_album(client: Any, source: str, identifier: str) -> Optional[dict]:
"""Fetch album metadata by id. Deezer names the method differently; the
rest share ``get_album``. ``include_tracks=False`` keeps the lookup cheap
(the modal re-fetches the full tracklist on open)."""
if source == 'deezer':
return client.get_album_metadata(identifier, include_tracks=False)
if source in ('itunes', 'musicbrainz'):
return client.get_album(identifier, include_tracks=False)
return client.get_album(identifier) # spotify
def _fetch_track(client: Any, source: str, identifier: str) -> Optional[dict]:
"""Fetch track metadata by id — uniform across all supported sources."""
return client.get_track_details(identifier)
def _empty_result(raw: str, source: str = '') -> dict:
return {
'source': source,
'albums': [],
'tracks': [],
'available': False,
'query': raw,
}
def resolve_identifier(
raw: str,
deps: Any,
preferred_source: Optional[str] = None,
client_resolver: Optional[Callable[[str], Any]] = None,
) -> dict:
"""Resolve a pasted link/ID to a single album or track card.
Returns a dropdown-compatible dict:
``{source, albums, tracks, available, query}``. ``available`` is True iff
a source returned a hit. The first resolving target wins, so the result
carries exactly one card (and the ``source`` that owns it).
``client_resolver`` maps a source name to a client (or None). It defaults
to the orchestrator's ``resolve_client``; tests inject fakes.
"""
if client_resolver is None:
from core.search.orchestrator import resolve_client
def client_resolver(source: str) -> Any: # noqa: E306
return resolve_client(source, deps)[0]
targets = parse_metadata_identifier(raw, preferred_source=preferred_source)
if not targets:
logger.info(f"Link/ID resolve: unrecognized identifier {raw!r}")
return _empty_result(raw, preferred_source or '')
for target in targets:
try:
client = client_resolver(target.source)
except Exception as e:
logger.debug(f"Link/ID resolve: client for {target.source} failed: {e}")
client = None
if client is None:
continue
kinds = (target.kind,) if target.kind else ('album', 'track')
for kind in kinds:
try:
if kind == 'album':
data = _fetch_album(client, target.source, target.id)
if data:
return {
'source': target.source,
'albums': [album_dict_to_card(data)],
'tracks': [],
'available': True,
'query': raw,
}
else:
data = _fetch_track(client, target.source, target.id)
if data:
return {
'source': target.source,
'albums': [],
'tracks': [track_dict_to_card(data)],
'available': True,
'query': raw,
}
except Exception as e:
logger.debug(
f"Link/ID resolve: {target.source} {kind} {target.id} failed: {e}"
)
logger.info(f"Link/ID resolve: no source resolved {raw!r}")
return _empty_result(raw, preferred_source or '')

View file

@ -0,0 +1,378 @@
"""Tests for core/search/by_id.py — paste-a-link/ID metadata resolution (#775).
Covers the three seams:
- ``parse_metadata_identifier`` provider URLs, the ``spotify:`` URI, and
bare IDs (UUID MusicBrainz, base62 Spotify, numeric Deezer/iTunes
fan-out with active-source bias).
- the shaping adapters projecting each source's get-by-id dict (which
differ in their ``artists`` field shape) onto the common card shape.
- ``resolve_identifier`` first-resolving-target-wins, kind fallback
(albumtrack), source fan-out, and the not-found regression.
Clients are injected via ``client_resolver`` so nothing touches the network
or real config.
"""
from __future__ import annotations
from core.search import by_id
from core.search.by_id import LookupTarget
# ---------------------------------------------------------------------------
# Fakes — a client exposing only the get-by-id methods the resolver calls.
# Each "source" can return album/track dicts in its native shape.
# ---------------------------------------------------------------------------
class _FakeClient:
def __init__(self, album=None, track=None, name='fake'):
self._album = album
self._track = track
self._name = name
self.album_calls: list[str] = []
self.track_calls: list[str] = []
# Spotify / iTunes / MusicBrainz album-by-id
def get_album(self, identifier, include_tracks=True):
self.album_calls.append(identifier)
return self._album
# Deezer album-by-id (different method name)
def get_album_metadata(self, identifier, include_tracks=True):
self.album_calls.append(identifier)
return self._album
# Uniform track-by-id
def get_track_details(self, identifier):
self.track_calls.append(identifier)
return self._track
def _resolver_from(mapping):
"""Build a client_resolver from {source: client}."""
return lambda source: mapping.get(source)
# ---------------------------------------------------------------------------
# parse_metadata_identifier — URLs
# ---------------------------------------------------------------------------
def test_parse_spotify_album_url():
out = by_id.parse_metadata_identifier(
'https://open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy'
)
assert out == [LookupTarget('spotify', 'album', '4aawyAB9vmqN3uQ7FjRGTy')]
def test_parse_spotify_track_url_with_intl_prefix():
out = by_id.parse_metadata_identifier(
'https://open.spotify.com/intl-de/track/11dFghVXANMlKmJXsNCbNl'
)
assert out == [LookupTarget('spotify', 'track', '11dFghVXANMlKmJXsNCbNl')]
def test_parse_spotify_uri():
assert by_id.parse_metadata_identifier('spotify:album:ABC') == [
LookupTarget('spotify', 'album', 'ABC')
]
assert by_id.parse_metadata_identifier('spotify:track:XYZ') == [
LookupTarget('spotify', 'track', 'XYZ')
]
def test_parse_apple_album_url():
out = by_id.parse_metadata_identifier(
'https://music.apple.com/us/album/in-rainbows/1109714933'
)
assert out == [LookupTarget('itunes', 'album', '1109714933')]
def test_parse_apple_track_url_uses_i_param():
out = by_id.parse_metadata_identifier(
'https://music.apple.com/us/album/in-rainbows/1109714933?i=1109714934'
)
assert out == [LookupTarget('itunes', 'track', '1109714934')]
def test_parse_apple_song_url():
out = by_id.parse_metadata_identifier(
'https://music.apple.com/us/song/15-step/1109714938'
)
assert out == [LookupTarget('itunes', 'track', '1109714938')]
def test_parse_musicbrainz_release_group_url():
mbid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
out = by_id.parse_metadata_identifier(
f'https://musicbrainz.org/release-group/{mbid}'
)
assert out == [LookupTarget('musicbrainz', 'album', mbid)]
def test_parse_musicbrainz_recording_url_is_track():
mbid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
out = by_id.parse_metadata_identifier(
f'https://musicbrainz.org/recording/{mbid}'
)
assert out == [LookupTarget('musicbrainz', 'track', mbid)]
def test_parse_deezer_album_url_with_locale():
out = by_id.parse_metadata_identifier('https://www.deezer.com/en/album/302127')
assert out == [LookupTarget('deezer', 'album', '302127')]
def test_parse_deezer_track_url_no_scheme():
out = by_id.parse_metadata_identifier('www.deezer.com/track/3135556')
assert out == [LookupTarget('deezer', 'track', '3135556')]
def test_parse_spotify_url_without_scheme_or_www():
# Known host detected even without a scheme.
out = by_id.parse_metadata_identifier('open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy')
assert out == [LookupTarget('spotify', 'album', '4aawyAB9vmqN3uQ7FjRGTy')]
# ---------------------------------------------------------------------------
# parse_metadata_identifier — bare IDs
# ---------------------------------------------------------------------------
def test_parse_bare_uuid_is_musicbrainz_album_then_track():
mbid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
out = by_id.parse_metadata_identifier(mbid)
assert out == [
LookupTarget('musicbrainz', 'album', mbid),
LookupTarget('musicbrainz', 'track', mbid),
]
def test_parse_bare_base62_is_spotify_album_then_track():
sid = '4aawyAB9vmqN3uQ7FjRGTy' # 22 chars, has letters
out = by_id.parse_metadata_identifier(sid)
assert out == [
LookupTarget('spotify', 'album', sid),
LookupTarget('spotify', 'track', sid),
]
def test_parse_bare_numeric_fans_out_deezer_then_itunes():
out = by_id.parse_metadata_identifier('302127')
assert out == [
LookupTarget('deezer', 'album', '302127'),
LookupTarget('deezer', 'track', '302127'),
LookupTarget('itunes', 'album', '302127'),
LookupTarget('itunes', 'track', '302127'),
]
def test_parse_bare_numeric_biases_preferred_source_first():
out = by_id.parse_metadata_identifier('302127', preferred_source='itunes')
# iTunes pulled to the front of the fan-out.
assert out[0] == LookupTarget('itunes', 'album', '302127')
assert out[1] == LookupTarget('itunes', 'track', '302127')
def test_parse_empty_and_garbage_return_empty():
assert by_id.parse_metadata_identifier('') == []
assert by_id.parse_metadata_identifier(' ') == []
assert by_id.parse_metadata_identifier('not an id!!') == []
# Unknown domain → no targets.
assert by_id.parse_metadata_identifier('https://example.com/album/1') == []
# ---------------------------------------------------------------------------
# Shaping adapters
# ---------------------------------------------------------------------------
def test_album_card_from_spotify_shaped_dict():
d = {
'id': 'abc',
'name': 'OK Computer',
'artists': [{'name': 'Radiohead', 'id': 'r1'}],
'images': [{'url': 'http://img/big.jpg', 'height': 640, 'width': 640}],
'release_date': '1997-05-21',
'total_tracks': 12,
'album_type': 'album',
'external_urls': {'spotify': 'http://spot/abc'},
}
card = by_id.album_dict_to_card(d)
assert card['id'] == 'abc'
assert card['name'] == 'OK Computer'
assert card['artist'] == 'Radiohead'
assert card['image_url'] == 'http://img/big.jpg'
assert card['total_tracks'] == 12
assert card['external_urls'] == {'spotify': 'http://spot/abc'}
def test_album_card_carries_optional_musicbrainz_fields():
d = {
'id': 'mbid', 'name': 'Kid A', 'artists': [{'name': 'Radiohead'}],
'images': [], 'release_date': '2000', 'total_tracks': 10,
'album_type': 'album', 'country': 'GB', 'label': 'Parlophone',
'release_group_id': 'rg1', 'external_urls': {},
}
card = by_id.album_dict_to_card(d)
assert card['country'] == 'GB'
assert card['label'] == 'Parlophone'
assert card['release_group_id'] == 'rg1'
def test_track_card_handles_list_of_string_artists():
# Spotify/iTunes shape: artists is a list of plain strings.
d = {
'id': 't1', 'name': 'Paranoid Android',
'artists': ['Radiohead'],
'album': {'name': 'OK Computer', 'release_date': '1997'},
'duration_ms': 387000,
}
card = by_id.track_dict_to_card(d)
assert card['artist'] == 'Radiohead'
assert card['album'] == 'OK Computer'
assert card['duration_ms'] == 387000
assert card['release_date'] == '1997'
def test_track_card_handles_list_of_dict_artists_and_album_image():
# MusicBrainz shape: artists is a list of dicts; album carries images.
d = {
'id': 't2', 'name': 'Idioteque',
'artists': [{'name': 'Radiohead', 'id': ''}],
'album': {
'name': 'Kid A',
'images': [{'url': 'http://img/kida.jpg', 'height': 250, 'width': 250}],
'release_date': '2000',
},
'duration_ms': 300000,
'external_urls': {'musicbrainz': 'http://mb/t2'},
}
card = by_id.track_dict_to_card(d)
assert card['artist'] == 'Radiohead'
assert card['album'] == 'Kid A'
assert card['image_url'] == 'http://img/kida.jpg'
assert card['external_urls'] == {'musicbrainz': 'http://mb/t2'}
def test_join_artists_empty_is_unknown():
assert by_id._join_artists([]) == 'Unknown Artist'
assert by_id._join_artists(None) == 'Unknown Artist'
# ---------------------------------------------------------------------------
# resolve_identifier — end-to-end with fake clients
# ---------------------------------------------------------------------------
_SPOTIFY_ALBUM = {
'id': 'abc', 'name': 'OK Computer',
'artists': [{'name': 'Radiohead'}],
'images': [{'url': 'http://i/a.jpg'}],
'release_date': '1997', 'total_tracks': 12, 'album_type': 'album',
'external_urls': {'spotify': 'http://s/abc'},
}
def test_resolve_spotify_album_link():
client = _FakeClient(album=_SPOTIFY_ALBUM)
res = by_id.resolve_identifier(
'https://open.spotify.com/album/abc', deps=None,
client_resolver=_resolver_from({'spotify': client}),
)
assert res['available'] is True
assert res['source'] == 'spotify'
assert len(res['albums']) == 1
assert res['albums'][0]['name'] == 'OK Computer'
assert res['tracks'] == []
assert client.album_calls == ['abc']
assert client.track_calls == [] # kind pinned to album — no track probe
def test_resolve_deezer_album_uses_get_album_metadata():
client = _FakeClient(album={'id': '302127', 'name': 'Discovery',
'artists': [{'name': 'Daft Punk'}], 'images': [],
'release_date': '2001', 'total_tracks': 14,
'album_type': 'album', 'external_urls': {}})
res = by_id.resolve_identifier(
'https://www.deezer.com/album/302127', deps=None,
client_resolver=_resolver_from({'deezer': client}),
)
assert res['available'] is True
assert res['source'] == 'deezer'
assert res['albums'][0]['name'] == 'Discovery'
assert client.album_calls == ['302127']
def test_resolve_kind_unknown_falls_back_album_then_track():
# Bare base62 → spotify, kind unknown. Album returns None, track resolves.
client = _FakeClient(album=None, track={
'id': 't1', 'name': 'Creep', 'artists': ['Radiohead'],
'album': {'name': 'Pablo Honey'}, 'duration_ms': 238000,
})
sid = '4aawyAB9vmqN3uQ7FjRGTy'
res = by_id.resolve_identifier(
sid, deps=None, client_resolver=_resolver_from({'spotify': client}),
)
assert res['available'] is True
assert res['tracks'][0]['name'] == 'Creep'
assert res['albums'] == []
assert client.album_calls == [sid] # tried album first
assert client.track_calls == [sid] # then track
def test_resolve_numeric_fanout_first_hit_wins():
# Deezer has no such id (returns None), iTunes does — resolver moves on.
deezer = _FakeClient(album=None, track=None)
itunes = _FakeClient(album={'id': '99', 'name': 'Thriller',
'artists': ['Michael Jackson'], 'images': [],
'release_date': '1982', 'total_tracks': 9,
'album_type': 'album', 'external_urls': {}})
res = by_id.resolve_identifier(
'99', deps=None,
client_resolver=_resolver_from({'deezer': deezer, 'itunes': itunes}),
)
assert res['available'] is True
assert res['source'] == 'itunes'
assert res['albums'][0]['name'] == 'Thriller'
# Deezer was tried (album + track) before iTunes resolved.
assert deezer.album_calls == ['99']
assert deezer.track_calls == ['99']
def test_resolve_unavailable_client_is_skipped():
# Spotify client is None (unauthed) — resolver returns not-found, no crash.
res = by_id.resolve_identifier(
'https://open.spotify.com/album/abc', deps=None,
client_resolver=_resolver_from({'spotify': None}),
)
assert res['available'] is False
assert res['albums'] == [] and res['tracks'] == []
assert res['source'] == ''
def test_resolve_client_exception_does_not_propagate():
def boom(_source):
raise RuntimeError('client init failed')
res = by_id.resolve_identifier(
'https://open.spotify.com/album/abc', deps=None, client_resolver=boom,
)
assert res['available'] is False
def test_resolve_unrecognized_identifier_returns_empty():
res = by_id.resolve_identifier(
'definitely not a link', deps=None,
client_resolver=_resolver_from({}),
)
assert res['available'] is False
assert res['query'] == 'definitely not a link'
def test_resolve_get_album_returning_none_yields_not_found():
# Regression: a pinned-kind link whose lookup returns None must report
# not-found, not raise or fabricate a card.
client = _FakeClient(album=None)
res = by_id.resolve_identifier(
'https://open.spotify.com/album/missing', deps=None,
client_resolver=_resolver_from({'spotify': client}),
)
assert res['available'] is False
assert res['albums'] == []

View file

@ -5607,6 +5607,7 @@ def start_sync():
# Search route bodies live in core/search/* — these routes are thin handlers.
from core.search import basic as _search_basic
from core.search import by_id as _search_by_id
from core.search import library_check as _search_library_check
from core.search import orchestrator as _search_orchestrator
from core.search import stream as _search_stream
@ -5725,6 +5726,35 @@ def enhanced_search():
return jsonify({"error": str(e)}), 500
@app.route('/api/enhanced-search/by-id', methods=['POST'])
def enhanced_search_by_id():
"""Resolve a pasted metadata link/ID to a single album or track (#775).
Source-agnostic: a provider URL (Spotify/Apple/MusicBrainz/Deezer) or a
bare ID is looked up directly on the owning source via its get-by-id
no fuzzy search, no scoring. Returns the same dropdown shape the normal
enhanced search renders, plus the resolving ``source`` so the frontend
can route downloads/imports through the existing flow.
Body: ``{"query": "<link or id>", "source": "<active source>"?}``. The
optional ``source`` only biases the fan-out for ambiguous bare numeric
IDs (Deezer vs iTunes).
"""
data = request.get_json() or {}
raw = (data.get('query') or '').strip()
preferred = (data.get('source') or '').strip().lower() or None
if not raw:
return jsonify(_search_by_id._empty_result(''))
try:
deps = _build_search_deps()
result = _search_by_id.resolve_identifier(raw, deps, preferred_source=preferred)
return jsonify(result)
except Exception as e:
logger.error(f"Link/ID resolve error: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/enhanced-search/source/<source_name>', methods=['POST'])
def enhanced_search_source(source_name):
"""Streaming NDJSON search for one alternate metadata source.

View file

@ -2201,6 +2201,19 @@
</div>
</div>
<!-- Link / ID lookup (#775): paste a provider link or
bare ID and resolve it directly on the owning source
(Spotify / Apple Music / MusicBrainz / Deezer) — no
fuzzy search. Results render in the dropdown below and
reuse the normal download/import flow. -->
<div class="enh-id-lookup">
<span class="enh-id-lookup-icon">🔗</span>
<input type="text" id="enh-id-input" class="enh-id-input"
placeholder="…or paste a Spotify / Apple Music / MusicBrainz / Deezer link or ID"
autocomplete="off" spellcheck="false">
<button id="enh-id-btn" class="enh-id-btn" type="button">Look up</button>
</div>
<!-- Enhanced Search Dropdown (Overlay Panel) -->
<div id="enhanced-dropdown" class="enhanced-dropdown hidden">
<div class="enhanced-dropdown-content">

View file

@ -271,6 +271,79 @@ function initializeSearchModeToggle() {
});
}
// ── Link / ID lookup (#775) ──────────────────────────────────────
// Paste a provider link or bare ID; the backend resolves it directly
// on the owning source (no fuzzy search) and returns the single hit
// plus that source. We adopt the resolved source as the active one so
// the existing album re-fetch + download/import flow routes correctly,
// then render through the same dropdown path as a normal search.
const idInput = document.getElementById('enh-id-input');
const idBtn = document.getElementById('enh-id-btn');
async function submitIdLookup() {
if (!idInput) return;
const raw = idInput.value.trim();
if (!raw) return;
// Show the controller's loading UI while resolving.
if (loadingState) {
loadingState.classList.remove('hidden');
const loadingText = document.getElementById('enhanced-loading-text');
if (loadingText) loadingText.textContent = 'Resolving link…';
}
if (emptyState) emptyState.classList.add('hidden');
if (resultsContainer) resultsContainer.classList.add('hidden');
showDropdown();
let data;
try {
const resp = await fetch('/api/enhanced-search/by-id', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: raw,
source: searchController.state.activeSource,
}),
});
data = await resp.json();
} catch (err) {
console.error('Link/ID lookup failed:', err);
if (loadingState) loadingState.classList.add('hidden');
if (typeof showToast === 'function') showToast('Link/ID lookup failed.', 'error');
return;
}
if (data && data.available && data.source) {
// Adopt the resolving source as active, seed its cache with the
// single hit, and render via the shared state-driven path.
searchController.state.activeSource = data.source;
searchController.state.query = raw;
searchController.state.sources[data.source] = {
db_artists: [],
artists: [],
albums: data.albums || [],
tracks: data.tracks || [],
};
if (typeof searchController.renderSourceRow === 'function') {
searchController.renderSourceRow();
}
_renderFromState(searchController.state);
} else {
// No source resolved the link — show the empty state.
if (loadingState) loadingState.classList.add('hidden');
if (resultsContainer) resultsContainer.classList.add('hidden');
if (emptyState) emptyState.classList.remove('hidden');
showDropdown();
}
}
if (idBtn) idBtn.addEventListener('click', submitIdLookup);
if (idInput) {
idInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') submitIdLookup();
});
}
// Close button inside dropdown (mobile)
const dropdownCloseBtn = document.getElementById('enhanced-dropdown-close');
if (dropdownCloseBtn) {

View file

@ -36918,6 +36918,62 @@ div.artist-hero-badge {
color: #fff;
}
/* Link / ID lookup row (#775) — paste a provider link/ID under the search bar */
.enh-id-lookup {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 4px 8px 4px 14px;
transition: all 0.3s ease;
}
.enh-id-lookup:focus-within {
border-color: rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.06);
}
.enh-id-lookup-icon {
font-size: 15px;
opacity: 0.55;
}
.enh-id-input {
flex-grow: 1;
background: transparent;
border: none;
outline: none;
font-family: 'Segoe UI', sans-serif;
font-size: 14px;
color: #ffffff;
padding: 8px 0;
}
.enh-id-input::placeholder {
color: rgba(255, 255, 255, 0.35);
}
.enh-id-btn {
flex-shrink: 0;
background: rgba(255, 255, 255, 0.08);
border: none;
border-radius: 8px;
padding: 7px 14px;
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: all 0.2s ease;
}
.enh-id-btn:hover {
background: rgba(255, 255, 255, 0.14);
color: #fff;
}
/* Enhanced Search Status */
.enhanced-search-status {
display: flex;