Full qobuz support
This commit is contained in:
parent
4390036556
commit
fb04d0f4bc
6 changed files with 1210 additions and 50 deletions
|
|
@ -188,7 +188,7 @@ class ConfigManager:
|
|||
"transfer_path": "./Transfer"
|
||||
},
|
||||
"download_source": {
|
||||
"mode": "soulseek", # Options: "soulseek", "youtube", "tidal", "hybrid"
|
||||
"mode": "soulseek", # Options: "soulseek", "youtube", "tidal", "qobuz", "hybrid"
|
||||
"hybrid_primary": "soulseek", # Which source to try first in hybrid mode
|
||||
"youtube_min_confidence": 0.65 # Minimum confidence for YouTube matches
|
||||
},
|
||||
|
|
@ -201,6 +201,14 @@ class ConfigManager:
|
|||
"expiry_time": 0
|
||||
}
|
||||
},
|
||||
"qobuz": {
|
||||
"quality": "lossless", # Options: "mp3", "lossless", "hires", "hires_max"
|
||||
"session": {
|
||||
"app_id": "",
|
||||
"app_secret": "",
|
||||
"user_auth_token": ""
|
||||
}
|
||||
},
|
||||
"listenbrainz": {
|
||||
"base_url": "",
|
||||
"token": ""
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
"""
|
||||
Download Orchestrator
|
||||
Routes downloads between Soulseek, YouTube, and Tidal based on configuration.
|
||||
Routes downloads between Soulseek, YouTube, Tidal, and Qobuz based on configuration.
|
||||
|
||||
Supports four modes:
|
||||
Supports five modes:
|
||||
- Soulseek Only: Traditional behavior
|
||||
- YouTube Only: YouTube-exclusive downloads
|
||||
- Tidal Only: Tidal-exclusive downloads
|
||||
- Hybrid: Try primary source first, fallback to secondary if it fails
|
||||
- Qobuz Only: Qobuz-exclusive downloads
|
||||
- Hybrid: Try primary source first, fallback to others
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
|
@ -18,6 +19,7 @@ from config.settings import config_manager
|
|||
from core.soulseek_client import SoulseekClient, TrackResult, AlbumResult, DownloadStatus
|
||||
from core.youtube_client import YouTubeClient
|
||||
from core.tidal_download_client import TidalDownloadClient
|
||||
from core.qobuz_client import QobuzClient
|
||||
|
||||
logger = get_logger("download_orchestrator")
|
||||
|
||||
|
|
@ -35,6 +37,7 @@ class DownloadOrchestrator:
|
|||
self.soulseek = SoulseekClient()
|
||||
self.youtube = YouTubeClient()
|
||||
self.tidal = TidalDownloadClient()
|
||||
self.qobuz = QobuzClient()
|
||||
|
||||
# Load mode from config
|
||||
self.mode = config_manager.get('download_source.mode', 'soulseek')
|
||||
|
|
@ -69,9 +72,10 @@ class DownloadOrchestrator:
|
|||
return self.youtube.is_configured()
|
||||
elif self.mode == 'tidal':
|
||||
return self.tidal.is_configured()
|
||||
elif self.mode == 'qobuz':
|
||||
return self.qobuz.is_configured()
|
||||
elif self.mode == 'hybrid':
|
||||
# In hybrid mode, at least one source must be configured
|
||||
return self.soulseek.is_configured() or self.youtube.is_configured() or self.tidal.is_configured()
|
||||
return self.soulseek.is_configured() or self.youtube.is_configured() or self.tidal.is_configured() or self.qobuz.is_configured()
|
||||
|
||||
return False
|
||||
|
||||
|
|
@ -87,16 +91,17 @@ class DownloadOrchestrator:
|
|||
return await self.youtube.check_connection()
|
||||
elif self.mode == 'tidal':
|
||||
return await self.tidal.check_connection()
|
||||
elif self.mode == 'qobuz':
|
||||
return await self.qobuz.check_connection()
|
||||
elif self.mode == 'hybrid':
|
||||
# In hybrid mode, check all sources
|
||||
soulseek_ok = await self.soulseek.check_connection()
|
||||
youtube_ok = await self.youtube.check_connection()
|
||||
tidal_ok = await self.tidal.check_connection()
|
||||
qobuz_ok = await self.qobuz.check_connection()
|
||||
|
||||
logger.info(f" Soulseek: {'✅' if soulseek_ok else '❌'} | YouTube: {'✅' if youtube_ok else '❌'} | Tidal: {'✅' if tidal_ok else '❌'}")
|
||||
logger.info(f" Soulseek: {'✅' if soulseek_ok else '❌'} | YouTube: {'✅' if youtube_ok else '❌'} | Tidal: {'✅' if tidal_ok else '❌'} | Qobuz: {'✅' if qobuz_ok else '❌'}")
|
||||
|
||||
# At least one must be available
|
||||
return soulseek_ok or youtube_ok or tidal_ok
|
||||
return soulseek_ok or youtube_ok or tidal_ok or qobuz_ok
|
||||
|
||||
return False
|
||||
|
||||
|
|
@ -124,12 +129,17 @@ class DownloadOrchestrator:
|
|||
logger.info(f"🔍 Searching Tidal: {query}")
|
||||
return await self.tidal.search(query, timeout, progress_callback)
|
||||
|
||||
elif self.mode == 'qobuz':
|
||||
logger.info(f"🔍 Searching Qobuz: {query}")
|
||||
return await self.qobuz.search(query, timeout, progress_callback)
|
||||
|
||||
elif self.mode == 'hybrid':
|
||||
# Build ordered client list: primary first, then remaining as fallbacks
|
||||
clients = {
|
||||
'soulseek': self.soulseek,
|
||||
'youtube': self.youtube,
|
||||
'tidal': self.tidal,
|
||||
'qobuz': self.qobuz,
|
||||
}
|
||||
primary = self.hybrid_primary if self.hybrid_primary in clients else 'soulseek'
|
||||
fallbacks = [name for name in clients if name != primary]
|
||||
|
|
@ -219,6 +229,9 @@ class DownloadOrchestrator:
|
|||
elif username == 'tidal':
|
||||
logger.info(f"📥 Downloading from Tidal: {filename}")
|
||||
return await self.tidal.download(username, filename, file_size)
|
||||
elif username == 'qobuz':
|
||||
logger.info(f"📥 Downloading from Qobuz: {filename}")
|
||||
return await self.qobuz.download(username, filename, file_size)
|
||||
else:
|
||||
logger.info(f"📥 Downloading from Soulseek: {filename}")
|
||||
return await self.soulseek.download(username, filename, file_size)
|
||||
|
|
@ -234,9 +247,9 @@ class DownloadOrchestrator:
|
|||
soulseek_downloads = await self.soulseek.get_all_downloads()
|
||||
youtube_downloads = await self.youtube.get_all_downloads()
|
||||
tidal_downloads = await self.tidal.get_all_downloads()
|
||||
qobuz_downloads = await self.qobuz.get_all_downloads()
|
||||
|
||||
# Combine and return
|
||||
return soulseek_downloads + youtube_downloads + tidal_downloads
|
||||
return soulseek_downloads + youtube_downloads + tidal_downloads + qobuz_downloads
|
||||
|
||||
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
|
||||
"""
|
||||
|
|
@ -263,6 +276,11 @@ class DownloadOrchestrator:
|
|||
if status:
|
||||
return status
|
||||
|
||||
# Try Qobuz
|
||||
status = await self.qobuz.get_download_status(download_id)
|
||||
if status:
|
||||
return status
|
||||
|
||||
return None
|
||||
|
||||
async def cancel_download(self, download_id: str, username: str = None, remove: bool = False) -> bool:
|
||||
|
|
@ -282,6 +300,8 @@ class DownloadOrchestrator:
|
|||
return await self.youtube.cancel_download(download_id, username, remove)
|
||||
elif username == 'tidal':
|
||||
return await self.tidal.cancel_download(download_id, username, remove)
|
||||
elif username == 'qobuz':
|
||||
return await self.qobuz.cancel_download(download_id, username, remove)
|
||||
elif username:
|
||||
return await self.soulseek.cancel_download(download_id, username, remove)
|
||||
|
||||
|
|
@ -295,7 +315,11 @@ class DownloadOrchestrator:
|
|||
return True
|
||||
|
||||
tidal_cancelled = await self.tidal.cancel_download(download_id, username, remove)
|
||||
return tidal_cancelled
|
||||
if tidal_cancelled:
|
||||
return True
|
||||
|
||||
qobuz_cancelled = await self.qobuz.cancel_download(download_id, username, remove)
|
||||
return qobuz_cancelled
|
||||
|
||||
async def signal_download_completion(self, download_id: str, username: str, remove: bool = True) -> bool:
|
||||
"""
|
||||
|
|
@ -320,11 +344,11 @@ class DownloadOrchestrator:
|
|||
True if successful
|
||||
"""
|
||||
soulseek_cleared = await self.soulseek.clear_all_completed_downloads()
|
||||
# YouTube and Tidal downloads must also be cleared from memory
|
||||
youtube_cleared = await self.youtube.clear_all_completed_downloads()
|
||||
tidal_cleared = await self.tidal.clear_all_completed_downloads()
|
||||
qobuz_cleared = await self.qobuz.clear_all_completed_downloads()
|
||||
|
||||
return soulseek_cleared and youtube_cleared and tidal_cleared
|
||||
return soulseek_cleared and youtube_cleared and tidal_cleared and qobuz_cleared
|
||||
|
||||
# ===== Soulseek-specific methods (for backwards compatibility) =====
|
||||
# These are internal methods that some parts of the codebase use directly
|
||||
|
|
@ -384,6 +408,6 @@ class DownloadOrchestrator:
|
|||
async def cancel_all_downloads(self) -> bool:
|
||||
"""Cancel and remove all downloads from all sources."""
|
||||
soulseek_ok = await self.soulseek.cancel_all_downloads()
|
||||
# Clear Tidal active downloads too
|
||||
tidal_ok = await self.tidal.clear_all_completed_downloads()
|
||||
await self.tidal.clear_all_completed_downloads()
|
||||
await self.qobuz.clear_all_completed_downloads()
|
||||
return soulseek_ok
|
||||
|
|
|
|||
941
core/qobuz_client.py
Normal file
941
core/qobuz_client.py
Normal file
|
|
@ -0,0 +1,941 @@
|
|||
"""
|
||||
Qobuz Download Client
|
||||
Alternative music download source using Qobuz's API.
|
||||
|
||||
This client provides:
|
||||
- Qobuz search with metadata
|
||||
- Email/password authentication
|
||||
- Hi-Res/Lossless/MP3 quality audio downloads
|
||||
- Drop-in replacement compatible with Soulseek interface
|
||||
|
||||
Requires a paid Qobuz subscription.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import hashlib
|
||||
import time
|
||||
import asyncio
|
||||
import uuid
|
||||
import threading
|
||||
import base64
|
||||
from typing import List, Optional, Dict, Any, Tuple
|
||||
from pathlib import Path
|
||||
|
||||
import requests as http_requests
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
from config.settings import config_manager
|
||||
|
||||
# Import Soulseek data structures for drop-in replacement compatibility
|
||||
from core.soulseek_client import TrackResult, AlbumResult, DownloadStatus
|
||||
|
||||
logger = get_logger("qobuz_client")
|
||||
|
||||
QOBUZ_API_BASE = "https://www.qobuz.com/api.json/0.2/"
|
||||
|
||||
# Quality tier definitions (format_id values)
|
||||
QOBUZ_QUALITY_MAP = {
|
||||
'mp3': {
|
||||
'format_id': 5,
|
||||
'label': 'MP3 320kbps',
|
||||
'extension': 'mp3',
|
||||
'bitrate': 320,
|
||||
'codec': 'mp3',
|
||||
},
|
||||
'lossless': {
|
||||
'format_id': 6,
|
||||
'label': 'FLAC 16-bit/44.1kHz (CD)',
|
||||
'extension': 'flac',
|
||||
'bitrate': 1411,
|
||||
'codec': 'flac',
|
||||
},
|
||||
'hires': {
|
||||
'format_id': 7,
|
||||
'label': 'FLAC 24-bit/96kHz (Hi-Res)',
|
||||
'extension': 'flac',
|
||||
'bitrate': 4608,
|
||||
'codec': 'flac',
|
||||
},
|
||||
'hires_max': {
|
||||
'format_id': 27,
|
||||
'label': 'FLAC 24-bit/192kHz (Hi-Res Max)',
|
||||
'extension': 'flac',
|
||||
'bitrate': 9216,
|
||||
'codec': 'flac',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class QobuzClient:
|
||||
"""
|
||||
Qobuz download client using Qobuz REST API.
|
||||
Provides search, matching, and download capabilities as a drop-in alternative to Soulseek/YouTube/Tidal.
|
||||
"""
|
||||
|
||||
def __init__(self, download_path: str = None):
|
||||
# Use Soulseek download path for consistency (post-processing expects files here)
|
||||
if download_path is None:
|
||||
download_path = config_manager.get('soulseek.download_path', './downloads')
|
||||
|
||||
self.download_path = Path(download_path)
|
||||
self.download_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger.info(f"Qobuz client using download path: {self.download_path}")
|
||||
|
||||
# Callback for shutdown check
|
||||
self.shutdown_check = None
|
||||
|
||||
# HTTP session
|
||||
self.session = http_requests.Session()
|
||||
self.session.headers.update({
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0',
|
||||
})
|
||||
|
||||
# Auth state
|
||||
self.app_id: Optional[str] = None
|
||||
self.app_secret: Optional[str] = None
|
||||
self.user_auth_token: Optional[str] = None
|
||||
self.user_info: Optional[Dict] = None
|
||||
self._auth_error: Optional[str] = None
|
||||
|
||||
# Download queue management
|
||||
self.active_downloads: Dict[str, Dict[str, Any]] = {}
|
||||
self._download_lock = threading.Lock()
|
||||
|
||||
# Try to restore saved session
|
||||
self._restore_session()
|
||||
|
||||
def set_shutdown_check(self, check_callable):
|
||||
"""Set a callback function to check for system shutdown"""
|
||||
self.shutdown_check = check_callable
|
||||
|
||||
# ===================== Auth =====================
|
||||
|
||||
def _restore_session(self):
|
||||
"""Try to restore saved session from config."""
|
||||
saved = config_manager.get('qobuz.session', {})
|
||||
app_id = saved.get('app_id', '')
|
||||
app_secret = saved.get('app_secret', '')
|
||||
user_auth_token = saved.get('user_auth_token', '')
|
||||
|
||||
if app_id and app_secret and user_auth_token:
|
||||
self.app_id = app_id
|
||||
self.app_secret = app_secret
|
||||
self.user_auth_token = user_auth_token
|
||||
self.session.headers.update({
|
||||
'X-App-Id': self.app_id,
|
||||
'X-User-Auth-Token': self.user_auth_token,
|
||||
})
|
||||
|
||||
# Verify the token is still valid
|
||||
try:
|
||||
resp = self.session.get(
|
||||
QOBUZ_API_BASE + 'user/get',
|
||||
params={'user_id': 'me'},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
self.user_info = data
|
||||
logger.info(f"Restored Qobuz session for user: {data.get('display_name', data.get('email', 'unknown'))}")
|
||||
return
|
||||
else:
|
||||
logger.warning(f"Saved Qobuz session invalid (HTTP {resp.status_code})")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not verify saved Qobuz session: {e}")
|
||||
|
||||
# Token invalid, clear it
|
||||
self.user_auth_token = None
|
||||
self.session.headers.pop('X-User-Auth-Token', None)
|
||||
|
||||
def _save_session(self):
|
||||
"""Persist session to config."""
|
||||
config_manager.set('qobuz.session', {
|
||||
'app_id': self.app_id or '',
|
||||
'app_secret': self.app_secret or '',
|
||||
'user_auth_token': self.user_auth_token or '',
|
||||
})
|
||||
|
||||
def _extract_app_credentials(self) -> bool:
|
||||
"""
|
||||
Extract app_id and app_secret from Qobuz web player bundle.
|
||||
|
||||
The secret is obfuscated across three base64 fragments tied to timezone entries:
|
||||
1. initialSeed() calls pair a seed with a timezone name
|
||||
2. Timezone objects have info and extras fields
|
||||
3. Concatenate seed + info + extras, drop last 44 chars, base64 decode
|
||||
|
||||
Returns True if successful.
|
||||
"""
|
||||
try:
|
||||
logger.info("Extracting Qobuz app credentials from web player...")
|
||||
|
||||
# Step 1: Fetch login page to find bundle.js URL
|
||||
login_page = self.session.get('https://play.qobuz.com/login', timeout=15)
|
||||
if login_page.status_code != 200:
|
||||
logger.error(f"Could not fetch Qobuz login page: HTTP {login_page.status_code}")
|
||||
return False
|
||||
|
||||
# Find bundle.js URL in the HTML
|
||||
bundle_pattern = r'<script\s+src="(/resources/\d+\.\d+\.\d+-[a-z]\d+/bundle\.js)"'
|
||||
match = re.search(bundle_pattern, login_page.text)
|
||||
if not match:
|
||||
bundle_pattern = r'<script\s+src="([^"]*bundle[^"]*\.js)"'
|
||||
match = re.search(bundle_pattern, login_page.text)
|
||||
|
||||
if not match:
|
||||
logger.error("Could not find bundle.js URL in Qobuz login page")
|
||||
return False
|
||||
|
||||
bundle_url = 'https://play.qobuz.com' + match.group(1)
|
||||
logger.info(f"Found bundle URL: {bundle_url}")
|
||||
|
||||
# Step 2: Download the bundle
|
||||
bundle_resp = self.session.get(bundle_url, timeout=30)
|
||||
if bundle_resp.status_code != 200:
|
||||
logger.error(f"Could not download Qobuz bundle: HTTP {bundle_resp.status_code}")
|
||||
return False
|
||||
|
||||
bundle_text = bundle_resp.text
|
||||
|
||||
# Step 3: Extract app_id
|
||||
app_id_match = re.search(r'production:\{api:\{appId:"(\d{9})"', bundle_text)
|
||||
if not app_id_match:
|
||||
app_id_match = re.search(r'app_id\s*[:=]\s*"(\d{9})"', bundle_text)
|
||||
|
||||
if not app_id_match:
|
||||
logger.error("Could not extract app_id from Qobuz bundle")
|
||||
return False
|
||||
|
||||
self.app_id = app_id_match.group(1)
|
||||
logger.info(f"Extracted app_id: {self.app_id}")
|
||||
|
||||
# Step 4: Extract seed + timezone pairs from initialSeed() calls
|
||||
from collections import OrderedDict
|
||||
seed_timezone_regex = re.compile(
|
||||
r'[a-z]\.initialSeed\("(?P<seed>[\w=]+)",\s*window\.utimezone\.(?P<timezone>[a-z]+)\)'
|
||||
)
|
||||
|
||||
secrets = OrderedDict()
|
||||
for m in seed_timezone_regex.finditer(bundle_text):
|
||||
seed = m.group("seed")
|
||||
timezone = m.group("timezone")
|
||||
secrets[timezone] = [seed]
|
||||
|
||||
if not secrets:
|
||||
logger.warning("No initialSeed() calls found in bundle — trying fallback extraction")
|
||||
return self._extract_app_credentials_fallback(bundle_text)
|
||||
|
||||
logger.info(f"Found {len(secrets)} seed/timezone pairs: {list(secrets.keys())}")
|
||||
|
||||
# Step 5: Extract info + extras for each timezone
|
||||
timezones_pattern = "|".join([tz.capitalize() for tz in secrets.keys()])
|
||||
info_extras_regex = re.compile(
|
||||
rf'name:"\w+/(?P<timezone>{timezones_pattern})",info:"(?P<info>[\w=]+)",extras:"(?P<extras>[\w=]+)"'
|
||||
)
|
||||
|
||||
for m in info_extras_regex.finditer(bundle_text):
|
||||
timezone = m.group("timezone").lower()
|
||||
info = m.group("info")
|
||||
extras = m.group("extras")
|
||||
if timezone in secrets:
|
||||
secrets[timezone].extend([info, extras])
|
||||
|
||||
# Step 6: Decode each candidate secret
|
||||
# Concatenate 3 fragments, drop last 44 chars, base64 decode
|
||||
decoded_secrets = []
|
||||
for tz, fragments in secrets.items():
|
||||
if len(fragments) != 3:
|
||||
logger.debug(f"Timezone {tz} has {len(fragments)} fragments (need 3), skipping")
|
||||
continue
|
||||
|
||||
combined = "".join(fragments)
|
||||
trimmed = combined[:-44]
|
||||
|
||||
try:
|
||||
decoded = base64.b64decode(trimmed).decode("utf-8")
|
||||
if decoded and len(decoded) >= 30:
|
||||
decoded_secrets.append((tz, decoded))
|
||||
logger.debug(f"Decoded candidate secret from {tz}: {decoded[:8]}...")
|
||||
except (base64.binascii.Error, UnicodeDecodeError) as e:
|
||||
logger.debug(f"Failed to decode secret from {tz}: {e}")
|
||||
continue
|
||||
|
||||
if not decoded_secrets:
|
||||
logger.warning("No valid secrets decoded — trying fallback")
|
||||
return self._extract_app_credentials_fallback(bundle_text)
|
||||
|
||||
# Step 7: Validate which secret works by test-signing an API call
|
||||
for tz, secret in decoded_secrets:
|
||||
if self._test_secret(secret):
|
||||
self.app_secret = secret
|
||||
logger.info(f"Found working app_secret via timezone: {tz}")
|
||||
return True
|
||||
|
||||
logger.error(f"None of {len(decoded_secrets)} decoded secrets passed validation")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to extract Qobuz app credentials: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
def _extract_app_credentials_fallback(self, bundle_text: str) -> bool:
|
||||
"""Fallback: try direct hex string extraction from bundle."""
|
||||
secret_matches = re.findall(r'["\']([a-f0-9]{32})["\']', bundle_text)
|
||||
for secret_candidate in secret_matches:
|
||||
if self._test_secret(secret_candidate):
|
||||
self.app_secret = secret_candidate
|
||||
logger.info("Found working app_secret via direct hex extraction")
|
||||
return True
|
||||
|
||||
logger.error("Could not extract working app_secret from Qobuz bundle (all methods exhausted)")
|
||||
return False
|
||||
|
||||
def _test_secret(self, secret: str) -> bool:
|
||||
"""Test if an app_secret works by making a signed stream URL request."""
|
||||
if not self.app_id or not secret:
|
||||
return False
|
||||
|
||||
try:
|
||||
ts = int(time.time())
|
||||
# Sign a request for track_id=1 with format_id=27 (same as qobuz-dl validation)
|
||||
sig_raw = f"trackgetFileUrlformat_id27intentstreamtrack_id1{ts}{secret}"
|
||||
sig = hashlib.md5(sig_raw.encode()).hexdigest()
|
||||
|
||||
resp = self.session.get(
|
||||
QOBUZ_API_BASE + 'track/getFileUrl',
|
||||
params={
|
||||
'track_id': 1,
|
||||
'format_id': 27,
|
||||
'intent': 'stream',
|
||||
'request_ts': ts,
|
||||
'request_sig': sig,
|
||||
},
|
||||
headers={'X-App-Id': self.app_id},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
# 400 = "Invalid Request Signature" means bad secret
|
||||
# 200/401/403 = signature was accepted (just auth/permission issue)
|
||||
is_valid = resp.status_code != 400
|
||||
if is_valid:
|
||||
logger.debug(f"Secret test passed (HTTP {resp.status_code})")
|
||||
else:
|
||||
logger.debug(f"Secret test failed (HTTP 400 — invalid signature)")
|
||||
return is_valid
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Secret test exception: {e}")
|
||||
return False
|
||||
|
||||
def login(self, email: str, password: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Login to Qobuz with email/password.
|
||||
|
||||
Returns dict with status info:
|
||||
{'status': 'success'|'error', 'message': '...', 'user': {...}}
|
||||
"""
|
||||
self._auth_error = None
|
||||
|
||||
try:
|
||||
# Step 1: Extract app credentials if we don't have them
|
||||
if not self.app_id or not self.app_secret:
|
||||
if not self._extract_app_credentials():
|
||||
self._auth_error = 'Could not extract Qobuz app credentials. Qobuz may have updated their web player.'
|
||||
return {'status': 'error', 'message': self._auth_error}
|
||||
|
||||
# Step 2: Login with email/password
|
||||
self.session.headers['X-App-Id'] = self.app_id
|
||||
|
||||
resp = self.session.get(
|
||||
QOBUZ_API_BASE + 'user/login',
|
||||
params={
|
||||
'email': email,
|
||||
'password': password,
|
||||
'app_id': self.app_id,
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
if resp.status_code == 401:
|
||||
self._auth_error = 'Invalid email or password'
|
||||
return {'status': 'error', 'message': self._auth_error}
|
||||
elif resp.status_code == 400:
|
||||
data = resp.json() if resp.text else {}
|
||||
self._auth_error = data.get('message', 'Login failed — check your credentials')
|
||||
return {'status': 'error', 'message': self._auth_error}
|
||||
elif resp.status_code != 200:
|
||||
self._auth_error = f'Qobuz API error (HTTP {resp.status_code})'
|
||||
return {'status': 'error', 'message': self._auth_error}
|
||||
|
||||
data = resp.json()
|
||||
|
||||
# Extract user auth token
|
||||
self.user_auth_token = data.get('user_auth_token')
|
||||
if not self.user_auth_token:
|
||||
self._auth_error = 'No auth token in response'
|
||||
return {'status': 'error', 'message': self._auth_error}
|
||||
|
||||
self.user_info = data.get('user', {})
|
||||
self.session.headers['X-User-Auth-Token'] = self.user_auth_token
|
||||
|
||||
# Check subscription status
|
||||
subscription = self.user_info.get('credential', {})
|
||||
sub_label = subscription.get('label', 'Unknown')
|
||||
|
||||
# Save session
|
||||
self._save_session()
|
||||
|
||||
display_name = self.user_info.get('display_name', self.user_info.get('email', email))
|
||||
logger.info(f"Qobuz login successful: {display_name} (plan: {sub_label})")
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': f'Logged in as {display_name}',
|
||||
'user': {
|
||||
'display_name': display_name,
|
||||
'subscription': sub_label,
|
||||
'email': self.user_info.get('email', email),
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self._auth_error = str(e)
|
||||
logger.error(f"Qobuz login failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {'status': 'error', 'message': self._auth_error}
|
||||
|
||||
def logout(self):
|
||||
"""Clear Qobuz session."""
|
||||
self.user_auth_token = None
|
||||
self.user_info = None
|
||||
self.app_id = None
|
||||
self.app_secret = None
|
||||
self._auth_error = None
|
||||
self.session.headers.pop('X-User-Auth-Token', None)
|
||||
self.session.headers.pop('X-App-Id', None)
|
||||
config_manager.set('qobuz.session', {})
|
||||
logger.info("Qobuz session cleared")
|
||||
|
||||
def is_authenticated(self) -> bool:
|
||||
"""Check if we have a valid Qobuz session."""
|
||||
return bool(self.user_auth_token and self.app_id and self.app_secret)
|
||||
|
||||
# ===================== Search =====================
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if Qobuz client is available and authenticated."""
|
||||
return self.is_authenticated()
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""Check if Qobuz client is configured (matches Soulseek interface)."""
|
||||
return self.is_available()
|
||||
|
||||
async def check_connection(self) -> bool:
|
||||
"""Test if Qobuz is accessible (async, Soulseek-compatible)."""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, self.is_available)
|
||||
except Exception as e:
|
||||
logger.error(f"Qobuz connection check failed: {e}")
|
||||
return False
|
||||
|
||||
def _api_request(self, endpoint: str, params: Dict = None) -> Optional[Dict]:
|
||||
"""Make an authenticated API request to Qobuz."""
|
||||
if not self.is_authenticated():
|
||||
logger.warning("Qobuz not authenticated")
|
||||
return None
|
||||
|
||||
try:
|
||||
resp = self.session.get(
|
||||
QOBUZ_API_BASE + endpoint,
|
||||
params=params or {},
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
if resp.status_code == 401:
|
||||
logger.warning("Qobuz auth token expired")
|
||||
self.user_auth_token = None
|
||||
return None
|
||||
elif resp.status_code != 200:
|
||||
logger.warning(f"Qobuz API error: {endpoint} returned HTTP {resp.status_code}")
|
||||
return None
|
||||
|
||||
return resp.json()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Qobuz API request failed ({endpoint}): {e}")
|
||||
return None
|
||||
|
||||
async def search(self, query: str, timeout: int = None, progress_callback=None) -> Tuple[List[TrackResult], List[AlbumResult]]:
|
||||
"""
|
||||
Search Qobuz for tracks (async, Soulseek-compatible interface).
|
||||
|
||||
Returns:
|
||||
Tuple of (track_results, album_results). Album results always empty.
|
||||
"""
|
||||
if not self.is_available():
|
||||
logger.warning("Qobuz not available for search (not authenticated)")
|
||||
return ([], [])
|
||||
|
||||
logger.info(f"Searching Qobuz for: {query}")
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _search():
|
||||
return self._api_request('track/search', {
|
||||
'query': query,
|
||||
'limit': 50,
|
||||
})
|
||||
|
||||
data = await loop.run_in_executor(None, _search)
|
||||
|
||||
if not data or 'tracks' not in data:
|
||||
logger.warning(f"No Qobuz results for: {query}")
|
||||
return ([], [])
|
||||
|
||||
tracks_data = data['tracks'].get('items', [])
|
||||
if not tracks_data:
|
||||
return ([], [])
|
||||
|
||||
# Get configured quality for display
|
||||
quality_key = config_manager.get('qobuz.quality', 'lossless')
|
||||
quality_info = QOBUZ_QUALITY_MAP.get(quality_key, QOBUZ_QUALITY_MAP['lossless'])
|
||||
|
||||
track_results = []
|
||||
for track in tracks_data:
|
||||
try:
|
||||
track_result = self._qobuz_to_track_result(track, quality_info)
|
||||
if track_result:
|
||||
track_results.append(track_result)
|
||||
except Exception as e:
|
||||
logger.debug(f"Skipping track conversion error: {e}")
|
||||
|
||||
logger.info(f"Found {len(track_results)} Qobuz tracks")
|
||||
return (track_results, [])
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Qobuz search failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return ([], [])
|
||||
|
||||
def _qobuz_to_track_result(self, track: Dict, quality_info: dict) -> Optional[TrackResult]:
|
||||
"""Convert Qobuz track dict to TrackResult (Soulseek-compatible format)."""
|
||||
track_id = track.get('id')
|
||||
if not track_id:
|
||||
return None
|
||||
|
||||
# Check if track is streamable
|
||||
if not track.get('streamable', False):
|
||||
return None
|
||||
|
||||
performer = track.get('performer', {})
|
||||
artist_name = performer.get('name', 'Unknown Artist') if isinstance(performer, dict) else str(performer)
|
||||
title = track.get('title', 'Unknown Title')
|
||||
|
||||
# Clean up title — Qobuz sometimes appends version info
|
||||
version = track.get('version')
|
||||
if version and version not in title:
|
||||
title = f"{title} ({version})"
|
||||
|
||||
album_data = track.get('album', {})
|
||||
album_name = album_data.get('title', None) if isinstance(album_data, dict) else None
|
||||
|
||||
# Duration in milliseconds
|
||||
duration_s = track.get('duration')
|
||||
duration_ms = int(duration_s * 1000) if duration_s else None
|
||||
|
||||
# Determine actual max quality available for this track
|
||||
hires_streamable = track.get('hires_streamable', False)
|
||||
max_bit_depth = album_data.get('maximum_bit_depth', 16) if isinstance(album_data, dict) else 16
|
||||
max_sample_rate = album_data.get('maximum_sampling_rate', 44.1) if isinstance(album_data, dict) else 44.1
|
||||
|
||||
# Build quality display string
|
||||
if hires_streamable and max_bit_depth >= 24:
|
||||
actual_quality = f"FLAC {max_bit_depth}-bit/{max_sample_rate}kHz"
|
||||
actual_bitrate = quality_info.get('bitrate', 1411)
|
||||
else:
|
||||
actual_quality = quality_info.get('codec', 'flac')
|
||||
actual_bitrate = quality_info.get('bitrate', 1411)
|
||||
|
||||
# Encode track_id in filename (same pattern as YouTube/Tidal: "id||display_name")
|
||||
display_name = f"{artist_name} - {title}"
|
||||
filename = f"{track_id}||{display_name}"
|
||||
|
||||
# Album cover URL
|
||||
album_image = None
|
||||
if isinstance(album_data, dict) and album_data.get('image'):
|
||||
album_image = album_data['image'].get('large', album_data['image'].get('small'))
|
||||
|
||||
track_result = TrackResult(
|
||||
username='qobuz',
|
||||
filename=filename,
|
||||
size=0, # Unknown until download
|
||||
bitrate=actual_bitrate,
|
||||
duration=duration_ms,
|
||||
quality=actual_quality,
|
||||
free_upload_slots=999,
|
||||
upload_speed=999999,
|
||||
queue_length=0,
|
||||
artist=artist_name,
|
||||
title=title,
|
||||
album=album_name,
|
||||
track_number=track.get('track_number'),
|
||||
)
|
||||
|
||||
return track_result
|
||||
|
||||
# ===================== Download =====================
|
||||
|
||||
def _get_stream_url(self, track_id, format_id: int) -> Optional[Dict]:
|
||||
"""
|
||||
Get a signed stream URL for a Qobuz track.
|
||||
|
||||
Returns dict with 'url', 'format_id', 'mime_type', etc. or None.
|
||||
"""
|
||||
if not self.app_secret:
|
||||
logger.error("No app_secret available for stream URL signing")
|
||||
return None
|
||||
|
||||
ts = str(int(time.time()))
|
||||
sig_raw = f"trackgetFileUrlformat_id{format_id}intentstreamtrack_id{track_id}{ts}{self.app_secret}"
|
||||
sig = hashlib.md5(sig_raw.encode()).hexdigest()
|
||||
|
||||
try:
|
||||
resp = self.session.get(
|
||||
QOBUZ_API_BASE + 'track/getFileUrl',
|
||||
params={
|
||||
'track_id': str(track_id),
|
||||
'format_id': format_id,
|
||||
'intent': 'stream',
|
||||
'request_ts': ts,
|
||||
'request_sig': sig,
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
if resp.status_code == 401:
|
||||
logger.warning("Qobuz stream URL auth failed — token may be expired")
|
||||
return None
|
||||
elif resp.status_code == 400:
|
||||
data = resp.json() if resp.text else {}
|
||||
logger.warning(f"Qobuz stream URL rejected: {data.get('message', 'unknown error')}")
|
||||
return None
|
||||
elif resp.status_code != 200:
|
||||
logger.warning(f"Qobuz stream URL failed: HTTP {resp.status_code}")
|
||||
return None
|
||||
|
||||
data = resp.json()
|
||||
if 'url' not in data:
|
||||
logger.warning("No URL in Qobuz stream response")
|
||||
return None
|
||||
|
||||
return data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get Qobuz stream URL: {e}")
|
||||
return None
|
||||
|
||||
async def download(self, username: str, filename: str, file_size: int = 0) -> Optional[str]:
|
||||
"""
|
||||
Download a Qobuz track (async, Soulseek-compatible interface).
|
||||
|
||||
Returns download_id immediately and runs download in background thread.
|
||||
|
||||
Args:
|
||||
username: Ignored for Qobuz (always "qobuz")
|
||||
filename: Encoded as "track_id||display_name"
|
||||
file_size: Ignored
|
||||
"""
|
||||
try:
|
||||
if '||' not in filename:
|
||||
logger.error(f"Invalid filename format: {filename}")
|
||||
return None
|
||||
|
||||
track_id_str, display_name = filename.split('||', 1)
|
||||
try:
|
||||
track_id = int(track_id_str)
|
||||
except ValueError:
|
||||
logger.error(f"Invalid Qobuz track ID: {track_id_str}")
|
||||
return None
|
||||
|
||||
logger.info(f"Starting Qobuz download: {display_name}")
|
||||
|
||||
download_id = str(uuid.uuid4())
|
||||
|
||||
with self._download_lock:
|
||||
self.active_downloads[download_id] = {
|
||||
'id': download_id,
|
||||
'filename': filename,
|
||||
'username': 'qobuz',
|
||||
'state': 'Initializing',
|
||||
'progress': 0.0,
|
||||
'size': 0,
|
||||
'transferred': 0,
|
||||
'speed': 0,
|
||||
'time_remaining': None,
|
||||
'track_id': track_id,
|
||||
'display_name': display_name,
|
||||
'file_path': None,
|
||||
}
|
||||
|
||||
# Start download in background thread
|
||||
download_thread = threading.Thread(
|
||||
target=self._download_thread_worker,
|
||||
args=(download_id, track_id, display_name, filename),
|
||||
daemon=True,
|
||||
)
|
||||
download_thread.start()
|
||||
|
||||
logger.info(f"Qobuz download {download_id} started in background")
|
||||
return download_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start Qobuz download: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
def _download_thread_worker(self, download_id: str, track_id: int, display_name: str, original_filename: str):
|
||||
"""Background thread worker for downloading Qobuz tracks."""
|
||||
try:
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['state'] = 'InProgress, Downloading'
|
||||
|
||||
file_path = self._download_sync(download_id, track_id, display_name)
|
||||
|
||||
if file_path:
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['state'] = 'Completed, Succeeded'
|
||||
self.active_downloads[download_id]['progress'] = 100.0
|
||||
self.active_downloads[download_id]['file_path'] = file_path
|
||||
|
||||
logger.info(f"Qobuz download {download_id} completed: {file_path}")
|
||||
else:
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['state'] = 'Errored'
|
||||
|
||||
logger.error(f"Qobuz download {download_id} failed")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Qobuz download thread failed for {download_id}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['state'] = 'Errored'
|
||||
|
||||
def _download_sync(self, download_id: str, track_id: int, display_name: str) -> Optional[str]:
|
||||
"""
|
||||
Synchronous download method (runs in background thread).
|
||||
|
||||
Returns file path if successful, None otherwise.
|
||||
"""
|
||||
if not self.is_authenticated():
|
||||
logger.error("Qobuz not authenticated")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Determine quality
|
||||
quality_key = config_manager.get('qobuz.quality', 'lossless')
|
||||
quality_info = QOBUZ_QUALITY_MAP.get(quality_key, QOBUZ_QUALITY_MAP['lossless'])
|
||||
|
||||
# Quality fallback chain: hires_max → hires → lossless → mp3
|
||||
quality_chain = ['hires_max', 'hires', 'lossless', 'mp3']
|
||||
start_idx = quality_chain.index(quality_key) if quality_key in quality_chain else 2
|
||||
chain = quality_chain[start_idx:]
|
||||
|
||||
stream_data = None
|
||||
actual_quality = None
|
||||
for q_key in chain:
|
||||
q_info = QOBUZ_QUALITY_MAP[q_key]
|
||||
stream_data = self._get_stream_url(track_id, q_info['format_id'])
|
||||
if stream_data and 'url' in stream_data:
|
||||
actual_quality = q_info
|
||||
logger.info(f"Got Qobuz stream at quality: {q_key} ({q_info['label']})")
|
||||
break
|
||||
else:
|
||||
logger.debug(f"Quality {q_key} unavailable, trying next")
|
||||
|
||||
if not stream_data or 'url' not in stream_data:
|
||||
logger.error("No Qobuz stream available at any quality")
|
||||
return None
|
||||
|
||||
download_url = stream_data['url']
|
||||
|
||||
# Determine file extension from stream response
|
||||
mime_type = stream_data.get('mime_type', '')
|
||||
if 'flac' in mime_type.lower():
|
||||
extension = 'flac'
|
||||
elif 'mpeg' in mime_type.lower() or 'mp3' in mime_type.lower():
|
||||
extension = 'mp3'
|
||||
else:
|
||||
extension = actual_quality.get('extension', 'flac') if actual_quality else 'flac'
|
||||
|
||||
# Build output filename
|
||||
safe_name = re.sub(r'[<>:"/\\|?*]', '_', display_name)
|
||||
out_filename = f"{safe_name}.{extension}"
|
||||
out_path = self.download_path / out_filename
|
||||
|
||||
# Check for shutdown
|
||||
if self.shutdown_check and self.shutdown_check():
|
||||
logger.info("Server shutting down, aborting Qobuz download")
|
||||
return None
|
||||
|
||||
# Download with progress tracking
|
||||
logger.info(f"Downloading from Qobuz: {out_filename}")
|
||||
response = http_requests.get(download_url, stream=True, timeout=120)
|
||||
response.raise_for_status()
|
||||
|
||||
total_size = int(response.headers.get('content-length', 0))
|
||||
downloaded = 0
|
||||
chunk_size = 64 * 1024 # 64KB chunks
|
||||
start_time = time.time()
|
||||
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['size'] = total_size
|
||||
|
||||
with open(out_path, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=chunk_size):
|
||||
if not chunk:
|
||||
continue
|
||||
|
||||
# Check for shutdown
|
||||
if self.shutdown_check and self.shutdown_check():
|
||||
logger.info("Server shutting down, aborting Qobuz download mid-stream")
|
||||
f.close()
|
||||
out_path.unlink(missing_ok=True)
|
||||
return None
|
||||
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
|
||||
# Calculate progress and speed
|
||||
elapsed = time.time() - start_time
|
||||
speed = downloaded / elapsed if elapsed > 0 else 0
|
||||
|
||||
if total_size > 0:
|
||||
progress = (downloaded / total_size) * 100
|
||||
remaining_bytes = total_size - downloaded
|
||||
time_remaining = remaining_bytes / speed if speed > 0 else None
|
||||
else:
|
||||
progress = 0
|
||||
time_remaining = None
|
||||
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['transferred'] = downloaded
|
||||
self.active_downloads[download_id]['progress'] = round(progress, 1)
|
||||
self.active_downloads[download_id]['speed'] = int(speed)
|
||||
self.active_downloads[download_id]['time_remaining'] = time_remaining
|
||||
|
||||
# Validate file size (Qobuz streams are DRM-free so this is mainly for network errors)
|
||||
MIN_AUDIO_SIZE = 100 * 1024 # 100KB
|
||||
if downloaded < MIN_AUDIO_SIZE:
|
||||
logger.error(
|
||||
f"Qobuz download too small ({downloaded} bytes) — likely an error. "
|
||||
f"Expected audio file for '{display_name}'. Deleting."
|
||||
)
|
||||
out_path.unlink(missing_ok=True)
|
||||
return None
|
||||
|
||||
final_size = out_path.stat().st_size if out_path.exists() else 0
|
||||
logger.info(f"Qobuz download complete: {out_path} ({final_size / (1024*1024):.1f} MB)")
|
||||
return str(out_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Qobuz download failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
# ===================== Status / Cancel / Clear =====================
|
||||
|
||||
async def get_all_downloads(self) -> List[DownloadStatus]:
|
||||
"""Get all active downloads (matches Soulseek interface)."""
|
||||
download_statuses = []
|
||||
|
||||
with self._download_lock:
|
||||
for download_id, info in self.active_downloads.items():
|
||||
status = DownloadStatus(
|
||||
id=info['id'],
|
||||
filename=info['filename'],
|
||||
username=info['username'],
|
||||
state=info['state'],
|
||||
progress=info['progress'],
|
||||
size=info['size'],
|
||||
transferred=info['transferred'],
|
||||
speed=info['speed'],
|
||||
time_remaining=info.get('time_remaining'),
|
||||
file_path=info.get('file_path'),
|
||||
)
|
||||
download_statuses.append(status)
|
||||
|
||||
return download_statuses
|
||||
|
||||
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
|
||||
"""Get status of a specific download (matches Soulseek interface)."""
|
||||
with self._download_lock:
|
||||
if download_id not in self.active_downloads:
|
||||
return None
|
||||
|
||||
info = self.active_downloads[download_id]
|
||||
return DownloadStatus(
|
||||
id=info['id'],
|
||||
filename=info['filename'],
|
||||
username=info['username'],
|
||||
state=info['state'],
|
||||
progress=info['progress'],
|
||||
size=info['size'],
|
||||
transferred=info['transferred'],
|
||||
speed=info['speed'],
|
||||
time_remaining=info.get('time_remaining'),
|
||||
file_path=info.get('file_path'),
|
||||
)
|
||||
|
||||
async def cancel_download(self, download_id: str, username: str = None, remove: bool = False) -> bool:
|
||||
"""Cancel an active download (matches Soulseek interface)."""
|
||||
try:
|
||||
with self._download_lock:
|
||||
if download_id not in self.active_downloads:
|
||||
logger.warning(f"Download {download_id} not found")
|
||||
return False
|
||||
|
||||
self.active_downloads[download_id]['state'] = 'Cancelled'
|
||||
logger.info(f"Marked Qobuz download {download_id} as cancelled")
|
||||
|
||||
if remove:
|
||||
del self.active_downloads[download_id]
|
||||
logger.info(f"Removed Qobuz download {download_id} from queue")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to cancel download {download_id}: {e}")
|
||||
return False
|
||||
|
||||
async def clear_all_completed_downloads(self) -> bool:
|
||||
"""Clear all terminal downloads from the list (matches Soulseek interface)."""
|
||||
try:
|
||||
with self._download_lock:
|
||||
ids_to_remove = [
|
||||
did for did, info in self.active_downloads.items()
|
||||
if info.get('state', '') in ('Completed, Succeeded', 'Cancelled', 'Errored', 'Aborted')
|
||||
]
|
||||
for did in ids_to_remove:
|
||||
del self.active_downloads[did]
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing downloads: {e}")
|
||||
return False
|
||||
109
web_server.py
109
web_server.py
|
|
@ -320,6 +320,9 @@ if soulseek_client:
|
|||
if hasattr(soulseek_client, 'tidal'):
|
||||
soulseek_client.tidal.set_shutdown_check(lambda: IS_SHUTTING_DOWN)
|
||||
print(" ✅ Configured Tidal download client shutdown callback")
|
||||
if hasattr(soulseek_client, 'qobuz'):
|
||||
soulseek_client.qobuz.set_shutdown_check(lambda: IS_SHUTTING_DOWN)
|
||||
print(" ✅ Configured Qobuz client shutdown callback")
|
||||
|
||||
# Initialize web scan manager for automatic post-download scanning
|
||||
try:
|
||||
|
|
@ -1604,12 +1607,12 @@ def get_cached_transfer_data():
|
|||
key = _make_context_key(transfer.get('username'), transfer.get('filename', ''))
|
||||
live_transfers_lookup[key] = transfer
|
||||
|
||||
# Also add YouTube/Tidal downloads (through orchestrator)
|
||||
# Also add YouTube/Tidal/Qobuz downloads (through orchestrator)
|
||||
try:
|
||||
all_downloads = run_async(soulseek_client.get_all_downloads())
|
||||
for download in all_downloads:
|
||||
# Only add YouTube/Tidal downloads (Soulseek ones are already in the lookup)
|
||||
if download.username in ('youtube', 'tidal'):
|
||||
# Only add streaming source downloads (Soulseek ones are already in the lookup)
|
||||
if download.username in ('youtube', 'tidal', 'qobuz'):
|
||||
key = _make_context_key(download.username, download.filename)
|
||||
# Convert DownloadStatus to transfer dict format
|
||||
live_transfers_lookup[key] = {
|
||||
|
|
@ -1623,7 +1626,7 @@ def get_cached_transfer_data():
|
|||
'averageSpeed': download.speed,
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"⚠️ Could not fetch YouTube/Tidal downloads: {e}")
|
||||
print(f"⚠️ Could not fetch streaming source downloads: {e}")
|
||||
|
||||
# Update cache
|
||||
transfer_data_cache['data'] = live_transfers_lookup
|
||||
|
|
@ -1928,12 +1931,12 @@ class WebUIDownloadMonitor:
|
|||
key = _make_context_key(username, file_info.get('filename', ''))
|
||||
live_transfers[key] = file_info
|
||||
|
||||
# Also get YouTube/Tidal downloads (through orchestrator)
|
||||
# Also get YouTube/Tidal/Qobuz downloads (through orchestrator)
|
||||
try:
|
||||
all_downloads = run_async(soulseek_client.get_all_downloads())
|
||||
for download in all_downloads:
|
||||
# Only add YouTube/Tidal downloads (Soulseek ones are already in the lookup)
|
||||
if download.username in ('youtube', 'tidal'):
|
||||
# Only add streaming source downloads (Soulseek ones are already in the lookup from slskd API)
|
||||
if download.username in ('youtube', 'tidal', 'qobuz'):
|
||||
key = _make_context_key(download.username, download.filename)
|
||||
# Convert DownloadStatus to transfer dict format for monitor compatibility
|
||||
live_transfers[key] = {
|
||||
|
|
@ -1947,7 +1950,7 @@ class WebUIDownloadMonitor:
|
|||
'averageSpeed': download.speed,
|
||||
}
|
||||
except Exception as yt_error:
|
||||
print(f"⚠️ Monitor: Could not fetch YouTube/Tidal downloads: {yt_error}")
|
||||
print(f"⚠️ Monitor: Could not fetch streaming source downloads: {yt_error}")
|
||||
|
||||
return live_transfers
|
||||
except Exception as e:
|
||||
|
|
@ -2963,19 +2966,21 @@ def _find_downloaded_file(download_path, track_data):
|
|||
audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'}
|
||||
target_filename = extract_filename(track_data.get('filename', ''))
|
||||
|
||||
# YOUTUBE/TIDAL SUPPORT: Handle encoded filename format "id||title"
|
||||
# YOUTUBE/TIDAL/QOBUZ SUPPORT: Handle encoded filename format "id||title"
|
||||
# The file on disk will be "title.ext", not "id||title"
|
||||
is_youtube = track_data.get('username') == 'youtube'
|
||||
is_tidal = track_data.get('username') == 'tidal'
|
||||
is_streaming_source = is_youtube or is_tidal
|
||||
is_qobuz = track_data.get('username') == 'qobuz'
|
||||
is_streaming_source = is_youtube or is_tidal or is_qobuz
|
||||
target_filename_youtube = None
|
||||
if is_streaming_source and '||' in target_filename:
|
||||
_, title = target_filename.split('||', 1)
|
||||
if is_tidal:
|
||||
# Tidal files can be flac or m4a — match any audio extension
|
||||
if is_tidal or is_qobuz:
|
||||
# Tidal/Qobuz files can be flac or m4a or mp3 — match any audio extension
|
||||
safe_title = re.sub(r'[<>:"/\\|?*]', '_', title)
|
||||
target_filename_youtube = safe_title # Extension-less for flexible matching
|
||||
print(f"🎵 [Tidal Stream] Looking for file starting with: {target_filename_youtube}")
|
||||
source_name = 'Qobuz' if is_qobuz else 'Tidal'
|
||||
print(f"🎵 [{source_name} Stream] Looking for file starting with: {target_filename_youtube}")
|
||||
else:
|
||||
# yt-dlp will create "Title.mp3" from "Title"
|
||||
target_filename_youtube = f"{title}.mp3"
|
||||
|
|
@ -3012,11 +3017,11 @@ def _find_downloaded_file(download_path, track_data):
|
|||
# For Tidal, compare without extension (file could be .flac or .m4a)
|
||||
compare_target = target_filename_youtube.lower()
|
||||
compare_file = file.lower()
|
||||
if is_tidal:
|
||||
if is_tidal or is_qobuz:
|
||||
compare_file = os.path.splitext(compare_file)[0]
|
||||
similarity = SequenceMatcher(None, compare_file, compare_target).ratio()
|
||||
|
||||
source_label = 'Tidal' if is_tidal else 'YouTube'
|
||||
source_label = 'Qobuz' if is_qobuz else ('Tidal' if is_tidal else 'YouTube')
|
||||
print(f"🔍 [{source_label} Stream] Comparing: '{file}' vs '{target_filename_youtube}' = {similarity:.2f}")
|
||||
|
||||
# Keep track of best match
|
||||
|
|
@ -3036,7 +3041,7 @@ def _find_downloaded_file(download_path, track_data):
|
|||
|
||||
# For YouTube/Tidal, if we found a good enough match (80%+), use it
|
||||
if is_streaming_source and best_match and best_similarity >= 0.80:
|
||||
source_label = 'Tidal' if is_tidal else 'YouTube'
|
||||
source_label = 'Qobuz' if is_qobuz else ('Tidal' if is_tidal else 'YouTube')
|
||||
print(f"✅ Found good match ({best_similarity:.2f}) for {source_label} streaming file: {best_match}")
|
||||
return best_match
|
||||
|
||||
|
|
@ -3994,7 +3999,7 @@ def handle_settings():
|
|||
if 'active_media_server' in new_settings:
|
||||
config_manager.set_active_media_server(new_settings['active_media_server'])
|
||||
|
||||
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export']:
|
||||
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export']:
|
||||
if service in new_settings:
|
||||
for key, value in new_settings[service].items():
|
||||
config_manager.set(f'{service}.{key}', value)
|
||||
|
|
@ -6435,7 +6440,7 @@ def stream_enhanced_search_track():
|
|||
search_queries = []
|
||||
import re
|
||||
|
||||
if download_mode in ('youtube', 'tidal') or (download_mode == 'hybrid' and config_manager.get('download_source.hybrid_primary') in ('youtube', 'tidal')):
|
||||
if download_mode in ('youtube', 'tidal', 'qobuz') or (download_mode == 'hybrid' and config_manager.get('download_source.hybrid_primary') in ('youtube', 'tidal', 'qobuz')):
|
||||
# YouTube/Tidal mode: Include artist for better context
|
||||
# Primary query: Artist + Track
|
||||
if artist_name and track_name:
|
||||
|
|
@ -6611,7 +6616,7 @@ def start_download():
|
|||
if download_id:
|
||||
# Register download for post-processing (simple transfer to /Transfer)
|
||||
context_key = _make_context_key(username, filename)
|
||||
is_streaming_source = username in ('youtube', 'tidal')
|
||||
is_streaming_source = username in ('youtube', 'tidal', 'qobuz')
|
||||
with matched_context_lock:
|
||||
matched_downloads_context[context_key] = {
|
||||
'search_result': {
|
||||
|
|
@ -6980,7 +6985,7 @@ def get_download_status():
|
|||
all_streaming_downloads = run_async(soulseek_client.get_all_downloads())
|
||||
|
||||
for download in all_streaming_downloads:
|
||||
if download.username in ('youtube', 'tidal'):
|
||||
if download.username in ('youtube', 'tidal', 'qobuz'):
|
||||
source_label = download.username.title()
|
||||
# Convert DownloadStatus to transfer format that frontend expects
|
||||
streaming_transfer = {
|
||||
|
|
@ -17946,8 +17951,8 @@ def get_valid_candidates(results, spotify_track, query):
|
|||
if not initial_candidates:
|
||||
return []
|
||||
|
||||
# Skip quality filtering for YouTube/Tidal results (quality is fixed by source, not user-selectable per-result)
|
||||
is_streaming_source = initial_candidates[0].username in ("youtube", "tidal") if initial_candidates else False
|
||||
# Skip quality filtering for YouTube/Tidal/Qobuz results (quality is fixed by source, not user-selectable per-result)
|
||||
is_streaming_source = initial_candidates[0].username in ("youtube", "tidal", "qobuz") if initial_candidates else False
|
||||
|
||||
if is_streaming_source:
|
||||
source_label = initial_candidates[0].username.title()
|
||||
|
|
@ -20013,8 +20018,8 @@ def _try_source_reuse(task_id, batch_id, track):
|
|||
if not source_tracks or not last_source:
|
||||
_sr.info(f"Skipped — no source_tracks or no last_source")
|
||||
return False
|
||||
if last_source.get('username') == 'youtube':
|
||||
_sr.info(f"Skipped — youtube source")
|
||||
if last_source.get('username') in ('youtube', 'tidal', 'qobuz'):
|
||||
_sr.info(f"Skipped — {last_source.get('username')} source (no folder-based reuse)")
|
||||
return False
|
||||
|
||||
source_username = last_source.get('username')
|
||||
|
|
@ -20115,7 +20120,7 @@ def _store_batch_source(batch_id, username, filename):
|
|||
"""Browse the successful download's folder and store results on the batch for reuse."""
|
||||
_sr = source_reuse_logger
|
||||
_sr.info(f"_store_batch_source called: batch={batch_id}, user={username}, file={filename}")
|
||||
if not batch_id or username in ('youtube', 'tidal'):
|
||||
if not batch_id or username in ('youtube', 'tidal', 'qobuz'):
|
||||
_sr.info(f"Skipped — no batch_id or streaming source ({username})")
|
||||
return
|
||||
|
||||
|
|
@ -22137,6 +22142,60 @@ def tidal_download_auth_status():
|
|||
return jsonify({"authenticated": False, "error": str(e)})
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# QOBUZ AUTH ENDPOINTS
|
||||
# ===================================================================
|
||||
|
||||
@app.route('/api/qobuz/auth/login', methods=['POST'])
|
||||
def qobuz_auth_login():
|
||||
"""Login to Qobuz with email/password."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
email = data.get('email', '').strip()
|
||||
password = data.get('password', '').strip()
|
||||
|
||||
if not email or not password:
|
||||
return jsonify({"success": False, "error": "Email and password required"}), 400
|
||||
|
||||
qobuz = soulseek_client.qobuz
|
||||
result = qobuz.login(email, password)
|
||||
|
||||
if result['status'] == 'success':
|
||||
return jsonify({"success": True, **result})
|
||||
else:
|
||||
return jsonify({"success": False, "error": result.get('message', 'Login failed')}), 400
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/qobuz/auth/status', methods=['GET'])
|
||||
def qobuz_auth_status():
|
||||
"""Check if Qobuz client is authenticated."""
|
||||
try:
|
||||
qobuz = soulseek_client.qobuz
|
||||
authenticated = qobuz.is_authenticated()
|
||||
user_info = {}
|
||||
if authenticated and qobuz.user_info:
|
||||
user_info = {
|
||||
'display_name': qobuz.user_info.get('display_name', ''),
|
||||
'subscription': qobuz.user_info.get('credential', {}).get('label', 'Unknown'),
|
||||
}
|
||||
return jsonify({"authenticated": authenticated, "user": user_info})
|
||||
except Exception as e:
|
||||
return jsonify({"authenticated": False, "error": str(e)})
|
||||
|
||||
|
||||
@app.route('/api/qobuz/auth/logout', methods=['POST'])
|
||||
def qobuz_auth_logout():
|
||||
"""Logout from Qobuz."""
|
||||
try:
|
||||
soulseek_client.qobuz.logout()
|
||||
return jsonify({"success": True})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# TIDAL PLAYLIST API ENDPOINTS
|
||||
# ===================================================================
|
||||
|
|
|
|||
|
|
@ -3642,6 +3642,7 @@
|
|||
<option value="soulseek">Soulseek Only</option>
|
||||
<option value="youtube">YouTube Only</option>
|
||||
<option value="tidal">Tidal Only</option>
|
||||
<option value="qobuz">Qobuz Only</option>
|
||||
<option value="hybrid">Hybrid (Try both with fallback)</option>
|
||||
</select>
|
||||
<div class="setting-help-text">
|
||||
|
|
@ -3658,6 +3659,7 @@
|
|||
<option value="soulseek">Try Soulseek First</option>
|
||||
<option value="youtube">Try YouTube First</option>
|
||||
<option value="tidal">Try Tidal First</option>
|
||||
<option value="qobuz">Try Qobuz First</option>
|
||||
</select>
|
||||
<div class="setting-help-text">
|
||||
Which source to try first when hybrid mode is enabled.
|
||||
|
|
@ -3702,6 +3704,48 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Qobuz Settings (shown only when qobuz mode is selected) -->
|
||||
<div id="qobuz-settings-container" style="display: none;">
|
||||
<div class="form-group">
|
||||
<label>Qobuz Download Quality:</label>
|
||||
<select id="qobuz-quality" class="form-select">
|
||||
<option value="mp3">MP3 320kbps</option>
|
||||
<option value="lossless">Lossless (FLAC 16-bit/44.1kHz)</option>
|
||||
<option value="hires">Hi-Res (FLAC 24-bit/96kHz)</option>
|
||||
<option value="hires_max">Hi-Res Max (FLAC 24-bit/192kHz)</option>
|
||||
</select>
|
||||
<div class="setting-help-text">
|
||||
Audio quality for Qobuz downloads. Hi-Res requires a Qobuz Studio or Sublime subscription.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Qobuz Account:</label>
|
||||
<div id="qobuz-auth-logged-in" style="display: none; margin-top: 4px;">
|
||||
<span id="qobuz-auth-user-info" class="setting-help-text" style="color: #4caf50;"></span>
|
||||
<button class="auth-button" onclick="logoutQobuz()" style="margin-left: 8px;">
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
<div id="qobuz-auth-form" style="margin-top: 4px;">
|
||||
<input type="email" id="qobuz-email" class="form-input"
|
||||
placeholder="Qobuz email" autocomplete="email"
|
||||
style="margin-bottom: 8px;">
|
||||
<input type="password" id="qobuz-password" class="form-input"
|
||||
placeholder="Qobuz password" autocomplete="current-password"
|
||||
style="margin-bottom: 8px;">
|
||||
<div class="form-actions">
|
||||
<button class="auth-button" id="qobuz-login-btn" onclick="loginQobuz()">
|
||||
Connect Qobuz
|
||||
</button>
|
||||
<span id="qobuz-auth-status" class="setting-help-text" style="margin-left: 8px;"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-help-text" style="margin-top: 6px;">
|
||||
Requires a paid Qobuz subscription. Streams are DRM-free.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- YouTube Settings (shown when youtube or hybrid mode) -->
|
||||
<div id="youtube-settings-container" style="display: none;">
|
||||
<div class="form-group">
|
||||
|
|
|
|||
|
|
@ -4549,6 +4549,7 @@ async function loadSettingsData() {
|
|||
document.getElementById('hybrid-primary-source').value = settings.download_source?.hybrid_primary || 'soulseek';
|
||||
document.getElementById('youtube-min-confidence').value = settings.download_source?.youtube_min_confidence || 0.65;
|
||||
document.getElementById('tidal-download-quality').value = settings.tidal_download?.quality || 'lossless';
|
||||
document.getElementById('qobuz-quality').value = settings.qobuz?.quality || 'lossless';
|
||||
|
||||
// Populate YouTube settings
|
||||
document.getElementById('youtube-cookies-browser').value = settings.youtube?.cookies_browser || '';
|
||||
|
|
@ -4737,21 +4738,20 @@ function updateDownloadSourceUI() {
|
|||
const mode = document.getElementById('download-source-mode').value;
|
||||
const hybridContainer = document.getElementById('hybrid-settings-container');
|
||||
const tidalContainer = document.getElementById('tidal-download-settings-container');
|
||||
const qobuzContainer = document.getElementById('qobuz-settings-container');
|
||||
const youtubeContainer = document.getElementById('youtube-settings-container');
|
||||
|
||||
// Show hybrid settings only when hybrid mode is selected
|
||||
hybridContainer.style.display = mode === 'hybrid' ? 'block' : 'none';
|
||||
|
||||
// Show Tidal download settings when tidal mode is selected
|
||||
tidalContainer.style.display = mode === 'tidal' ? 'block' : 'none';
|
||||
|
||||
// Show YouTube settings when youtube or hybrid mode is selected
|
||||
tidalContainer.style.display = (mode === 'tidal' || mode === 'hybrid') ? 'block' : 'none';
|
||||
qobuzContainer.style.display = (mode === 'qobuz' || mode === 'hybrid') ? 'block' : 'none';
|
||||
youtubeContainer.style.display = (mode === 'youtube' || mode === 'hybrid') ? 'block' : 'none';
|
||||
|
||||
// Check Tidal download auth status when switching to tidal mode
|
||||
if (mode === 'tidal') {
|
||||
if (mode === 'tidal' || mode === 'hybrid') {
|
||||
checkTidalDownloadAuthStatus();
|
||||
}
|
||||
if (mode === 'qobuz' || mode === 'hybrid') {
|
||||
checkQobuzAuthStatus();
|
||||
}
|
||||
}
|
||||
|
||||
// ===============================
|
||||
|
|
@ -5272,6 +5272,9 @@ async function saveSettings(quiet = false) {
|
|||
tidal_download: {
|
||||
quality: document.getElementById('tidal-download-quality').value || 'lossless'
|
||||
},
|
||||
qobuz: {
|
||||
quality: document.getElementById('qobuz-quality').value || 'lossless'
|
||||
},
|
||||
database: {
|
||||
max_workers: parseInt(document.getElementById('max-workers').value)
|
||||
},
|
||||
|
|
@ -5922,6 +5925,87 @@ async function startTidalDownloadAuth() {
|
|||
}
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// QOBUZ AUTH FUNCTIONS
|
||||
// ===============================
|
||||
|
||||
async function checkQobuzAuthStatus() {
|
||||
try {
|
||||
const resp = await fetch('/api/qobuz/auth/status');
|
||||
const data = await resp.json();
|
||||
const formEl = document.getElementById('qobuz-auth-form');
|
||||
const loggedInEl = document.getElementById('qobuz-auth-logged-in');
|
||||
const userInfoEl = document.getElementById('qobuz-auth-user-info');
|
||||
|
||||
if (data.authenticated) {
|
||||
const user = data.user || {};
|
||||
userInfoEl.textContent = `Connected: ${user.display_name || 'Qobuz User'} (${user.subscription || 'Active'})`;
|
||||
loggedInEl.style.display = 'flex';
|
||||
formEl.style.display = 'none';
|
||||
} else {
|
||||
loggedInEl.style.display = 'none';
|
||||
formEl.style.display = 'block';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Qobuz auth status check failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loginQobuz() {
|
||||
const btn = document.getElementById('qobuz-login-btn');
|
||||
const statusEl = document.getElementById('qobuz-auth-status');
|
||||
const email = document.getElementById('qobuz-email').value.trim();
|
||||
const password = document.getElementById('qobuz-password').value;
|
||||
|
||||
if (!email || !password) {
|
||||
showToast('Please enter your Qobuz email and password', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Connecting...';
|
||||
statusEl.textContent = '';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/qobuz/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.success) {
|
||||
showToast('Qobuz connected successfully!', 'success');
|
||||
// Clear password field
|
||||
document.getElementById('qobuz-password').value = '';
|
||||
checkQobuzAuthStatus();
|
||||
} else {
|
||||
statusEl.textContent = data.error || 'Login failed';
|
||||
statusEl.style.color = '#ff5555';
|
||||
showToast(data.error || 'Qobuz login failed', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Qobuz login error:', error);
|
||||
statusEl.textContent = 'Connection error';
|
||||
statusEl.style.color = '#ff5555';
|
||||
showToast('Failed to connect to Qobuz', 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Connect Qobuz';
|
||||
}
|
||||
}
|
||||
|
||||
async function logoutQobuz() {
|
||||
try {
|
||||
await fetch('/api/qobuz/auth/logout', { method: 'POST' });
|
||||
showToast('Qobuz disconnected', 'success');
|
||||
checkQobuzAuthStatus();
|
||||
} catch (e) {
|
||||
console.error('Qobuz logout error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
const PATH_INPUT_IDS = {
|
||||
download: 'download-path',
|
||||
transfer: 'transfer-path',
|
||||
|
|
|
|||
Loading…
Reference in a new issue