diff --git a/core/search/by_id.py b/core/search/by_id.py index 7b8f73d1..032c87f4 100644 --- a/core/search/by_id.py +++ b/core/search/by_id.py @@ -33,6 +33,7 @@ an injected ``client_resolver`` (defaulting to the orchestrator's from __future__ import annotations import logging +import re from typing import Any, Callable, NamedTuple, Optional from urllib.parse import parse_qs, urlparse @@ -42,13 +43,13 @@ logger = logging.getLogger(__name__) # 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') +SUPPORTED_SOURCES = ('spotify', 'itunes', 'musicbrainz', 'deezer', 'discogs') # 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', + 'musicbrainz.org', 'deezer.com', 'discogs.com', ) @@ -72,7 +73,7 @@ class LookupTarget(NamedTuple): 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'): + if keyword in ('album', 'release', 'release-group', 'master'): return 'album' if keyword in ('track', 'recording', 'song'): return 'track' @@ -130,6 +131,18 @@ def _parse_url(raw: str) -> list[LookupTarget]: # redirect; only handle canonical /album/ /track/ paths. return _by_keyword('deezer') + if 'discogs.com' in host: + # Discogs paths are /artist/-Slug, /release/-Slug, + # /master/-Slug — the id is embedded with a slug, so strip to the + # leading number. (Discogs has no standalone track URLs; tracks live + # inside a release, so only artist/album resolve.) + out = [] + for t in _by_keyword('discogs'): + m = re.match(r'(\d+)', t.id) + if m: + out.append(t._replace(id=m.group(1))) + return out + return [] @@ -254,7 +267,7 @@ def _fetch_album(client: Any, source: str, identifier: str) -> Optional[dict]: (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'): + if source in ('itunes', 'musicbrainz', 'discogs'): return client.get_album(identifier, include_tracks=False) return client.get_album(identifier) # spotify @@ -274,8 +287,8 @@ def _fetch_artist(client: Any, source: str, identifier: str) -> Optional[dict]: # 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 ' - '(a bare ID is ambiguous).' + 'Paste a full link from Spotify, Apple Music, Deezer, Discogs, or ' + 'MusicBrainz (a bare ID is ambiguous).' ) _MSG_NOT_FOUND = "Couldn't resolve that link — double-check it's correct." diff --git a/tests/search/test_search_by_id.py b/tests/search/test_search_by_id.py index 172cb5b8..07f1a425 100644 --- a/tests/search/test_search_by_id.py +++ b/tests/search/test_search_by_id.py @@ -449,3 +449,53 @@ def test_resolve_get_album_returning_none_yields_not_found(): ) assert res['available'] is False assert res['albums'] == [] + + +# ── Discogs (#813 — extend paste-link to Discogs) ────────────────────────── + +def test_parse_discogs_release_url_strips_slug(): + out = by_id.parse_metadata_identifier( + 'https://www.discogs.com/release/678910-Some-Album-Title') + assert out == [by_id.LookupTarget('discogs', 'album', '678910')] + + +def test_parse_discogs_master_url_is_album(): + out = by_id.parse_metadata_identifier( + 'https://www.discogs.com/master/555-A-Master') + assert out == [by_id.LookupTarget('discogs', 'album', '555')] + + +def test_parse_discogs_artist_url(): + out = by_id.parse_metadata_identifier( + 'https://www.discogs.com/artist/12345-Some-Artist') + assert out == [by_id.LookupTarget('discogs', 'artist', '12345')] + + +def test_parse_discogs_url_no_scheme(): + out = by_id.parse_metadata_identifier('discogs.com/release/999-X') + assert out == [by_id.LookupTarget('discogs', 'album', '999')] + + +def test_resolve_discogs_release_uses_get_album_with_numeric_id(): + client = _FakeClient(album={'id': '678910', 'name': 'Some Album', + 'artists': [{'name': 'Some Artist'}]}) + res = by_id.resolve_identifier( + 'https://www.discogs.com/release/678910-Some-Album-Title', deps=None, + client_resolver=_resolver_from({'discogs': client})) + assert res['available'] is True and res['source'] == 'discogs' + assert res['albums'] and res['albums'][0]['name'] == 'Some Album' + assert client.album_calls == ['678910'] # numeric id, slug stripped + + +def test_resolve_discogs_artist(): + client = _FakeClient(artist={'id': '12345', 'name': 'Some Artist'}) + res = by_id.resolve_identifier( + 'https://www.discogs.com/artist/12345-Some-Artist', deps=None, + client_resolver=_resolver_from({'discogs': client})) + assert res['available'] is True and res['source'] == 'discogs' + assert res['artists'] and res['artists'][0]['name'] == 'Some Artist' + assert client.artist_calls == ['12345'] + + +def test_discogs_in_supported_sources(): + assert 'discogs' in by_id.SUPPORTED_SOURCES