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:
parent
b2de64e87d
commit
cea0e9d63c
4 changed files with 228 additions and 2 deletions
97
core/downloads/track_link.py
Normal file
97
core/downloads/track_link.py
Normal 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)
|
||||
80
tests/downloads/test_track_link.py
Normal file
80
tests/downloads/test_track_link.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue