From 06f01e29e8b3284a82293fea8f3e4315e79d2308 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Fri, 5 Jun 2026 06:55:35 -0700 Subject: [PATCH] #775: add artist links (paste an artist URL -> opens the artist) Spotify/Apple/MusicBrainz/Deezer artist links now resolve via each source's get-by-id (get_artist / Deezer get_artist_info), shaped to the artist card and rendered as an artist result that opens the artist detail page through the existing flow. Album/track link handling is unchanged; bare IDs still rejected. --- core/search/by_id.py | 67 +++++++++++++++------ tests/search/test_search_by_id.py | 97 ++++++++++++++++++++++++++++++- webui/static/search.js | 2 +- 3 files changed, 147 insertions(+), 19 deletions(-) diff --git a/core/search/by_id.py b/core/search/by_id.py index a5189a25..7b8f73d1 100644 --- a/core/search/by_id.py +++ b/core/search/by_id.py @@ -1,4 +1,4 @@ -"""Resolve a pasted metadata link or ID to a single album/track result. +"""Resolve a pasted metadata link to a single album/track/artist 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 @@ -76,6 +76,8 @@ def _kind_from_keyword(keyword: str) -> Optional[str]: return 'album' if keyword in ('track', 'recording', 'song'): return 'track' + if keyword == 'artist': + return 'artist' return None @@ -231,6 +233,17 @@ def track_dict_to_card(d: dict) -> dict: } +def artist_dict_to_card(d: dict) -> dict: + """Project a get_artist / get_artist_info dict onto the artist card shape + (mirrors ``core/search/sources.py`` ``search_kind('artists')``).""" + return { + 'id': str(d.get('id', '')), + 'name': d.get('name', ''), + 'image_url': _first_image(d), + 'external_urls': d.get('external_urls') or {}, + } + + # -------------------------------------------------------------------------- # Fetch dispatch — per-source method names differ slightly # -------------------------------------------------------------------------- @@ -251,6 +264,14 @@ def _fetch_track(client: Any, source: str, identifier: str) -> Optional[dict]: return client.get_track_details(identifier) +def _fetch_artist(client: Any, source: str, identifier: str) -> Optional[dict]: + """Fetch artist metadata by id. Deezer names the method differently; the + rest share ``get_artist``.""" + if source == 'deezer': + return client.get_artist_info(identifier) + return client.get_artist(identifier) + + # Shown in the dropdown's empty state so the user knows what to do next. _MSG_NOT_A_LINK = ( 'Paste a full link from Spotify, Apple Music, MusicBrainz, or Deezer ' @@ -264,21 +285,38 @@ def _empty_result(raw: str, source: str = '', message: str = '') -> dict: 'source': source, 'albums': [], 'tracks': [], + 'artists': [], 'available': False, 'query': raw, 'message': message, } +def _hit_result(raw: str, source: str, key: str, card: dict) -> dict: + """Build a success result carrying the single resolved card under ``key`` + ('albums' | 'tracks' | 'artists'); the other lists stay empty.""" + result = { + 'source': source, + 'albums': [], + 'tracks': [], + 'artists': [], + 'available': True, + 'query': raw, + 'message': '', + } + result[key] = [card] + return result + + def resolve_identifier( raw: str, deps: Any, client_resolver: Optional[Callable[[str], Any]] = None, ) -> dict: - """Resolve a pasted provider link to a single album or track card. + """Resolve a pasted provider link to a single album, track, or artist card. Returns a dropdown-compatible dict: - ``{source, albums, tracks, available, query, message}``. ``available`` is + ``{source, albums, tracks, artists, available, query, message}``. ``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). ``message`` is a user-facing hint when nothing resolved. @@ -312,23 +350,18 @@ def resolve_identifier( 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, - } + return _hit_result(raw, target.source, 'albums', + album_dict_to_card(data)) + elif kind == 'artist': + data = _fetch_artist(client, target.source, target.id) + if data: + return _hit_result(raw, target.source, 'artists', + artist_dict_to_card(data)) 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, - } + return _hit_result(raw, target.source, 'tracks', + track_dict_to_card(data)) except Exception as e: logger.debug( f"Link/ID resolve: {target.source} {kind} {target.id} failed: {e}" diff --git a/tests/search/test_search_by_id.py b/tests/search/test_search_by_id.py index e27264fc..172cb5b8 100644 --- a/tests/search/test_search_by_id.py +++ b/tests/search/test_search_by_id.py @@ -26,12 +26,14 @@ from core.search.by_id import LookupTarget # --------------------------------------------------------------------------- class _FakeClient: - def __init__(self, album=None, track=None, name='fake'): + def __init__(self, album=None, track=None, artist=None, name='fake'): self._album = album self._track = track + self._artist = artist self._name = name self.album_calls: list[str] = [] self.track_calls: list[str] = [] + self.artist_calls: list[str] = [] # Spotify / iTunes / MusicBrainz album-by-id def get_album(self, identifier, include_tracks=True): @@ -48,6 +50,16 @@ class _FakeClient: self.track_calls.append(identifier) return self._track + # Spotify / iTunes / MusicBrainz artist-by-id + def get_artist(self, identifier): + self.artist_calls.append(identifier) + return self._artist + + # Deezer artist-by-id (different method name) + def get_artist_info(self, identifier): + self.artist_calls.append(identifier) + return self._artist + def _resolver_from(mapping): """Build a client_resolver from {source: client}.""" @@ -134,6 +146,41 @@ def test_parse_spotify_url_without_scheme_or_www(): assert out == [LookupTarget('spotify', 'album', '4aawyAB9vmqN3uQ7FjRGTy')] +# --------------------------------------------------------------------------- +# parse_metadata_identifier — artist links +# --------------------------------------------------------------------------- + +def test_parse_spotify_artist_url(): + out = by_id.parse_metadata_identifier( + 'https://open.spotify.com/artist/3TVXtAsR1Inumwj472S9r4' + ) + assert out == [LookupTarget('spotify', 'artist', '3TVXtAsR1Inumwj472S9r4')] + + +def test_parse_spotify_artist_uri(): + assert by_id.parse_metadata_identifier('spotify:artist:ABC') == [ + LookupTarget('spotify', 'artist', 'ABC') + ] + + +def test_parse_apple_artist_url(): + out = by_id.parse_metadata_identifier( + 'https://music.apple.com/us/artist/kendrick-lamar/368183298' + ) + assert out == [LookupTarget('itunes', 'artist', '368183298')] + + +def test_parse_musicbrainz_artist_url(): + mbid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + out = by_id.parse_metadata_identifier(f'https://musicbrainz.org/artist/{mbid}') + assert out == [LookupTarget('musicbrainz', 'artist', mbid)] + + +def test_parse_deezer_artist_url(): + out = by_id.parse_metadata_identifier('https://www.deezer.com/artist/13') + assert out == [LookupTarget('deezer', 'artist', '13')] + + # --------------------------------------------------------------------------- # parse_metadata_identifier — bare IDs are rejected (links only) # --------------------------------------------------------------------------- @@ -239,6 +286,22 @@ def test_join_artists_empty_is_unknown(): assert by_id._join_artists(None) == 'Unknown Artist' +def test_artist_card_from_spotify_shaped_dict(): + d = { + 'id': 'a1', 'name': 'Radiohead', + 'images': [{'url': 'http://img/rh.jpg', 'height': 640, 'width': 640}], + 'external_urls': {'spotify': 'http://spot/a1'}, + 'genres': ['rock'], 'popularity': 80, + } + card = by_id.artist_dict_to_card(d) + assert card == { + 'id': 'a1', + 'name': 'Radiohead', + 'image_url': 'http://img/rh.jpg', + 'external_urls': {'spotify': 'http://spot/a1'}, + } + + # --------------------------------------------------------------------------- # resolve_identifier — end-to-end with fake clients # --------------------------------------------------------------------------- @@ -299,6 +362,38 @@ def test_resolve_track_link(): assert client.track_calls == ['t1'] +def test_resolve_artist_link(): + # An artist URL pins kind=artist — only get_artist is called. + client = _FakeClient(artist={ + 'id': 'a1', 'name': 'Radiohead', + 'images': [{'url': 'http://i/rh.jpg'}], + 'external_urls': {'spotify': 'http://s/a1'}, + }) + res = by_id.resolve_identifier( + 'https://open.spotify.com/artist/a1', deps=None, + client_resolver=_resolver_from({'spotify': client}), + ) + assert res['available'] is True + assert res['source'] == 'spotify' + assert res['artists'][0]['name'] == 'Radiohead' + assert res['albums'] == [] and res['tracks'] == [] + assert client.artist_calls == ['a1'] + assert client.album_calls == [] and client.track_calls == [] + + +def test_resolve_deezer_artist_uses_get_artist_info(): + client = _FakeClient(artist={'id': '13', 'name': 'Daft Punk', + 'images': [], 'external_urls': {}}) + res = by_id.resolve_identifier( + 'https://www.deezer.com/artist/13', deps=None, + client_resolver=_resolver_from({'deezer': client}), + ) + assert res['available'] is True + assert res['source'] == 'deezer' + assert res['artists'][0]['name'] == 'Daft Punk' + assert client.artist_calls == ['13'] + + def test_resolve_bare_id_rejected_with_hint(): # The #775 follow-up regression: a bare number must not resolve; it # returns not-found with a link hint instead of an unrelated album. diff --git a/webui/static/search.js b/webui/static/search.js index 2daff1bf..750bde56 100644 --- a/webui/static/search.js +++ b/webui/static/search.js @@ -317,7 +317,7 @@ function initializeSearchModeToggle() { searchController.state.query = raw; searchController.state.sources[data.source] = { db_artists: [], - artists: [], + artists: data.artists || [], albums: data.albums || [], tracks: data.tracks || [], };