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',
|
'navidrome.password',
|
||||||
# Download sources
|
# Download sources
|
||||||
'soulseek.api_key',
|
'soulseek.api_key',
|
||||||
|
'deezer_download.arl',
|
||||||
# Enrichment services
|
# Enrichment services
|
||||||
'listenbrainz.token',
|
'listenbrainz.token',
|
||||||
'acoustid.api_key',
|
'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:
|
if 'active_media_server' in new_settings:
|
||||||
config_manager.set_active_media_server(new_settings['active_media_server'])
|
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:
|
if service in new_settings:
|
||||||
for key, value in new_settings[service].items():
|
for key, value in new_settings[service].items():
|
||||||
config_manager.set(f'{service}.{key}', value)
|
config_manager.set(f'{service}.{key}', value)
|
||||||
|
|
@ -26862,6 +26862,61 @@ def hifi_status():
|
||||||
return jsonify({"available": False, "error": str(e)})
|
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
|
# TIDAL DOWNLOAD AUTH ENDPOINTS
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
|
|
|
||||||
|
|
@ -3937,6 +3937,7 @@
|
||||||
<option value="tidal">Tidal Only</option>
|
<option value="tidal">Tidal Only</option>
|
||||||
<option value="qobuz">Qobuz Only</option>
|
<option value="qobuz">Qobuz Only</option>
|
||||||
<option value="hifi">HiFi Only (Free Lossless)</option>
|
<option value="hifi">HiFi Only (Free Lossless)</option>
|
||||||
|
<option value="deezer_dl">Deezer Only</option>
|
||||||
<option value="hybrid">Hybrid (Primary + Fallback)</option>
|
<option value="hybrid">Hybrid (Primary + Fallback)</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="setting-help-text">
|
<div class="setting-help-text">
|
||||||
|
|
@ -3954,8 +3955,8 @@
|
||||||
<!-- Populated by JS -->
|
<!-- Populated by JS -->
|
||||||
</div>
|
</div>
|
||||||
<!-- Hidden selects for backward compatibility with saveSettings -->
|
<!-- 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-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></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>
|
</div>
|
||||||
|
|
||||||
<!-- Soulseek Settings (shown when soulseek is active source) -->
|
<!-- Soulseek Settings (shown when soulseek is active source) -->
|
||||||
|
|
@ -4119,6 +4120,41 @@
|
||||||
</div>
|
</div>
|
||||||
</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) -->
|
<!-- YouTube Settings (shown when youtube or hybrid mode) -->
|
||||||
<div id="youtube-settings-container" style="display: none;">
|
<div id="youtube-settings-container" style="display: none;">
|
||||||
<div class="form-group">
|
<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: '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: 'qobuz', name: 'Qobuz', icon: 'https://www.svgrepo.com/show/504778/qobuz.svg', emoji: '🎧' },
|
||||||
{ id: 'hifi', name: 'HiFi', icon: null, 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 _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
|
let _hybridVisualOrder = null; // Full visual order including disabled sources
|
||||||
|
|
||||||
function buildHybridSourceList() {
|
function buildHybridSourceList() {
|
||||||
|
|
@ -5550,6 +5551,8 @@ async function loadSettingsData() {
|
||||||
document.getElementById('tidal-download-quality').value = settings.tidal_download?.quality || 'lossless';
|
document.getElementById('tidal-download-quality').value = settings.tidal_download?.quality || 'lossless';
|
||||||
document.getElementById('qobuz-quality').value = settings.qobuz?.quality || 'lossless';
|
document.getElementById('qobuz-quality').value = settings.qobuz?.quality || 'lossless';
|
||||||
document.getElementById('hifi-download-quality').value = settings.hifi_download?.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
|
// Populate YouTube settings
|
||||||
document.getElementById('youtube-cookies-browser').value = settings.youtube?.cookies_browser || '';
|
document.getElementById('youtube-cookies-browser').value = settings.youtube?.cookies_browser || '';
|
||||||
|
|
@ -5797,6 +5800,7 @@ function updateDownloadSourceUI() {
|
||||||
const qobuzContainer = document.getElementById('qobuz-settings-container');
|
const qobuzContainer = document.getElementById('qobuz-settings-container');
|
||||||
const youtubeContainer = document.getElementById('youtube-settings-container');
|
const youtubeContainer = document.getElementById('youtube-settings-container');
|
||||||
const hifiContainer = document.getElementById('hifi-download-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';
|
hybridContainer.style.display = mode === 'hybrid' ? 'block' : 'none';
|
||||||
|
|
||||||
|
|
@ -5816,6 +5820,7 @@ function updateDownloadSourceUI() {
|
||||||
qobuzContainer.style.display = activeSources.has('qobuz') ? 'block' : 'none';
|
qobuzContainer.style.display = activeSources.has('qobuz') ? 'block' : 'none';
|
||||||
youtubeContainer.style.display = activeSources.has('youtube') ? 'block' : 'none';
|
youtubeContainer.style.display = activeSources.has('youtube') ? 'block' : 'none';
|
||||||
hifiContainer.style.display = activeSources.has('hifi') ? '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
|
// Quality profile is Soulseek-only and downloads-tab-only
|
||||||
const qualityProfileSection = document.getElementById('quality-profile-section');
|
const qualityProfileSection = document.getElementById('quality-profile-section');
|
||||||
|
|
@ -6549,6 +6554,10 @@ async function saveSettings(quiet = false) {
|
||||||
hifi_download: {
|
hifi_download: {
|
||||||
quality: document.getElementById('hifi-download-quality').value || 'lossless'
|
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: {
|
qobuz: {
|
||||||
quality: document.getElementById('qobuz-quality').value || 'lossless',
|
quality: document.getElementById('qobuz-quality').value || 'lossless',
|
||||||
embed_tags: document.getElementById('embed-qobuz').checked,
|
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() {
|
async function checkTidalDownloadAuthStatus() {
|
||||||
const statusEl = document.getElementById('tidal-download-auth-status');
|
const statusEl = document.getElementById('tidal-download-auth-status');
|
||||||
const btn = document.getElementById('tidal-download-auth-btn');
|
const btn = document.getElementById('tidal-download-auth-btn');
|
||||||
|
|
@ -35552,7 +35593,7 @@ function updateServiceStatus(service, statusData) {
|
||||||
|
|
||||||
// Update download source title on dashboard card
|
// Update download source title on dashboard card
|
||||||
if (service === 'soulseek' && statusData.source) {
|
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 displayName = sourceNames[statusData.source] || 'Soulseek';
|
||||||
const titleEl = document.getElementById('download-source-title');
|
const titleEl = document.getElementById('download-source-title');
|
||||||
if (titleEl) titleEl.textContent = displayName;
|
if (titleEl) titleEl.textContent = displayName;
|
||||||
|
|
@ -35600,7 +35641,7 @@ function updateSidebarServiceStatus(service, statusData) {
|
||||||
|
|
||||||
// Update download source name based on configured mode
|
// Update download source name based on configured mode
|
||||||
if (service === 'soulseek' && statusData.source) {
|
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 displayName = sourceNames[statusData.source] || 'Soulseek';
|
||||||
const sidebarName = document.getElementById('download-source-name');
|
const sidebarName = document.getElementById('download-source-name');
|
||||||
if (sidebarName) sidebarName.textContent = displayName;
|
if (sidebarName) sidebarName.textContent = displayName;
|
||||||
|
|
|
||||||
|
|
@ -45936,6 +45936,7 @@ tr.tag-diff-same {
|
||||||
#settings-page #tidal-download-settings-container,
|
#settings-page #tidal-download-settings-container,
|
||||||
#settings-page #qobuz-settings-container,
|
#settings-page #qobuz-settings-container,
|
||||||
#settings-page #hifi-download-settings-container,
|
#settings-page #hifi-download-settings-container,
|
||||||
|
#settings-page #deezer-download-settings-container,
|
||||||
#settings-page #youtube-settings-container,
|
#settings-page #youtube-settings-container,
|
||||||
#settings-page #hybrid-settings-container {
|
#settings-page #hybrid-settings-container {
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
|
@ -45949,6 +45950,7 @@ tr.tag-diff-same {
|
||||||
#settings-page #tidal-download-settings-container:hover,
|
#settings-page #tidal-download-settings-container:hover,
|
||||||
#settings-page #qobuz-settings-container:hover,
|
#settings-page #qobuz-settings-container:hover,
|
||||||
#settings-page #hifi-download-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 #youtube-settings-container:hover,
|
||||||
#settings-page #hybrid-settings-container:hover {
|
#settings-page #hybrid-settings-container:hover {
|
||||||
border-color: rgba(255, 255, 255, 0.1);
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue