soulsync/core/tidal_download_client.py
Broque Thomas 0d0bbf38c9 Add query-shortening retry + qualifier guard to Tidal search
Tidal's search engine chokes on long queries with multiple qualifier
words (remix credits, edit labels, bonus-disc markers). User reported
case: "maduk transformations remixed fire away fred v remix" returns 0,
but shortening to "maduk transformations remixed fire away" works.

Behaviour change:
- On a 0-result search, retry with progressively-shortened variants
  (capped at 5 total attempts, 100ms pause between).
- Variants (in priority order):
    1. strip trailing "(...)" / "[...]"
    2. strip all parentheticals/brackets
    3-5. drop last 1 / 2 / 3 tokens
    6. keep first half of tokens (rounded up)
- Dedupes so identical variants don't re-query.

Safety — qualifier-aware filter:
- Variant keywords (Live / Remix / Acoustic / Extended / Unplugged /
  Instrumental / Karaoke / etc.) are extracted from the original query
  using word-boundary match so "edit" doesn't match "edition" and
  "mix" doesn't match "remixed".
- If the original query carries any qualifiers, fallback results MUST
  contain those qualifiers in their track names — otherwise a shortened
  query could silently downgrade "Song (Live)" to the studio "Song".
- Tracks that fail the filter are dropped. If no variant produces
  qualifier-matching tracks, returns ([], []) — the same outcome as the
  original code, so no regression.

Contract preservation:
- Never raises to caller (outer try/except catches orchestration errors).
- Returns ([], []) on any failure path, same as original.
- Original-query successes take the same code path as before — no
  behavioural change for queries that already work.
- Defensive guards for None/empty/non-string query (early return).

Logging:
- Preserves original warning/error/info messages for back-compat log
  scraping.
- Adds fallback-success INFO log ("Tidal fallback query succeeded: ...")
  so successful retries are visible in production logs.
- Adds qualifier-filter INFO/DEBUG logs with kept/total counts.
- Per-attempt exception logs at DEBUG (not ERROR) to avoid noise when
  retries succeed.
- Traceback preserved on final failure.

Tests (16 regression tests in tests/test_tidal_search_shortening.py):
- Skowl's reported query reaches his working variant within the cap.
- Paren/bracket stripping priority.
- Short queries produce no variants.
- All variants unique (dedup guard).
- Progressive token drops present for long queries.
- Qualifier extraction is word-bounded (no "edit" in "edition").
- Qualifier extraction is case-insensitive.
- Track name filter requires ALL qualifiers.
- Empty-qualifier list passes every track (original-query behaviour).

All 292 tests pass.
2026-04-22 07:42:16 -07:00

941 lines
40 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
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
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 = 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')
# Verify quality wasn't silently downgraded: if HiRes was requested but the
# codec/manifest points to standard FLAC, log a clear warning.
if q_key == 'hires' and codec:
codec_lower = codec.lower()
if 'flac' in codec_lower or 'alac' in codec_lower:
# HiRes should be 24-bit — we can't confirm bit-depth from the codec
# string alone, but we log the received codec so users can diagnose.
logger.info(f"HiRes stream codec: {codec} (verify file bit-depth after download)")
elif 'mp4a' in codec_lower or 'aac' in codec_lower:
logger.warning(
f"HiRes requested but received AAC stream (codec: {codec}) — "
f"account may not have HiRes subscription or track isn't available in HiRes"
)
# 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