diff --git a/config/settings.py b/config/settings.py index 8ccc0554..7d872b1d 100644 --- a/config/settings.py +++ b/config/settings.py @@ -188,7 +188,7 @@ class ConfigManager: "transfer_path": "./Transfer" }, "download_source": { - "mode": "soulseek", # Options: "soulseek", "youtube", "tidal", "hybrid" + "mode": "soulseek", # Options: "soulseek", "youtube", "tidal", "qobuz", "hybrid" "hybrid_primary": "soulseek", # Which source to try first in hybrid mode "youtube_min_confidence": 0.65 # Minimum confidence for YouTube matches }, @@ -201,6 +201,14 @@ class ConfigManager: "expiry_time": 0 } }, + "qobuz": { + "quality": "lossless", # Options: "mp3", "lossless", "hires", "hires_max" + "session": { + "app_id": "", + "app_secret": "", + "user_auth_token": "" + } + }, "listenbrainz": { "base_url": "", "token": "" diff --git a/core/download_orchestrator.py b/core/download_orchestrator.py index 0bdf473c..eb2807c6 100644 --- a/core/download_orchestrator.py +++ b/core/download_orchestrator.py @@ -1,12 +1,13 @@ """ Download Orchestrator -Routes downloads between Soulseek, YouTube, and Tidal based on configuration. +Routes downloads between Soulseek, YouTube, Tidal, and Qobuz based on configuration. -Supports four modes: +Supports five modes: - Soulseek Only: Traditional behavior - YouTube Only: YouTube-exclusive downloads - Tidal Only: Tidal-exclusive downloads -- Hybrid: Try primary source first, fallback to secondary if it fails +- Qobuz Only: Qobuz-exclusive downloads +- Hybrid: Try primary source first, fallback to others """ import asyncio @@ -18,6 +19,7 @@ from config.settings import config_manager from core.soulseek_client import SoulseekClient, TrackResult, AlbumResult, DownloadStatus from core.youtube_client import YouTubeClient from core.tidal_download_client import TidalDownloadClient +from core.qobuz_client import QobuzClient logger = get_logger("download_orchestrator") @@ -35,6 +37,7 @@ class DownloadOrchestrator: self.soulseek = SoulseekClient() self.youtube = YouTubeClient() self.tidal = TidalDownloadClient() + self.qobuz = QobuzClient() # Load mode from config self.mode = config_manager.get('download_source.mode', 'soulseek') @@ -69,9 +72,10 @@ class DownloadOrchestrator: return self.youtube.is_configured() elif self.mode == 'tidal': return self.tidal.is_configured() + elif self.mode == 'qobuz': + return self.qobuz.is_configured() elif self.mode == 'hybrid': - # In hybrid mode, at least one source must be configured - return self.soulseek.is_configured() or self.youtube.is_configured() or self.tidal.is_configured() + return self.soulseek.is_configured() or self.youtube.is_configured() or self.tidal.is_configured() or self.qobuz.is_configured() return False @@ -87,16 +91,17 @@ class DownloadOrchestrator: return await self.youtube.check_connection() elif self.mode == 'tidal': return await self.tidal.check_connection() + elif self.mode == 'qobuz': + return await self.qobuz.check_connection() elif self.mode == 'hybrid': - # In hybrid mode, check all sources soulseek_ok = await self.soulseek.check_connection() youtube_ok = await self.youtube.check_connection() tidal_ok = await self.tidal.check_connection() + qobuz_ok = await self.qobuz.check_connection() - logger.info(f" Soulseek: {'✅' if soulseek_ok else '❌'} | YouTube: {'✅' if youtube_ok else '❌'} | Tidal: {'✅' if tidal_ok else '❌'}") + logger.info(f" Soulseek: {'✅' if soulseek_ok else '❌'} | YouTube: {'✅' if youtube_ok else '❌'} | Tidal: {'✅' if tidal_ok else '❌'} | Qobuz: {'✅' if qobuz_ok else '❌'}") - # At least one must be available - return soulseek_ok or youtube_ok or tidal_ok + return soulseek_ok or youtube_ok or tidal_ok or qobuz_ok return False @@ -124,12 +129,17 @@ class DownloadOrchestrator: logger.info(f"🔍 Searching Tidal: {query}") return await self.tidal.search(query, timeout, progress_callback) + elif self.mode == 'qobuz': + logger.info(f"🔍 Searching Qobuz: {query}") + return await self.qobuz.search(query, timeout, progress_callback) + elif self.mode == 'hybrid': # Build ordered client list: primary first, then remaining as fallbacks clients = { 'soulseek': self.soulseek, 'youtube': self.youtube, 'tidal': self.tidal, + 'qobuz': self.qobuz, } primary = self.hybrid_primary if self.hybrid_primary in clients else 'soulseek' fallbacks = [name for name in clients if name != primary] @@ -219,6 +229,9 @@ class DownloadOrchestrator: elif username == 'tidal': logger.info(f"📥 Downloading from Tidal: {filename}") return await self.tidal.download(username, filename, file_size) + elif username == 'qobuz': + logger.info(f"📥 Downloading from Qobuz: {filename}") + return await self.qobuz.download(username, filename, file_size) else: logger.info(f"📥 Downloading from Soulseek: {filename}") return await self.soulseek.download(username, filename, file_size) @@ -234,9 +247,9 @@ class DownloadOrchestrator: soulseek_downloads = await self.soulseek.get_all_downloads() youtube_downloads = await self.youtube.get_all_downloads() tidal_downloads = await self.tidal.get_all_downloads() + qobuz_downloads = await self.qobuz.get_all_downloads() - # Combine and return - return soulseek_downloads + youtube_downloads + tidal_downloads + return soulseek_downloads + youtube_downloads + tidal_downloads + qobuz_downloads async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]: """ @@ -263,6 +276,11 @@ class DownloadOrchestrator: if status: return status + # Try Qobuz + status = await self.qobuz.get_download_status(download_id) + if status: + return status + return None async def cancel_download(self, download_id: str, username: str = None, remove: bool = False) -> bool: @@ -282,6 +300,8 @@ class DownloadOrchestrator: return await self.youtube.cancel_download(download_id, username, remove) elif username == 'tidal': return await self.tidal.cancel_download(download_id, username, remove) + elif username == 'qobuz': + return await self.qobuz.cancel_download(download_id, username, remove) elif username: return await self.soulseek.cancel_download(download_id, username, remove) @@ -295,7 +315,11 @@ class DownloadOrchestrator: return True tidal_cancelled = await self.tidal.cancel_download(download_id, username, remove) - return tidal_cancelled + if tidal_cancelled: + return True + + qobuz_cancelled = await self.qobuz.cancel_download(download_id, username, remove) + return qobuz_cancelled async def signal_download_completion(self, download_id: str, username: str, remove: bool = True) -> bool: """ @@ -320,11 +344,11 @@ class DownloadOrchestrator: True if successful """ soulseek_cleared = await self.soulseek.clear_all_completed_downloads() - # YouTube and Tidal downloads must also be cleared from memory youtube_cleared = await self.youtube.clear_all_completed_downloads() tidal_cleared = await self.tidal.clear_all_completed_downloads() + qobuz_cleared = await self.qobuz.clear_all_completed_downloads() - return soulseek_cleared and youtube_cleared and tidal_cleared + return soulseek_cleared and youtube_cleared and tidal_cleared and qobuz_cleared # ===== Soulseek-specific methods (for backwards compatibility) ===== # These are internal methods that some parts of the codebase use directly @@ -384,6 +408,6 @@ class DownloadOrchestrator: async def cancel_all_downloads(self) -> bool: """Cancel and remove all downloads from all sources.""" soulseek_ok = await self.soulseek.cancel_all_downloads() - # Clear Tidal active downloads too - tidal_ok = await self.tidal.clear_all_completed_downloads() + await self.tidal.clear_all_completed_downloads() + await self.qobuz.clear_all_completed_downloads() return soulseek_ok diff --git a/core/qobuz_client.py b/core/qobuz_client.py new file mode 100644 index 00000000..f59ca3a6 --- /dev/null +++ b/core/qobuz_client.py @@ -0,0 +1,941 @@ +""" +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'[\w=]+)",\s*window\.utimezone\.(?P[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{timezones_pattern})",info:"(?P[\w=]+)",extras:"(?P[\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 + if self.shutdown_check and self.shutdown_check(): + logger.info("Server shutting down, aborting Qobuz download mid-stream") + f.close() + out_path.unlink(missing_ok=True) + return None + + 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 + + # 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 diff --git a/web_server.py b/web_server.py index fa8d7898..2e8b8d1f 100644 --- a/web_server.py +++ b/web_server.py @@ -320,6 +320,9 @@ if soulseek_client: if hasattr(soulseek_client, 'tidal'): soulseek_client.tidal.set_shutdown_check(lambda: IS_SHUTTING_DOWN) print(" ✅ Configured Tidal download client shutdown callback") + if hasattr(soulseek_client, 'qobuz'): + soulseek_client.qobuz.set_shutdown_check(lambda: IS_SHUTTING_DOWN) + print(" ✅ Configured Qobuz client shutdown callback") # Initialize web scan manager for automatic post-download scanning try: @@ -1604,12 +1607,12 @@ def get_cached_transfer_data(): key = _make_context_key(transfer.get('username'), transfer.get('filename', '')) live_transfers_lookup[key] = transfer - # Also add YouTube/Tidal downloads (through orchestrator) + # Also add YouTube/Tidal/Qobuz downloads (through orchestrator) try: all_downloads = run_async(soulseek_client.get_all_downloads()) for download in all_downloads: - # Only add YouTube/Tidal downloads (Soulseek ones are already in the lookup) - if download.username in ('youtube', 'tidal'): + # Only add streaming source downloads (Soulseek ones are already in the lookup) + if download.username in ('youtube', 'tidal', 'qobuz'): key = _make_context_key(download.username, download.filename) # Convert DownloadStatus to transfer dict format live_transfers_lookup[key] = { @@ -1623,7 +1626,7 @@ def get_cached_transfer_data(): 'averageSpeed': download.speed, } except Exception as e: - print(f"⚠️ Could not fetch YouTube/Tidal downloads: {e}") + print(f"⚠️ Could not fetch streaming source downloads: {e}") # Update cache transfer_data_cache['data'] = live_transfers_lookup @@ -1928,12 +1931,12 @@ class WebUIDownloadMonitor: key = _make_context_key(username, file_info.get('filename', '')) live_transfers[key] = file_info - # Also get YouTube/Tidal downloads (through orchestrator) + # Also get YouTube/Tidal/Qobuz downloads (through orchestrator) try: all_downloads = run_async(soulseek_client.get_all_downloads()) for download in all_downloads: - # Only add YouTube/Tidal downloads (Soulseek ones are already in the lookup) - if download.username in ('youtube', 'tidal'): + # Only add streaming source downloads (Soulseek ones are already in the lookup from slskd API) + if download.username in ('youtube', 'tidal', 'qobuz'): key = _make_context_key(download.username, download.filename) # Convert DownloadStatus to transfer dict format for monitor compatibility live_transfers[key] = { @@ -1947,7 +1950,7 @@ class WebUIDownloadMonitor: 'averageSpeed': download.speed, } except Exception as yt_error: - print(f"⚠️ Monitor: Could not fetch YouTube/Tidal downloads: {yt_error}") + print(f"⚠️ Monitor: Could not fetch streaming source downloads: {yt_error}") return live_transfers except Exception as e: @@ -2963,19 +2966,21 @@ def _find_downloaded_file(download_path, track_data): audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'} target_filename = extract_filename(track_data.get('filename', '')) - # YOUTUBE/TIDAL SUPPORT: Handle encoded filename format "id||title" + # YOUTUBE/TIDAL/QOBUZ SUPPORT: Handle encoded filename format "id||title" # The file on disk will be "title.ext", not "id||title" is_youtube = track_data.get('username') == 'youtube' is_tidal = track_data.get('username') == 'tidal' - is_streaming_source = is_youtube or is_tidal + is_qobuz = track_data.get('username') == 'qobuz' + is_streaming_source = is_youtube or is_tidal or is_qobuz target_filename_youtube = None if is_streaming_source and '||' in target_filename: _, title = target_filename.split('||', 1) - if is_tidal: - # Tidal files can be flac or m4a — match any audio extension + if is_tidal or is_qobuz: + # Tidal/Qobuz files can be flac or m4a or mp3 — match any audio extension safe_title = re.sub(r'[<>:"/\\|?*]', '_', title) target_filename_youtube = safe_title # Extension-less for flexible matching - print(f"🎵 [Tidal Stream] Looking for file starting with: {target_filename_youtube}") + source_name = 'Qobuz' if is_qobuz else 'Tidal' + print(f"🎵 [{source_name} Stream] Looking for file starting with: {target_filename_youtube}") else: # yt-dlp will create "Title.mp3" from "Title" target_filename_youtube = f"{title}.mp3" @@ -3012,11 +3017,11 @@ def _find_downloaded_file(download_path, track_data): # For Tidal, compare without extension (file could be .flac or .m4a) compare_target = target_filename_youtube.lower() compare_file = file.lower() - if is_tidal: + if is_tidal or is_qobuz: compare_file = os.path.splitext(compare_file)[0] similarity = SequenceMatcher(None, compare_file, compare_target).ratio() - source_label = 'Tidal' if is_tidal else 'YouTube' + source_label = 'Qobuz' if is_qobuz else ('Tidal' if is_tidal else 'YouTube') print(f"🔍 [{source_label} Stream] Comparing: '{file}' vs '{target_filename_youtube}' = {similarity:.2f}") # Keep track of best match @@ -3036,7 +3041,7 @@ def _find_downloaded_file(download_path, track_data): # For YouTube/Tidal, if we found a good enough match (80%+), use it if is_streaming_source and best_match and best_similarity >= 0.80: - source_label = 'Tidal' if is_tidal else 'YouTube' + source_label = 'Qobuz' if is_qobuz else ('Tidal' if is_tidal else 'YouTube') print(f"✅ Found good match ({best_similarity:.2f}) for {source_label} streaming file: {best_match}") return best_match @@ -3994,7 +3999,7 @@ def handle_settings(): if 'active_media_server' in new_settings: config_manager.set_active_media_server(new_settings['active_media_server']) - for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export']: + for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export']: if service in new_settings: for key, value in new_settings[service].items(): config_manager.set(f'{service}.{key}', value) @@ -6435,7 +6440,7 @@ def stream_enhanced_search_track(): search_queries = [] import re - if download_mode in ('youtube', 'tidal') or (download_mode == 'hybrid' and config_manager.get('download_source.hybrid_primary') in ('youtube', 'tidal')): + if download_mode in ('youtube', 'tidal', 'qobuz') or (download_mode == 'hybrid' and config_manager.get('download_source.hybrid_primary') in ('youtube', 'tidal', 'qobuz')): # YouTube/Tidal mode: Include artist for better context # Primary query: Artist + Track if artist_name and track_name: @@ -6611,7 +6616,7 @@ def start_download(): if download_id: # Register download for post-processing (simple transfer to /Transfer) context_key = _make_context_key(username, filename) - is_streaming_source = username in ('youtube', 'tidal') + is_streaming_source = username in ('youtube', 'tidal', 'qobuz') with matched_context_lock: matched_downloads_context[context_key] = { 'search_result': { @@ -6980,7 +6985,7 @@ def get_download_status(): all_streaming_downloads = run_async(soulseek_client.get_all_downloads()) for download in all_streaming_downloads: - if download.username in ('youtube', 'tidal'): + if download.username in ('youtube', 'tidal', 'qobuz'): source_label = download.username.title() # Convert DownloadStatus to transfer format that frontend expects streaming_transfer = { @@ -17946,8 +17951,8 @@ def get_valid_candidates(results, spotify_track, query): if not initial_candidates: return [] - # Skip quality filtering for YouTube/Tidal results (quality is fixed by source, not user-selectable per-result) - is_streaming_source = initial_candidates[0].username in ("youtube", "tidal") if initial_candidates else False + # Skip quality filtering for YouTube/Tidal/Qobuz results (quality is fixed by source, not user-selectable per-result) + is_streaming_source = initial_candidates[0].username in ("youtube", "tidal", "qobuz") if initial_candidates else False if is_streaming_source: source_label = initial_candidates[0].username.title() @@ -20013,8 +20018,8 @@ def _try_source_reuse(task_id, batch_id, track): if not source_tracks or not last_source: _sr.info(f"Skipped — no source_tracks or no last_source") return False - if last_source.get('username') == 'youtube': - _sr.info(f"Skipped — youtube source") + if last_source.get('username') in ('youtube', 'tidal', 'qobuz'): + _sr.info(f"Skipped — {last_source.get('username')} source (no folder-based reuse)") return False source_username = last_source.get('username') @@ -20115,7 +20120,7 @@ def _store_batch_source(batch_id, username, filename): """Browse the successful download's folder and store results on the batch for reuse.""" _sr = source_reuse_logger _sr.info(f"_store_batch_source called: batch={batch_id}, user={username}, file={filename}") - if not batch_id or username in ('youtube', 'tidal'): + if not batch_id or username in ('youtube', 'tidal', 'qobuz'): _sr.info(f"Skipped — no batch_id or streaming source ({username})") return @@ -22137,6 +22142,60 @@ def tidal_download_auth_status(): return jsonify({"authenticated": False, "error": str(e)}) +# =================================================================== +# QOBUZ AUTH ENDPOINTS +# =================================================================== + +@app.route('/api/qobuz/auth/login', methods=['POST']) +def qobuz_auth_login(): + """Login to Qobuz with email/password.""" + try: + data = request.get_json() + email = data.get('email', '').strip() + password = data.get('password', '').strip() + + if not email or not password: + return jsonify({"success": False, "error": "Email and password required"}), 400 + + qobuz = soulseek_client.qobuz + result = qobuz.login(email, password) + + if result['status'] == 'success': + return jsonify({"success": True, **result}) + else: + return jsonify({"success": False, "error": result.get('message', 'Login failed')}), 400 + + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/qobuz/auth/status', methods=['GET']) +def qobuz_auth_status(): + """Check if Qobuz client is authenticated.""" + try: + qobuz = soulseek_client.qobuz + authenticated = qobuz.is_authenticated() + user_info = {} + if authenticated and qobuz.user_info: + user_info = { + 'display_name': qobuz.user_info.get('display_name', ''), + 'subscription': qobuz.user_info.get('credential', {}).get('label', 'Unknown'), + } + return jsonify({"authenticated": authenticated, "user": user_info}) + except Exception as e: + return jsonify({"authenticated": False, "error": str(e)}) + + +@app.route('/api/qobuz/auth/logout', methods=['POST']) +def qobuz_auth_logout(): + """Logout from Qobuz.""" + try: + soulseek_client.qobuz.logout() + return jsonify({"success": True}) + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + # =================================================================== # TIDAL PLAYLIST API ENDPOINTS # =================================================================== diff --git a/webui/index.html b/webui/index.html index e74151f3..ebe42ec0 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3642,6 +3642,7 @@ +
@@ -3658,6 +3659,7 @@ +
Which source to try first when hybrid mode is enabled. @@ -3702,6 +3704,48 @@
+ + +