soulsync/tests/downloads/test_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

233 lines
8.5 KiB
Python

"""Parse pasted Tidal/Qobuz track links for the manual download search (#813)."""
from core.downloads.track_link import parse_download_track_link as p
def test_tidal_track_url_with_region_suffix():
assert p('https://tidal.com/track/434945950/u') == ('tidal', '434945950')
def test_tidal_browse_and_listen_hosts():
assert p('https://tidal.com/browse/track/434945950') == ('tidal', '434945950')
assert p('https://listen.tidal.com/track/434945950') == ('tidal', '434945950')
def test_qobuz_track_urls():
assert p('https://open.qobuz.com/track/12345678') == ('qobuz', '12345678')
assert p('https://play.qobuz.com/track/12345678') == ('qobuz', '12345678')
def test_scheme_less():
assert p('tidal.com/track/999') == ('tidal', '999')
def test_id_with_slug_suffix():
assert p('https://www.qobuz.com/track/555-some-slug') == ('qobuz', '555')
def test_non_track_links_rejected():
assert p('https://tidal.com/album/123') is None # album, not track
assert p('https://tidal.com/artist/123') is None
assert p('https://open.spotify.com/track/abc') is None # unsupported source
assert p('https://example.com/track/123') is None
def test_garbage_rejected():
assert p('') is None
assert p('just some text') is None
assert p('Habbit (T-Mass Remix)') is None
# ── query_from_track_payload (pure per-source parsing) ──
from core.downloads.track_link import query_from_track_payload as q
def test_tidal_payload_appends_version():
# Tidal attributes: title + version → remix link searches for the remix.
raw = {'title': 'Habbit', 'version': 'T-Mass Remix',
'artists': [{'name': 'Rain Man'}, {'name': 'Krysta Youngs'}]}
assert q('tidal', raw) == 'Rain Man Habbit (T-Mass Remix)'
def test_tidal_payload_no_version_no_artist():
assert q('tidal', {'title': 'Bloom'}) == 'Bloom'
def test_tidal_payload_singular_artist():
assert q('tidal', {'title': 'X', 'artist': {'name': 'Jinco'}}) == 'Jinco X'
def test_tidal_version_already_in_title_not_doubled():
raw = {'title': 'Bloom (Nurko Remix)', 'version': 'Nurko Remix',
'artists': [{'name': 'Dabin'}]}
assert q('tidal', raw) == 'Dabin Bloom (Nurko Remix)'
def test_qobuz_payload_performer():
raw = {'title': "What's Good For Me", 'performer': {'name': 'Jinco'}}
assert q('qobuz', raw) == "Jinco What's Good For Me"
def test_qobuz_payload_falls_back_to_album_artist():
raw = {'title': 'Song', 'album': {'artist': {'name': 'Some Artist'}}}
assert q('qobuz', raw) == 'Some Artist Song'
def test_payload_non_dict_or_empty():
assert q('tidal', None) is None
assert q('tidal', {}) is None
assert q('qobuz', 'garbage') is None
# ── bubble the pasted-link track to the top (#932) ──
from types import SimpleNamespace
from core.downloads.track_link import linked_track_id, bubble_linked_track_first
def _result(track_id=None):
"""Mimic a TrackResult: NO top-level `id`, the source id lives in
_source_metadata['track_id'] (or absent entirely)."""
meta = {'source': 'qobuz', 'track_id': track_id} if track_id is not None else None
return SimpleNamespace(_source_metadata=meta, title='t')
def test_linked_track_id_reads_source_metadata():
assert linked_track_id(_result('296427754')) == '296427754'
def test_linked_track_id_empty_when_absent():
# the exact #932 trap: there is no top-level `id`, so getattr(t,'id') would miss.
r = _result()
assert not hasattr(r, 'id')
assert linked_track_id(r) == ''
def test_bubble_floats_exact_track_to_top():
fuzzy_a, exact, fuzzy_b = _result('111'), _result('296427754'), _result('222')
out = bubble_linked_track_first([fuzzy_a, exact, fuzzy_b], '296427754')
assert out[0] is exact # exact track surfaced first
assert out[1:] == [fuzzy_a, fuzzy_b] # stable order for the rest
def test_bubble_handles_int_vs_str_id():
out = bubble_linked_track_first([_result('999'), _result('296427754')], 296427754)
assert linked_track_id(out[0]) == '296427754'
def test_bubble_noop_when_nothing_matches_or_empty():
a, b = _result('1'), _result('2')
assert bubble_linked_track_first([a, b], '296427754') == [a, b] # unchanged
assert bubble_linked_track_first([], '296427754') == []
assert bubble_linked_track_first([a, b], '') == [a, b]
# ── inject the EXACT fetched track first (#932 reopen: text search misses obscure tracks) ──
from core.downloads.track_link import inject_linked_track_first
def test_inject_puts_fetched_track_first_when_search_missed_it():
# The reported case: the linked track isn't among the fuzzy text-search
# results at all, so the directly-fetched result is injected at the top.
fetched = _result('296427754')
search = [_result('111'), _result('222')] # unrelated lookalikes
out = inject_linked_track_first(search, fetched, '296427754')
assert out[0] is fetched
assert len(out) == 3
def test_inject_dedups_a_search_copy_of_the_linked_track():
fetched = _result('296427754')
dup = _result('296427754') # search also returned it
search = [_result('111'), dup, _result('222')]
out = inject_linked_track_first(search, fetched, '296427754')
assert out[0] is fetched
# exactly one element carries the linked id, and it's the injected one
assert [linked_track_id(t) for t in out].count('296427754') == 1
assert all(t is not dup for t in out) # the search copy (by identity) was dropped
assert len(out) == 3
def test_inject_falls_back_to_bubble_without_a_fetched_result():
exact = _result('296427754')
out = inject_linked_track_first([_result('111'), exact, _result('222')], None, '296427754')
assert linked_track_id(out[0]) == '296427754' # bubbled, not injected
assert len(out) == 3
def test_inject_is_noop_without_a_link_id():
search = [_result('111')]
assert inject_linked_track_first(search, _result('x'), '') == search
def test_inject_int_track_id_is_str_safe():
fetched = _result('296427754')
out = inject_linked_track_first([_result('111')], fetched, 296427754)
assert out[0] is fetched
# ── QobuzClient.get_track_result: fetch by id → downloadable TrackResult ──
from core.qobuz_client import QobuzClient
def _bare_qobuz():
return QobuzClient.__new__(QobuzClient) # no network / __init__
def test_get_track_result_converts_fetched_track(monkeypatch):
client = _bare_qobuz()
sentinel = object()
monkeypatch.setattr(client, 'get_track', lambda tid: {'id': tid, 'streamable': True})
monkeypatch.setattr(client, '_qobuz_to_track_result',
lambda track, qi, require_streamable=True: sentinel)
assert client.get_track_result('296427754') is sentinel
def test_get_track_result_none_when_track_unavailable(monkeypatch):
client = _bare_qobuz()
monkeypatch.setattr(client, 'get_track', lambda tid: None)
assert client.get_track_result('nope') is None
def test_get_track_result_none_on_exception(monkeypatch):
client = _bare_qobuz()
def _boom(_tid):
raise RuntimeError('api down')
monkeypatch.setattr(client, 'get_track', _boom)
assert client.get_track_result('x') is None
# ── #932 hardening: a pasted-link fetch must not be dropped by a missing
# 'streamable' flag (track/get may omit it). Exercises the REAL converter. ──
_QOBUZ_TRACK = {'id': 296427754, 'title': 'foreign lavennew',
'performer': {'name': 'colacola'}, 'duration': 180}
def test_converter_rejects_non_streamable_on_search_path():
# Default (bulk search) still filters on streamable — unchanged behaviour.
client = _bare_qobuz()
qi = {'codec': 'flac', 'bitrate': 1411}
assert client._qobuz_to_track_result(dict(_QOBUZ_TRACK), qi) is None # no streamable flag
def test_converter_builds_for_link_fetch_without_streamable():
client = _bare_qobuz()
qi = {'codec': 'flac', 'bitrate': 1411}
r = client._qobuz_to_track_result(dict(_QOBUZ_TRACK), qi, require_streamable=False)
assert r is not None
assert linked_track_id(r) == '296427754' # carries the id → injectable + downloadable
def test_get_track_result_survives_missing_streamable_flag(monkeypatch):
# The feared track/get shape (no 'streamable') must STILL yield the track —
# this is the end-to-end gap that couldn't be confirmed against a live API.
client = _bare_qobuz()
monkeypatch.setattr(client, 'get_track', lambda _tid: dict(_QOBUZ_TRACK))
r = client.get_track_result('296427754')
assert r is not None
assert linked_track_id(r) == '296427754'