From b1f061a2a8e61377ba732d5ad25e7c3f42c25dbb Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Fri, 26 Jun 2026 21:45:41 -0700 Subject: [PATCH] manual search: float the pasted Qobuz/Tidal track to the top (#932) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a pasted track link IS resolved + searched, but the 'bubble the exact track to the top' step read getattr(t,'id') — and TrackResult has no top-level id (the source id lives in _source_metadata['track_id']). so the bubble was a silent no-op: the linked track sat buried among fuzzy text-search lookalikes and the user saw unrelated tracks. qobuz made it worse — _qobuz_to_track_result never stamped _source_metadata at all, so the track had no id to match. - stamp _source_metadata={'source':'qobuz','track_id':...} on qobuz TrackResults (mirrors tidal) - extract the bubble into pure, tested helpers (linked_track_id / bubble_linked_track_first) that read _source_metadata['track_id'] — fixes it for tidal too, str/int-safe, stable no-op 19 track-link tests (+6 new) + 87 qobuz/download tests green. --- core/downloads/track_link.py | 24 +++++++++++++++- core/qobuz_client.py | 4 +++ tests/downloads/test_track_link.py | 44 ++++++++++++++++++++++++++++++ web_server.py | 12 ++++---- 4 files changed, 77 insertions(+), 7 deletions(-) diff --git a/core/downloads/track_link.py b/core/downloads/track_link.py index e6dec8bd..3565a5ff 100644 --- a/core/downloads/track_link.py +++ b/core/downloads/track_link.py @@ -13,9 +13,31 @@ Pure + import-safe: parsing only, no network. from __future__ import annotations import re -from typing import Any, Optional, Tuple +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) + # host substring → download source id. Only ID-downloadable streaming sources. _HOSTS = ( ('tidal.com', 'tidal'), diff --git a/core/qobuz_client.py b/core/qobuz_client.py index 1c7c3b6f..e90e934a 100644 --- a/core/qobuz_client.py +++ b/core/qobuz_client.py @@ -1074,6 +1074,10 @@ class QobuzClient(DownloadSourcePlugin): title=title, album=album_name, track_number=track.get('track_number'), + # Stamp the Qobuz track id so a pasted-link manual search can float the + # exact track to the top (#932). Without this the bubble never matched + # and the linked track stayed buried among fuzzy lookalikes. + _source_metadata={'source': 'qobuz', 'track_id': str(track.get('id') or '')}, ) # Stamp real API quality so the global ranker sees actual diff --git a/tests/downloads/test_track_link.py b/tests/downloads/test_track_link.py index 39dfe8a1..06cbc1bb 100644 --- a/tests/downloads/test_track_link.py +++ b/tests/downloads/test_track_link.py @@ -78,3 +78,47 @@ 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] diff --git a/web_server.py b/web_server.py index b87f2ba7..19f9bc52 100644 --- a/web_server.py +++ b/web_server.py @@ -7533,13 +7533,13 @@ def manual_search_for_task(task_id): "error": error, }) + "\n" continue - # Pasted-link exact match: bubble the track whose id matches - # the link to the top so the user sees the exact version - # first (graceful no-op if ids don't line up). + # Pasted-link exact match: bubble the track whose source id + # matches the link to the top so the user sees the exact version + # first. Reads _source_metadata['track_id'] (TrackResult has no + # top-level id) — the old getattr(t,'id') always missed (#932). if src_name == link_source and link_track_id and tracks: - tracks = sorted( - tracks, - key=lambda t: str(getattr(t, 'id', '')) != str(link_track_id)) + from core.downloads.track_link import bubble_linked_track_first + tracks = bubble_linked_track_first(tracks, link_track_id) serialized = [] for t in tracks: s = _serialize_candidate(t, source_override=src_name)