Cin's review feedback: the plugin contract was discoverable only from the registry, not from the client files themselves. Reading `youtube_client.py` cold gave no signal that the class participates in the DownloadSourcePlugin contract. Every download client class now inherits DownloadSourcePlugin explicitly: - SoulseekClient(DownloadSourcePlugin) - YouTubeClient(DownloadSourcePlugin) - TidalDownloadClient(DownloadSourcePlugin) - QobuzClient(DownloadSourcePlugin) - HiFiClient(DownloadSourcePlugin) - DeezerDownloadClient(DownloadSourcePlugin) - SoundcloudClient(DownloadSourcePlugin) - LidarrDownloadClient(DownloadSourcePlugin) Adjustments: - core/download_plugins/base.py — moved TrackResult/AlbumResult/ DownloadStatus imports under TYPE_CHECKING since they're only used in type annotations. Without this, clients inheriting the contract create a circular import. - core/download_plugins/__init__.py — drops DownloadPluginRegistry re-export. Importing the package no longer triggers the registry's eager client imports (which would also be circular for clients that import from the package). Callers that need the registry import it directly: `from core.download_plugins.registry import DownloadPluginRegistry`. Suite still green (335 download tests).
846 lines
33 KiB
Python
846 lines
33 KiB
Python
"""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 as exc:
|
|
raise ImportError(
|
|
"Deezer downloads require pycryptodome or cryptography package. "
|
|
"Install with: pip install pycryptodome"
|
|
) from exc
|
|
|
|
|
|
from core.download_plugins.base import DownloadSourcePlugin
|
|
|
|
|
|
class DeezerDownloadClient(DownloadSourcePlugin):
|
|
"""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)
|
|
|
|
# Engine reference is populated by set_engine() at registration
|
|
# time. None until orchestrator wires the registry.
|
|
self._engine = None
|
|
|
|
# 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})")
|
|
|
|
def set_engine(self, engine):
|
|
"""Engine callback — wires the central thread worker + state store."""
|
|
self._engine = engine
|
|
|
|
# ─── 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')
|
|
|
|
# ─── User Playlists (ARL-authenticated) ─────────────────────
|
|
|
|
def get_user_playlists(self) -> list:
|
|
"""Fetch the authenticated user's playlists via Deezer public API with ARL cookies."""
|
|
if not self._authenticated or not self._user_data:
|
|
return []
|
|
user_id = self._user_data.get('USER_ID')
|
|
if not user_id:
|
|
return []
|
|
|
|
playlists = []
|
|
index = 0
|
|
while True:
|
|
try:
|
|
resp = self._session.get(
|
|
f'https://api.deezer.com/user/{user_id}/playlists',
|
|
params={'index': index, 'limit': 100},
|
|
timeout=15
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
if 'error' in data:
|
|
logger.warning(f"Deezer playlists error: {data['error']}")
|
|
break
|
|
items = data.get('data', [])
|
|
if not items:
|
|
break
|
|
for p in items:
|
|
playlists.append({
|
|
'id': str(p.get('id', '')),
|
|
'name': p.get('title', ''),
|
|
'track_count': p.get('nb_tracks', 0),
|
|
'image_url': p.get('picture_medium', ''),
|
|
'owner': p.get('creator', {}).get('name', ''),
|
|
'description': p.get('description', ''),
|
|
})
|
|
if not data.get('next'):
|
|
break
|
|
index += len(items)
|
|
except Exception as e:
|
|
logger.error(f"Error fetching user playlists at index {index}: {e}")
|
|
break
|
|
|
|
logger.info(f"Fetched {len(playlists)} user playlists from Deezer")
|
|
return playlists
|
|
|
|
def get_user_favorite_artists(self, limit: int = 200) -> list:
|
|
"""Fetch the authenticated user's favorite artists via public API with ARL cookies."""
|
|
if not self._authenticated or not self._user_data:
|
|
return []
|
|
user_id = self._user_data.get('USER_ID')
|
|
if not user_id:
|
|
return []
|
|
|
|
artists = []
|
|
index = 0
|
|
while len(artists) < limit:
|
|
try:
|
|
resp = self._session.get(
|
|
f'https://api.deezer.com/user/{user_id}/artists',
|
|
params={'index': index, 'limit': min(100, limit - len(artists))},
|
|
timeout=15
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
if 'error' in data:
|
|
logger.warning(f"Deezer artists error: {data['error']}")
|
|
break
|
|
items = data.get('data', [])
|
|
if not items:
|
|
break
|
|
for a in items:
|
|
artists.append({
|
|
'deezer_id': str(a.get('id', '')),
|
|
'name': a.get('name', ''),
|
|
'image_url': a.get('picture_xl') or a.get('picture_big') or a.get('picture_medium', ''),
|
|
})
|
|
if not data.get('next'):
|
|
break
|
|
index += len(items)
|
|
except Exception as e:
|
|
logger.error(f"Error fetching favorite artists at index {index}: {e}")
|
|
break
|
|
|
|
logger.info(f"Fetched {len(artists)} favorite artists from Deezer (ARL)")
|
|
return artists
|
|
|
|
def get_user_favorite_albums(self, limit: int = 200) -> list:
|
|
"""Fetch the authenticated user's favorite albums via public API with ARL cookies."""
|
|
if not self._authenticated or not self._user_data:
|
|
return []
|
|
user_id = self._user_data.get('USER_ID')
|
|
if not user_id:
|
|
return []
|
|
|
|
albums = []
|
|
index = 0
|
|
while len(albums) < limit:
|
|
try:
|
|
resp = self._session.get(
|
|
f'https://api.deezer.com/user/{user_id}/albums',
|
|
params={'index': index, 'limit': min(100, limit - len(albums))},
|
|
timeout=15
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
if 'error' in data:
|
|
logger.warning(f"Deezer albums error: {data['error']}")
|
|
break
|
|
items = data.get('data', [])
|
|
if not items:
|
|
break
|
|
for a in items:
|
|
artist_name = ''
|
|
if isinstance(a.get('artist'), dict):
|
|
artist_name = a['artist'].get('name', '')
|
|
albums.append({
|
|
'deezer_id': str(a.get('id', '')),
|
|
'album_name': a.get('title', ''),
|
|
'artist_name': artist_name,
|
|
'image_url': a.get('cover_xl') or a.get('cover_big') or a.get('cover_medium', ''),
|
|
'release_date': a.get('release_date', ''),
|
|
'total_tracks': a.get('nb_tracks', 0),
|
|
})
|
|
if not data.get('next'):
|
|
break
|
|
index += len(items)
|
|
except Exception as e:
|
|
logger.error(f"Error fetching favorite albums at index {index}: {e}")
|
|
break
|
|
|
|
logger.info(f"Fetched {len(albums)} favorite albums from Deezer (ARL)")
|
|
return albums
|
|
|
|
def get_playlist_tracks(self, playlist_id: str) -> Optional[dict]:
|
|
"""Fetch full playlist details with tracks via public API (ARL cookies grant private access)."""
|
|
try:
|
|
resp = self._session.get(
|
|
f'https://api.deezer.com/playlist/{playlist_id}',
|
|
timeout=15
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
if 'error' in data:
|
|
logger.error(f"Deezer playlist error: {data['error']}")
|
|
return None
|
|
|
|
total_tracks = data.get('nb_tracks', 0)
|
|
raw_tracks = data.get('tracks', {}).get('data', [])
|
|
|
|
# Paginate if needed
|
|
while len(raw_tracks) < total_tracks:
|
|
idx = len(raw_tracks)
|
|
page_resp = self._session.get(
|
|
f'https://api.deezer.com/playlist/{playlist_id}/tracks',
|
|
params={'index': idx, 'limit': 400},
|
|
timeout=15
|
|
)
|
|
page_resp.raise_for_status()
|
|
page_data = page_resp.json()
|
|
if 'error' in page_data:
|
|
break
|
|
page_tracks = page_data.get('data', [])
|
|
if not page_tracks:
|
|
break
|
|
raw_tracks.extend(page_tracks)
|
|
|
|
# Batch-fetch release dates for unique albums (cache-first)
|
|
album_ids = set()
|
|
for t in raw_tracks:
|
|
aid = t.get('album', {}).get('id')
|
|
if aid:
|
|
album_ids.add(str(aid))
|
|
album_release_dates = {}
|
|
try:
|
|
from core.metadata.cache import get_metadata_cache
|
|
cache = get_metadata_cache()
|
|
except Exception:
|
|
cache = None
|
|
for aid in album_ids:
|
|
# Check metadata cache first
|
|
if cache:
|
|
try:
|
|
cached = cache.get_entity('deezer', 'album', aid)
|
|
if cached and cached.get('release_date'):
|
|
album_release_dates[aid] = cached['release_date']
|
|
continue
|
|
except Exception:
|
|
pass
|
|
# Cache miss — fetch from API
|
|
try:
|
|
time.sleep(0.3) # Respect rate limits
|
|
a_resp = self._session.get(f'https://api.deezer.com/album/{aid}', timeout=10)
|
|
if a_resp.ok:
|
|
a_data = a_resp.json()
|
|
album_release_dates[aid] = a_data.get('release_date', '')
|
|
# Store in metadata cache for future use
|
|
if cache:
|
|
try:
|
|
cache.store_entity('deezer', 'album', aid, a_data)
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
tracks = []
|
|
for i, t in enumerate(raw_tracks, start=1):
|
|
artist_name = t.get('artist', {}).get('name', 'Unknown Artist')
|
|
album_data = t.get('album', {})
|
|
album_cover = album_data.get('cover_medium') or album_data.get('cover_small') or ''
|
|
album_id = str(album_data.get('id', ''))
|
|
tracks.append({
|
|
'id': str(t.get('id', '')),
|
|
'name': t.get('title', ''),
|
|
'artists': [{'name': artist_name}],
|
|
'album': {
|
|
'name': album_data.get('title', ''),
|
|
'images': [{'url': album_cover}] if album_cover else [],
|
|
'release_date': album_release_dates.get(album_id, ''),
|
|
'album_type': 'album',
|
|
'total_tracks': total_tracks,
|
|
'id': album_id,
|
|
},
|
|
'duration_ms': t.get('duration', 0) * 1000,
|
|
'track_number': i,
|
|
})
|
|
|
|
return {
|
|
'id': str(data.get('id', '')),
|
|
'name': data.get('title', ''),
|
|
'description': data.get('description', ''),
|
|
'track_count': total_tracks,
|
|
'image_url': data.get('picture_medium', ''),
|
|
'owner': data.get('creator', {}).get('name', ''),
|
|
'tracks': tracks,
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Error fetching playlist {playlist_id}: {e}")
|
|
return None
|
|
|
|
# ─── 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
|
|
if self._engine is None:
|
|
logger.error("Deezer client has no engine reference — cannot dispatch 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}"
|
|
|
|
return self._engine.worker.dispatch(
|
|
source_name='deezer',
|
|
target_id=track_id,
|
|
display_name=display_name,
|
|
original_filename=filename,
|
|
impl_callable=self._download_sync,
|
|
extra_record_fields={
|
|
'track_id': track_id,
|
|
'display_name': display_name,
|
|
'size': file_size,
|
|
'error': None,
|
|
},
|
|
# Legacy username slot — frontend status indicators key off
|
|
# ``deezer_dl``, not the canonical ``deezer``.
|
|
username_override='deezer_dl',
|
|
# Diagnostic thread name for multi-thread debugging.
|
|
thread_name=f'deezer-dl-{track_id}',
|
|
)
|
|
|
|
def _set_error(self, download_id: str, message: str) -> None:
|
|
"""Helper: set the engine record's `error` slot. No-op if
|
|
engine isn't wired or record was already removed."""
|
|
if self._engine is None:
|
|
return
|
|
self._engine.update_record('deezer', download_id, {'error': message})
|
|
|
|
def _is_cancelled(self, download_id: str) -> bool:
|
|
if self._engine is None:
|
|
return False
|
|
record = self._engine.get_record('deezer', download_id)
|
|
return record is not None and record.get('state') == 'Cancelled'
|
|
|
|
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():
|
|
if self._engine is not None:
|
|
self._engine.update_record('deezer', download_id, {'state': 'Aborted'})
|
|
return None
|
|
|
|
# Get track data from private API
|
|
track_data = self._get_track_data(track_id)
|
|
if not track_data:
|
|
self._set_error(download_id, 'Failed to get track data')
|
|
return None
|
|
|
|
track_token = track_data.get('TRACK_TOKEN', '')
|
|
if not track_token:
|
|
self._set_error(download_id, '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()
|
|
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:
|
|
self._set_error(download_id, '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}")
|
|
|
|
ext = '.flac' if actual_quality == 'flac' else '.mp3'
|
|
safe_name = self._sanitize_filename(display_name)
|
|
out_path = str(self.download_path / f"{safe_name}{ext}")
|
|
|
|
# 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))
|
|
if self._engine is not None:
|
|
self._engine.update_record('deezer', 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():
|
|
if self._engine is not None:
|
|
self._engine.update_record('deezer', download_id, {'state': 'Aborted'})
|
|
try:
|
|
os.remove(out_path)
|
|
except OSError:
|
|
pass
|
|
return None
|
|
|
|
if self._is_cancelled(download_id):
|
|
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
|
|
|
|
if self._engine is not None:
|
|
self._engine.update_record('deezer', download_id, {
|
|
'transferred': downloaded,
|
|
'progress': min(progress, 99.9),
|
|
'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
|
|
self._set_error(download_id, 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
|
|
self._set_error(download_id, str(e))
|
|
return None
|
|
|
|
# ─── Download Status ─────────────────────────────────────────
|
|
|
|
def _record_to_status(self, record: dict) -> DownloadStatus:
|
|
return DownloadStatus(
|
|
id=record['id'],
|
|
filename=record['filename'],
|
|
username=record['username'],
|
|
state=record['state'],
|
|
progress=record['progress'],
|
|
size=record.get('size', 0),
|
|
transferred=record.get('transferred', 0),
|
|
speed=record.get('speed', 0),
|
|
file_path=record.get('file_path'),
|
|
)
|
|
|
|
async def get_all_downloads(self) -> List[DownloadStatus]:
|
|
if self._engine is None:
|
|
return []
|
|
return [
|
|
self._record_to_status(record)
|
|
for record in self._engine.iter_records_for_source('deezer')
|
|
]
|
|
|
|
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
|
|
if self._engine is None:
|
|
return None
|
|
record = self._engine.get_record('deezer', download_id)
|
|
return self._record_to_status(record) if record is not None else None
|
|
|
|
async def cancel_download(self, download_id: str, username: str = None,
|
|
remove: bool = False) -> bool:
|
|
if self._engine is None:
|
|
return False
|
|
if self._engine.get_record('deezer', download_id) is None:
|
|
return False
|
|
self._engine.update_record('deezer', download_id, {'state': 'Cancelled'})
|
|
if remove:
|
|
self._engine.remove_record('deezer', download_id)
|
|
return True
|
|
|
|
async def clear_all_completed_downloads(self) -> bool:
|
|
if self._engine is None:
|
|
return True
|
|
terminal = {'Completed, Succeeded', 'Cancelled', 'Errored', 'Aborted'}
|
|
for record in list(self._engine.iter_records_for_source('deezer')):
|
|
if record.get('state') in terminal:
|
|
self._engine.remove_record('deezer', record['id'])
|
|
return True
|
|
|
|
# ─── 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'
|