Manual download search: paste a Tidal/Qobuz track link to grab the exact version (#813)

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/<id>, 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.
This commit is contained in:
BoulderBadgeDad 2026-06-08 15:03:54 -07:00
parent b2de64e87d
commit cea0e9d63c
4 changed files with 228 additions and 2 deletions

View file

@ -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/<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)

View file

@ -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

View file

@ -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/<task_id>/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)

View file

@ -3127,8 +3127,8 @@ function _renderCandidatesModal(data) {
<input type="text"
class="candidates-manual-search-input"
id="candidates-manual-search-input"
placeholder="Search for a different track..."
maxlength="200" />
placeholder="Search, or paste a Tidal / Qobuz track link..."
maxlength="300" />
${sourceControl}
<button class="candidates-manual-search-btn"
id="candidates-manual-search-btn"