soulsync/core/qobuz_client.py
2026-03-11 18:10:35 -07:00

953 lines
37 KiB
Python

"""
Qobuz Download Client
Alternative music download source using Qobuz's API.
This client provides:
- Qobuz search with metadata
- Email/password authentication
- Hi-Res/Lossless/MP3 quality audio downloads
- Drop-in replacement compatible with Soulseek interface
Requires a paid Qobuz subscription.
"""
import os
import re
import hashlib
import time
import asyncio
import uuid
import threading
import base64
from typing import List, Optional, Dict, Any, Tuple
from pathlib import Path
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("qobuz_client")
QOBUZ_API_BASE = "https://www.qobuz.com/api.json/0.2/"
# Quality tier definitions (format_id values)
QOBUZ_QUALITY_MAP = {
'mp3': {
'format_id': 5,
'label': 'MP3 320kbps',
'extension': 'mp3',
'bitrate': 320,
'codec': 'mp3',
},
'lossless': {
'format_id': 6,
'label': 'FLAC 16-bit/44.1kHz (CD)',
'extension': 'flac',
'bitrate': 1411,
'codec': 'flac',
},
'hires': {
'format_id': 7,
'label': 'FLAC 24-bit/96kHz (Hi-Res)',
'extension': 'flac',
'bitrate': 4608,
'codec': 'flac',
},
'hires_max': {
'format_id': 27,
'label': 'FLAC 24-bit/192kHz (Hi-Res Max)',
'extension': 'flac',
'bitrate': 9216,
'codec': 'flac',
},
}
class QobuzClient:
"""
Qobuz download client using Qobuz REST API.
Provides search, matching, and download capabilities as a drop-in alternative to Soulseek/YouTube/Tidal.
"""
def __init__(self, download_path: str = None):
# 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"Qobuz client using download path: {self.download_path}")
# Callback for shutdown check
self.shutdown_check = None
# HTTP session
self.session = http_requests.Session()
self.session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0',
})
# Auth state
self.app_id: Optional[str] = None
self.app_secret: Optional[str] = None
self.user_auth_token: Optional[str] = None
self.user_info: Optional[Dict] = None
self._auth_error: Optional[str] = None
# Download queue management
self.active_downloads: Dict[str, Dict[str, Any]] = {}
self._download_lock = threading.Lock()
# Try to restore saved session
self._restore_session()
def set_shutdown_check(self, check_callable):
"""Set a callback function to check for system shutdown"""
self.shutdown_check = check_callable
# ===================== Auth =====================
def _restore_session(self):
"""Try to restore saved session from config."""
saved = config_manager.get('qobuz.session', {})
app_id = saved.get('app_id', '')
app_secret = saved.get('app_secret', '')
user_auth_token = saved.get('user_auth_token', '')
if app_id and app_secret and user_auth_token:
self.app_id = app_id
self.app_secret = app_secret
self.user_auth_token = user_auth_token
self.session.headers.update({
'X-App-Id': self.app_id,
'X-User-Auth-Token': self.user_auth_token,
})
# Verify the token is still valid
try:
resp = self.session.get(
QOBUZ_API_BASE + 'user/get',
params={'user_id': 'me'},
timeout=10,
)
if resp.status_code == 200:
data = resp.json()
self.user_info = data
logger.info(f"Restored Qobuz session for user: {data.get('display_name', data.get('email', 'unknown'))}")
return
else:
logger.warning(f"Saved Qobuz session invalid (HTTP {resp.status_code})")
except Exception as e:
logger.warning(f"Could not verify saved Qobuz session: {e}")
# Token invalid, clear it
self.user_auth_token = None
self.session.headers.pop('X-User-Auth-Token', None)
def _save_session(self):
"""Persist session to config."""
config_manager.set('qobuz.session', {
'app_id': self.app_id or '',
'app_secret': self.app_secret or '',
'user_auth_token': self.user_auth_token or '',
})
def _extract_app_credentials(self) -> bool:
"""
Extract app_id and app_secret from Qobuz web player bundle.
The secret is obfuscated across three base64 fragments tied to timezone entries:
1. initialSeed() calls pair a seed with a timezone name
2. Timezone objects have info and extras fields
3. Concatenate seed + info + extras, drop last 44 chars, base64 decode
Returns True if successful.
"""
try:
logger.info("Extracting Qobuz app credentials from web player...")
# Step 1: Fetch login page to find bundle.js URL
login_page = self.session.get('https://play.qobuz.com/login', timeout=15)
if login_page.status_code != 200:
logger.error(f"Could not fetch Qobuz login page: HTTP {login_page.status_code}")
return False
# Find bundle.js URL in the HTML
bundle_pattern = r'<script\s+src="(/resources/\d+\.\d+\.\d+-[a-z]\d+/bundle\.js)"'
match = re.search(bundle_pattern, login_page.text)
if not match:
bundle_pattern = r'<script\s+src="([^"]*bundle[^"]*\.js)"'
match = re.search(bundle_pattern, login_page.text)
if not match:
logger.error("Could not find bundle.js URL in Qobuz login page")
return False
bundle_url = 'https://play.qobuz.com' + match.group(1)
logger.info(f"Found bundle URL: {bundle_url}")
# Step 2: Download the bundle
bundle_resp = self.session.get(bundle_url, timeout=30)
if bundle_resp.status_code != 200:
logger.error(f"Could not download Qobuz bundle: HTTP {bundle_resp.status_code}")
return False
bundle_text = bundle_resp.text
# Step 3: Extract app_id
app_id_match = re.search(r'production:\{api:\{appId:"(\d{9})"', bundle_text)
if not app_id_match:
app_id_match = re.search(r'app_id\s*[:=]\s*"(\d{9})"', bundle_text)
if not app_id_match:
logger.error("Could not extract app_id from Qobuz bundle")
return False
self.app_id = app_id_match.group(1)
logger.info(f"Extracted app_id: {self.app_id}")
# Step 4: Extract seed + timezone pairs from initialSeed() calls
from collections import OrderedDict
seed_timezone_regex = re.compile(
r'[a-z]\.initialSeed\("(?P<seed>[\w=]+)",\s*window\.utimezone\.(?P<timezone>[a-z]+)\)'
)
secrets = OrderedDict()
for m in seed_timezone_regex.finditer(bundle_text):
seed = m.group("seed")
timezone = m.group("timezone")
secrets[timezone] = [seed]
if not secrets:
logger.warning("No initialSeed() calls found in bundle — trying fallback extraction")
return self._extract_app_credentials_fallback(bundle_text)
logger.info(f"Found {len(secrets)} seed/timezone pairs: {list(secrets.keys())}")
# Step 5: Extract info + extras for each timezone
timezones_pattern = "|".join([tz.capitalize() for tz in secrets.keys()])
info_extras_regex = re.compile(
rf'name:"\w+/(?P<timezone>{timezones_pattern})",info:"(?P<info>[\w=]+)",extras:"(?P<extras>[\w=]+)"'
)
for m in info_extras_regex.finditer(bundle_text):
timezone = m.group("timezone").lower()
info = m.group("info")
extras = m.group("extras")
if timezone in secrets:
secrets[timezone].extend([info, extras])
# Step 6: Decode each candidate secret
# Concatenate 3 fragments, drop last 44 chars, base64 decode
decoded_secrets = []
for tz, fragments in secrets.items():
if len(fragments) != 3:
logger.debug(f"Timezone {tz} has {len(fragments)} fragments (need 3), skipping")
continue
combined = "".join(fragments)
trimmed = combined[:-44]
try:
decoded = base64.b64decode(trimmed).decode("utf-8")
if decoded and len(decoded) >= 30:
decoded_secrets.append((tz, decoded))
logger.debug(f"Decoded candidate secret from {tz}: {decoded[:8]}...")
except (base64.binascii.Error, UnicodeDecodeError) as e:
logger.debug(f"Failed to decode secret from {tz}: {e}")
continue
if not decoded_secrets:
logger.warning("No valid secrets decoded — trying fallback")
return self._extract_app_credentials_fallback(bundle_text)
# Step 7: Validate which secret works by test-signing an API call
for tz, secret in decoded_secrets:
if self._test_secret(secret):
self.app_secret = secret
logger.info(f"Found working app_secret via timezone: {tz}")
return True
logger.error(f"None of {len(decoded_secrets)} decoded secrets passed validation")
return False
except Exception as e:
logger.error(f"Failed to extract Qobuz app credentials: {e}")
import traceback
traceback.print_exc()
return False
def _extract_app_credentials_fallback(self, bundle_text: str) -> bool:
"""Fallback: try direct hex string extraction from bundle."""
secret_matches = re.findall(r'["\']([a-f0-9]{32})["\']', bundle_text)
for secret_candidate in secret_matches:
if self._test_secret(secret_candidate):
self.app_secret = secret_candidate
logger.info("Found working app_secret via direct hex extraction")
return True
logger.error("Could not extract working app_secret from Qobuz bundle (all methods exhausted)")
return False
def _test_secret(self, secret: str) -> bool:
"""Test if an app_secret works by making a signed stream URL request."""
if not self.app_id or not secret:
return False
try:
ts = int(time.time())
# Sign a request for track_id=1 with format_id=27 (same as qobuz-dl validation)
sig_raw = f"trackgetFileUrlformat_id27intentstreamtrack_id1{ts}{secret}"
sig = hashlib.md5(sig_raw.encode()).hexdigest()
resp = self.session.get(
QOBUZ_API_BASE + 'track/getFileUrl',
params={
'track_id': 1,
'format_id': 27,
'intent': 'stream',
'request_ts': ts,
'request_sig': sig,
},
headers={'X-App-Id': self.app_id},
timeout=10,
)
# 400 = "Invalid Request Signature" means bad secret
# 200/401/403 = signature was accepted (just auth/permission issue)
is_valid = resp.status_code != 400
if is_valid:
logger.debug(f"Secret test passed (HTTP {resp.status_code})")
else:
logger.debug(f"Secret test failed (HTTP 400 — invalid signature)")
return is_valid
except Exception as e:
logger.debug(f"Secret test exception: {e}")
return False
def login(self, email: str, password: str) -> Dict[str, Any]:
"""
Login to Qobuz with email/password.
Returns dict with status info:
{'status': 'success'|'error', 'message': '...', 'user': {...}}
"""
self._auth_error = None
try:
# Step 1: Extract app credentials if we don't have them
if not self.app_id or not self.app_secret:
if not self._extract_app_credentials():
self._auth_error = 'Could not extract Qobuz app credentials. Qobuz may have updated their web player.'
return {'status': 'error', 'message': self._auth_error}
# Step 2: Login with email/password
self.session.headers['X-App-Id'] = self.app_id
resp = self.session.get(
QOBUZ_API_BASE + 'user/login',
params={
'email': email,
'password': password,
'app_id': self.app_id,
},
timeout=15,
)
if resp.status_code == 401:
self._auth_error = 'Invalid email or password'
return {'status': 'error', 'message': self._auth_error}
elif resp.status_code == 400:
data = resp.json() if resp.text else {}
self._auth_error = data.get('message', 'Login failed — check your credentials')
return {'status': 'error', 'message': self._auth_error}
elif resp.status_code != 200:
self._auth_error = f'Qobuz API error (HTTP {resp.status_code})'
return {'status': 'error', 'message': self._auth_error}
data = resp.json()
# Extract user auth token
self.user_auth_token = data.get('user_auth_token')
if not self.user_auth_token:
self._auth_error = 'No auth token in response'
return {'status': 'error', 'message': self._auth_error}
self.user_info = data.get('user', {})
self.session.headers['X-User-Auth-Token'] = self.user_auth_token
# Check subscription status
subscription = self.user_info.get('credential', {})
sub_label = subscription.get('label', 'Unknown')
# Save session
self._save_session()
display_name = self.user_info.get('display_name', self.user_info.get('email', email))
logger.info(f"Qobuz login successful: {display_name} (plan: {sub_label})")
return {
'status': 'success',
'message': f'Logged in as {display_name}',
'user': {
'display_name': display_name,
'subscription': sub_label,
'email': self.user_info.get('email', email),
},
}
except Exception as e:
self._auth_error = str(e)
logger.error(f"Qobuz login failed: {e}")
import traceback
traceback.print_exc()
return {'status': 'error', 'message': self._auth_error}
def logout(self):
"""Clear Qobuz session."""
self.user_auth_token = None
self.user_info = None
self.app_id = None
self.app_secret = None
self._auth_error = None
self.session.headers.pop('X-User-Auth-Token', None)
self.session.headers.pop('X-App-Id', None)
config_manager.set('qobuz.session', {})
logger.info("Qobuz session cleared")
def is_authenticated(self) -> bool:
"""Check if we have a valid Qobuz session."""
return bool(self.user_auth_token and self.app_id and self.app_secret)
# ===================== Search =====================
def is_available(self) -> bool:
"""Check if Qobuz client is available and authenticated."""
return self.is_authenticated()
def is_configured(self) -> bool:
"""Check if Qobuz client is configured (matches Soulseek interface)."""
return self.is_available()
async def check_connection(self) -> bool:
"""Test if Qobuz 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"Qobuz connection check failed: {e}")
return False
def _api_request(self, endpoint: str, params: Dict = None) -> Optional[Dict]:
"""Make an authenticated API request to Qobuz."""
if not self.is_authenticated():
logger.warning("Qobuz not authenticated")
return None
try:
resp = self.session.get(
QOBUZ_API_BASE + endpoint,
params=params or {},
timeout=15,
)
if resp.status_code == 401:
logger.warning("Qobuz auth token expired")
self.user_auth_token = None
return None
elif resp.status_code != 200:
logger.warning(f"Qobuz API error: {endpoint} returned HTTP {resp.status_code}")
return None
return resp.json()
except Exception as e:
logger.error(f"Qobuz API request failed ({endpoint}): {e}")
return None
async def search(self, query: str, timeout: int = None, progress_callback=None) -> Tuple[List[TrackResult], List[AlbumResult]]:
"""
Search Qobuz for tracks (async, Soulseek-compatible interface).
Returns:
Tuple of (track_results, album_results). Album results always empty.
"""
if not self.is_available():
logger.warning("Qobuz not available for search (not authenticated)")
return ([], [])
logger.info(f"Searching Qobuz for: {query}")
try:
loop = asyncio.get_event_loop()
def _search():
return self._api_request('track/search', {
'query': query,
'limit': 50,
})
data = await loop.run_in_executor(None, _search)
if not data or 'tracks' not in data:
logger.warning(f"No Qobuz results for: {query}")
return ([], [])
tracks_data = data['tracks'].get('items', [])
if not tracks_data:
return ([], [])
# Get configured quality for display
quality_key = config_manager.get('qobuz.quality', 'lossless')
quality_info = QOBUZ_QUALITY_MAP.get(quality_key, QOBUZ_QUALITY_MAP['lossless'])
track_results = []
for track in tracks_data:
try:
track_result = self._qobuz_to_track_result(track, quality_info)
if track_result:
track_results.append(track_result)
except Exception as e:
logger.debug(f"Skipping track conversion error: {e}")
logger.info(f"Found {len(track_results)} Qobuz tracks")
return (track_results, [])
except Exception as e:
logger.error(f"Qobuz search failed: {e}")
import traceback
traceback.print_exc()
return ([], [])
def _qobuz_to_track_result(self, track: Dict, quality_info: dict) -> Optional[TrackResult]:
"""Convert Qobuz track dict to TrackResult (Soulseek-compatible format)."""
track_id = track.get('id')
if not track_id:
return None
# Check if track is streamable
if not track.get('streamable', False):
return None
performer = track.get('performer', {})
artist_name = performer.get('name', 'Unknown Artist') if isinstance(performer, dict) else str(performer)
title = track.get('title', 'Unknown Title')
# Clean up title — Qobuz sometimes appends version info
version = track.get('version')
if version and version not in title:
title = f"{title} ({version})"
album_data = track.get('album', {})
album_name = album_data.get('title', None) if isinstance(album_data, dict) else None
# Duration in milliseconds
duration_s = track.get('duration')
duration_ms = int(duration_s * 1000) if duration_s else None
# Determine actual max quality available for this track
hires_streamable = track.get('hires_streamable', False)
max_bit_depth = album_data.get('maximum_bit_depth', 16) if isinstance(album_data, dict) else 16
max_sample_rate = album_data.get('maximum_sampling_rate', 44.1) if isinstance(album_data, dict) else 44.1
# Build quality display string
if hires_streamable and max_bit_depth >= 24:
actual_quality = f"FLAC {max_bit_depth}-bit/{max_sample_rate}kHz"
actual_bitrate = quality_info.get('bitrate', 1411)
else:
actual_quality = quality_info.get('codec', 'flac')
actual_bitrate = quality_info.get('bitrate', 1411)
# Encode track_id in filename (same pattern as YouTube/Tidal: "id||display_name")
display_name = f"{artist_name} - {title}"
filename = f"{track_id}||{display_name}"
# Album cover URL
album_image = None
if isinstance(album_data, dict) and album_data.get('image'):
album_image = album_data['image'].get('large', album_data['image'].get('small'))
track_result = TrackResult(
username='qobuz',
filename=filename,
size=0, # Unknown until download
bitrate=actual_bitrate,
duration=duration_ms,
quality=actual_quality,
free_upload_slots=999,
upload_speed=999999,
queue_length=0,
artist=artist_name,
title=title,
album=album_name,
track_number=track.get('track_number'),
)
return track_result
# ===================== Download =====================
def _get_stream_url(self, track_id, format_id: int) -> Optional[Dict]:
"""
Get a signed stream URL for a Qobuz track.
Returns dict with 'url', 'format_id', 'mime_type', etc. or None.
"""
if not self.app_secret:
logger.error("No app_secret available for stream URL signing")
return None
ts = str(int(time.time()))
sig_raw = f"trackgetFileUrlformat_id{format_id}intentstreamtrack_id{track_id}{ts}{self.app_secret}"
sig = hashlib.md5(sig_raw.encode()).hexdigest()
try:
resp = self.session.get(
QOBUZ_API_BASE + 'track/getFileUrl',
params={
'track_id': str(track_id),
'format_id': format_id,
'intent': 'stream',
'request_ts': ts,
'request_sig': sig,
},
timeout=15,
)
if resp.status_code == 401:
logger.warning("Qobuz stream URL auth failed — token may be expired")
return None
elif resp.status_code == 400:
data = resp.json() if resp.text else {}
logger.warning(f"Qobuz stream URL rejected: {data.get('message', 'unknown error')}")
return None
elif resp.status_code != 200:
logger.warning(f"Qobuz stream URL failed: HTTP {resp.status_code}")
return None
data = resp.json()
if 'url' not in data:
logger.warning("No URL in Qobuz stream response")
return None
return data
except Exception as e:
logger.error(f"Failed to get Qobuz stream URL: {e}")
return None
async def download(self, username: str, filename: str, file_size: int = 0) -> Optional[str]:
"""
Download a Qobuz track (async, Soulseek-compatible interface).
Returns download_id immediately and runs download in background thread.
Args:
username: Ignored for Qobuz (always "qobuz")
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 Qobuz track ID: {track_id_str}")
return None
logger.info(f"Starting Qobuz download: {display_name}")
download_id = str(uuid.uuid4())
with self._download_lock:
self.active_downloads[download_id] = {
'id': download_id,
'filename': filename,
'username': 'qobuz',
'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"Qobuz download {download_id} started in background")
return download_id
except Exception as e:
logger.error(f"Failed to start Qobuz 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 Qobuz 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"Qobuz 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"Qobuz download {download_id} failed")
except Exception as e:
logger.error(f"Qobuz 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.is_authenticated():
logger.error("Qobuz not authenticated")
return None
try:
# Determine quality
quality_key = config_manager.get('qobuz.quality', 'lossless')
quality_info = QOBUZ_QUALITY_MAP.get(quality_key, QOBUZ_QUALITY_MAP['lossless'])
# Quality fallback chain: hires_max → hires → lossless → mp3
quality_chain = ['hires_max', 'hires', 'lossless', 'mp3']
start_idx = quality_chain.index(quality_key) if quality_key in quality_chain else 2
chain = quality_chain[start_idx:]
stream_data = None
actual_quality = None
for q_key in chain:
q_info = QOBUZ_QUALITY_MAP[q_key]
stream_data = self._get_stream_url(track_id, q_info['format_id'])
if stream_data and 'url' in stream_data:
actual_quality = q_info
logger.info(f"Got Qobuz stream at quality: {q_key} ({q_info['label']})")
break
else:
logger.debug(f"Quality {q_key} unavailable, trying next")
if not stream_data or 'url' not in stream_data:
logger.error("No Qobuz stream available at any quality")
return None
download_url = stream_data['url']
# Determine file extension from stream response
mime_type = stream_data.get('mime_type', '')
if 'flac' in mime_type.lower():
extension = 'flac'
elif 'mpeg' in mime_type.lower() or 'mp3' in mime_type.lower():
extension = 'mp3'
else:
extension = actual_quality.get('extension', 'flac') if actual_quality else '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
if self.shutdown_check and self.shutdown_check():
logger.info("Server shutting down, aborting Qobuz download")
return None
# Download with progress tracking
logger.info(f"Downloading from Qobuz: {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
start_time = time.time()
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
# Check for shutdown or cancellation
cancelled = False
with self._download_lock:
if download_id in self.active_downloads:
cancelled = self.active_downloads[download_id].get('state') == 'Cancelled'
if cancelled or (self.shutdown_check and self.shutdown_check()):
reason = "cancelled" if cancelled else "server shutting down"
logger.info(f"Aborting Qobuz download mid-stream: {reason}")
break
f.write(chunk)
downloaded += len(chunk)
# Calculate progress and speed
elapsed = time.time() - start_time
speed = downloaded / elapsed if elapsed > 0 else 0
if total_size > 0:
progress = (downloaded / total_size) * 100
remaining_bytes = total_size - downloaded
time_remaining = remaining_bytes / speed if speed > 0 else None
else:
progress = 0
time_remaining = None
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)
self.active_downloads[download_id]['speed'] = int(speed)
self.active_downloads[download_id]['time_remaining'] = time_remaining
# If download was aborted (shutdown/cancel), clean up partial file
abort_check = False
with self._download_lock:
if download_id in self.active_downloads:
abort_check = self.active_downloads[download_id].get('state') == 'Cancelled'
if abort_check or (self.shutdown_check and self.shutdown_check()):
out_path.unlink(missing_ok=True)
return None
# Validate file size (Qobuz streams are DRM-free so this is mainly for network errors)
MIN_AUDIO_SIZE = 100 * 1024 # 100KB
if downloaded < MIN_AUDIO_SIZE:
logger.error(
f"Qobuz download too small ({downloaded} bytes) — likely an error. "
f"Expected audio file for '{display_name}'. Deleting."
)
out_path.unlink(missing_ok=True)
return None
final_size = out_path.stat().st_size if out_path.exists() else 0
logger.info(f"Qobuz download complete: {out_path} ({final_size / (1024*1024):.1f} MB)")
return str(out_path)
except Exception as e:
logger.error(f"Qobuz download failed: {e}")
import traceback
traceback.print_exc()
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 Qobuz download {download_id} as cancelled")
if remove:
del self.active_downloads[download_id]
logger.info(f"Removed Qobuz 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