From cea0e9d63c5c9ea8807cce28553518e8d14e08d5 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Mon, 8 Jun 2026 15:03:54 -0700 Subject: [PATCH] Manual download search: paste a Tidal/Qobuz track link to grab the exact version (#813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a track shows "Not found", the manual search now accepts a pasted Tidal or Qobuz track link, not just a typed query (CubeComming: the fuzzy search misses versions; he can find the track on Tidal but can't get it to appear). How it works (robust, reuses the proven path): parse the link → (source, track_id) → fetch the track via the source client's get_track → build a clean "artist title (version)" query → run THAT source's normal search → bubble the result whose id matches the link to the top. So the candidate is a normal, already-downloadable streaming result — no hand-built download encoding — and it downloads through the existing verified flow. Degrades gracefully: if the source isn't connected or the link can't be resolved, it falls back to a normal text search of the raw input — the user is never worse off than typing it themselves. Scoped to Tidal + Qobuz (the streaming sources that download by track id, with public track URLs); Soulseek can't take a link (P2P, no ids), YouTube/SoundCloud are URL-native via a different path (future). - core/downloads/track_link.py: pure parse_download_track_link (tidal/qobuz /track/, slug/region suffixes, scheme-less) + query_from_track_payload (per-source title/artist, Tidal version-append). - manual-search endpoint: link detection → resolve → restrict to that source → id-match bubble. - placeholder hint mentions pasting a link; maxlength 200→300 for long URLs. Tests: 14 (parser shapes + payload extraction incl. remix version-append + qobuz performer/album-artist fallback). JS valid. --- core/downloads/track_link.py | 97 ++++++++++++++++++++++++++++++ tests/downloads/test_track_link.py | 80 ++++++++++++++++++++++++ web_server.py | 49 +++++++++++++++ webui/static/downloads.js | 4 +- 4 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 core/downloads/track_link.py create mode 100644 tests/downloads/test_track_link.py diff --git a/core/downloads/track_link.py b/core/downloads/track_link.py new file mode 100644 index 00000000..e6dec8bd --- /dev/null +++ b/core/downloads/track_link.py @@ -0,0 +1,97 @@ +"""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, Optional, Tuple +from urllib.parse import urlparse + +# 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/[/u]``, ``listen.tidal.com/track/``, + ``tidal.com/browse/track/``, ``open.qobuz.com/track/``, + ``play.qobuz.com/track/`` — 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) diff --git a/tests/downloads/test_track_link.py b/tests/downloads/test_track_link.py new file mode 100644 index 00000000..39dfe8a1 --- /dev/null +++ b/tests/downloads/test_track_link.py @@ -0,0 +1,80 @@ +"""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 diff --git a/web_server.py b/web_server.py index 56b1566e..3d1961c6 100644 --- a/web_server.py +++ b/web_server.py @@ -7083,6 +7083,28 @@ def download_selected_candidate(task_id): return jsonify({"error": str(e)}), 500 +def _resolve_link_track_query(source: str, track_id: str): + """Resolve a pasted (source, track_id) to a clean "artist title" search + query via the source client's get_track (#813). Returns (query, None) or + (None, error). Used so a pasted Tidal/Qobuz link runs the source's normal + search (proven-downloadable candidates) instead of a hand-built one.""" + from core.downloads.track_link import query_from_track_payload + client = download_orchestrator.client(source) if download_orchestrator else None + if not client or not hasattr(client, 'get_track'): + return None, f"{source.title()} is not connected" + try: + raw = client.get_track(track_id) + except Exception as e: + return None, f"Could not resolve {source.title()} track: {e}" + if not raw: + return None, f"{source.title()} track {track_id} not found" + + query = query_from_track_payload(source, raw) + if not query: + return None, f"Could not read the track title from {source.title()}" + return query, None + + @app.route('/api/downloads/task//manual-search', methods=['POST']) def manual_search_for_task(task_id): """Run a user-driven search against one (or all) configured download @@ -7122,6 +7144,26 @@ def manual_search_for_task(task_id): download_mode, available_sources = _list_available_download_sources() valid_source_ids = {s['id'] for s in available_sources} + # Pasted streaming-source track link (#813): resolve it to a clean + # "artist title" query and search ONLY that source, then bubble the + # exact track to the top. Falls back to a normal text search if the + # source isn't connected or the link can't be resolved — so the user is + # never worse off than typing the query themselves. + from core.downloads.track_link import parse_download_track_link + link = parse_download_track_link(query) + link_source = None + link_track_id = None + if link: + _src, _tid = link + if _src in valid_source_ids: + clean_q, link_err = _resolve_link_track_query(_src, _tid) + if clean_q: + query = clean_q + source = _src + link_source, link_track_id = _src, _tid + else: + logger.info(f"[Manual Search] link resolve fell back: {link_err}") + if source != 'all': if source not in valid_source_ids: return jsonify({ @@ -7181,6 +7223,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). + 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)) serialized = [] for t in tracks: s = _serialize_candidate(t, source_override=src_name) diff --git a/webui/static/downloads.js b/webui/static/downloads.js index 5d5ad688..aeca1bd1 100644 --- a/webui/static/downloads.js +++ b/webui/static/downloads.js @@ -3127,8 +3127,8 @@ function _renderCandidatesModal(data) { + placeholder="Search, or paste a Tidal / Qobuz track link..." + maxlength="300" /> ${sourceControl}