soulsync/core/downloads/track_link.py
BoulderBadgeDad b72febbf1c manual search: INJECT the exact pasted-link track, don't rely on text search surfacing it (#932)
reopened by diegocade1: pasting a Qobuz track link still showed unrelated results. the earlier
fix (b1f061a) only BUBBLED the linked track to the top — but a pasted link is resolved to an
"artist title" text query and searched, and for an obscure track ("foreign lavennew" by colacola)
that text search returns broad lookalikes ("Foreign Bird", "Foreign Spies", …) and never the
actual track. nothing to bubble → user sees junk.

fix: since the link is already resolved via get_track(id), fetch that exact track AS a downloadable
result and inject it at the top (Qobuz downloads by id, so the result is fully usable). the text
search still runs for alternatives.

- QobuzClient.get_track_result(id): get_track + _qobuz_to_track_result; None on any failure.
- _qobuz_to_track_result gains require_streamable (default True for bulk search). the link fetch
  passes False: track/get may OMIT the streamable flag, which would default-False and wrongly drop
  the exact track the user explicitly asked for. (this closes the one shape assumption that
  couldn't be verified against a live Qobuz API — the track is no longer gated on it.)
- track_link.inject_linked_track_first(tracks, linked_result, id): pure seam — prepend the fetched
  result + drop any search duplicate; falls back to the bubble when no result was fetched.
- manual-search endpoint fetches linked_result defensively (getattr 'get_track_result') and calls
  the seam. Tidal/HiFi (get_track returns a dict but the converter wants an object — shape
  mismatch) have no get_track_result, so they keep the existing bubble path: NO regression.

14 tests: inject puts the fetched track first when search missed it / dedups a search copy / falls
back to bubble / str-safe id / noop; get_track_result convert/none/exception; and the REAL
converter builds a valid downloadable result from a track/get dict that OMITS streamable (search
path still rejects it). 85 track-link/qobuz tests green, ruff clean.
2026-06-28 12:42:20 -07:00

136 lines
5.2 KiB
Python

"""Recognize a pasted streaming-source track link in the manual download
search (#813).
A user pastes e.g. ``https://tidal.com/track/434945950/u`` instead of typing a
query, to grab the exact version. We only recognize sources that download by
track ID (Tidal, Qobuz) — the manual search then resolves the link to that
track and runs the source's own search so the result is a normal, downloadable
candidate (no hand-built download encoding).
Pure + import-safe: parsing only, no network.
"""
from __future__ import annotations
import re
from typing import Any, List, Optional, Tuple
from urllib.parse import urlparse
def linked_track_id(track: Any) -> str:
"""The source track id stamped on a search result, read from
``_source_metadata['track_id']`` — the field every ID-downloadable source
(Tidal, Qobuz) records. Empty string when absent. ``TrackResult`` has no
top-level ``id``, so callers must NOT use ``getattr(t, 'id')`` (that always
missed and left the pasted-link bubble a silent no-op — #932)."""
meta = getattr(track, '_source_metadata', None)
if not isinstance(meta, dict):
return ''
return str(meta.get('track_id') or '')
def bubble_linked_track_first(tracks: List[Any], link_track_id: str) -> List[Any]:
"""Float the result whose source id matches a pasted link to the top so the
user sees the EXACT track they linked, not a fuzzy text-search lookalike
(#813/#932). Stable + a graceful no-op when no result carries the id."""
if not link_track_id or not tracks:
return tracks
target = str(link_track_id)
return sorted(tracks, key=lambda t: linked_track_id(t) != target)
def inject_linked_track_first(
tracks: List[Any], linked_result: Any, link_track_id: str
) -> List[Any]:
"""Put the EXACT linked track first.
When ``linked_result`` is the track fetched directly by id, prepend it and
drop any search duplicate of it — so an obscure track a text search never
surfaced is still present and downloadable (#932). When it's None (the source
can't fetch one), fall back to bubbling a matching search result. Pure."""
if not link_track_id:
return tracks
target = str(link_track_id)
if linked_result is not None:
return [linked_result] + [t for t in tracks if linked_track_id(t) != target]
return bubble_linked_track_first(tracks, target)
# host substring → download source id. Only ID-downloadable streaming sources.
_HOSTS = (
('tidal.com', 'tidal'),
('qobuz.com', 'qobuz'),
)
def parse_download_track_link(raw: str) -> Optional[Tuple[str, str]]:
"""Parse a pasted Tidal/Qobuz track URL into ``(source, track_id)``.
Returns None when the input isn't a recognized track link (so the caller
falls back to a normal text search). Handles the common URL shapes:
``tidal.com/track/<id>[/u]``, ``listen.tidal.com/track/<id>``,
``tidal.com/browse/track/<id>``, ``open.qobuz.com/track/<id>``,
``play.qobuz.com/track/<id>`` — with or without the scheme.
"""
raw = (raw or '').strip()
if not raw:
return None
lowered = raw.lower()
if '://' not in raw and not any(h in lowered for h, _ in _HOSTS):
return None # not even a URL we care about
url = raw if '://' in raw else f'https://{raw}'
parsed = urlparse(url)
host = (parsed.netloc or '').lower()
source = next((sid for h, sid in _HOSTS if h in host), None)
if not source:
return None
segs = [s for s in (parsed.path or '').split('/') if s]
for i, seg in enumerate(segs):
if seg.lower() == 'track' and i + 1 < len(segs):
m = re.match(r'(\d+)', segs[i + 1]) # id may carry a slug/suffix
if m:
return (source, m.group(1))
return None
def _first_artist_name(value: Any) -> str:
"""First artist name from a list of {'name': ...}/strings, or a single
{'name': ...}/string."""
if isinstance(value, list):
value = value[0] if value else None
if isinstance(value, dict):
return str(value.get('name') or '')
return str(value or '')
def query_from_track_payload(source: str, raw: Any) -> Optional[str]:
"""Build a clean ``"artist title"`` search query from a source ``get_track``
payload — pure, so the per-source shape parsing is unit-testable without a
live client.
- Tidal: attributes dict (``title`` + optional ``version`` + maybe
``artists``/``artist``). The version is appended so a remix link searches
for the remix.
- Qobuz: track dict (``title`` + ``performer``/``album.artist``).
"""
if not isinstance(raw, dict):
return None
title = (raw.get('title') or '').strip()
artist = ''
if source == 'tidal':
version = (raw.get('version') or '').strip()
if version and version.lower() not in title.lower():
title = f"{title} ({version})" if title else version
artist = _first_artist_name(raw.get('artists') or raw.get('artist'))
elif source == 'qobuz':
artist = _first_artist_name(raw.get('performer'))
if not artist:
album = raw.get('album') if isinstance(raw.get('album'), dict) else {}
artist = _first_artist_name(album.get('artist'))
query = f"{artist} {title}".strip()
return query or (title or None)