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
|
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'])
|
@app.route('/api/downloads/task/<task_id>/manual-search', methods=['POST'])
|
||||||
def manual_search_for_task(task_id):
|
def manual_search_for_task(task_id):
|
||||||
"""Run a user-driven search against one (or all) configured download
|
"""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()
|
download_mode, available_sources = _list_available_download_sources()
|
||||||
valid_source_ids = {s['id'] for s in available_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 != 'all':
|
||||||
if source not in valid_source_ids:
|
if source not in valid_source_ids:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
|
|
@ -7181,6 +7223,13 @@ def manual_search_for_task(task_id):
|
||||||
"error": error,
|
"error": error,
|
||||||
}) + "\n"
|
}) + "\n"
|
||||||
continue
|
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 = []
|
serialized = []
|
||||||
for t in tracks:
|
for t in tracks:
|
||||||
s = _serialize_candidate(t, source_override=src_name)
|
s = _serialize_candidate(t, source_override=src_name)
|
||||||
|
|
|
||||||
|
|
@ -3127,8 +3127,8 @@ function _renderCandidatesModal(data) {
|
||||||
<input type="text"
|
<input type="text"
|
||||||
class="candidates-manual-search-input"
|
class="candidates-manual-search-input"
|
||||||
id="candidates-manual-search-input"
|
id="candidates-manual-search-input"
|
||||||
placeholder="Search for a different track..."
|
placeholder="Search, or paste a Tidal / Qobuz track link..."
|
||||||
maxlength="200" />
|
maxlength="300" />
|
||||||
${sourceControl}
|
${sourceControl}
|
||||||
<button class="candidates-manual-search-btn"
|
<button class="candidates-manual-search-btn"
|
||||||
id="candidates-manual-search-btn"
|
id="candidates-manual-search-btn"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue