"""Deezer Download Client — download tracks from Deezer using ARL authentication. Follows the same interface contract as Tidal, Qobuz, YouTube, and HiFi clients. Supports FLAC (HiFi subscription), MP3 320 (Premium), and MP3 128 (Free) with automatic quality fallback. Authentication: User provides an ARL token (browser cookie from deezer.com). """ import asyncio import hashlib import json import os import struct import threading import time import uuid from pathlib import Path from typing import Any, Dict, List, Optional, Tuple import requests from core.soulseek_client import AlbumResult, DownloadStatus, TrackResult from utils.logging_config import get_logger logger = get_logger("deezer_download") # Deezer API endpoints _GW_API = "https://www.deezer.com/ajax/gw-light.php" _MEDIA_API = "https://media.deezer.com/v1/get_url" # Blowfish decryption secret (public knowledge, used by all Deezer clients) _BF_SECRET = b"g4el58wc0zvf9na1" # Quality format codes for media API _QUALITY_FORMATS = { 'flac': {'cipher': 'BF_CBC_STRIPE', 'format': 'FLAC'}, 'mp3_320': {'cipher': 'BF_CBC_STRIPE', 'format': 'MP3_320'}, 'mp3_128': {'cipher': 'BF_CBC_STRIPE', 'format': 'MP3_128'}, } # Quality preference order (highest first) _QUALITY_ORDER = ['flac', 'mp3_320', 'mp3_128'] # Chunk size for Blowfish decryption (Deezer standard) _CHUNK_SIZE = 2048 # Minimum valid file size (100KB — anything smaller is likely an error) _MIN_FILE_SIZE = 100 * 1024 def _get_blowfish_key(track_id: str) -> bytes: """Derive the Blowfish decryption key for a track.""" md5_hex = hashlib.md5(str(track_id).encode()).hexdigest() return bytes([ ord(md5_hex[i]) ^ ord(md5_hex[i + 16]) ^ _BF_SECRET[i] for i in range(16) ]) def _decrypt_chunk(chunk: bytes, key: bytes) -> bytes: """Decrypt a single chunk using Blowfish CBC with null IV.""" try: from Crypto.Cipher import Blowfish iv = b'\x00\x01\x02\x03\x04\x05\x06\x07' cipher = Blowfish.new(key, Blowfish.MODE_CBC, iv) return cipher.decrypt(chunk) except ImportError: try: from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes iv = b'\x00\x01\x02\x03\x04\x05\x06\x07' cipher = Cipher(algorithms.Blowfish(key), modes.CBC(iv)) decryptor = cipher.decryptor() return decryptor.update(chunk) + decryptor.finalize() except ImportError: raise ImportError( "Deezer downloads require pycryptodome or cryptography package. " "Install with: pip install pycryptodome" ) class DeezerDownloadClient: """Deezer download client using ARL token authentication.""" def __init__(self, download_path: str = None): from config.settings import config_manager self._config = config_manager 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) # Download tracking (same pattern as Tidal/Qobuz/HiFi) self.active_downloads: Dict[str, Dict[str, Any]] = {} self._download_lock = threading.Lock() # Shutdown check callback (set by web_server) self.shutdown_check = None # Rate limiting self._last_request = 0 self._min_interval = 0.5 # 500ms between API calls self._api_lock = threading.Lock() # Session state self._session = requests.Session() self._session.headers.update({ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Accept-Language': 'en-US,en;q=0.9', }) self._api_token = None self._license_token = None self._user_data = None self._authenticated = False # Quality preference self._quality = config_manager.get('deezer_download.quality', 'flac') # Try to authenticate on init if ARL is configured arl = config_manager.get('deezer_download.arl', '') if arl: self._authenticate(arl) logger.info(f"Deezer download client initialized (download path: {self.download_path})") # ─── Authentication ────────────────────────────────────────── def _authenticate(self, arl: str) -> bool: """Authenticate with Deezer using ARL cookie token.""" try: self._session.cookies.set('arl', arl) # Get user data and API token resp = self._gw_call('deezer.getUserData') if not resp: logger.error("Failed to get user data from Deezer") return False user = resp.get('USER', {}) user_id = user.get('USER_ID', 0) if not user_id or user_id == 0: logger.error("Invalid ARL token — Deezer returned no user") return False self._api_token = resp.get('checkForm', '') self._license_token = user.get('OPTIONS', {}).get('license_token', '') self._user_data = user self._authenticated = True user_name = user.get('BLOG_NAME', 'Unknown') can_stream_lossless = user.get('OPTIONS', {}).get('web_lossless', False) can_stream_hq = user.get('OPTIONS', {}).get('web_hq', False) tier = 'Free' if can_stream_lossless: tier = 'HiFi' elif can_stream_hq: tier = 'Premium' logger.info(f"Deezer authenticated as '{user_name}' (tier: {tier})") return True except Exception as e: logger.error(f"Deezer authentication failed: {e}") self._authenticated = False return False def _gw_call(self, method: str, params: dict = None) -> Optional[dict]: """Call the Deezer gateway API.""" with self._api_lock: elapsed = time.time() - self._last_request if elapsed < self._min_interval: time.sleep(self._min_interval - elapsed) self._last_request = time.time() try: url_params = {'method': method, 'api_version': '1.0'} url_params['api_token'] = self._api_token if self._api_token else 'null' resp = self._session.post( _GW_API, params=url_params, json=params or {}, timeout=15 ) resp.raise_for_status() data = resp.json() if data.get('error'): error = data['error'] if isinstance(error, dict): error_msg = error.get('VALID_TOKEN_REQUIRED') or error.get('GATEWAY_ERROR') or str(error) else: error_msg = str(error) if error_msg: logger.warning(f"Deezer API error ({method}): {error_msg}") return None return data.get('results', {}) except Exception as e: logger.error(f"Deezer API call failed ({method}): {e}") return None # ─── Status & Config ───────────────────────────────────────── def set_shutdown_check(self, check_callable): self.shutdown_check = check_callable def is_configured(self) -> bool: return self._authenticated def is_available(self) -> bool: return self._authenticated def is_authenticated(self) -> bool: return self._authenticated async def check_connection(self) -> bool: loop = asyncio.get_event_loop() return await loop.run_in_executor(None, self.is_available) def reconnect(self, arl: str = None) -> bool: """Re-authenticate with a new or existing ARL.""" if arl is None: arl = self._config.get('deezer_download.arl', '') if not arl: return False self._authenticated = False return self._authenticate(arl) def get_quality_label(self) -> str: """Get human-readable label for current quality setting.""" labels = {'flac': 'FLAC (Lossless)', 'mp3_320': 'MP3 320kbps', 'mp3_128': 'MP3 128kbps'} return labels.get(self._quality, 'MP3 320kbps') # ─── Track Info ────────────────────────────────────────────── def _get_track_data(self, track_id: str) -> Optional[dict]: """Get full track data from Deezer private API.""" return self._gw_call('song.getData', {'sng_id': str(track_id)}) def _get_media_url(self, track_token: str, quality: str) -> Optional[str]: """Get the download URL for a track at the specified quality.""" if not self._license_token: logger.error("No license token — cannot get media URL") return None fmt = _QUALITY_FORMATS.get(quality) if not fmt: logger.error(f"Unknown quality: {quality}") return None try: payload = { 'license_token': self._license_token, 'media': [{ 'type': 'FULL', 'formats': [fmt] }], 'track_tokens': [track_token] } resp = self._session.post(_MEDIA_API, json=payload, timeout=15) resp.raise_for_status() data = resp.json() media_list = data.get('data', []) if not media_list: return None media = media_list[0].get('media', []) if not media: return None sources = media[0].get('sources', []) if not sources: return None # Prefer the first URL return sources[0].get('url') except Exception as e: logger.error(f"Failed to get media URL: {e}") return None # ─── Search ────────────────────────────────────────────────── async def search(self, query: str, timeout: int = None, progress_callback=None) -> Tuple[List[TrackResult], List[AlbumResult]]: """Search Deezer for tracks matching the query.""" loop = asyncio.get_event_loop() return await loop.run_in_executor(None, self._search_sync, query) def _search_sync(self, query: str) -> Tuple[List[TrackResult], List[AlbumResult]]: """Synchronous search implementation.""" if not self._authenticated: logger.warning("Deezer not authenticated — cannot search") return [], [] try: resp = self._session.get( 'https://api.deezer.com/search', params={'q': query, 'limit': 30}, timeout=10 ) resp.raise_for_status() data = resp.json() results = [] for item in data.get('data', []): track_id = str(item.get('id', '')) if not track_id: continue artist = item.get('artist', {}).get('name', 'Unknown') title = item.get('title', 'Unknown') album = item.get('album', {}).get('title', '') duration_ms = (item.get('duration', 0)) * 1000 # Deezer returns seconds # Estimate size based on quality duration_s = item.get('duration', 0) if self._quality == 'flac': est_size = duration_s * 176400 # ~1411kbps bitrate = 1411 quality = 'flac' elif self._quality == 'mp3_320': est_size = duration_s * 40000 # ~320kbps bitrate = 320 quality = 'mp3' else: est_size = duration_s * 16000 # ~128kbps bitrate = 128 quality = 'mp3' results.append(TrackResult( username='deezer_dl', filename=f"{track_id}||{artist} - {title}", size=est_size, bitrate=bitrate, duration=duration_ms, quality=quality, free_upload_slots=999, upload_speed=999999, queue_length=0, artist=artist, title=title, album=album, track_number=item.get('track_position'), )) logger.info(f"Deezer search for '{query}' returned {len(results)} results") return results, [] except Exception as e: logger.error(f"Deezer search failed: {e}") return [], [] # ─── Download ──────────────────────────────────────────────── async def download(self, username: str, filename: str, file_size: int = 0) -> Optional[str]: """Start a download. Returns download_id immediately.""" if not self._authenticated: logger.error("Deezer not authenticated — cannot download") return None # Parse filename: "track_id||display_name" parts = filename.split('||', 1) track_id = parts[0] display_name = parts[1] if len(parts) > 1 else f"Track {track_id}" download_id = str(uuid.uuid4()) with self._download_lock: self.active_downloads[download_id] = { 'id': download_id, 'track_id': track_id, 'display_name': display_name, 'filename': filename, 'username': 'deezer_dl', 'state': 'Initializing', 'progress': 0.0, 'size': file_size, 'transferred': 0, 'speed': 0, 'file_path': None, 'error': None, } thread = threading.Thread( target=self._download_thread_worker, args=(download_id, track_id, display_name), daemon=True, name=f'deezer-dl-{track_id}' ) thread.start() logger.info(f"Started Deezer download {download_id}: {display_name}") return download_id def _download_thread_worker(self, download_id: str, track_id: str, display_name: str): """Background worker for a single download.""" try: result_path = self._download_sync(download_id, track_id, display_name) with self._download_lock: if download_id in self.active_downloads: dl = self.active_downloads[download_id] if dl['state'] == 'Cancelled': return if result_path: dl['state'] = 'Completed, Succeeded' dl['progress'] = 100.0 dl['file_path'] = result_path logger.info(f"Deezer download {download_id} completed: {result_path}") else: dl['state'] = 'Errored' logger.error(f"Deezer download {download_id} failed: {dl.get('error', 'unknown')}") except Exception as e: logger.error(f"Deezer download thread error: {e}") with self._download_lock: if download_id in self.active_downloads: self.active_downloads[download_id]['state'] = 'Errored' self.active_downloads[download_id]['error'] = str(e) def _download_sync(self, download_id: str, track_id: str, display_name: str) -> Optional[str]: """Synchronous download: get URL, download, decrypt, save.""" # Check for shutdown if self.shutdown_check and self.shutdown_check(): with self._download_lock: if download_id in self.active_downloads: self.active_downloads[download_id]['state'] = 'Aborted' return None # Get track data from private API track_data = self._get_track_data(track_id) if not track_data: with self._download_lock: if download_id in self.active_downloads: self.active_downloads[download_id]['error'] = 'Failed to get track data' return None track_token = track_data.get('TRACK_TOKEN', '') if not track_token: with self._download_lock: if download_id in self.active_downloads: self.active_downloads[download_id]['error'] = 'No track token available' return None # Determine quality and get media URL with fallback media_url = None actual_quality = None allow_fallback = self._config.get('deezer_download.allow_fallback', True) if allow_fallback: quality_order = _QUALITY_ORDER.copy() # Start from user's preferred quality try: pref_idx = quality_order.index(self._quality) quality_order = quality_order[pref_idx:] + quality_order[:pref_idx] except ValueError: pass else: quality_order = [self._quality] for q in quality_order: url = self._get_media_url(track_token, q) if url: media_url = url actual_quality = q break if not media_url: with self._download_lock: if download_id in self.active_downloads: self.active_downloads[download_id]['error'] = 'No media URL available (may require higher subscription tier)' return None if actual_quality != self._quality: logger.info(f"Quality fallback: {self._quality} → {actual_quality} for {display_name}") # Determine file extension ext = '.flac' if actual_quality == 'flac' else '.mp3' # Sanitize filename safe_name = self._sanitize_filename(display_name) out_path = str(self.download_path / f"{safe_name}{ext}") # Update state with self._download_lock: if download_id in self.active_downloads: dl = self.active_downloads[download_id] dl['state'] = 'InProgress, Downloading' # Download and decrypt try: bf_key = _get_blowfish_key(track_id) resp = self._session.get(media_url, stream=True, timeout=30) resp.raise_for_status() total_size = int(resp.headers.get('content-length', 0)) with self._download_lock: if download_id in self.active_downloads: self.active_downloads[download_id]['size'] = total_size downloaded = 0 chunk_index = 0 start_time = time.time() with open(out_path, 'wb') as f: for raw_chunk in resp.iter_content(chunk_size=_CHUNK_SIZE): if not raw_chunk: continue # Check for cancellation/shutdown if self.shutdown_check and self.shutdown_check(): with self._download_lock: if download_id in self.active_downloads: self.active_downloads[download_id]['state'] = 'Aborted' try: os.remove(out_path) except OSError: pass return None with self._download_lock: if download_id in self.active_downloads: if self.active_downloads[download_id]['state'] == 'Cancelled': try: os.remove(out_path) except OSError: pass return None # Decrypt every 3rd chunk (Deezer's encryption pattern) if chunk_index % 3 == 0 and len(raw_chunk) == _CHUNK_SIZE: chunk_to_write = _decrypt_chunk(raw_chunk, bf_key) else: chunk_to_write = raw_chunk f.write(chunk_to_write) downloaded += len(raw_chunk) chunk_index += 1 # Update progress elapsed = time.time() - start_time speed = int(downloaded / elapsed) if elapsed > 0 else 0 progress = (downloaded / total_size * 100) if total_size > 0 else 0 with self._download_lock: if download_id in self.active_downloads: dl = self.active_downloads[download_id] dl['transferred'] = downloaded dl['progress'] = min(progress, 99.9) dl['speed'] = speed # Validate file size file_size = os.path.getsize(out_path) if file_size < _MIN_FILE_SIZE: logger.warning(f"Downloaded file too small ({file_size} bytes): {out_path}") try: os.remove(out_path) except OSError: pass with self._download_lock: if download_id in self.active_downloads: self.active_downloads[download_id]['error'] = f'File too small ({file_size} bytes)' return None logger.info(f"Deezer download complete: {out_path} ({file_size / 1048576:.1f} MB, {actual_quality})") return out_path except Exception as e: logger.error(f"Download error for {display_name}: {e}") try: os.remove(out_path) except OSError: pass with self._download_lock: if download_id in self.active_downloads: self.active_downloads[download_id]['error'] = str(e) return None # ─── Download Status ───────────────────────────────────────── async def get_all_downloads(self) -> List[DownloadStatus]: """Return all active downloads.""" with self._download_lock: return [self._to_status(dl) for dl in self.active_downloads.values()] async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]: """Get status of a specific download.""" with self._download_lock: dl = self.active_downloads.get(download_id) return self._to_status(dl) if dl else None async def cancel_download(self, download_id: str, username: str = None, remove: bool = False) -> bool: """Cancel a download.""" with self._download_lock: dl = self.active_downloads.get(download_id) if not dl: return False dl['state'] = 'Cancelled' if remove: del self.active_downloads[download_id] return True async def clear_all_completed_downloads(self) -> bool: """Remove all terminal downloads.""" terminal_states = {'Completed, Succeeded', 'Cancelled', 'Errored', 'Aborted'} with self._download_lock: to_remove = [k for k, v in self.active_downloads.items() if v['state'] in terminal_states] for k in to_remove: del self.active_downloads[k] return True def _to_status(self, dl: dict) -> DownloadStatus: """Convert internal dict to DownloadStatus.""" return DownloadStatus( id=dl['id'], filename=dl['filename'], username=dl['username'], state=dl['state'], progress=dl['progress'], size=dl['size'], transferred=dl['transferred'], speed=dl['speed'], file_path=dl.get('file_path'), ) # ─── Utilities ─────────────────────────────────────────────── @staticmethod def _sanitize_filename(name: str) -> str: """Sanitize a string for use as a filename.""" import re name = re.sub(r'[<>:"/\\|?*]', '', name) name = name.strip('. ') return name[:200] if name else 'unknown'