#775: links only — reject bare IDs (ambiguous), add not-found hint

Follow-up to the bare-ID footgun: a bare number like 525046 carries no
source and no entity type, so it resolved to whatever album happened to own
that id (a user pasting Kendrick's Deezer artist id got an unrelated album).

Now the resolver accepts provider URLs (and the explicit spotify: URI) only;
a bare/unrecognized string is rejected and the dropdown surfaces a hint to
paste a full link. URL parsing + album/track resolution are unchanged.
This commit is contained in:
BoulderBadgeDad 2026-06-05 06:44:01 -07:00
parent 9772d5313c
commit e05979ea07
5 changed files with 87 additions and 136 deletions

View file

@ -6,13 +6,14 @@ entity up *directly* on the owning source — no scoring, no guessing.
Design notes Design notes
------------ ------------
- **Source-agnostic input.** A full URL carries its source in the domain - **Links only.** A full URL carries its source in the domain
(``open.spotify.com`` spotify, ``musicbrainz.org`` musicbrainz, ) (``open.spotify.com`` spotify, ``musicbrainz.org`` musicbrainz, )
and its kind in the path (``/album/`` vs ``/track/``), so it resolves to and its kind in the path (``/album/`` vs ``/track/``), so it resolves to
exactly one lookup. A *bare* ID is disambiguated by format where we can exactly one unambiguous lookup. The ``spotify:album:ID`` URI is accepted
(UUID MusicBrainz, 22-char base62 Spotify) and otherwise fanned out too since it's equally explicit. Bare IDs are intentionally rejected: a
across the numeric-ID sources (Deezer, iTunes); the first source that bare number like ``525046`` carries no source and no entity type, so it
returns a hit wins. Paste the full link to remove that ambiguity. would resolve to whatever album/track happens to own that id on some
source often an unrelated entity. Paste the link instead.
- **Reuses existing per-source get-by-id.** Spotify/iTunes/MusicBrainz all - **Reuses existing per-source get-by-id.** Spotify/iTunes/MusicBrainz all
expose ``get_album``; Deezer exposes ``get_album_metadata``; all four expose ``get_album``; Deezer exposes ``get_album_metadata``; all four
@ -32,7 +33,6 @@ an injected ``client_resolver`` (defaulting to the orchestrator's
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
from typing import Any, Callable, NamedTuple, Optional from typing import Any, Callable, NamedTuple, Optional
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
@ -44,15 +44,6 @@ logger = logging.getLogger(__name__)
# return raw API shapes and aren't metadata-link sources, so they're omitted. # 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')
_UUID_RE = re.compile(
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.I
)
_SPOTIFY_ID_RE = re.compile(r'^[0-9A-Za-z]{22}$')
_NUMERIC_RE = re.compile(r'^\d+$')
# Numeric-ID sources, tried in this order when a bare number is pasted.
_NUMERIC_SOURCES = ('deezer', 'itunes')
# Domains we recognize — used to detect a pasted URL even when the user # Domains we recognize — used to detect a pasted URL even when the user
# omitted the scheme (e.g. "open.spotify.com/album/…"). # omitted the scheme (e.g. "open.spotify.com/album/…").
_KNOWN_HOSTS = ( _KNOWN_HOSTS = (
@ -64,9 +55,10 @@ _KNOWN_HOSTS = (
class LookupTarget(NamedTuple): class LookupTarget(NamedTuple):
"""One (source, kind, id) lookup to attempt. """One (source, kind, id) lookup to attempt.
``kind`` is ``'album'`` or ``'track'`` when the input pins it (a URL path ``kind`` is ``'album'`` or ``'track'`` always pinned by the URL path or
or URI type), or ``None`` when unknown the resolver then tries album URI type (links-only input). The ``Optional`` typing is kept defensively:
first, then track. the resolver falls back to album-then-track if a future parser path ever
yields ``None``.
""" """
source: str source: str
@ -139,14 +131,12 @@ def _parse_url(raw: str) -> list[LookupTarget]:
return [] return []
def parse_metadata_identifier( def parse_metadata_identifier(raw: str) -> list[LookupTarget]:
raw: str, preferred_source: Optional[str] = None """Parse a pasted provider link (or ``spotify:`` URI) into lookup targets.
) -> list[LookupTarget]:
"""Parse a pasted link/ID into an ordered list of lookup targets.
Returns the candidates to try in order; the first that resolves wins. Links only a bare ID has no source/type and is rejected (returns ``[]``).
``preferred_source`` (the user's active source) only reorders the A URL resolves to exactly one target; the list type is kept for the
fan-out for ambiguous *bare numeric* IDs. ``spotify:`` URI path and future multi-target patterns.
""" """
raw = (raw or '').strip() raw = (raw or '').strip()
if not raw: if not raw:
@ -164,34 +154,9 @@ def parse_metadata_identifier(
) )
if looks_like_url: if looks_like_url:
url = raw if '://' in raw else f'https://{raw}' url = raw if '://' in raw else f'https://{raw}'
targets = _parse_url(url) return _parse_url(url)
if targets:
return targets
# Fall through: maybe a bare id slipped in with a stray scheme.
# Bare identifier — disambiguate by format.
if _UUID_RE.match(raw):
# MusicBrainz MBID: could be release / release-group (album) or
# recording (track). Try album first, then track.
return [LookupTarget('musicbrainz', 'album', raw),
LookupTarget('musicbrainz', 'track', raw)]
if _NUMERIC_RE.match(raw):
# Numeric IDs collide across Deezer/iTunes — fan out, first hit wins.
order = list(_NUMERIC_SOURCES)
if preferred_source in order:
order.remove(preferred_source)
order.insert(0, preferred_source)
targets: list[LookupTarget] = []
for src in order:
targets.append(LookupTarget(src, 'album', raw))
targets.append(LookupTarget(src, 'track', raw))
return targets
if _SPOTIFY_ID_RE.match(raw):
return [LookupTarget('spotify', 'album', raw),
LookupTarget('spotify', 'track', raw)]
# Bare ID (or anything we don't recognize as a link) — rejected.
return [] return []
@ -286,28 +251,37 @@ def _fetch_track(client: Any, source: str, identifier: str) -> Optional[dict]:
return client.get_track_details(identifier) return client.get_track_details(identifier)
def _empty_result(raw: str, source: str = '') -> 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).'
)
_MSG_NOT_FOUND = "Couldn't resolve that link — double-check it's correct."
def _empty_result(raw: str, source: str = '', message: str = '') -> dict:
return { return {
'source': source, 'source': source,
'albums': [], 'albums': [],
'tracks': [], 'tracks': [],
'available': False, 'available': False,
'query': raw, 'query': raw,
'message': message,
} }
def resolve_identifier( def resolve_identifier(
raw: str, raw: str,
deps: Any, deps: Any,
preferred_source: Optional[str] = None,
client_resolver: Optional[Callable[[str], Any]] = None, client_resolver: Optional[Callable[[str], Any]] = None,
) -> dict: ) -> dict:
"""Resolve a pasted link/ID to a single album or track card. """Resolve a pasted provider link to a single album or track card.
Returns a dropdown-compatible dict: Returns a dropdown-compatible dict:
``{source, albums, tracks, available, query}``. ``available`` is True iff ``{source, albums, tracks, available, query, message}``. ``available`` is
a source returned a hit. The first resolving target wins, so the result True iff a source returned a hit; the first resolving target wins, so the
carries exactly one card (and the ``source`` that owns it). result carries exactly one card (and the ``source`` that owns it).
``message`` is a user-facing hint when nothing resolved.
``client_resolver`` maps a source name to a client (or None). It defaults ``client_resolver`` maps a source name to a client (or None). It defaults
to the orchestrator's ``resolve_client``; tests inject fakes. to the orchestrator's ``resolve_client``; tests inject fakes.
@ -318,10 +292,10 @@ def resolve_identifier(
def client_resolver(source: str) -> Any: # noqa: E306 def client_resolver(source: str) -> Any: # noqa: E306
return resolve_client(source, deps)[0] return resolve_client(source, deps)[0]
targets = parse_metadata_identifier(raw, preferred_source=preferred_source) targets = parse_metadata_identifier(raw)
if not targets: if not targets:
logger.info(f"Link/ID resolve: unrecognized identifier {raw!r}") logger.info(f"Link/ID resolve: not a recognized link {raw!r}")
return _empty_result(raw, preferred_source or '') return _empty_result(raw, message=_MSG_NOT_A_LINK)
for target in targets: for target in targets:
try: try:
@ -361,4 +335,4 @@ def resolve_identifier(
) )
logger.info(f"Link/ID resolve: no source resolved {raw!r}") logger.info(f"Link/ID resolve: no source resolved {raw!r}")
return _empty_result(raw, preferred_source or '') return _empty_result(raw, source=targets[0].source, message=_MSG_NOT_FOUND)

View file

@ -135,42 +135,23 @@ def test_parse_spotify_url_without_scheme_or_www():
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# parse_metadata_identifier — bare IDs # parse_metadata_identifier — bare IDs are rejected (links only)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_parse_bare_uuid_is_musicbrainz_album_then_track(): def test_parse_bare_numeric_id_rejected():
mbid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' # The footgun case (#775 follow-up): a bare number has no source/type, so
out = by_id.parse_metadata_identifier(mbid) # it must NOT resolve to whatever album happens to own that id.
assert out == [ assert by_id.parse_metadata_identifier('525046') == []
LookupTarget('musicbrainz', 'album', mbid),
LookupTarget('musicbrainz', 'track', mbid),
]
def test_parse_bare_base62_is_spotify_album_then_track(): def test_parse_bare_uuid_rejected():
sid = '4aawyAB9vmqN3uQ7FjRGTy' # 22 chars, has letters assert by_id.parse_metadata_identifier(
out = by_id.parse_metadata_identifier(sid) 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
assert out == [ ) == []
LookupTarget('spotify', 'album', sid),
LookupTarget('spotify', 'track', sid),
]
def test_parse_bare_numeric_fans_out_deezer_then_itunes(): def test_parse_bare_base62_rejected():
out = by_id.parse_metadata_identifier('302127') assert by_id.parse_metadata_identifier('4aawyAB9vmqN3uQ7FjRGTy') == []
assert out == [
LookupTarget('deezer', 'album', '302127'),
LookupTarget('deezer', 'track', '302127'),
LookupTarget('itunes', 'album', '302127'),
LookupTarget('itunes', 'track', '302127'),
]
def test_parse_bare_numeric_biases_preferred_source_first():
out = by_id.parse_metadata_identifier('302127', preferred_source='itunes')
# iTunes pulled to the front of the fan-out.
assert out[0] == LookupTarget('itunes', 'album', '302127')
assert out[1] == LookupTarget('itunes', 'track', '302127')
def test_parse_empty_and_garbage_return_empty(): def test_parse_empty_and_garbage_return_empty():
@ -301,40 +282,35 @@ def test_resolve_deezer_album_uses_get_album_metadata():
assert client.album_calls == ['302127'] assert client.album_calls == ['302127']
def test_resolve_kind_unknown_falls_back_album_then_track(): def test_resolve_track_link():
# Bare base62 → spotify, kind unknown. Album returns None, track resolves. # A track URL pins kind=track — only get_track_details is called.
client = _FakeClient(album=None, track={ client = _FakeClient(album=None, track={
'id': 't1', 'name': 'Creep', 'artists': ['Radiohead'], 'id': 't1', 'name': 'Creep', 'artists': ['Radiohead'],
'album': {'name': 'Pablo Honey'}, 'duration_ms': 238000, 'album': {'name': 'Pablo Honey'}, 'duration_ms': 238000,
}) })
sid = '4aawyAB9vmqN3uQ7FjRGTy'
res = by_id.resolve_identifier( res = by_id.resolve_identifier(
sid, deps=None, client_resolver=_resolver_from({'spotify': client}), 'https://open.spotify.com/track/t1', deps=None,
client_resolver=_resolver_from({'spotify': client}),
) )
assert res['available'] is True assert res['available'] is True
assert res['tracks'][0]['name'] == 'Creep' assert res['tracks'][0]['name'] == 'Creep'
assert res['albums'] == [] assert res['albums'] == []
assert client.album_calls == [sid] # tried album first assert client.album_calls == [] # kind pinned to track — no album probe
assert client.track_calls == [sid] # then track assert client.track_calls == ['t1']
def test_resolve_numeric_fanout_first_hit_wins(): def test_resolve_bare_id_rejected_with_hint():
# Deezer has no such id (returns None), iTunes does — resolver moves on. # The #775 follow-up regression: a bare number must not resolve; it
deezer = _FakeClient(album=None, track=None) # returns not-found with a link hint instead of an unrelated album.
itunes = _FakeClient(album={'id': '99', 'name': 'Thriller', called = []
'artists': ['Michael Jackson'], 'images': [],
'release_date': '1982', 'total_tracks': 9,
'album_type': 'album', 'external_urls': {}})
res = by_id.resolve_identifier( res = by_id.resolve_identifier(
'99', deps=None, '525046', deps=None,
client_resolver=_resolver_from({'deezer': deezer, 'itunes': itunes}), client_resolver=lambda s: called.append(s), # must never be invoked
) )
assert res['available'] is True assert res['available'] is False
assert res['source'] == 'itunes' assert res['albums'] == [] and res['tracks'] == []
assert res['albums'][0]['name'] == 'Thriller' assert 'link' in res['message'].lower()
# Deezer was tried (album + track) before iTunes resolved. assert called == [] # no source was even probed
assert deezer.album_calls == ['99']
assert deezer.track_calls == ['99']
def test_resolve_unavailable_client_is_skipped(): def test_resolve_unavailable_client_is_skipped():
@ -345,7 +321,9 @@ def test_resolve_unavailable_client_is_skipped():
) )
assert res['available'] is False assert res['available'] is False
assert res['albums'] == [] and res['tracks'] == [] assert res['albums'] == [] and res['tracks'] == []
assert res['source'] == '' # The source we tried is reported even on miss.
assert res['source'] == 'spotify'
assert res['message']
def test_resolve_client_exception_does_not_propagate(): def test_resolve_client_exception_does_not_propagate():

View file

@ -5728,27 +5728,26 @@ def enhanced_search():
@app.route('/api/enhanced-search/by-id', methods=['POST']) @app.route('/api/enhanced-search/by-id', methods=['POST'])
def enhanced_search_by_id(): def enhanced_search_by_id():
"""Resolve a pasted metadata link/ID to a single album or track (#775). """Resolve a pasted metadata link to a single album or track (#775).
Source-agnostic: a provider URL (Spotify/Apple/MusicBrainz/Deezer) or a A provider URL (Spotify/Apple/MusicBrainz/Deezer) is looked up directly
bare ID is looked up directly on the owning source via its get-by-id on the owning source via its get-by-id no fuzzy search, no scoring. The
no fuzzy search, no scoring. Returns the same dropdown shape the normal domain pins the source and the path pins album-vs-track, so it's
enhanced search renders, plus the resolving ``source`` so the frontend unambiguous. Returns the same dropdown shape the normal enhanced search
can route downloads/imports through the existing flow. renders, plus the resolving ``source`` so the frontend can route
downloads/imports through the existing flow.
Body: ``{"query": "<link or id>", "source": "<active source>"?}``. The Body: ``{"query": "<provider link or spotify: URI>"}``. Links only a
optional ``source`` only biases the fan-out for ambiguous bare numeric bare ID is rejected with a hint, since it carries no source or type.
IDs (Deezer vs iTunes).
""" """
data = request.get_json() or {} data = request.get_json() or {}
raw = (data.get('query') or '').strip() raw = (data.get('query') or '').strip()
preferred = (data.get('source') or '').strip().lower() or None
if not raw: if not raw:
return jsonify(_search_by_id._empty_result('')) return jsonify(_search_by_id._empty_result(''))
try: try:
deps = _build_search_deps() deps = _build_search_deps()
result = _search_by_id.resolve_identifier(raw, deps, preferred_source=preferred) result = _search_by_id.resolve_identifier(raw, deps)
return jsonify(result) return jsonify(result)
except Exception as e: except Exception as e:
logger.error(f"Link/ID resolve error: {e}") logger.error(f"Link/ID resolve error: {e}")

View file

@ -2201,15 +2201,15 @@
</div> </div>
</div> </div>
<!-- Link / ID lookup (#775): paste a provider link or <!-- Link lookup (#775): paste a provider link and resolve
bare ID and resolve it directly on the owning source it directly on the owning source (Spotify / Apple Music
(Spotify / Apple Music / MusicBrainz / Deezer) — no / MusicBrainz / Deezer) — no fuzzy search. Results render
fuzzy search. Results render in the dropdown below and in the dropdown below and reuse the normal download/
reuse the normal download/import flow. --> import flow. Links only: a bare ID is ambiguous. -->
<div class="enh-id-lookup"> <div class="enh-id-lookup">
<span class="enh-id-lookup-icon">🔗</span> <span class="enh-id-lookup-icon">🔗</span>
<input type="text" id="enh-id-input" class="enh-id-input" <input type="text" id="enh-id-input" class="enh-id-input"
placeholder="…or paste a Spotify / Apple Music / MusicBrainz / Deezer link or ID" placeholder="…or paste a Spotify / Apple Music / MusicBrainz / Deezer link"
autocomplete="off" spellcheck="false"> autocomplete="off" spellcheck="false">
<button id="enh-id-btn" class="enh-id-btn" type="button">Look up</button> <button id="enh-id-btn" class="enh-id-btn" type="button">Look up</button>
</div> </div>

View file

@ -300,10 +300,7 @@ function initializeSearchModeToggle() {
const resp = await fetch('/api/enhanced-search/by-id', { const resp = await fetch('/api/enhanced-search/by-id', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({ query: raw }),
query: raw,
source: searchController.state.activeSource,
}),
}); });
data = await resp.json(); data = await resp.json();
} catch (err) { } catch (err) {
@ -329,11 +326,14 @@ function initializeSearchModeToggle() {
} }
_renderFromState(searchController.state); _renderFromState(searchController.state);
} else { } else {
// No source resolved the link — show the empty state. // Not a link, or nothing resolved — surface the backend's hint
// via a toast (non-destructive) and show the empty state.
if (loadingState) loadingState.classList.add('hidden'); if (loadingState) loadingState.classList.add('hidden');
if (resultsContainer) resultsContainer.classList.add('hidden'); if (resultsContainer) resultsContainer.classList.add('hidden');
if (emptyState) emptyState.classList.remove('hidden'); if (emptyState) emptyState.classList.remove('hidden');
showDropdown(); showDropdown();
const msg = (data && data.message) || 'No match for that link.';
if (typeof showToast === 'function') showToast(msg, 'warning');
} }
} }