mirror of https://github.com/Nezreka/SoulSync.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
710 lines
28 KiB
710 lines
28 KiB
"""
|
|
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
|
|
|
|
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 ([], [])
|
|
|
|
logger.info(f"Searching Tidal for: {query}")
|
|
|
|
try:
|
|
loop = asyncio.get_event_loop()
|
|
|
|
def _search():
|
|
results = self.session.search(query, models=[tidalapi.media.Track], limit=50)
|
|
return results.get('tracks', []) if isinstance(results, dict) else []
|
|
|
|
tidal_tracks = await loop.run_in_executor(None, _search)
|
|
|
|
if not tidal_tracks:
|
|
logger.warning(f"No Tidal results for: {query}")
|
|
return ([], [])
|
|
|
|
# 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:
|
|
logger.error(f"Tidal search 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
|
|
chain = quality_chain[start_idx:]
|
|
|
|
MIN_AUDIO_SIZE = 100 * 1024 # 100KB
|
|
|
|
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:
|
|
logger.warning(f"Quality {q_key} returned no stream, trying next")
|
|
continue
|
|
logger.info(f"Got Tidal stream at quality: {q_key}")
|
|
except Exception as e:
|
|
logger.warning(f"Quality {q_key} unavailable: {e}")
|
|
continue
|
|
|
|
# --- Step 2: Parse manifest ---
|
|
manifest = stream.get_stream_manifest()
|
|
urls = manifest.get_urls()
|
|
if not urls:
|
|
logger.warning(f"No download URLs for quality {q_key}, trying next")
|
|
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')
|
|
|
|
# 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}")
|
|
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."
|
|
)
|
|
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"
|
|
)
|
|
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"
|
|
)
|
|
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
|
|
logger.error(f"No Tidal quality tier produced a valid download for '{display_name}'")
|
|
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
|