Full qobuz support

This commit is contained in:
Broque Thomas 2026-03-11 17:43:27 -07:00
parent 4390036556
commit fb04d0f4bc
6 changed files with 1210 additions and 50 deletions

View file

@ -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": ""

View file

@ -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
View 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

View file

@ -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
# ===================================================================

View file

@ -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">

View file

@ -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',