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"
|
"transfer_path": "./Transfer"
|
||||||
},
|
},
|
||||||
"download_source": {
|
"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
|
"hybrid_primary": "soulseek", # Which source to try first in hybrid mode
|
||||||
"youtube_min_confidence": 0.65 # Minimum confidence for YouTube matches
|
"youtube_min_confidence": 0.65 # Minimum confidence for YouTube matches
|
||||||
},
|
},
|
||||||
|
|
@ -201,6 +201,14 @@ class ConfigManager:
|
||||||
"expiry_time": 0
|
"expiry_time": 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"qobuz": {
|
||||||
|
"quality": "lossless", # Options: "mp3", "lossless", "hires", "hires_max"
|
||||||
|
"session": {
|
||||||
|
"app_id": "",
|
||||||
|
"app_secret": "",
|
||||||
|
"user_auth_token": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
"listenbrainz": {
|
"listenbrainz": {
|
||||||
"base_url": "",
|
"base_url": "",
|
||||||
"token": ""
|
"token": ""
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
"""
|
"""
|
||||||
Download Orchestrator
|
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
|
- Soulseek Only: Traditional behavior
|
||||||
- YouTube Only: YouTube-exclusive downloads
|
- YouTube Only: YouTube-exclusive downloads
|
||||||
- Tidal Only: Tidal-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
|
import asyncio
|
||||||
|
|
@ -18,6 +19,7 @@ from config.settings import config_manager
|
||||||
from core.soulseek_client import SoulseekClient, TrackResult, AlbumResult, DownloadStatus
|
from core.soulseek_client import SoulseekClient, TrackResult, AlbumResult, DownloadStatus
|
||||||
from core.youtube_client import YouTubeClient
|
from core.youtube_client import YouTubeClient
|
||||||
from core.tidal_download_client import TidalDownloadClient
|
from core.tidal_download_client import TidalDownloadClient
|
||||||
|
from core.qobuz_client import QobuzClient
|
||||||
|
|
||||||
logger = get_logger("download_orchestrator")
|
logger = get_logger("download_orchestrator")
|
||||||
|
|
||||||
|
|
@ -35,6 +37,7 @@ class DownloadOrchestrator:
|
||||||
self.soulseek = SoulseekClient()
|
self.soulseek = SoulseekClient()
|
||||||
self.youtube = YouTubeClient()
|
self.youtube = YouTubeClient()
|
||||||
self.tidal = TidalDownloadClient()
|
self.tidal = TidalDownloadClient()
|
||||||
|
self.qobuz = QobuzClient()
|
||||||
|
|
||||||
# Load mode from config
|
# Load mode from config
|
||||||
self.mode = config_manager.get('download_source.mode', 'soulseek')
|
self.mode = config_manager.get('download_source.mode', 'soulseek')
|
||||||
|
|
@ -69,9 +72,10 @@ class DownloadOrchestrator:
|
||||||
return self.youtube.is_configured()
|
return self.youtube.is_configured()
|
||||||
elif self.mode == 'tidal':
|
elif self.mode == 'tidal':
|
||||||
return self.tidal.is_configured()
|
return self.tidal.is_configured()
|
||||||
|
elif self.mode == 'qobuz':
|
||||||
|
return self.qobuz.is_configured()
|
||||||
elif self.mode == 'hybrid':
|
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() or self.qobuz.is_configured()
|
||||||
return self.soulseek.is_configured() or self.youtube.is_configured() or self.tidal.is_configured()
|
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
@ -87,16 +91,17 @@ class DownloadOrchestrator:
|
||||||
return await self.youtube.check_connection()
|
return await self.youtube.check_connection()
|
||||||
elif self.mode == 'tidal':
|
elif self.mode == 'tidal':
|
||||||
return await self.tidal.check_connection()
|
return await self.tidal.check_connection()
|
||||||
|
elif self.mode == 'qobuz':
|
||||||
|
return await self.qobuz.check_connection()
|
||||||
elif self.mode == 'hybrid':
|
elif self.mode == 'hybrid':
|
||||||
# In hybrid mode, check all sources
|
|
||||||
soulseek_ok = await self.soulseek.check_connection()
|
soulseek_ok = await self.soulseek.check_connection()
|
||||||
youtube_ok = await self.youtube.check_connection()
|
youtube_ok = await self.youtube.check_connection()
|
||||||
tidal_ok = await self.tidal.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 or qobuz_ok
|
||||||
return soulseek_ok or youtube_ok or tidal_ok
|
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
@ -124,12 +129,17 @@ class DownloadOrchestrator:
|
||||||
logger.info(f"🔍 Searching Tidal: {query}")
|
logger.info(f"🔍 Searching Tidal: {query}")
|
||||||
return await self.tidal.search(query, timeout, progress_callback)
|
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':
|
elif self.mode == 'hybrid':
|
||||||
# Build ordered client list: primary first, then remaining as fallbacks
|
# Build ordered client list: primary first, then remaining as fallbacks
|
||||||
clients = {
|
clients = {
|
||||||
'soulseek': self.soulseek,
|
'soulseek': self.soulseek,
|
||||||
'youtube': self.youtube,
|
'youtube': self.youtube,
|
||||||
'tidal': self.tidal,
|
'tidal': self.tidal,
|
||||||
|
'qobuz': self.qobuz,
|
||||||
}
|
}
|
||||||
primary = self.hybrid_primary if self.hybrid_primary in clients else 'soulseek'
|
primary = self.hybrid_primary if self.hybrid_primary in clients else 'soulseek'
|
||||||
fallbacks = [name for name in clients if name != primary]
|
fallbacks = [name for name in clients if name != primary]
|
||||||
|
|
@ -219,6 +229,9 @@ class DownloadOrchestrator:
|
||||||
elif username == 'tidal':
|
elif username == 'tidal':
|
||||||
logger.info(f"📥 Downloading from Tidal: {filename}")
|
logger.info(f"📥 Downloading from Tidal: {filename}")
|
||||||
return await self.tidal.download(username, filename, file_size)
|
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:
|
else:
|
||||||
logger.info(f"📥 Downloading from Soulseek: {filename}")
|
logger.info(f"📥 Downloading from Soulseek: {filename}")
|
||||||
return await self.soulseek.download(username, filename, file_size)
|
return await self.soulseek.download(username, filename, file_size)
|
||||||
|
|
@ -234,9 +247,9 @@ class DownloadOrchestrator:
|
||||||
soulseek_downloads = await self.soulseek.get_all_downloads()
|
soulseek_downloads = await self.soulseek.get_all_downloads()
|
||||||
youtube_downloads = await self.youtube.get_all_downloads()
|
youtube_downloads = await self.youtube.get_all_downloads()
|
||||||
tidal_downloads = await self.tidal.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 + qobuz_downloads
|
||||||
return soulseek_downloads + youtube_downloads + tidal_downloads
|
|
||||||
|
|
||||||
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
|
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -263,6 +276,11 @@ class DownloadOrchestrator:
|
||||||
if status:
|
if status:
|
||||||
return status
|
return status
|
||||||
|
|
||||||
|
# Try Qobuz
|
||||||
|
status = await self.qobuz.get_download_status(download_id)
|
||||||
|
if status:
|
||||||
|
return status
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def cancel_download(self, download_id: str, username: str = None, remove: bool = False) -> bool:
|
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)
|
return await self.youtube.cancel_download(download_id, username, remove)
|
||||||
elif username == 'tidal':
|
elif username == 'tidal':
|
||||||
return await self.tidal.cancel_download(download_id, username, remove)
|
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:
|
elif username:
|
||||||
return await self.soulseek.cancel_download(download_id, username, remove)
|
return await self.soulseek.cancel_download(download_id, username, remove)
|
||||||
|
|
||||||
|
|
@ -295,7 +315,11 @@ class DownloadOrchestrator:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
tidal_cancelled = await self.tidal.cancel_download(download_id, username, remove)
|
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:
|
async def signal_download_completion(self, download_id: str, username: str, remove: bool = True) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|
@ -320,11 +344,11 @@ class DownloadOrchestrator:
|
||||||
True if successful
|
True if successful
|
||||||
"""
|
"""
|
||||||
soulseek_cleared = await self.soulseek.clear_all_completed_downloads()
|
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()
|
youtube_cleared = await self.youtube.clear_all_completed_downloads()
|
||||||
tidal_cleared = await self.tidal.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) =====
|
# ===== Soulseek-specific methods (for backwards compatibility) =====
|
||||||
# These are internal methods that some parts of the codebase use directly
|
# 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:
|
async def cancel_all_downloads(self) -> bool:
|
||||||
"""Cancel and remove all downloads from all sources."""
|
"""Cancel and remove all downloads from all sources."""
|
||||||
soulseek_ok = await self.soulseek.cancel_all_downloads()
|
soulseek_ok = await self.soulseek.cancel_all_downloads()
|
||||||
# Clear Tidal active downloads too
|
await self.tidal.clear_all_completed_downloads()
|
||||||
tidal_ok = await self.tidal.clear_all_completed_downloads()
|
await self.qobuz.clear_all_completed_downloads()
|
||||||
return soulseek_ok
|
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'):
|
if hasattr(soulseek_client, 'tidal'):
|
||||||
soulseek_client.tidal.set_shutdown_check(lambda: IS_SHUTTING_DOWN)
|
soulseek_client.tidal.set_shutdown_check(lambda: IS_SHUTTING_DOWN)
|
||||||
print(" ✅ Configured Tidal download client shutdown callback")
|
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
|
# Initialize web scan manager for automatic post-download scanning
|
||||||
try:
|
try:
|
||||||
|
|
@ -1604,12 +1607,12 @@ def get_cached_transfer_data():
|
||||||
key = _make_context_key(transfer.get('username'), transfer.get('filename', ''))
|
key = _make_context_key(transfer.get('username'), transfer.get('filename', ''))
|
||||||
live_transfers_lookup[key] = transfer
|
live_transfers_lookup[key] = transfer
|
||||||
|
|
||||||
# Also add YouTube/Tidal downloads (through orchestrator)
|
# Also add YouTube/Tidal/Qobuz downloads (through orchestrator)
|
||||||
try:
|
try:
|
||||||
all_downloads = run_async(soulseek_client.get_all_downloads())
|
all_downloads = run_async(soulseek_client.get_all_downloads())
|
||||||
for download in all_downloads:
|
for download in all_downloads:
|
||||||
# Only add YouTube/Tidal downloads (Soulseek ones are already in the lookup)
|
# Only add streaming source downloads (Soulseek ones are already in the lookup)
|
||||||
if download.username in ('youtube', 'tidal'):
|
if download.username in ('youtube', 'tidal', 'qobuz'):
|
||||||
key = _make_context_key(download.username, download.filename)
|
key = _make_context_key(download.username, download.filename)
|
||||||
# Convert DownloadStatus to transfer dict format
|
# Convert DownloadStatus to transfer dict format
|
||||||
live_transfers_lookup[key] = {
|
live_transfers_lookup[key] = {
|
||||||
|
|
@ -1623,7 +1626,7 @@ def get_cached_transfer_data():
|
||||||
'averageSpeed': download.speed,
|
'averageSpeed': download.speed,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"⚠️ Could not fetch YouTube/Tidal downloads: {e}")
|
print(f"⚠️ Could not fetch streaming source downloads: {e}")
|
||||||
|
|
||||||
# Update cache
|
# Update cache
|
||||||
transfer_data_cache['data'] = live_transfers_lookup
|
transfer_data_cache['data'] = live_transfers_lookup
|
||||||
|
|
@ -1928,12 +1931,12 @@ class WebUIDownloadMonitor:
|
||||||
key = _make_context_key(username, file_info.get('filename', ''))
|
key = _make_context_key(username, file_info.get('filename', ''))
|
||||||
live_transfers[key] = file_info
|
live_transfers[key] = file_info
|
||||||
|
|
||||||
# Also get YouTube/Tidal downloads (through orchestrator)
|
# Also get YouTube/Tidal/Qobuz downloads (through orchestrator)
|
||||||
try:
|
try:
|
||||||
all_downloads = run_async(soulseek_client.get_all_downloads())
|
all_downloads = run_async(soulseek_client.get_all_downloads())
|
||||||
for download in all_downloads:
|
for download in all_downloads:
|
||||||
# Only add YouTube/Tidal downloads (Soulseek ones are already in the lookup)
|
# Only add streaming source downloads (Soulseek ones are already in the lookup from slskd API)
|
||||||
if download.username in ('youtube', 'tidal'):
|
if download.username in ('youtube', 'tidal', 'qobuz'):
|
||||||
key = _make_context_key(download.username, download.filename)
|
key = _make_context_key(download.username, download.filename)
|
||||||
# Convert DownloadStatus to transfer dict format for monitor compatibility
|
# Convert DownloadStatus to transfer dict format for monitor compatibility
|
||||||
live_transfers[key] = {
|
live_transfers[key] = {
|
||||||
|
|
@ -1947,7 +1950,7 @@ class WebUIDownloadMonitor:
|
||||||
'averageSpeed': download.speed,
|
'averageSpeed': download.speed,
|
||||||
}
|
}
|
||||||
except Exception as yt_error:
|
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
|
return live_transfers
|
||||||
except Exception as e:
|
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'}
|
audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'}
|
||||||
target_filename = extract_filename(track_data.get('filename', ''))
|
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"
|
# The file on disk will be "title.ext", not "id||title"
|
||||||
is_youtube = track_data.get('username') == 'youtube'
|
is_youtube = track_data.get('username') == 'youtube'
|
||||||
is_tidal = track_data.get('username') == 'tidal'
|
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
|
target_filename_youtube = None
|
||||||
if is_streaming_source and '||' in target_filename:
|
if is_streaming_source and '||' in target_filename:
|
||||||
_, title = target_filename.split('||', 1)
|
_, title = target_filename.split('||', 1)
|
||||||
if is_tidal:
|
if is_tidal or is_qobuz:
|
||||||
# Tidal files can be flac or m4a — match any audio extension
|
# Tidal/Qobuz files can be flac or m4a or mp3 — match any audio extension
|
||||||
safe_title = re.sub(r'[<>:"/\\|?*]', '_', title)
|
safe_title = re.sub(r'[<>:"/\\|?*]', '_', title)
|
||||||
target_filename_youtube = safe_title # Extension-less for flexible matching
|
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:
|
else:
|
||||||
# yt-dlp will create "Title.mp3" from "Title"
|
# yt-dlp will create "Title.mp3" from "Title"
|
||||||
target_filename_youtube = f"{title}.mp3"
|
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)
|
# For Tidal, compare without extension (file could be .flac or .m4a)
|
||||||
compare_target = target_filename_youtube.lower()
|
compare_target = target_filename_youtube.lower()
|
||||||
compare_file = file.lower()
|
compare_file = file.lower()
|
||||||
if is_tidal:
|
if is_tidal or is_qobuz:
|
||||||
compare_file = os.path.splitext(compare_file)[0]
|
compare_file = os.path.splitext(compare_file)[0]
|
||||||
similarity = SequenceMatcher(None, compare_file, compare_target).ratio()
|
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}")
|
print(f"🔍 [{source_label} Stream] Comparing: '{file}' vs '{target_filename_youtube}' = {similarity:.2f}")
|
||||||
|
|
||||||
# Keep track of best match
|
# 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
|
# 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:
|
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}")
|
print(f"✅ Found good match ({best_similarity:.2f}) for {source_label} streaming file: {best_match}")
|
||||||
return best_match
|
return best_match
|
||||||
|
|
||||||
|
|
@ -3994,7 +3999,7 @@ def handle_settings():
|
||||||
if 'active_media_server' in new_settings:
|
if 'active_media_server' in new_settings:
|
||||||
config_manager.set_active_media_server(new_settings['active_media_server'])
|
config_manager.set_active_media_server(new_settings['active_media_server'])
|
||||||
|
|
||||||
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', '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:
|
if service in new_settings:
|
||||||
for key, value in new_settings[service].items():
|
for key, value in new_settings[service].items():
|
||||||
config_manager.set(f'{service}.{key}', value)
|
config_manager.set(f'{service}.{key}', value)
|
||||||
|
|
@ -6435,7 +6440,7 @@ def stream_enhanced_search_track():
|
||||||
search_queries = []
|
search_queries = []
|
||||||
import re
|
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
|
# YouTube/Tidal mode: Include artist for better context
|
||||||
# Primary query: Artist + Track
|
# Primary query: Artist + Track
|
||||||
if artist_name and track_name:
|
if artist_name and track_name:
|
||||||
|
|
@ -6611,7 +6616,7 @@ def start_download():
|
||||||
if download_id:
|
if download_id:
|
||||||
# Register download for post-processing (simple transfer to /Transfer)
|
# Register download for post-processing (simple transfer to /Transfer)
|
||||||
context_key = _make_context_key(username, filename)
|
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:
|
with matched_context_lock:
|
||||||
matched_downloads_context[context_key] = {
|
matched_downloads_context[context_key] = {
|
||||||
'search_result': {
|
'search_result': {
|
||||||
|
|
@ -6980,7 +6985,7 @@ def get_download_status():
|
||||||
all_streaming_downloads = run_async(soulseek_client.get_all_downloads())
|
all_streaming_downloads = run_async(soulseek_client.get_all_downloads())
|
||||||
|
|
||||||
for download in all_streaming_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()
|
source_label = download.username.title()
|
||||||
# Convert DownloadStatus to transfer format that frontend expects
|
# Convert DownloadStatus to transfer format that frontend expects
|
||||||
streaming_transfer = {
|
streaming_transfer = {
|
||||||
|
|
@ -17946,8 +17951,8 @@ def get_valid_candidates(results, spotify_track, query):
|
||||||
if not initial_candidates:
|
if not initial_candidates:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Skip quality filtering for YouTube/Tidal results (quality is fixed by source, not user-selectable per-result)
|
# 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") if initial_candidates else False
|
is_streaming_source = initial_candidates[0].username in ("youtube", "tidal", "qobuz") if initial_candidates else False
|
||||||
|
|
||||||
if is_streaming_source:
|
if is_streaming_source:
|
||||||
source_label = initial_candidates[0].username.title()
|
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:
|
if not source_tracks or not last_source:
|
||||||
_sr.info(f"Skipped — no source_tracks or no last_source")
|
_sr.info(f"Skipped — no source_tracks or no last_source")
|
||||||
return False
|
return False
|
||||||
if last_source.get('username') == 'youtube':
|
if last_source.get('username') in ('youtube', 'tidal', 'qobuz'):
|
||||||
_sr.info(f"Skipped — youtube source")
|
_sr.info(f"Skipped — {last_source.get('username')} source (no folder-based reuse)")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
source_username = last_source.get('username')
|
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."""
|
"""Browse the successful download's folder and store results on the batch for reuse."""
|
||||||
_sr = source_reuse_logger
|
_sr = source_reuse_logger
|
||||||
_sr.info(f"_store_batch_source called: batch={batch_id}, user={username}, file={filename}")
|
_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})")
|
_sr.info(f"Skipped — no batch_id or streaming source ({username})")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -22137,6 +22142,60 @@ def tidal_download_auth_status():
|
||||||
return jsonify({"authenticated": False, "error": str(e)})
|
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
|
# TIDAL PLAYLIST API ENDPOINTS
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
|
|
|
||||||
|
|
@ -3642,6 +3642,7 @@
|
||||||
<option value="soulseek">Soulseek Only</option>
|
<option value="soulseek">Soulseek Only</option>
|
||||||
<option value="youtube">YouTube Only</option>
|
<option value="youtube">YouTube Only</option>
|
||||||
<option value="tidal">Tidal Only</option>
|
<option value="tidal">Tidal Only</option>
|
||||||
|
<option value="qobuz">Qobuz Only</option>
|
||||||
<option value="hybrid">Hybrid (Try both with fallback)</option>
|
<option value="hybrid">Hybrid (Try both with fallback)</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="setting-help-text">
|
<div class="setting-help-text">
|
||||||
|
|
@ -3658,6 +3659,7 @@
|
||||||
<option value="soulseek">Try Soulseek First</option>
|
<option value="soulseek">Try Soulseek First</option>
|
||||||
<option value="youtube">Try YouTube First</option>
|
<option value="youtube">Try YouTube First</option>
|
||||||
<option value="tidal">Try Tidal First</option>
|
<option value="tidal">Try Tidal First</option>
|
||||||
|
<option value="qobuz">Try Qobuz First</option>
|
||||||
</select>
|
</select>
|
||||||
<div class="setting-help-text">
|
<div class="setting-help-text">
|
||||||
Which source to try first when hybrid mode is enabled.
|
Which source to try first when hybrid mode is enabled.
|
||||||
|
|
@ -3702,6 +3704,48 @@
|
||||||
</div>
|
</div>
|
||||||
</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) -->
|
<!-- YouTube Settings (shown when youtube or hybrid mode) -->
|
||||||
<div id="youtube-settings-container" style="display: none;">
|
<div id="youtube-settings-container" style="display: none;">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
|
||||||
|
|
@ -4549,6 +4549,7 @@ async function loadSettingsData() {
|
||||||
document.getElementById('hybrid-primary-source').value = settings.download_source?.hybrid_primary || 'soulseek';
|
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('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('tidal-download-quality').value = settings.tidal_download?.quality || 'lossless';
|
||||||
|
document.getElementById('qobuz-quality').value = settings.qobuz?.quality || 'lossless';
|
||||||
|
|
||||||
// Populate YouTube settings
|
// Populate YouTube settings
|
||||||
document.getElementById('youtube-cookies-browser').value = settings.youtube?.cookies_browser || '';
|
document.getElementById('youtube-cookies-browser').value = settings.youtube?.cookies_browser || '';
|
||||||
|
|
@ -4737,21 +4738,20 @@ function updateDownloadSourceUI() {
|
||||||
const mode = document.getElementById('download-source-mode').value;
|
const mode = document.getElementById('download-source-mode').value;
|
||||||
const hybridContainer = document.getElementById('hybrid-settings-container');
|
const hybridContainer = document.getElementById('hybrid-settings-container');
|
||||||
const tidalContainer = document.getElementById('tidal-download-settings-container');
|
const tidalContainer = document.getElementById('tidal-download-settings-container');
|
||||||
|
const qobuzContainer = document.getElementById('qobuz-settings-container');
|
||||||
const youtubeContainer = document.getElementById('youtube-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';
|
hybridContainer.style.display = mode === 'hybrid' ? 'block' : 'none';
|
||||||
|
tidalContainer.style.display = (mode === 'tidal' || mode === 'hybrid') ? 'block' : 'none';
|
||||||
// Show Tidal download settings when tidal mode is selected
|
qobuzContainer.style.display = (mode === 'qobuz' || mode === 'hybrid') ? 'block' : 'none';
|
||||||
tidalContainer.style.display = mode === 'tidal' ? 'block' : 'none';
|
|
||||||
|
|
||||||
// Show YouTube settings when youtube or hybrid mode is selected
|
|
||||||
youtubeContainer.style.display = (mode === 'youtube' || 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' || mode === 'hybrid') {
|
||||||
if (mode === 'tidal') {
|
|
||||||
checkTidalDownloadAuthStatus();
|
checkTidalDownloadAuthStatus();
|
||||||
}
|
}
|
||||||
|
if (mode === 'qobuz' || mode === 'hybrid') {
|
||||||
|
checkQobuzAuthStatus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===============================
|
// ===============================
|
||||||
|
|
@ -5272,6 +5272,9 @@ async function saveSettings(quiet = false) {
|
||||||
tidal_download: {
|
tidal_download: {
|
||||||
quality: document.getElementById('tidal-download-quality').value || 'lossless'
|
quality: document.getElementById('tidal-download-quality').value || 'lossless'
|
||||||
},
|
},
|
||||||
|
qobuz: {
|
||||||
|
quality: document.getElementById('qobuz-quality').value || 'lossless'
|
||||||
|
},
|
||||||
database: {
|
database: {
|
||||||
max_workers: parseInt(document.getElementById('max-workers').value)
|
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 = {
|
const PATH_INPUT_IDS = {
|
||||||
download: 'download-path',
|
download: 'download-path',
|
||||||
transfer: 'transfer-path',
|
transfer: 'transfer-path',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue