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.
233 lines
8.5 KiB
Python
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'
|