Search: extend pasted-link resolution to Discogs (#813)

#775 already resolves pasted Spotify / Apple / MusicBrainz / Deezer links to an
exact artist/album/track on the Search page. Added Discogs to that set (the one
source in the request not already covered; Amazon left out per request).

- by_id resolver: discogs in SUPPORTED_SOURCES + _KNOWN_HOSTS; parse
  /artist/<id>-slug, /release/<id>-slug, /master/<id>-slug (master→album), and
  strip the slug to the leading numeric id. Discogs has no standalone track
  URLs (tracks live inside a release), so artist + album resolve.
- Fetch dispatch: discogs albums use get_album(include_tracks=False) like
  itunes/musicbrainz; artists use get_artist; both already return the common
  normalized card shape. Updated the not-a-link hint to mention Discogs.

Frontend needs no change — it adopts whatever source the resolver returns and
Discogs is already a search source. Tests: parse release/master/artist
(slug-stripped, scheme-less) + resolve release→get_album(numeric id) and
artist→get_artist. 43 by_id tests pass.
This commit is contained in:
BoulderBadgeDad 2026-06-08 14:37:48 -07:00
parent df898b5212
commit b2de64e87d
2 changed files with 69 additions and 6 deletions

View file

@ -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/<id>-Slug, /release/<id>-Slug,
# /master/<id>-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."

View file

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