Add Deezer download source: client, settings UI, ARL authentication
- New core/deezer_download_client.py: full download client with ARL auth, Blowfish decryption, quality fallback (FLAC/MP3 320/MP3 128), search, thread-safe download tracking. Not yet integrated into orchestrator. - Settings UI: Deezer download quality selector, ARL token input, test connection button. Appears in download source dropdown and hybrid list. - Test endpoint: /api/deezer-download/test verifies ARL and returns tier. - Added deezer_download to settings save whitelist and sensitive paths. - Fixed Spotify enrichment worker default to unpaused (like other workers).
This commit is contained in:
parent
d4a57ae654
commit
2ae5050ef1
6 changed files with 778 additions and 6 deletions
|
|
@ -78,6 +78,7 @@ class ConfigManager:
|
|||
'navidrome.password',
|
||||
# Download sources
|
||||
'soulseek.api_key',
|
||||
'deezer_download.arl',
|
||||
# Enrichment services
|
||||
'listenbrainz.token',
|
||||
'acoustid.api_key',
|
||||
|
|
|
|||
637
core/deezer_download_client.py
Normal file
637
core/deezer_download_client.py
Normal file
|
|
@ -0,0 +1,637 @@
|
|||
"""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
|
||||
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
|
||||
|
||||
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'
|
||||
|
|
@ -4499,7 +4499,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', 'qobuz', 'hifi_download', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'listening_stats', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb', 'metadata', 'hydrabase']:
|
||||
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'deezer_download', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'listening_stats', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb', 'metadata', 'hydrabase']:
|
||||
if service in new_settings:
|
||||
for key, value in new_settings[service].items():
|
||||
config_manager.set(f'{service}.{key}', value)
|
||||
|
|
@ -26862,6 +26862,61 @@ def hifi_status():
|
|||
return jsonify({"available": False, "error": str(e)})
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# DEEZER DOWNLOAD ENDPOINTS
|
||||
# ===================================================================
|
||||
|
||||
@app.route('/api/deezer-download/test', methods=['POST'])
|
||||
def deezer_download_test():
|
||||
"""Test Deezer ARL token authentication."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
arl = data.get('arl', '')
|
||||
if not arl:
|
||||
return jsonify({'success': False, 'error': 'No ARL token provided'})
|
||||
|
||||
import requests as req
|
||||
import threading
|
||||
|
||||
session = req.Session()
|
||||
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',
|
||||
})
|
||||
session.cookies.set('arl', arl)
|
||||
|
||||
resp = session.post(
|
||||
'https://www.deezer.com/ajax/gw-light.php',
|
||||
params={'method': 'deezer.getUserData', 'api_version': '1.0', 'api_token': 'null'},
|
||||
json={},
|
||||
timeout=15
|
||||
)
|
||||
logger.debug(f"Deezer test raw response status={resp.status_code}, body_preview={resp.text[:500]}")
|
||||
resp.raise_for_status()
|
||||
result = resp.json().get('results', {})
|
||||
|
||||
user = result.get('USER', {})
|
||||
user_id = user.get('USER_ID', 0)
|
||||
logger.info(f"Deezer test: USER_ID={user_id}, keys={list(result.keys())}, user_keys={list(user.keys()) if user else 'none'}")
|
||||
if not user_id or user_id == 0:
|
||||
# Log more detail for debugging
|
||||
error_info = result.get('error', result.get('ERROR', ''))
|
||||
logger.warning(f"Deezer ARL test failed — USER_ID={user_id}, error={error_info}, response_keys={list(result.keys())}")
|
||||
return jsonify({'success': False, 'error': f'Invalid ARL token — Deezer returned no user (USER_ID={user_id})'})
|
||||
|
||||
user_name = user.get('BLOG_NAME', 'Unknown')
|
||||
options = user.get('OPTIONS', {})
|
||||
can_lossless = options.get('web_lossless', False)
|
||||
can_hq = options.get('web_hq', False)
|
||||
tier = 'HiFi' if can_lossless else ('Premium' if can_hq else 'Free')
|
||||
|
||||
return jsonify({'success': True, 'user': user_name, 'tier': tier})
|
||||
except Exception as e:
|
||||
logger.error(f"Deezer download test failed: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# TIDAL DOWNLOAD AUTH ENDPOINTS
|
||||
# ===================================================================
|
||||
|
|
|
|||
|
|
@ -3937,6 +3937,7 @@
|
|||
<option value="tidal">Tidal Only</option>
|
||||
<option value="qobuz">Qobuz Only</option>
|
||||
<option value="hifi">HiFi Only (Free Lossless)</option>
|
||||
<option value="deezer_dl">Deezer Only</option>
|
||||
<option value="hybrid">Hybrid (Primary + Fallback)</option>
|
||||
</select>
|
||||
<div class="setting-help-text">
|
||||
|
|
@ -3954,8 +3955,8 @@
|
|||
<!-- Populated by JS -->
|
||||
</div>
|
||||
<!-- Hidden selects for backward compatibility with saveSettings -->
|
||||
<select id="hybrid-primary-source" style="display:none;"><option value="soulseek">Soulseek</option><option value="youtube">YouTube</option><option value="tidal">Tidal</option><option value="qobuz">Qobuz</option><option value="hifi">HiFi</option></select>
|
||||
<select id="hybrid-secondary-source" style="display:none;"><option value="soulseek">Soulseek</option><option value="youtube">YouTube</option><option value="tidal">Tidal</option><option value="qobuz">Qobuz</option><option value="hifi">HiFi</option></select>
|
||||
<select id="hybrid-primary-source" style="display:none;"><option value="soulseek">Soulseek</option><option value="youtube">YouTube</option><option value="tidal">Tidal</option><option value="qobuz">Qobuz</option><option value="hifi">HiFi</option><option value="deezer_dl">Deezer</option></select>
|
||||
<select id="hybrid-secondary-source" style="display:none;"><option value="soulseek">Soulseek</option><option value="youtube">YouTube</option><option value="tidal">Tidal</option><option value="qobuz">Qobuz</option><option value="hifi">HiFi</option><option value="deezer_dl">Deezer</option></select>
|
||||
</div>
|
||||
|
||||
<!-- Soulseek Settings (shown when soulseek is active source) -->
|
||||
|
|
@ -4119,6 +4120,41 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deezer Download Settings (shown only when deezer_dl mode is selected) -->
|
||||
<div id="deezer-download-settings-container" style="display: none;">
|
||||
<div class="form-group">
|
||||
<label>Deezer Download Quality:</label>
|
||||
<select id="deezer-download-quality" class="form-select">
|
||||
<option value="mp3_128">MP3 128kbps (Free)</option>
|
||||
<option value="mp3_320">MP3 320kbps (Premium)</option>
|
||||
<option value="flac">FLAC Lossless (HiFi)</option>
|
||||
</select>
|
||||
<div class="setting-help-text">
|
||||
Audio quality for Deezer downloads. FLAC requires a Deezer HiFi subscription.
|
||||
MP3 320 requires Premium or higher.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Deezer ARL Token:</label>
|
||||
<input type="password" id="deezer-download-arl" class="form-input"
|
||||
placeholder="Paste your ARL cookie token here">
|
||||
<div class="setting-help-text">
|
||||
Your ARL token authenticates with Deezer. To get it:
|
||||
log into <a href="https://www.deezer.com" target="_blank" style="color: rgb(var(--accent-rgb));">deezer.com</a>
|
||||
→ open browser DevTools (F12) → Application tab → Cookies → copy the <code>arl</code> value.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Deezer Status:</label>
|
||||
<div class="form-actions" style="margin-top: 4px;">
|
||||
<button class="test-button" id="deezer-download-test-btn" onclick="testDeezerDownloadConnection()">
|
||||
Test Connection
|
||||
</button>
|
||||
<span id="deezer-download-status" class="setting-help-text" style="margin-left: 8px;"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- YouTube Settings (shown when youtube or hybrid mode) -->
|
||||
<div id="youtube-settings-container" style="display: none;">
|
||||
<div class="form-group">
|
||||
|
|
|
|||
|
|
@ -5254,10 +5254,11 @@ const HYBRID_SOURCES = [
|
|||
{ id: 'tidal', name: 'Tidal', icon: 'https://www.svgrepo.com/show/519734/tidal.svg', emoji: '🌊' },
|
||||
{ id: 'qobuz', name: 'Qobuz', icon: 'https://www.svgrepo.com/show/504778/qobuz.svg', emoji: '🎧' },
|
||||
{ id: 'hifi', name: 'HiFi', icon: null, emoji: '🎶' },
|
||||
{ id: 'deezer_dl', name: 'Deezer', icon: 'https://www.svgrepo.com/show/519734/deezer.svg', emoji: '🎧' },
|
||||
];
|
||||
|
||||
let _hybridSourceOrder = ['soulseek', 'youtube'];
|
||||
let _hybridSourceEnabled = { soulseek: true, youtube: true, tidal: false, qobuz: false, hifi: false };
|
||||
let _hybridSourceEnabled = { soulseek: true, youtube: true, tidal: false, qobuz: false, hifi: false, deezer_dl: false };
|
||||
let _hybridVisualOrder = null; // Full visual order including disabled sources
|
||||
|
||||
function buildHybridSourceList() {
|
||||
|
|
@ -5550,6 +5551,8 @@ async function loadSettingsData() {
|
|||
document.getElementById('tidal-download-quality').value = settings.tidal_download?.quality || 'lossless';
|
||||
document.getElementById('qobuz-quality').value = settings.qobuz?.quality || 'lossless';
|
||||
document.getElementById('hifi-download-quality').value = settings.hifi_download?.quality || 'lossless';
|
||||
document.getElementById('deezer-download-quality').value = settings.deezer_download?.quality || 'flac';
|
||||
document.getElementById('deezer-download-arl').value = settings.deezer_download?.arl || '';
|
||||
|
||||
// Populate YouTube settings
|
||||
document.getElementById('youtube-cookies-browser').value = settings.youtube?.cookies_browser || '';
|
||||
|
|
@ -5797,6 +5800,7 @@ function updateDownloadSourceUI() {
|
|||
const qobuzContainer = document.getElementById('qobuz-settings-container');
|
||||
const youtubeContainer = document.getElementById('youtube-settings-container');
|
||||
const hifiContainer = document.getElementById('hifi-download-settings-container');
|
||||
const deezerDlContainer = document.getElementById('deezer-download-settings-container');
|
||||
|
||||
hybridContainer.style.display = mode === 'hybrid' ? 'block' : 'none';
|
||||
|
||||
|
|
@ -5816,6 +5820,7 @@ function updateDownloadSourceUI() {
|
|||
qobuzContainer.style.display = activeSources.has('qobuz') ? 'block' : 'none';
|
||||
youtubeContainer.style.display = activeSources.has('youtube') ? 'block' : 'none';
|
||||
hifiContainer.style.display = activeSources.has('hifi') ? 'block' : 'none';
|
||||
if (deezerDlContainer) deezerDlContainer.style.display = activeSources.has('deezer_dl') ? 'block' : 'none';
|
||||
|
||||
// Quality profile is Soulseek-only and downloads-tab-only
|
||||
const qualityProfileSection = document.getElementById('quality-profile-section');
|
||||
|
|
@ -6549,6 +6554,10 @@ async function saveSettings(quiet = false) {
|
|||
hifi_download: {
|
||||
quality: document.getElementById('hifi-download-quality').value || 'lossless'
|
||||
},
|
||||
deezer_download: {
|
||||
quality: document.getElementById('deezer-download-quality').value || 'flac',
|
||||
arl: document.getElementById('deezer-download-arl').value || '',
|
||||
},
|
||||
qobuz: {
|
||||
quality: document.getElementById('qobuz-quality').value || 'lossless',
|
||||
embed_tags: document.getElementById('embed-qobuz').checked,
|
||||
|
|
@ -7216,6 +7225,38 @@ async function testHiFiConnection() {
|
|||
}
|
||||
}
|
||||
|
||||
async function testDeezerDownloadConnection() {
|
||||
const statusEl = document.getElementById('deezer-download-status');
|
||||
if (!statusEl) return;
|
||||
statusEl.textContent = 'Checking...';
|
||||
statusEl.style.color = '#aaa';
|
||||
try {
|
||||
// Save the ARL first so the backend can use it
|
||||
const arl = document.getElementById('deezer-download-arl')?.value || '';
|
||||
if (!arl) {
|
||||
statusEl.textContent = 'No ARL token provided';
|
||||
statusEl.style.color = '#ff9800';
|
||||
return;
|
||||
}
|
||||
const resp = await fetch('/api/deezer-download/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ arl }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
statusEl.textContent = `Connected as ${data.user || 'Unknown'} (${data.tier || 'Free'})`;
|
||||
statusEl.style.color = '#4caf50';
|
||||
} else {
|
||||
statusEl.textContent = data.error || 'Authentication failed';
|
||||
statusEl.style.color = '#f44336';
|
||||
}
|
||||
} catch (e) {
|
||||
statusEl.textContent = 'Connection error';
|
||||
statusEl.style.color = '#f44336';
|
||||
}
|
||||
}
|
||||
|
||||
async function checkTidalDownloadAuthStatus() {
|
||||
const statusEl = document.getElementById('tidal-download-auth-status');
|
||||
const btn = document.getElementById('tidal-download-auth-btn');
|
||||
|
|
@ -35552,7 +35593,7 @@ function updateServiceStatus(service, statusData) {
|
|||
|
||||
// Update download source title on dashboard card
|
||||
if (service === 'soulseek' && statusData.source) {
|
||||
const sourceNames = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', hybrid: 'Hybrid' };
|
||||
const sourceNames = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', hybrid: 'Hybrid' };
|
||||
const displayName = sourceNames[statusData.source] || 'Soulseek';
|
||||
const titleEl = document.getElementById('download-source-title');
|
||||
if (titleEl) titleEl.textContent = displayName;
|
||||
|
|
@ -35600,7 +35641,7 @@ function updateSidebarServiceStatus(service, statusData) {
|
|||
|
||||
// Update download source name based on configured mode
|
||||
if (service === 'soulseek' && statusData.source) {
|
||||
const sourceNames = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', hybrid: 'Hybrid' };
|
||||
const sourceNames = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', hybrid: 'Hybrid' };
|
||||
const displayName = sourceNames[statusData.source] || 'Soulseek';
|
||||
const sidebarName = document.getElementById('download-source-name');
|
||||
if (sidebarName) sidebarName.textContent = displayName;
|
||||
|
|
|
|||
|
|
@ -45936,6 +45936,7 @@ tr.tag-diff-same {
|
|||
#settings-page #tidal-download-settings-container,
|
||||
#settings-page #qobuz-settings-container,
|
||||
#settings-page #hifi-download-settings-container,
|
||||
#settings-page #deezer-download-settings-container,
|
||||
#settings-page #youtube-settings-container,
|
||||
#settings-page #hybrid-settings-container {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
|
|
@ -45949,6 +45950,7 @@ tr.tag-diff-same {
|
|||
#settings-page #tidal-download-settings-container:hover,
|
||||
#settings-page #qobuz-settings-container:hover,
|
||||
#settings-page #hifi-download-settings-container:hover,
|
||||
#settings-page #deezer-download-settings-container:hover,
|
||||
#settings-page #youtube-settings-container:hover,
|
||||
#settings-page #hybrid-settings-container:hover {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
|
|
|
|||
Loading…
Reference in a new issue