diff --git a/core/search/by_id.py b/core/search/by_id.py new file mode 100644 index 00000000..7f55a314 --- /dev/null +++ b/core/search/by_id.py @@ -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=; 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 '') diff --git a/tests/search/test_search_by_id.py b/tests/search/test_search_by_id.py new file mode 100644 index 00000000..131525ff --- /dev/null +++ b/tests/search/test_search_by_id.py @@ -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 + (album→track), 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'] == [] diff --git a/web_server.py b/web_server.py index cb869f23..ac01991b 100644 --- a/web_server.py +++ b/web_server.py @@ -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": "", "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/', methods=['POST']) def enhanced_search_source(source_name): """Streaming NDJSON search for one alternate metadata source. diff --git a/webui/index.html b/webui/index.html index aa21bbf2..323bfe3f 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2201,6 +2201,19 @@ + +
+ 🔗 + + +
+