#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.
This commit is contained in:
BoulderBadgeDad 2026-06-05 06:55:35 -07:00
parent e05979ea07
commit 06f01e29e8
3 changed files with 147 additions and 19 deletions

View file

@ -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}"

View file

@ -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.

View file

@ -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 || [],
};