Reported on Discord by Netti93: with Tidal configured for "HiRes only"
and "Allow Quality Fallback" disabled, tracks were still downloading
successfully — as m4a 320kbps files. Some "successful" downloads were
less than half the file size of the same track pulled via Tidarr/tiddl
from the same Tidal account.
Root cause: Tidal's API silently degrades to the best quality your
account + the track + your region permits. Setting
`session.audio_quality = Quality.hi_res_lossless` and calling
`track.get_stream()` on a track that's only available in AAC returns
an AAC stream with no error. The downloader wrote the m4a file to
disk, the ~7MB size sailed past the 100KB stub threshold, and the
download reported success.
The pre-existing "verify quality wasn't silently downgraded" block
only LOGGED a warning when this happened; it did not fail the tier.
Two knock-on effects:
- Users with "HiRes only, no fallback" got m4a files anyway, which
defeats the setting entirely.
- The worker-level fallback chain (hires → lossless → high → low)
couldn't advance past the first tier, because every tier
"succeeded" at whatever Tidal happened to serve.
Fix: after `track.get_stream()`, compare `stream.audio_quality`
against the tier we asked for using a rank-based ordering:
LOW < HIGH < LOSSLESS < HI_RES < HI_RES_LOSSLESS
- Same tier or higher → accept (so the occasional Tidal upgrade
doesn't get rejected just because it's not an exact match).
- Lower tier → reject THIS tier. The loop `continue`s and the next
fallback tier is tried, or the whole download fails honestly
when the user has fallback disabled. The existing final-error
log already has a hint directing users to enable fallback if
they want automatic Lossless substitution.
- Unrecognized `audioQuality` value (e.g. a new Tidal tier we
haven't mapped) → reject conservatively, so the next fallback
tier gets a chance and the diagnostic log names the unknown
value.
Why the rank-based approach instead of strict equality:
Tidal's API doesn't technically promise an exact-tier match on
serving; on tracks that are flagged in its catalog as a higher
tier, it can serve higher than the session setting. Rejecting
higher-than-asked quality would be user-hostile. And the `HI_RES`
(legacy MQA) value — not in tidalapi's modern `Quality` enum but
possibly still present on old catalog entries — needs to rank
below `HI_RES_LOSSLESS`: users asking for true lossless HiRes
should reject MQA since MQA is a lossy format.
tidalapi's `Quality` enum is a `str` subclass whose VALUES (not
member names) match what the Tidal API returns in the
`audioQuality` field (e.g. `Quality.hi_res_lossless.value ==
'HI_RES_LOSSLESS'`, `Quality.low_320k.value == 'HIGH'`). Both
sides of the comparison are coerced to `str` before use, so the
check is robust to whichever tidalapi version exposes the served
quality as an enum or a plain string.
The check is extracted as `_verify_stream_tier(stream, q_info,
q_key) -> (ok, reason)` at module scope — a pure function with no
I/O, unit-tested independently. Ten tests: match, three upgrade
cases (LOSSLESS → HI_RES_LOSSLESS, LOSSLESS → HI_RES, LOW → any
higher), three downgrade cases (the reported HiRes → AAC, HiRes
Lossless → MQA HiRes, Lossless → AAC), one unrecognized-tier case,
and two defensive paths for older tidalapi builds without
`audio_quality` on the stream object and for QUALITY_MAP entries
that lack `tidal_quality` (e.g. tidalapi wasn't importable at
module load). Test stub updated to use uppercase `Quality` values
matching real tidalapi so case-sensitivity regressions get caught.
Also removed the old codec-string-based warning block — the new
tier check is strictly stronger, and keeping the warning around
would just be dead code waiting to drift out of sync.
Deliberately NOT tackling in this PR (documented as follow-ups):
- Bit-depth verification of HiRes FLAC files via mutagen. The
`stream.audio_quality` tier check catches the main "HiRes
requested, got AAC" case; bit-depth would only matter if Tidal
labeled a stream HI_RES_LOSSLESS but served a 16-bit FLAC
(`Stream.bit_depth` isn't reliable for this — tidalapi defaults
missing `bitDepth` fields to 16, so a trust-the-stream check
would spuriously reject valid HiRes whenever Tidal omits the
field). A proper fix runs mutagen post-download to inspect the
actual file, then decides whether to delete + retry the next
tier — a whole new failure mode with design trade-offs that
deserve their own PR. The support logs don't show this
happening.
- The "manual remap still says Not Found" symptom. Might be
downstream of this same bug (silent-AAC "success" hitting a
later rejection), might be a separate task-state issue. Not
guessing without logs from the retry path.
- Quality-aware stub threshold. 100KB is a reasonable floor for
real stub/preview detection and there's no evidence the
universal threshold is misfiring in the wild.
Field-verified status: desk-verified via unit tests and empirical
checks against a live tidalapi import (confirming the `Quality`
enum's str-subclass behavior). Not yet smoke-tested end-to-end
against a real Tidal account with a HiRes-only-no-fallback
setting — Netti93 or anyone else with that config should notice
either the fix working (non-HiRes tracks fail honestly with a
clear log line) or any regression before wider release.
Files:
- core/tidal_download_client.py — new `_verify_stream_tier` helper
and `_QUALITY_RANK` table at module scope, called in the
download loop after the stream is fetched and before any
bandwidth is spent. Removed the old inline codec-based warning
since the new check supersedes it.
- tests/test_tidal_stream_tier_verification.py — ten tests covering
match / upgrade / downgrade / unknown / defensive paths.
- tests/test_tidal_search_shortening.py — fake `Quality` values
brought in line with tidalapi's real values so both files share
a consistent stub regardless of pytest collection order.
- webui/static/helper.js — WHATS_NEW entry under 2.40 describing
the rank-based tier comparison.
Reported on Discord by Netti93 — the "same account works via
Tidarr" comparison narrowed the cause to SoulSync's download path
rather than an account/region issue.
1015 lines
42 KiB
Python
1015 lines
42 KiB
Python
"""
|
|
Tidal Download Client
|
|
Alternative music download source using tidalapi.
|
|
|
|
This client provides:
|
|
- Tidal search with metadata
|
|
- Device flow authentication (link.tidal.com)
|
|
- HiRes/Lossless/High quality audio downloads
|
|
- Drop-in replacement compatible with Soulseek interface
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import asyncio
|
|
import uuid
|
|
import threading
|
|
import shutil
|
|
import subprocess
|
|
from typing import List, Optional, Dict, Any, Tuple
|
|
from pathlib import Path
|
|
from datetime import datetime, timezone
|
|
|
|
try:
|
|
import tidalapi
|
|
except ImportError:
|
|
tidalapi = None
|
|
|
|
import requests as http_requests
|
|
|
|
from utils.logging_config import get_logger
|
|
from config.settings import config_manager
|
|
|
|
# Import Soulseek data structures for drop-in replacement compatibility
|
|
from core.soulseek_client import TrackResult, AlbumResult, DownloadStatus
|
|
|
|
logger = get_logger("tidal_download_client")
|
|
|
|
|
|
# Quality tier definitions
|
|
QUALITY_MAP = {
|
|
'low': {
|
|
'tidal_quality': 'LOW' if tidalapi is None else None, # set dynamically
|
|
'label': 'AAC 96kbps',
|
|
'extension': 'm4a',
|
|
'bitrate': 96,
|
|
'codec': 'aac',
|
|
},
|
|
'high': {
|
|
'tidal_quality': 'HIGH' if tidalapi is None else None,
|
|
'label': 'AAC 320kbps',
|
|
'extension': 'm4a',
|
|
'bitrate': 320,
|
|
'codec': 'aac',
|
|
},
|
|
'lossless': {
|
|
'tidal_quality': 'LOSSLESS' if tidalapi is None else None,
|
|
'label': 'FLAC 16-bit/44.1kHz',
|
|
'extension': 'flac',
|
|
'bitrate': 1411,
|
|
'codec': 'flac',
|
|
},
|
|
'hires': {
|
|
'tidal_quality': 'HI_RES_LOSSLESS' if tidalapi is None else None,
|
|
'label': 'FLAC 24-bit/96kHz',
|
|
'extension': 'flac',
|
|
'bitrate': 9216,
|
|
'codec': 'flac',
|
|
},
|
|
}
|
|
|
|
# Initialize quality map with actual tidalapi constants if available
|
|
if tidalapi is not None:
|
|
QUALITY_MAP['low']['tidal_quality'] = tidalapi.Quality.low_96k
|
|
QUALITY_MAP['high']['tidal_quality'] = tidalapi.Quality.low_320k
|
|
QUALITY_MAP['lossless']['tidal_quality'] = tidalapi.Quality.high_lossless
|
|
QUALITY_MAP['hires']['tidal_quality'] = tidalapi.Quality.hi_res_lossless
|
|
|
|
|
|
# Ordering of Tidal's audioQuality values, worst to best. Used to accept
|
|
# tier upgrades (Tidal serving higher than the user asked) while still
|
|
# rejecting downgrades. Values are the strings tidalapi's `Quality` enum
|
|
# exposes — and the strings Tidal's API returns in the `audioQuality`
|
|
# field. `HI_RES` (legacy MQA) isn't in the modern `Quality` enum but
|
|
# may still come back for old catalog tracks; we rank it below
|
|
# `HI_RES_LOSSLESS` so it's treated as a downgrade when the user asked
|
|
# for true HiRes lossless.
|
|
_QUALITY_RANK = {
|
|
'LOW': 1,
|
|
'HIGH': 2,
|
|
'LOSSLESS': 3,
|
|
'HI_RES': 4,
|
|
'HI_RES_LOSSLESS': 5,
|
|
}
|
|
|
|
|
|
def _verify_stream_tier(stream, q_info: dict, q_key: str) -> Tuple[bool, Optional[str]]:
|
|
"""Return ``(True, None)`` when the tier Tidal actually served is
|
|
acceptable (same as requested, or a higher tier), ``(False, reason)``
|
|
when Tidal silently downgraded.
|
|
|
|
Tidal's API degrades quality without raising: ask for HI_RES_LOSSLESS
|
|
on a track that's only in LOW_320K and you get LOW_320K back with no
|
|
error. The downloader used to accept that and write the resulting
|
|
AAC file, which defeated "HiRes only" with no fallback and made the
|
|
worker's fallback chain ineffective (every tier "succeeded" at the
|
|
first one that returned anything).
|
|
|
|
We accept upgrades because Tidal occasionally serves a higher tier
|
|
than requested on tracks flagged as such in its catalog — rejecting
|
|
a higher quality than asked for would be user-hostile.
|
|
|
|
Defensive paths:
|
|
- No ``audio_quality`` on the stream (older tidalapi builds): pass
|
|
through, let the pre-existing codec / file-size guards decide.
|
|
- QUALITY_MAP entry without ``tidal_quality`` (tidalapi wasn't
|
|
importable at module load): pass through for the same reason.
|
|
- Unrecognized served quality value (new Tidal tier we haven't
|
|
mapped yet): reject, surfacing a "can't verify" reason so the
|
|
next tier gets a chance or the final diagnostic names the
|
|
unknown value.
|
|
"""
|
|
served = getattr(stream, 'audio_quality', None)
|
|
expected = q_info.get('tidal_quality')
|
|
if served is None or expected is None:
|
|
return True, None
|
|
|
|
# Both sides may be enum instances (str subclass) or plain strings;
|
|
# coerce to str to compare values only.
|
|
served_str = str(served)
|
|
expected_str = str(expected)
|
|
|
|
if served_str == expected_str:
|
|
return True, None
|
|
|
|
served_rank = _QUALITY_RANK.get(served_str)
|
|
expected_rank = _QUALITY_RANK.get(expected_str)
|
|
|
|
if expected_rank is None:
|
|
# Shouldn't happen — every entry in QUALITY_MAP resolves to a
|
|
# known tier. If it does, don't reject valid downloads.
|
|
return True, None
|
|
|
|
if served_rank is None:
|
|
return False, (
|
|
f"{q_key}: Tidal returned unrecognized audioQuality "
|
|
f"'{served_str}' — can't verify the tier matches '{expected_str}'"
|
|
)
|
|
|
|
if served_rank >= expected_rank:
|
|
return True, None
|
|
|
|
return False, (
|
|
f"{q_key}: Tidal served '{served_str}' instead of "
|
|
f"'{expected_str}' — account tier, track licensing, "
|
|
f"or region doesn't permit {q_key} for this track"
|
|
)
|
|
|
|
|
|
class TidalDownloadClient:
|
|
"""
|
|
Tidal download client using tidalapi.
|
|
Provides search, matching, and download capabilities as a drop-in alternative to YouTube/Soulseek.
|
|
"""
|
|
|
|
def __init__(self, download_path: str = None):
|
|
if tidalapi is None:
|
|
logger.warning("tidalapi not installed — Tidal downloads unavailable")
|
|
|
|
# Use Soulseek download path for consistency (post-processing expects files here)
|
|
if download_path is None:
|
|
download_path = config_manager.get('soulseek.download_path', './downloads')
|
|
|
|
self.download_path = Path(download_path)
|
|
self.download_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
logger.info(f"Tidal download client using download path: {self.download_path}")
|
|
|
|
# Callback for shutdown check (avoids circular imports)
|
|
self.shutdown_check = None
|
|
|
|
# tidalapi session
|
|
self.session: Optional['tidalapi.Session'] = None
|
|
self._init_session()
|
|
|
|
# Download queue management (mirrors YouTube's download tracking)
|
|
self.active_downloads: Dict[str, Dict[str, Any]] = {}
|
|
self._download_lock = threading.Lock()
|
|
|
|
# Device auth state
|
|
self._device_auth_future = None
|
|
self._device_auth_link = None
|
|
|
|
def set_shutdown_check(self, check_callable):
|
|
"""Set a callback function to check for system shutdown"""
|
|
self.shutdown_check = check_callable
|
|
|
|
# ===================== Auth =====================
|
|
|
|
def _init_session(self):
|
|
"""Create a tidalapi session and try to restore saved tokens."""
|
|
if tidalapi is None:
|
|
return
|
|
|
|
self.session = tidalapi.Session()
|
|
|
|
# Try to restore saved session
|
|
saved = config_manager.get('tidal_download.session', {})
|
|
token_type = saved.get('token_type', '')
|
|
access_token = saved.get('access_token', '')
|
|
refresh_token = saved.get('refresh_token', '')
|
|
expiry_time = saved.get('expiry_time', 0)
|
|
|
|
if token_type and access_token:
|
|
try:
|
|
# Convert stored float timestamp back to datetime for tidalapi
|
|
expiry_dt = datetime.fromtimestamp(expiry_time, tz=timezone.utc) if expiry_time else None
|
|
|
|
# tidalapi's load_oauth_session restores from saved tokens
|
|
restored = self.session.load_oauth_session(
|
|
token_type=token_type,
|
|
access_token=access_token,
|
|
refresh_token=refresh_token,
|
|
expiry_time=expiry_dt,
|
|
)
|
|
if restored and self.session.check_login():
|
|
logger.info("Restored Tidal download session from saved tokens")
|
|
self._save_session() # refresh may have rotated tokens
|
|
return
|
|
else:
|
|
logger.warning("Saved Tidal session tokens are invalid/expired")
|
|
except Exception as e:
|
|
logger.warning(f"Could not restore Tidal session: {e}")
|
|
|
|
def _save_session(self):
|
|
"""Persist session tokens to config."""
|
|
if not self.session:
|
|
return
|
|
config_manager.set('tidal_download.session', {
|
|
'token_type': self.session.token_type or '',
|
|
'access_token': self.session.access_token or '',
|
|
'refresh_token': self.session.refresh_token or '',
|
|
'expiry_time': self.session.expiry_time.timestamp() if self.session.expiry_time else 0,
|
|
})
|
|
|
|
def is_authenticated(self) -> bool:
|
|
"""Check if we have a valid Tidal session."""
|
|
if not self.session:
|
|
return False
|
|
try:
|
|
return self.session.check_login()
|
|
except Exception:
|
|
return False
|
|
|
|
def start_device_auth(self) -> Optional[Dict[str, str]]:
|
|
"""
|
|
Start the device-code OAuth flow.
|
|
Returns dict with 'verification_uri' and 'user_code', or None on error.
|
|
"""
|
|
if tidalapi is None:
|
|
return None
|
|
|
|
try:
|
|
if not self.session:
|
|
self.session = tidalapi.Session()
|
|
|
|
login, future = self.session.login_oauth()
|
|
self._device_auth_future = future
|
|
self._device_auth_link = {
|
|
'verification_uri': login.verification_uri_complete or f"https://link.tidal.com/{login.user_code}",
|
|
'user_code': login.user_code,
|
|
}
|
|
logger.info(f"Tidal device auth started — code: {login.user_code}")
|
|
return self._device_auth_link
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to start Tidal device auth: {e}")
|
|
return None
|
|
|
|
def check_device_auth(self) -> Dict[str, Any]:
|
|
"""
|
|
Check if device auth has completed.
|
|
Returns {'status': 'pending'|'completed'|'error', ...}
|
|
"""
|
|
if not self._device_auth_future:
|
|
return {'status': 'error', 'message': 'No auth in progress'}
|
|
|
|
try:
|
|
if self._device_auth_future.running():
|
|
return {
|
|
'status': 'pending',
|
|
'verification_uri': self._device_auth_link.get('verification_uri', ''),
|
|
'user_code': self._device_auth_link.get('user_code', ''),
|
|
}
|
|
|
|
# Future is done — check result
|
|
result = self._device_auth_future.result(timeout=0)
|
|
if self.session and self.session.check_login():
|
|
self._save_session()
|
|
logger.info("Tidal device auth completed successfully")
|
|
return {'status': 'completed', 'message': 'Authenticated successfully'}
|
|
else:
|
|
return {'status': 'error', 'message': 'Auth completed but session invalid'}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Tidal device auth check error: {e}")
|
|
return {'status': 'error', 'message': str(e)}
|
|
|
|
# ===================== Search =====================
|
|
|
|
def is_available(self) -> bool:
|
|
"""Check if Tidal download client is available (tidalapi installed and authenticated)."""
|
|
return tidalapi is not None and self.is_authenticated()
|
|
|
|
def is_configured(self) -> bool:
|
|
"""Check if Tidal client is configured and ready (matches Soulseek interface)."""
|
|
return self.is_available()
|
|
|
|
async def check_connection(self) -> bool:
|
|
"""Test if Tidal is accessible (async, Soulseek-compatible)."""
|
|
try:
|
|
loop = asyncio.get_event_loop()
|
|
return await loop.run_in_executor(None, self.is_available)
|
|
except Exception as e:
|
|
logger.error(f"Tidal connection check failed: {e}")
|
|
return False
|
|
|
|
# Words that distinguish a specific audio variant from the original track.
|
|
# If any of these appear in a query, the fallback-retry results must also
|
|
# contain them — otherwise we'd silently downgrade a "(Live)" or
|
|
# "(Acoustic)" search to the studio version when shortened queries match
|
|
# too broadly.
|
|
_QUALIFIER_KEYWORDS = frozenset({
|
|
'remix', 'mix', 'edit', 'version', 'dub', 'rmx', 'vip', 'cut',
|
|
'rework', 'bootleg', 'flip',
|
|
'live', 'concert', 'unplugged', 'acoustic', 'session',
|
|
'instrumental', 'karaoke', 'demo', 'bonus',
|
|
'extended', 'radio',
|
|
})
|
|
|
|
@classmethod
|
|
def _extract_qualifiers(cls, query: str) -> List[str]:
|
|
"""Return the qualifier keywords that appear as whole words in the
|
|
query (case-insensitive). Word-boundary match prevents false hits like
|
|
"edit" matching "edition" or "mix" matching "remix"."""
|
|
if not query:
|
|
return []
|
|
found = []
|
|
q_lower = query.lower()
|
|
for kw in cls._QUALIFIER_KEYWORDS:
|
|
if re.search(r'\b' + re.escape(kw) + r'\b', q_lower):
|
|
found.append(kw)
|
|
return found
|
|
|
|
@staticmethod
|
|
def _track_name_contains_qualifiers(track_name: str, qualifiers: List[str]) -> bool:
|
|
"""True iff the track name contains every qualifier as a whole word."""
|
|
if not qualifiers:
|
|
return True
|
|
if not track_name:
|
|
return False
|
|
name_lower = track_name.lower()
|
|
for kw in qualifiers:
|
|
if not re.search(r'\b' + re.escape(kw) + r'\b', name_lower):
|
|
return False
|
|
return True
|
|
|
|
@staticmethod
|
|
def _generate_shortened_queries(original: str) -> List[str]:
|
|
"""Generate progressively-shorter variants of a search query.
|
|
|
|
Tidal's search engine chokes on long queries with lots of qualifiers
|
|
(remix credits, edit labels, bonus-disc markers). When the original
|
|
returns 0 results, we retry with shortened variants in order of
|
|
conservativeness — the first variant that returns results wins.
|
|
|
|
Variants are returned in priority order. Dedupes against the original
|
|
and against previously-added variants so we never issue duplicate
|
|
requests.
|
|
"""
|
|
variants: List[str] = []
|
|
seen = {original.strip().lower()}
|
|
|
|
def _add(candidate: str) -> None:
|
|
candidate = candidate.strip()
|
|
if candidate and candidate.lower() not in seen:
|
|
variants.append(candidate)
|
|
seen.add(candidate.lower())
|
|
|
|
# 1. Strip a trailing parenthetical/bracketed suffix.
|
|
# "Song (Radio Edit)" → "Song"
|
|
_add(re.sub(r'\s*[\(\[][^\)\]]*[\)\]]\s*$', '', original))
|
|
|
|
# 2. Strip ALL parentheticals/brackets (mid-string too).
|
|
# "Song (feat X) [Remix]" → "Song"
|
|
_add(re.sub(r'\s*[\(\[][^\)\]]*[\)\]]', ' ', original))
|
|
|
|
tokens = original.split()
|
|
|
|
# 3. Drop the last token — covers trailing 1-word modifiers
|
|
# (e.g. "… Remix", "… Extended").
|
|
if len(tokens) >= 3:
|
|
_add(' '.join(tokens[:-1]))
|
|
|
|
# 4. Drop the last two tokens.
|
|
if len(tokens) >= 4:
|
|
_add(' '.join(tokens[:-2]))
|
|
|
|
# 5. Drop the last three tokens — covers "fred v remix" style
|
|
# 3-word modifiers common in remix/bonus track names.
|
|
if len(tokens) >= 5:
|
|
_add(' '.join(tokens[:-3]))
|
|
|
|
# 6. Aggressive: keep roughly the first half (rounded up).
|
|
if len(tokens) >= 7:
|
|
_add(' '.join(tokens[:len(tokens) // 2 + 1]))
|
|
|
|
return variants
|
|
|
|
async def search(self, query: str, timeout: int = None, progress_callback=None) -> Tuple[List[TrackResult], List[AlbumResult]]:
|
|
"""
|
|
Search Tidal for tracks (async, Soulseek-compatible interface).
|
|
|
|
Returns:
|
|
Tuple of (track_results, album_results). Album results always empty.
|
|
"""
|
|
if not self.is_available():
|
|
logger.warning("Tidal not available for search (not authenticated)")
|
|
return ([], [])
|
|
|
|
# Defensive guard — None/empty query would blow up the shortener's
|
|
# .strip() call. Match the original behaviour (log + empty tuple).
|
|
if not query or not isinstance(query, str):
|
|
logger.warning(f"Invalid Tidal search query: {query!r}")
|
|
return ([], [])
|
|
|
|
logger.info(f"Searching Tidal for: {query}")
|
|
|
|
# Outer try/except preserves the original contract: any unexpected
|
|
# error returns ([], []) so the caller can fall back to other sources
|
|
# instead of crashing. Traceback is logged once, not per-attempt.
|
|
try:
|
|
# Build the retry ladder: original query, then progressively-shortened
|
|
# variants. Capped at 5 total requests to avoid hammering Tidal on
|
|
# genuinely-missing tracks, while still allowing enough variants to
|
|
# cover multi-word trailing modifiers like remix credits.
|
|
queries_to_try = [query] + self._generate_shortened_queries(query)
|
|
queries_to_try = queries_to_try[:5]
|
|
|
|
# Qualifier-aware safety net: if the original query contains variant
|
|
# keywords (Live, Remix, Acoustic, Extended, etc.), fallback results
|
|
# MUST still contain those qualifiers in their track names. Otherwise
|
|
# a shortened query could silently downgrade "Song (Live)" to the
|
|
# studio "Song" and the caller would download the wrong variant.
|
|
required_qualifiers = self._extract_qualifiers(query)
|
|
|
|
tidal_tracks: list = []
|
|
successful_query: Optional[str] = None
|
|
last_error: Optional[Exception] = None
|
|
# Tracks whether ANY fallback attempt returned broader matches that
|
|
# got rejected by the qualifier filter — used to give an accurate
|
|
# "no qualifier-matching variant" log message at the end instead of
|
|
# a generic "0 results".
|
|
any_fallback_filtered_out = False
|
|
|
|
loop = asyncio.get_event_loop()
|
|
for attempt_idx, attempt_query in enumerate(queries_to_try):
|
|
try:
|
|
q_copy = attempt_query
|
|
|
|
def _search(q=q_copy):
|
|
results = self.session.search(q, models=[tidalapi.media.Track], limit=50)
|
|
return results.get('tracks', []) if isinstance(results, dict) else []
|
|
|
|
found = await loop.run_in_executor(None, _search)
|
|
|
|
if found:
|
|
# Fallback attempts get qualifier-filtered. We trust the
|
|
# original query to return only appropriate matches, but
|
|
# shortened queries are more permissive and can return
|
|
# wrong-variant tracks (e.g. studio when Live was asked
|
|
# for). Drop any result whose title doesn't carry all
|
|
# original qualifier words.
|
|
is_fallback = attempt_idx > 0
|
|
if is_fallback and required_qualifiers:
|
|
filtered = [
|
|
t for t in found
|
|
if self._track_name_contains_qualifiers(getattr(t, 'name', ''), required_qualifiers)
|
|
]
|
|
if filtered:
|
|
tidal_tracks = filtered
|
|
successful_query = attempt_query
|
|
logger.info(
|
|
f"Tidal fallback kept {len(filtered)}/{len(found)} tracks "
|
|
f"after qualifier filter {required_qualifiers} for '{attempt_query}'"
|
|
)
|
|
break
|
|
else:
|
|
any_fallback_filtered_out = True
|
|
logger.debug(
|
|
f"Tidal fallback '{attempt_query}' returned {len(found)} tracks "
|
|
f"but none matched original qualifiers {required_qualifiers} — "
|
|
f"trying next variant"
|
|
)
|
|
if attempt_idx < len(queries_to_try) - 1:
|
|
await asyncio.sleep(0.1)
|
|
continue
|
|
else:
|
|
tidal_tracks = found
|
|
successful_query = attempt_query
|
|
break
|
|
|
|
if attempt_idx < len(queries_to_try) - 1:
|
|
logger.debug(f"Tidal returned 0 results for '{attempt_query}' — trying shortened variant")
|
|
# Small pause so we're not hammering Tidal with rapid retries
|
|
await asyncio.sleep(0.1)
|
|
except Exception as e:
|
|
last_error = e
|
|
logger.debug(f"Tidal search attempt {attempt_idx + 1} failed: {e}")
|
|
|
|
if not tidal_tracks:
|
|
if last_error is not None:
|
|
import traceback
|
|
tb_str = ''.join(traceback.format_exception(
|
|
type(last_error), last_error, last_error.__traceback__
|
|
))
|
|
logger.error(
|
|
f"Tidal search failed after {len(queries_to_try)} attempts: {last_error}\n{tb_str}"
|
|
)
|
|
elif any_fallback_filtered_out:
|
|
logger.warning(
|
|
f"No Tidal results for '{query}' — fallbacks found broader matches but "
|
|
f"none preserved required qualifiers {required_qualifiers}"
|
|
)
|
|
else:
|
|
logger.warning(f"No Tidal results for: {query}")
|
|
return ([], [])
|
|
|
|
if successful_query and successful_query != query:
|
|
logger.info(f"Tidal fallback query succeeded: '{successful_query}' (original: '{query}')")
|
|
|
|
# Get configured quality for display
|
|
quality_key = config_manager.get('tidal_download.quality', 'lossless')
|
|
quality_info = QUALITY_MAP.get(quality_key, QUALITY_MAP['lossless'])
|
|
|
|
track_results = []
|
|
for track in tidal_tracks:
|
|
try:
|
|
track_result = self._tidal_to_track_result(track, quality_info)
|
|
track_results.append(track_result)
|
|
except Exception as e:
|
|
logger.debug(f"Skipping track conversion error: {e}")
|
|
|
|
logger.info(f"Found {len(track_results)} Tidal tracks")
|
|
return (track_results, [])
|
|
|
|
except Exception as e:
|
|
# Unhandled error in the retry orchestration itself (not in an
|
|
# individual attempt, which is already caught above). Preserves
|
|
# the original contract of returning ([], []) on any failure so
|
|
# the caller's fallback chain isn't broken.
|
|
logger.error(f"Tidal search orchestration failed: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
return ([], [])
|
|
|
|
def _tidal_to_track_result(self, track, quality_info: dict) -> TrackResult:
|
|
"""Convert tidalapi Track to TrackResult (Soulseek-compatible format)."""
|
|
artist_name = track.artist.name if track.artist else 'Unknown Artist'
|
|
title = track.name or 'Unknown Title'
|
|
album_name = track.album.name if track.album else None
|
|
|
|
# Duration in milliseconds
|
|
duration_ms = int(track.duration * 1000) if track.duration else None
|
|
|
|
# Encode track_id in filename (same pattern as YouTube: "id||display_name")
|
|
display_name = f"{artist_name} - {title}"
|
|
filename = f"{track.id}||{display_name}"
|
|
|
|
track_result = TrackResult(
|
|
username='tidal',
|
|
filename=filename,
|
|
size=0, # Unknown until download
|
|
bitrate=quality_info.get('bitrate'),
|
|
duration=duration_ms,
|
|
quality=quality_info.get('codec', 'flac'),
|
|
free_upload_slots=999,
|
|
upload_speed=999999,
|
|
queue_length=0,
|
|
artist=artist_name,
|
|
title=title,
|
|
album=album_name,
|
|
track_number=track.track_num,
|
|
)
|
|
|
|
return track_result
|
|
|
|
# ===================== Download =====================
|
|
|
|
async def download(self, username: str, filename: str, file_size: int = 0) -> Optional[str]:
|
|
"""
|
|
Download a Tidal track (async, Soulseek-compatible interface).
|
|
|
|
Returns download_id immediately and runs download in background thread.
|
|
|
|
Args:
|
|
username: Ignored for Tidal (always "tidal")
|
|
filename: Encoded as "track_id||display_name"
|
|
file_size: Ignored
|
|
"""
|
|
try:
|
|
if '||' not in filename:
|
|
logger.error(f"Invalid filename format: {filename}")
|
|
return None
|
|
|
|
track_id_str, display_name = filename.split('||', 1)
|
|
try:
|
|
track_id = int(track_id_str)
|
|
except ValueError:
|
|
logger.error(f"Invalid Tidal track ID: {track_id_str}")
|
|
return None
|
|
|
|
logger.info(f"Starting Tidal download: {display_name}")
|
|
|
|
download_id = str(uuid.uuid4())
|
|
|
|
with self._download_lock:
|
|
self.active_downloads[download_id] = {
|
|
'id': download_id,
|
|
'filename': filename, # Keep original encoded format for context matching
|
|
'username': 'tidal',
|
|
'state': 'Initializing',
|
|
'progress': 0.0,
|
|
'size': 0,
|
|
'transferred': 0,
|
|
'speed': 0,
|
|
'time_remaining': None,
|
|
'track_id': track_id,
|
|
'display_name': display_name,
|
|
'file_path': None,
|
|
}
|
|
|
|
# Start download in background thread
|
|
download_thread = threading.Thread(
|
|
target=self._download_thread_worker,
|
|
args=(download_id, track_id, display_name, filename),
|
|
daemon=True,
|
|
)
|
|
download_thread.start()
|
|
|
|
logger.info(f"Tidal download {download_id} started in background")
|
|
return download_id
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to start Tidal download: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
return None
|
|
|
|
def _download_thread_worker(self, download_id: str, track_id: int, display_name: str, original_filename: str):
|
|
"""Background thread worker for downloading Tidal tracks."""
|
|
try:
|
|
with self._download_lock:
|
|
if download_id in self.active_downloads:
|
|
self.active_downloads[download_id]['state'] = 'InProgress, Downloading'
|
|
|
|
file_path = self._download_sync(download_id, track_id, display_name)
|
|
|
|
if file_path:
|
|
with self._download_lock:
|
|
if download_id in self.active_downloads:
|
|
self.active_downloads[download_id]['state'] = 'Completed, Succeeded'
|
|
self.active_downloads[download_id]['progress'] = 100.0
|
|
self.active_downloads[download_id]['file_path'] = file_path
|
|
|
|
logger.info(f"Tidal download {download_id} completed: {file_path}")
|
|
else:
|
|
with self._download_lock:
|
|
if download_id in self.active_downloads:
|
|
self.active_downloads[download_id]['state'] = 'Errored'
|
|
|
|
logger.error(f"Tidal download {download_id} failed")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Tidal download thread failed for {download_id}: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
|
|
with self._download_lock:
|
|
if download_id in self.active_downloads:
|
|
self.active_downloads[download_id]['state'] = 'Errored'
|
|
|
|
def _download_sync(self, download_id: str, track_id: int, display_name: str) -> Optional[str]:
|
|
"""
|
|
Synchronous download method (runs in background thread).
|
|
|
|
Returns file path if successful, None otherwise.
|
|
"""
|
|
if not self.session or not self.session.check_login():
|
|
logger.error("Tidal session not authenticated")
|
|
return None
|
|
|
|
try:
|
|
# Get track object
|
|
track = self.session.track(track_id)
|
|
if not track:
|
|
logger.error(f"Could not fetch Tidal track: {track_id}")
|
|
return None
|
|
|
|
# Determine quality
|
|
quality_key = config_manager.get('tidal_download.quality', 'lossless')
|
|
quality_info = QUALITY_MAP.get(quality_key, QUALITY_MAP['lossless'])
|
|
|
|
# Try quality fallback chain: hires → lossless → high → low
|
|
# The entire download+validation is inside the loop so that garbage
|
|
# files (stubs, empty HiRes responses) trigger a retry at the next tier.
|
|
quality_chain = ['hires', 'lossless', 'high', 'low']
|
|
start_idx = quality_chain.index(quality_key) if quality_key in quality_chain else 1
|
|
allow_fallback = config_manager.get('tidal_download.allow_fallback', True)
|
|
chain = quality_chain[start_idx:] if allow_fallback else [quality_key]
|
|
|
|
MIN_AUDIO_SIZE = 100 * 1024 # 100KB
|
|
|
|
quality_error_reasons = []
|
|
|
|
for q_key in chain:
|
|
q_info = QUALITY_MAP[q_key]
|
|
|
|
# --- Step 1: Get stream ---
|
|
try:
|
|
self.session.audio_quality = q_info['tidal_quality']
|
|
stream = track.get_stream()
|
|
if not stream or not stream.manifest_mime_type:
|
|
reason = f"{q_key}: no stream returned"
|
|
logger.warning(f"Quality {q_key} returned no stream, trying next")
|
|
quality_error_reasons.append(reason)
|
|
continue
|
|
|
|
ok, reason = _verify_stream_tier(stream, q_info, q_key)
|
|
if not ok:
|
|
logger.warning(reason)
|
|
quality_error_reasons.append(reason)
|
|
continue
|
|
|
|
logger.info(f"Got Tidal stream at quality: {q_key}")
|
|
except Exception as e:
|
|
reason = f"{q_key}: {type(e).__name__}: {e}"
|
|
logger.warning(f"Quality {q_key} unavailable: {e}")
|
|
quality_error_reasons.append(reason)
|
|
continue
|
|
|
|
# --- Step 2: Parse manifest ---
|
|
manifest = stream.get_stream_manifest()
|
|
urls = manifest.get_urls()
|
|
if not urls:
|
|
reason = f"{q_key}: manifest returned no URLs"
|
|
logger.warning(f"No download URLs for quality {q_key}, trying next")
|
|
quality_error_reasons.append(reason)
|
|
continue
|
|
|
|
download_url = urls[0]
|
|
|
|
# Determine file extension from manifest codec (HiRes FLAC
|
|
# can arrive wrapped in MP4 — unwrapped at Step 4).
|
|
codec = manifest.get_codecs()
|
|
if codec and 'flac' in codec.lower():
|
|
extension = 'flac'
|
|
elif codec and ('mp4a' in codec.lower() or 'aac' in codec.lower()):
|
|
extension = 'm4a'
|
|
elif codec and 'alac' in codec.lower():
|
|
extension = 'm4a'
|
|
else:
|
|
extension = q_info.get('extension', 'flac')
|
|
|
|
# Build output filename
|
|
safe_name = re.sub(r'[<>:"/\\|?*]', '_', display_name)
|
|
out_filename = f"{safe_name}.{extension}"
|
|
out_path = self.download_path / out_filename
|
|
|
|
# Check for shutdown before downloading
|
|
if self.shutdown_check and self.shutdown_check():
|
|
logger.info("Server shutting down, aborting Tidal download")
|
|
return None
|
|
|
|
# --- Step 3: Download ---
|
|
try:
|
|
logger.info(f"Downloading from Tidal ({q_key}): {out_filename}")
|
|
response = http_requests.get(download_url, stream=True, timeout=120)
|
|
response.raise_for_status()
|
|
|
|
total_size = int(response.headers.get('content-length', 0))
|
|
downloaded = 0
|
|
chunk_size = 64 * 1024 # 64KB chunks
|
|
|
|
with self._download_lock:
|
|
if download_id in self.active_downloads:
|
|
self.active_downloads[download_id]['size'] = total_size
|
|
|
|
with open(out_path, 'wb') as f:
|
|
for chunk in response.iter_content(chunk_size=chunk_size):
|
|
if not chunk:
|
|
continue
|
|
|
|
if self.shutdown_check and self.shutdown_check():
|
|
logger.info("Server shutting down, aborting Tidal download mid-stream")
|
|
f.close()
|
|
out_path.unlink(missing_ok=True)
|
|
return None
|
|
|
|
f.write(chunk)
|
|
downloaded += len(chunk)
|
|
|
|
if total_size > 0:
|
|
progress = (downloaded / total_size) * 100
|
|
else:
|
|
progress = 0
|
|
|
|
with self._download_lock:
|
|
if download_id in self.active_downloads:
|
|
self.active_downloads[download_id]['transferred'] = downloaded
|
|
self.active_downloads[download_id]['progress'] = round(progress, 1)
|
|
|
|
except Exception as dl_err:
|
|
logger.warning(f"Download failed at quality {q_key}: {dl_err}")
|
|
quality_error_reasons.append(f"{q_key}: download error: {type(dl_err).__name__}: {dl_err}")
|
|
out_path.unlink(missing_ok=True)
|
|
continue
|
|
|
|
# --- Step 4: Validate ---
|
|
if downloaded < MIN_AUDIO_SIZE:
|
|
logger.warning(
|
|
f"Tidal download too small at {q_key} ({downloaded} bytes) — "
|
|
f"likely a stub/preview for '{display_name}'. Trying next quality."
|
|
)
|
|
quality_error_reasons.append(f"{q_key}: file too small ({downloaded} bytes), likely a stub")
|
|
out_path.unlink(missing_ok=True)
|
|
continue
|
|
|
|
# HiRes FLAC in MP4 container: extract raw FLAC with FFmpeg
|
|
if extension == 'flac' and self._is_mp4_container(out_path):
|
|
extracted = self._extract_flac_from_mp4(out_path)
|
|
if extracted:
|
|
out_path = Path(extracted)
|
|
else:
|
|
logger.warning(
|
|
f"Cannot extract FLAC from MP4 container at {q_key} — "
|
|
f"deleting and trying next quality"
|
|
)
|
|
quality_error_reasons.append(f"{q_key}: FLAC extraction from MP4 container failed")
|
|
out_path.unlink(missing_ok=True)
|
|
continue
|
|
|
|
# Final size check after any extraction
|
|
final_size = out_path.stat().st_size if out_path.exists() else 0
|
|
if final_size < MIN_AUDIO_SIZE:
|
|
logger.warning(
|
|
f"Final file too small after processing at {q_key} "
|
|
f"({final_size} bytes) — trying next quality"
|
|
)
|
|
quality_error_reasons.append(f"{q_key}: final file too small after extraction ({final_size} bytes)")
|
|
out_path.unlink(missing_ok=True)
|
|
continue
|
|
|
|
# Success — file is valid
|
|
logger.info(f"Tidal download complete ({q_key}): {out_path} ({final_size / (1024*1024):.1f} MB)")
|
|
return str(out_path)
|
|
|
|
# All quality tiers exhausted — build a diagnostic message
|
|
# Re-use quality_key/allow_fallback already read above to stay consistent
|
|
# with how the chain was built (avoids config-change-mid-download inconsistency).
|
|
reasons_str = '; '.join(quality_error_reasons) if quality_error_reasons else 'unknown'
|
|
if quality_key == 'hires' and not allow_fallback:
|
|
hint = (
|
|
" HiRes quality is unavailable for this track on your account or in your region. "
|
|
"Enable 'Quality Fallback' in Tidal settings to fall back to Lossless automatically."
|
|
)
|
|
else:
|
|
hint = ""
|
|
logger.error(
|
|
f"No Tidal quality tier produced a valid download for '{display_name}'."
|
|
f"{hint} Failure reasons: [{reasons_str}]"
|
|
)
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.error(f"Tidal download failed: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
return None
|
|
|
|
def _is_mp4_container(self, filepath: Path) -> bool:
|
|
"""Check if a file is actually an MP4 container (HiRes FLAC can be wrapped in MP4)."""
|
|
try:
|
|
with open(filepath, 'rb') as f:
|
|
header = f.read(12)
|
|
# MP4 files have 'ftyp' at offset 4
|
|
return b'ftyp' in header
|
|
except Exception:
|
|
return False
|
|
|
|
def _extract_flac_from_mp4(self, mp4_path: Path) -> Optional[str]:
|
|
"""Extract FLAC audio from MP4 container using FFmpeg."""
|
|
ffmpeg = shutil.which('ffmpeg')
|
|
if not ffmpeg:
|
|
# Also check tools directory
|
|
tools_dir = Path(__file__).parent.parent / 'tools'
|
|
ffmpeg_candidate = tools_dir / ('ffmpeg.exe' if os.name == 'nt' else 'ffmpeg')
|
|
if ffmpeg_candidate.exists():
|
|
ffmpeg = str(ffmpeg_candidate)
|
|
else:
|
|
logger.warning("FFmpeg not found — cannot extract FLAC from MP4 container")
|
|
return None
|
|
|
|
flac_path = mp4_path.with_suffix('.flac')
|
|
temp_path = mp4_path.with_suffix('.tmp.flac')
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
[ffmpeg, '-i', str(mp4_path), '-vn', '-acodec', 'copy', str(temp_path), '-y'],
|
|
capture_output=True, text=True, timeout=120,
|
|
)
|
|
|
|
if result.returncode == 0 and temp_path.exists() and temp_path.stat().st_size > 0:
|
|
mp4_path.unlink(missing_ok=True)
|
|
temp_path.rename(flac_path)
|
|
logger.info(f"Extracted FLAC from MP4 container: {flac_path.name}")
|
|
return str(flac_path)
|
|
else:
|
|
logger.warning(f"FFmpeg extraction failed: {result.stderr[:200] if result.stderr else 'unknown error'}")
|
|
temp_path.unlink(missing_ok=True)
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.warning(f"FFmpeg extraction error: {e}")
|
|
temp_path.unlink(missing_ok=True)
|
|
return None
|
|
|
|
# ===================== Status / Cancel / Clear =====================
|
|
|
|
async def get_all_downloads(self) -> List[DownloadStatus]:
|
|
"""Get all active downloads (matches Soulseek interface)."""
|
|
download_statuses = []
|
|
|
|
with self._download_lock:
|
|
for _download_id, info in self.active_downloads.items():
|
|
status = DownloadStatus(
|
|
id=info['id'],
|
|
filename=info['filename'],
|
|
username=info['username'],
|
|
state=info['state'],
|
|
progress=info['progress'],
|
|
size=info['size'],
|
|
transferred=info['transferred'],
|
|
speed=info['speed'],
|
|
time_remaining=info.get('time_remaining'),
|
|
file_path=info.get('file_path'),
|
|
)
|
|
download_statuses.append(status)
|
|
|
|
return download_statuses
|
|
|
|
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
|
|
"""Get status of a specific download (matches Soulseek interface)."""
|
|
with self._download_lock:
|
|
if download_id not in self.active_downloads:
|
|
return None
|
|
|
|
info = self.active_downloads[download_id]
|
|
return DownloadStatus(
|
|
id=info['id'],
|
|
filename=info['filename'],
|
|
username=info['username'],
|
|
state=info['state'],
|
|
progress=info['progress'],
|
|
size=info['size'],
|
|
transferred=info['transferred'],
|
|
speed=info['speed'],
|
|
time_remaining=info.get('time_remaining'),
|
|
file_path=info.get('file_path'),
|
|
)
|
|
|
|
async def cancel_download(self, download_id: str, username: str = None, remove: bool = False) -> bool:
|
|
"""Cancel an active download (matches Soulseek interface)."""
|
|
try:
|
|
with self._download_lock:
|
|
if download_id not in self.active_downloads:
|
|
logger.warning(f"Download {download_id} not found")
|
|
return False
|
|
|
|
self.active_downloads[download_id]['state'] = 'Cancelled'
|
|
logger.info(f"Marked Tidal download {download_id} as cancelled")
|
|
|
|
if remove:
|
|
del self.active_downloads[download_id]
|
|
logger.info(f"Removed Tidal download {download_id} from queue")
|
|
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Failed to cancel download {download_id}: {e}")
|
|
return False
|
|
|
|
async def clear_all_completed_downloads(self) -> bool:
|
|
"""Clear all terminal downloads from the list (matches Soulseek interface)."""
|
|
try:
|
|
with self._download_lock:
|
|
ids_to_remove = [
|
|
did for did, info in self.active_downloads.items()
|
|
if info.get('state', '') in ('Completed, Succeeded', 'Cancelled', 'Errored', 'Aborted')
|
|
]
|
|
for did in ids_to_remove:
|
|
del self.active_downloads[did]
|
|
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Error clearing downloads: {e}")
|
|
return False
|