#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:
parent
e05979ea07
commit
06f01e29e8
3 changed files with 147 additions and 19 deletions
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 || [],
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue