Add Tidal as a download source with full pipeline integration

Adds Tidal as a third download source alongside Soulseek and YouTube. Uses the tidalapi library with device-flow authentication to search and download tracks in configurable quality (Low/High/Lossless/HiRes) with automatic fallback. Integrates into the download orchestrator for all modes (Tidal Only, Hybrid with fallback chain), the transfer monitor, post-processing pipeline, and file finder. Frontend includes download settings with quality selector, device auth flow, and dynamic sidebar/dashboard labels that reflect the active download source. No breaking changes for existing users.
This commit is contained in:
Broque Thomas 2026-02-28 21:47:19 -08:00
parent 49c769ff5c
commit acfc26a4bd
7 changed files with 1064 additions and 135 deletions

View file

@ -188,10 +188,19 @@ class ConfigManager:
"transfer_path": "./Transfer" "transfer_path": "./Transfer"
}, },
"download_source": { "download_source": {
"mode": "soulseek", # Options: "soulseek", "youtube", "hybrid" "mode": "soulseek", # Options: "soulseek", "youtube", "tidal", "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
}, },
"tidal_download": {
"quality": "lossless", # Options: "low", "high", "lossless", "hires"
"session": {
"token_type": "",
"access_token": "",
"refresh_token": "",
"expiry_time": 0
}
},
"listenbrainz": { "listenbrainz": {
"token": "" "token": ""
}, },

View file

@ -1,10 +1,11 @@
""" """
Download Orchestrator Download Orchestrator
Routes downloads between Soulseek and YouTube based on configuration. Routes downloads between Soulseek, YouTube, and Tidal based on configuration.
Supports three modes: Supports four modes:
- Soulseek Only: Traditional behavior - Soulseek Only: Traditional behavior
- YouTube Only: YouTube-exclusive downloads - YouTube Only: YouTube-exclusive downloads
- Tidal Only: Tidal-exclusive downloads
- Hybrid: Try primary source first, fallback to secondary if it fails - Hybrid: Try primary source first, fallback to secondary if it fails
""" """
@ -16,6 +17,7 @@ from utils.logging_config import get_logger
from config.settings import config_manager 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
logger = get_logger("download_orchestrator") logger = get_logger("download_orchestrator")
@ -29,9 +31,10 @@ class DownloadOrchestrator:
""" """
def __init__(self): def __init__(self):
"""Initialize orchestrator with both clients""" """Initialize orchestrator with all clients"""
self.soulseek = SoulseekClient() self.soulseek = SoulseekClient()
self.youtube = YouTubeClient() self.youtube = YouTubeClient()
self.tidal = TidalDownloadClient()
# 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')
@ -64,9 +67,11 @@ class DownloadOrchestrator:
return self.soulseek.is_configured() return self.soulseek.is_configured()
elif self.mode == 'youtube': elif self.mode == 'youtube':
return self.youtube.is_configured() return self.youtube.is_configured()
elif self.mode == 'tidal':
return self.tidal.is_configured()
elif self.mode == 'hybrid': elif self.mode == 'hybrid':
# In hybrid mode, at least one source must be configured # In hybrid mode, at least one source must be configured
return self.soulseek.is_configured() or self.youtube.is_configured() return self.soulseek.is_configured() or self.youtube.is_configured() or self.tidal.is_configured()
return False return False
@ -80,15 +85,18 @@ class DownloadOrchestrator:
return await self.soulseek.check_connection() return await self.soulseek.check_connection()
elif self.mode == 'youtube': elif self.mode == 'youtube':
return await self.youtube.check_connection() return await self.youtube.check_connection()
elif self.mode == 'tidal':
return await self.tidal.check_connection()
elif self.mode == 'hybrid': elif self.mode == 'hybrid':
# In hybrid mode, check both sources # 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()
logger.info(f" Soulseek: {'' if soulseek_ok else ''} | YouTube: {'' if youtube_ok else ''}") logger.info(f" Soulseek: {'' if soulseek_ok else ''} | YouTube: {'' if youtube_ok else ''} | Tidal: {'' if tidal_ok else ''}")
# At least one must be available # At least one must be available
return soulseek_ok or youtube_ok return soulseek_ok or youtube_ok or tidal_ok
return False return False
@ -112,33 +120,38 @@ class DownloadOrchestrator:
logger.info(f"🔍 Searching YouTube: {query}") logger.info(f"🔍 Searching YouTube: {query}")
return await self.youtube.search(query, timeout, progress_callback) return await self.youtube.search(query, timeout, progress_callback)
elif self.mode == 'tidal':
logger.info(f"🔍 Searching Tidal: {query}")
return await self.tidal.search(query, timeout, progress_callback)
elif self.mode == 'hybrid': elif self.mode == 'hybrid':
# Build ordered client list: primary first, then remaining as fallbacks
clients = {
'soulseek': self.soulseek,
'youtube': self.youtube,
'tidal': self.tidal,
}
primary = self.hybrid_primary if self.hybrid_primary in clients else 'soulseek'
fallbacks = [name for name in clients if name != primary]
# Try primary source first # Try primary source first
if self.hybrid_primary == 'soulseek': logger.info(f"🔍 Hybrid search - trying {primary} first: {query}")
logger.info(f"🔍 Hybrid search - trying Soulseek first: {query}") tracks, albums = await clients[primary].search(query, timeout, progress_callback)
tracks, albums = await self.soulseek.search(query, timeout, progress_callback) if tracks:
logger.info(f"{primary} found {len(tracks)} tracks")
return (tracks, albums)
# If Soulseek found good results, return them # Try fallbacks in order
for fallback_name in fallbacks:
logger.info(f"🔄 {primary} found nothing, trying {fallback_name} fallback")
tracks, albums = await clients[fallback_name].search(query, timeout, progress_callback)
if tracks: if tracks:
logger.info(f"✅ Soulseek found {len(tracks)} tracks") logger.info(f"{fallback_name} found {len(tracks)} tracks")
return (tracks, albums) return (tracks, albums)
# Otherwise, try YouTube as fallback # Nothing found from any source
logger.info(f"🔄 Soulseek found nothing, trying YouTube fallback") logger.warning(f"❌ Hybrid search exhausted all sources for: {query}")
return await self.youtube.search(query, timeout, progress_callback) return ([], [])
else: # YouTube first
logger.info(f"🔍 Hybrid search - trying YouTube first: {query}")
tracks, albums = await self.youtube.search(query, timeout, progress_callback)
# If YouTube found good results, return them
if tracks:
logger.info(f"✅ YouTube found {len(tracks)} tracks")
return (tracks, albums)
# Otherwise, try Soulseek as fallback
logger.info(f"🔄 YouTube found nothing, trying Soulseek fallback")
return await self.soulseek.search(query, timeout, progress_callback)
# Fallback: empty results # Fallback: empty results
return ([], []) return ([], [])
@ -203,6 +216,9 @@ class DownloadOrchestrator:
if username == 'youtube': if username == 'youtube':
logger.info(f"📥 Downloading from YouTube: {filename}") logger.info(f"📥 Downloading from YouTube: {filename}")
return await self.youtube.download(username, filename, file_size) return await self.youtube.download(username, filename, file_size)
elif username == 'tidal':
logger.info(f"📥 Downloading from Tidal: {filename}")
return await self.tidal.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)
@ -214,12 +230,13 @@ class DownloadOrchestrator:
Returns: Returns:
List of DownloadStatus objects List of DownloadStatus objects
""" """
# Get downloads from both sources # Get downloads from all sources
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()
# Combine and return # Combine and return
return soulseek_downloads + youtube_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]:
""" """
@ -241,6 +258,11 @@ class DownloadOrchestrator:
if status: if status:
return status return status
# Try Tidal
status = await self.tidal.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:
@ -258,16 +280,22 @@ class DownloadOrchestrator:
# If username is provided, route directly # If username is provided, route directly
if username == 'youtube': if username == 'youtube':
return await self.youtube.cancel_download(download_id, username, remove) 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: elif username:
return await self.soulseek.cancel_download(download_id, username, remove) return await self.soulseek.cancel_download(download_id, username, remove)
# Otherwise, try both sources # Otherwise, try all sources
soulseek_cancelled = await self.soulseek.cancel_download(download_id, username, remove) soulseek_cancelled = await self.soulseek.cancel_download(download_id, username, remove)
if soulseek_cancelled: if soulseek_cancelled:
return True return True
youtube_cancelled = await self.youtube.cancel_download(download_id, username, remove) youtube_cancelled = await self.youtube.cancel_download(download_id, username, remove)
return youtube_cancelled if youtube_cancelled:
return True
tidal_cancelled = await self.tidal.cancel_download(download_id, username, remove)
return tidal_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:
""" """
@ -292,10 +320,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 downloads must also be cleared from memory # 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()
return soulseek_cleared and youtube_cleared return soulseek_cleared and youtube_cleared and tidal_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
@ -353,5 +382,8 @@ class DownloadOrchestrator:
return await self.soulseek.maintain_search_history_with_buffer(keep_searches, trigger_threshold) return await self.soulseek.maintain_search_history_with_buffer(keep_searches, trigger_threshold)
async def cancel_all_downloads(self) -> bool: async def cancel_all_downloads(self) -> bool:
"""Cancel and remove all downloads from slskd.""" """Cancel and remove all downloads from all sources."""
return await self.soulseek.cancel_all_downloads() soulseek_ok = await self.soulseek.cancel_all_downloads()
# Clear Tidal active downloads too
tidal_ok = await self.tidal.clear_all_completed_downloads()
return soulseek_ok

View file

@ -0,0 +1,676 @@
"""
Tidal Download Client
Alternative music download source using tidalapi.
This client provides:
- Tidal search with metadata
- Device flow authentication (link.tidal.com)
- HiRes/Lossless/High quality audio downloads
- Drop-in replacement compatible with Soulseek interface
"""
import os
import re
import asyncio
import uuid
import threading
import shutil
import subprocess
from typing import List, Optional, Dict, Any, Tuple
from pathlib import Path
from datetime import datetime, timezone
try:
import tidalapi
except ImportError:
tidalapi = None
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("tidal_download_client")
# Quality tier definitions
QUALITY_MAP = {
'low': {
'tidal_quality': 'LOW' if tidalapi is None else None, # set dynamically
'label': 'AAC 96kbps',
'extension': 'm4a',
'bitrate': 96,
'codec': 'aac',
},
'high': {
'tidal_quality': 'HIGH' if tidalapi is None else None,
'label': 'AAC 320kbps',
'extension': 'm4a',
'bitrate': 320,
'codec': 'aac',
},
'lossless': {
'tidal_quality': 'LOSSLESS' if tidalapi is None else None,
'label': 'FLAC 16-bit/44.1kHz',
'extension': 'flac',
'bitrate': 1411,
'codec': 'flac',
},
'hires': {
'tidal_quality': 'HI_RES_LOSSLESS' if tidalapi is None else None,
'label': 'FLAC 24-bit/96kHz',
'extension': 'flac',
'bitrate': 9216,
'codec': 'flac',
},
}
# Initialize quality map with actual tidalapi constants if available
if tidalapi is not None:
QUALITY_MAP['low']['tidal_quality'] = tidalapi.Quality.low_96k
QUALITY_MAP['high']['tidal_quality'] = tidalapi.Quality.low_320k
QUALITY_MAP['lossless']['tidal_quality'] = tidalapi.Quality.high_lossless
QUALITY_MAP['hires']['tidal_quality'] = tidalapi.Quality.hi_res_lossless
class TidalDownloadClient:
"""
Tidal download client using tidalapi.
Provides search, matching, and download capabilities as a drop-in alternative to YouTube/Soulseek.
"""
def __init__(self, download_path: str = None):
if tidalapi is None:
logger.warning("tidalapi not installed — Tidal downloads unavailable")
# 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"Tidal download client using download path: {self.download_path}")
# Callback for shutdown check (avoids circular imports)
self.shutdown_check = None
# tidalapi session
self.session: Optional['tidalapi.Session'] = None
self._init_session()
# Download queue management (mirrors YouTube's download tracking)
self.active_downloads: Dict[str, Dict[str, Any]] = {}
self._download_lock = threading.Lock()
# Device auth state
self._device_auth_future = None
self._device_auth_link = None
def set_shutdown_check(self, check_callable):
"""Set a callback function to check for system shutdown"""
self.shutdown_check = check_callable
# ===================== Auth =====================
def _init_session(self):
"""Create a tidalapi session and try to restore saved tokens."""
if tidalapi is None:
return
self.session = tidalapi.Session()
# Try to restore saved session
saved = config_manager.get('tidal_download.session', {})
token_type = saved.get('token_type', '')
access_token = saved.get('access_token', '')
refresh_token = saved.get('refresh_token', '')
expiry_time = saved.get('expiry_time', 0)
if token_type and access_token:
try:
# Convert stored float timestamp back to datetime for tidalapi
expiry_dt = datetime.fromtimestamp(expiry_time, tz=timezone.utc) if expiry_time else None
# tidalapi's load_oauth_session restores from saved tokens
restored = self.session.load_oauth_session(
token_type=token_type,
access_token=access_token,
refresh_token=refresh_token,
expiry_time=expiry_dt,
)
if restored and self.session.check_login():
logger.info("Restored Tidal download session from saved tokens")
self._save_session() # refresh may have rotated tokens
return
else:
logger.warning("Saved Tidal session tokens are invalid/expired")
except Exception as e:
logger.warning(f"Could not restore Tidal session: {e}")
def _save_session(self):
"""Persist session tokens to config."""
if not self.session:
return
config_manager.set('tidal_download.session', {
'token_type': self.session.token_type or '',
'access_token': self.session.access_token or '',
'refresh_token': self.session.refresh_token or '',
'expiry_time': self.session.expiry_time.timestamp() if self.session.expiry_time else 0,
})
def is_authenticated(self) -> bool:
"""Check if we have a valid Tidal session."""
if not self.session:
return False
try:
return self.session.check_login()
except Exception:
return False
def start_device_auth(self) -> Optional[Dict[str, str]]:
"""
Start the device-code OAuth flow.
Returns dict with 'verification_uri' and 'user_code', or None on error.
"""
if tidalapi is None:
return None
try:
if not self.session:
self.session = tidalapi.Session()
login, future = self.session.login_oauth()
self._device_auth_future = future
self._device_auth_link = {
'verification_uri': login.verification_uri_complete or f"https://link.tidal.com/{login.user_code}",
'user_code': login.user_code,
}
logger.info(f"Tidal device auth started — code: {login.user_code}")
return self._device_auth_link
except Exception as e:
logger.error(f"Failed to start Tidal device auth: {e}")
return None
def check_device_auth(self) -> Dict[str, Any]:
"""
Check if device auth has completed.
Returns {'status': 'pending'|'completed'|'error', ...}
"""
if not self._device_auth_future:
return {'status': 'error', 'message': 'No auth in progress'}
try:
if self._device_auth_future.running():
return {
'status': 'pending',
'verification_uri': self._device_auth_link.get('verification_uri', ''),
'user_code': self._device_auth_link.get('user_code', ''),
}
# Future is done — check result
result = self._device_auth_future.result(timeout=0)
if self.session and self.session.check_login():
self._save_session()
logger.info("Tidal device auth completed successfully")
return {'status': 'completed', 'message': 'Authenticated successfully'}
else:
return {'status': 'error', 'message': 'Auth completed but session invalid'}
except Exception as e:
logger.error(f"Tidal device auth check error: {e}")
return {'status': 'error', 'message': str(e)}
# ===================== Search =====================
def is_available(self) -> bool:
"""Check if Tidal download client is available (tidalapi installed and authenticated)."""
return tidalapi is not None and self.is_authenticated()
def is_configured(self) -> bool:
"""Check if Tidal client is configured and ready (matches Soulseek interface)."""
return self.is_available()
async def check_connection(self) -> bool:
"""Test if Tidal 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"Tidal connection check failed: {e}")
return False
async def search(self, query: str, timeout: int = None, progress_callback=None) -> Tuple[List[TrackResult], List[AlbumResult]]:
"""
Search Tidal for tracks (async, Soulseek-compatible interface).
Returns:
Tuple of (track_results, album_results). Album results always empty.
"""
if not self.is_available():
logger.warning("Tidal not available for search (not authenticated)")
return ([], [])
logger.info(f"Searching Tidal for: {query}")
try:
loop = asyncio.get_event_loop()
def _search():
results = self.session.search(query, models=[tidalapi.media.Track], limit=50)
return results.get('tracks', []) if isinstance(results, dict) else []
tidal_tracks = await loop.run_in_executor(None, _search)
if not tidal_tracks:
logger.warning(f"No Tidal results for: {query}")
return ([], [])
# Get configured quality for display
quality_key = config_manager.get('tidal_download.quality', 'lossless')
quality_info = QUALITY_MAP.get(quality_key, QUALITY_MAP['lossless'])
track_results = []
for track in tidal_tracks:
try:
track_result = self._tidal_to_track_result(track, quality_info)
track_results.append(track_result)
except Exception as e:
logger.debug(f"Skipping track conversion error: {e}")
logger.info(f"Found {len(track_results)} Tidal tracks")
return (track_results, [])
except Exception as e:
logger.error(f"Tidal search failed: {e}")
import traceback
traceback.print_exc()
return ([], [])
def _tidal_to_track_result(self, track, quality_info: dict) -> TrackResult:
"""Convert tidalapi Track to TrackResult (Soulseek-compatible format)."""
artist_name = track.artist.name if track.artist else 'Unknown Artist'
title = track.name or 'Unknown Title'
album_name = track.album.name if track.album else None
# Duration in milliseconds
duration_ms = int(track.duration * 1000) if track.duration else None
# Encode track_id in filename (same pattern as YouTube: "id||display_name")
display_name = f"{artist_name} - {title}"
filename = f"{track.id}||{display_name}"
track_result = TrackResult(
username='tidal',
filename=filename,
size=0, # Unknown until download
bitrate=quality_info.get('bitrate'),
duration=duration_ms,
quality=quality_info.get('codec', 'flac'),
free_upload_slots=999,
upload_speed=999999,
queue_length=0,
artist=artist_name,
title=title,
album=album_name,
track_number=track.track_num,
)
return track_result
# ===================== Download =====================
async def download(self, username: str, filename: str, file_size: int = 0) -> Optional[str]:
"""
Download a Tidal track (async, Soulseek-compatible interface).
Returns download_id immediately and runs download in background thread.
Args:
username: Ignored for Tidal (always "tidal")
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 Tidal track ID: {track_id_str}")
return None
logger.info(f"Starting Tidal download: {display_name}")
download_id = str(uuid.uuid4())
with self._download_lock:
self.active_downloads[download_id] = {
'id': download_id,
'filename': filename, # Keep original encoded format for context matching
'username': 'tidal',
'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"Tidal download {download_id} started in background")
return download_id
except Exception as e:
logger.error(f"Failed to start Tidal 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 Tidal 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"Tidal 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"Tidal download {download_id} failed")
except Exception as e:
logger.error(f"Tidal 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.session or not self.session.check_login():
logger.error("Tidal session not authenticated")
return None
try:
# Get track object
track = self.session.track(track_id)
if not track:
logger.error(f"Could not fetch Tidal track: {track_id}")
return None
# Determine quality
quality_key = config_manager.get('tidal_download.quality', 'lossless')
quality_info = QUALITY_MAP.get(quality_key, QUALITY_MAP['lossless'])
# Try quality fallback chain: hires → lossless → high → low
quality_chain = ['hires', 'lossless', 'high', 'low']
start_idx = quality_chain.index(quality_key) if quality_key in quality_chain else 1
chain = quality_chain[start_idx:]
stream = None
actual_quality = None
for q_key in chain:
q_info = QUALITY_MAP[q_key]
try:
# Set session quality before requesting stream
self.session.audio_quality = q_info['tidal_quality']
stream = track.get_stream()
if stream and stream.manifest_mime_type:
actual_quality = q_info
logger.info(f"Got Tidal stream at quality: {q_key}")
break
except Exception as e:
logger.warning(f"Quality {q_key} unavailable: {e}")
continue
if not stream:
logger.error("No Tidal stream available at any quality")
return None
# Parse manifest to get download URL
manifest = stream.get_stream_manifest()
urls = manifest.get_urls()
if not urls:
logger.error("No download URLs in Tidal stream manifest")
return None
download_url = urls[0]
# Determine file extension from manifest
codec = manifest.get_codecs()
if codec and 'flac' in codec.lower():
extension = 'flac'
elif codec and ('mp4a' in codec.lower() or 'aac' in codec.lower()):
extension = 'm4a'
elif codec and 'alac' in codec.lower():
extension = 'm4a'
else:
# Default based on quality
extension = actual_quality.get('extension', 'flac') if actual_quality else 'flac'
# Build output filename: "Artist - Title.ext"
safe_name = re.sub(r'[<>:"/\\|?*]', '_', display_name)
out_filename = f"{safe_name}.{extension}"
out_path = self.download_path / out_filename
# Check for shutdown before downloading
if self.shutdown_check and self.shutdown_check():
logger.info("Server shutting down, aborting Tidal download")
return None
# Download with progress tracking
logger.info(f"Downloading from Tidal: {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
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 Tidal download mid-stream")
f.close()
out_path.unlink(missing_ok=True)
return None
f.write(chunk)
downloaded += len(chunk)
# Update progress
if total_size > 0:
progress = (downloaded / total_size) * 100
else:
progress = 0
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)
# HiRes FLAC in MP4 container: extract raw FLAC with FFmpeg if available
if extension == 'flac' and self._is_mp4_container(out_path):
extracted = self._extract_flac_from_mp4(out_path)
if extracted:
out_path = Path(extracted)
logger.info(f"Tidal download complete: {out_path}")
return str(out_path)
except Exception as e:
logger.error(f"Tidal download failed: {e}")
import traceback
traceback.print_exc()
return None
def _is_mp4_container(self, filepath: Path) -> bool:
"""Check if a file is actually an MP4 container (HiRes FLAC can be wrapped in MP4)."""
try:
with open(filepath, 'rb') as f:
header = f.read(12)
# MP4 files have 'ftyp' at offset 4
return b'ftyp' in header
except Exception:
return False
def _extract_flac_from_mp4(self, mp4_path: Path) -> Optional[str]:
"""Extract FLAC audio from MP4 container using FFmpeg."""
ffmpeg = shutil.which('ffmpeg')
if not ffmpeg:
# Also check tools directory
tools_dir = Path(__file__).parent.parent / 'tools'
ffmpeg_candidate = tools_dir / ('ffmpeg.exe' if os.name == 'nt' else 'ffmpeg')
if ffmpeg_candidate.exists():
ffmpeg = str(ffmpeg_candidate)
else:
logger.warning("FFmpeg not found — cannot extract FLAC from MP4 container")
return None
flac_path = mp4_path.with_suffix('.flac')
temp_path = mp4_path.with_suffix('.tmp.flac')
try:
result = subprocess.run(
[ffmpeg, '-i', str(mp4_path), '-vn', '-acodec', 'copy', str(temp_path), '-y'],
capture_output=True, text=True, timeout=120,
)
if result.returncode == 0 and temp_path.exists() and temp_path.stat().st_size > 0:
mp4_path.unlink(missing_ok=True)
temp_path.rename(flac_path)
logger.info(f"Extracted FLAC from MP4 container: {flac_path.name}")
return str(flac_path)
else:
logger.warning(f"FFmpeg extraction failed: {result.stderr[:200] if result.stderr else 'unknown error'}")
temp_path.unlink(missing_ok=True)
return None
except Exception as e:
logger.warning(f"FFmpeg extraction error: {e}")
temp_path.unlink(missing_ok=True)
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 Tidal download {download_id} as cancelled")
if remove:
del self.active_downloads[download_id]
logger.info(f"Removed Tidal 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

@ -42,4 +42,7 @@ asyncio-mqtt>=0.16.0
pyacoustid>=1.3.0 pyacoustid>=1.3.0
# WebSocket client for Hydrabase connection # WebSocket client for Hydrabase connection
websocket-client>=1.7.0 websocket-client>=1.7.0
# Tidal download support
tidalapi>=0.7.6

View file

@ -137,8 +137,8 @@ def extract_filename(full_path):
Extract filename by working backwards from the end until we hit a separator. Extract filename by working backwards from the end until we hit a separator.
This is cross-platform compatible and handles both Windows and Unix path separators. This is cross-platform compatible and handles both Windows and Unix path separators.
Special handling for YouTube: If the filename contains '||' (YouTube encoding format), Special handling for YouTube/Tidal: If the filename contains '||' (encoded format),
treat it as a filename, not a path, to avoid splitting on '/' in video titles. treat it as a filename, not a path, to avoid splitting on '/' in titles.
""" """
if not full_path: if not full_path:
return "" return ""
@ -177,11 +177,14 @@ try:
matching_engine = MusicMatchingEngine() matching_engine = MusicMatchingEngine()
sync_service = PlaylistSyncService(spotify_client, plex_client, soulseek_client, jellyfin_client, navidrome_client) sync_service = PlaylistSyncService(spotify_client, plex_client, soulseek_client, jellyfin_client, navidrome_client)
# Inject shutdown check callback into YouTube client (avoids circular imports) # Inject shutdown check callback into YouTube and Tidal clients (avoids circular imports)
# The callback uses the global IS_SHUTTING_DOWN flag from this module # The callback uses the global IS_SHUTTING_DOWN flag from this module
if hasattr(soulseek_client, 'youtube'): if hasattr(soulseek_client, 'youtube'):
soulseek_client.youtube.set_shutdown_check(lambda: IS_SHUTTING_DOWN) soulseek_client.youtube.set_shutdown_check(lambda: IS_SHUTTING_DOWN)
print("✅ Configured YouTube client shutdown callback") print("✅ Configured YouTube client shutdown callback")
if hasattr(soulseek_client, 'tidal'):
soulseek_client.tidal.set_shutdown_check(lambda: IS_SHUTTING_DOWN)
print("✅ Configured Tidal download client shutdown callback")
# Initialize web scan manager for automatic post-download scanning # Initialize web scan manager for automatic post-download scanning
media_clients = { media_clients = {
@ -382,12 +385,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 downloads (through orchestrator) # Also add YouTube/Tidal 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 downloads (Soulseek ones are already in the lookup) # Only add YouTube/Tidal downloads (Soulseek ones are already in the lookup)
if download.username == 'youtube': if download.username in ('youtube', 'tidal'):
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] = {
@ -401,7 +404,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 downloads: {e}") print(f"⚠️ Could not fetch YouTube/Tidal downloads: {e}")
# Update cache # Update cache
transfer_data_cache['data'] = live_transfers_lookup transfer_data_cache['data'] = live_transfers_lookup
@ -706,12 +709,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 downloads (through orchestrator) # Also get YouTube/Tidal 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 downloads (Soulseek ones are already in the lookup) # Only add YouTube/Tidal downloads (Soulseek ones are already in the lookup)
if download.username == 'youtube': if download.username in ('youtube', 'tidal'):
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] = {
@ -725,7 +728,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 downloads: {yt_error}") print(f"⚠️ Monitor: Could not fetch YouTube/Tidal downloads: {yt_error}")
return live_transfers return live_transfers
except Exception as e: except Exception as e:
@ -1568,7 +1571,7 @@ def _prepare_stream_task(track_data):
def _find_streaming_download_in_all_downloads(all_downloads, track_data): def _find_streaming_download_in_all_downloads(all_downloads, track_data):
""" """
Find streaming download in DownloadStatus list (works for both Soulseek and YouTube). Find streaming download in DownloadStatus list (works for Soulseek, YouTube, and Tidal).
Replaces the old _find_streaming_download_in_transfers function. Replaces the old _find_streaming_download_in_transfers function.
""" """
try: try:
@ -1600,26 +1603,34 @@ def _find_streaming_download_in_all_downloads(all_downloads, track_data):
return None return None
def _find_downloaded_file(download_path, track_data): def _find_downloaded_file(download_path, track_data):
"""Find the downloaded audio file in the downloads directory tree (works for Soulseek and YouTube)""" """Find the downloaded audio file in the downloads directory tree (works for Soulseek, YouTube, and Tidal)"""
# Ensure path is accessible in Docker (handles E:/ -> /host/mnt/e/) # Ensure path is accessible in Docker (handles E:/ -> /host/mnt/e/)
download_path = docker_resolve_path(download_path) download_path = docker_resolve_path(download_path)
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 SUPPORT: Handle encoded filename format "video_id||title" # YOUTUBE/TIDAL SUPPORT: Handle encoded filename format "id||title"
# The file on disk will be "title.mp3", not "video_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_streaming_source = is_youtube or is_tidal
target_filename_youtube = None target_filename_youtube = None
if is_youtube and '||' in target_filename: if is_streaming_source and '||' in target_filename:
_, title = target_filename.split('||', 1) _, title = target_filename.split('||', 1)
# yt-dlp will create "Title.mp3" from "Title" if is_tidal:
target_filename_youtube = f"{title}.mp3" # Tidal files can be flac or m4a — match any audio extension
print(f"🎵 [YouTube Stream] Looking for file: {target_filename_youtube}") safe_title = re.sub(r'[<>:"/\\|?*]', '_', title)
elif is_youtube: target_filename_youtube = safe_title # Extension-less for flexible matching
# Fallback: if YouTube but no encoded format, use as-is print(f"🎵 [Tidal Stream] Looking for file starting with: {target_filename_youtube}")
else:
# yt-dlp will create "Title.mp3" from "Title"
target_filename_youtube = f"{title}.mp3"
print(f"🎵 [YouTube Stream] Looking for file: {target_filename_youtube}")
elif is_streaming_source:
# Fallback: if streaming source but no encoded format, use as-is
target_filename_youtube = target_filename target_filename_youtube = target_filename
print(f"🎵 [YouTube Stream] Using direct filename: {target_filename_youtube}") print(f"🎵 [Stream] Using direct filename: {target_filename_youtube}")
try: try:
# Walk through the downloads directory to find the file # Walk through the downloads directory to find the file
@ -1642,15 +1653,18 @@ def _find_downloaded_file(download_path, track_data):
continue continue
# Check if this is our target file # Check if this is our target file
if is_youtube and target_filename_youtube: if is_streaming_source and target_filename_youtube:
# For YouTube, use fuzzy matching (case-insensitive, flexible) # For YouTube/Tidal, use fuzzy matching (case-insensitive, flexible)
# Because yt-dlp might sanitize the filename differently
from difflib import SequenceMatcher from difflib import SequenceMatcher
similarity = SequenceMatcher(None, # For Tidal, compare without extension (file could be .flac or .m4a)
file.lower(), compare_target = target_filename_youtube.lower()
target_filename_youtube.lower()).ratio() compare_file = file.lower()
if is_tidal:
compare_file = os.path.splitext(compare_file)[0]
similarity = SequenceMatcher(None, compare_file, compare_target).ratio()
print(f"🔍 [YouTube Stream] Comparing: '{file}' vs '{target_filename_youtube}' = {similarity:.2f}") source_label = 'Tidal' if is_tidal else 'YouTube'
print(f"🔍 [{source_label} Stream] Comparing: '{file}' vs '{target_filename_youtube}' = {similarity:.2f}")
# Keep track of best match # Keep track of best match
if similarity > best_similarity: if similarity > best_similarity:
@ -1667,13 +1681,14 @@ def _find_downloaded_file(download_path, track_data):
print(f"✅ Found streaming file: {file_path}") print(f"✅ Found streaming file: {file_path}")
return file_path return file_path
# For YouTube, 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_youtube and best_match and best_similarity >= 0.80: if is_streaming_source and best_match and best_similarity >= 0.80:
print(f"✅ Found good match ({best_similarity:.2f}) for YouTube streaming file: {best_match}") source_label = 'Tidal' if is_tidal else 'YouTube'
print(f"✅ Found good match ({best_similarity:.2f}) for {source_label} streaming file: {best_match}")
return best_match return best_match
print(f"❌ Could not find downloaded file: {target_filename}") print(f"❌ Could not find downloaded file: {target_filename}")
if is_youtube: if is_streaming_source:
print(f" Looking for: {target_filename_youtube}") print(f" Looking for: {target_filename_youtube}")
print(f" Best similarity: {best_similarity:.2f}") print(f" Best similarity: {best_similarity:.2f}")
return None return None
@ -1798,6 +1813,7 @@ def run_service_test(service, test_config):
mode_messages = { mode_messages = {
'soulseek': "Successfully connected to Soulseek network via slskd.", 'soulseek': "Successfully connected to Soulseek network via slskd.",
'youtube': "YouTube download source ready.", 'youtube': "YouTube download source ready.",
'tidal': "Tidal download source ready.",
'hybrid': "Download sources ready (Hybrid mode)." 'hybrid': "Download sources ready (Hybrid mode)."
} }
message = mode_messages.get(download_mode, "Download source connected.") message = mode_messages.get(download_mode, "Download source connected.")
@ -1807,6 +1823,7 @@ def run_service_test(service, test_config):
mode_errors = { mode_errors = {
'soulseek': "slskd is not connected to the Soulseek network. Check slskd status and credentials.", 'soulseek': "slskd is not connected to the Soulseek network. Check slskd status and credentials.",
'youtube': "YouTube download source not available.", 'youtube': "YouTube download source not available.",
'tidal': "Tidal download source not available. Check authentication.",
'hybrid': "Could not connect to download sources. Check configuration." 'hybrid': "Could not connect to download sources. Check configuration."
} }
error = mode_errors.get(download_mode, "Download source connection failed.") error = mode_errors.get(download_mode, "Download source connection failed.")
@ -2217,6 +2234,10 @@ def get_status():
} }
_status_cache_timestamps['soulseek'] = current_time _status_cache_timestamps['soulseek'] = current_time
# Include download source mode so frontend can update labels
download_mode = config_manager.get('download_source.mode', 'soulseek')
_status_cache['soulseek']['source'] = download_mode
status_data = { status_data = {
'spotify': _status_cache['spotify'], 'spotify': _status_cache['spotify'],
'media_server': _status_cache['media_server'], 'media_server': _status_cache['media_server'],
@ -2514,7 +2535,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', 'listenbrainz', 'acoustid', 'import', 'lossy_copy']: for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'listenbrainz', 'acoustid', 'import', 'lossy_copy']:
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)
@ -4346,8 +4367,8 @@ def stream_enhanced_search_track():
search_queries = [] search_queries = []
import re import re
if download_mode == 'youtube' or (download_mode == 'hybrid' and config_manager.get('download_source.hybrid_primary') == 'youtube'): if download_mode in ('youtube', 'tidal') or (download_mode == 'hybrid' and config_manager.get('download_source.hybrid_primary') in ('youtube', 'tidal')):
# YouTube 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:
search_queries.append(f"{artist_name} {track_name}".strip()) search_queries.append(f"{artist_name} {track_name}".strip())
@ -4358,7 +4379,7 @@ def stream_enhanced_search_track():
if cleaned_name and cleaned_name.lower() != track_name.lower(): if cleaned_name and cleaned_name.lower() != track_name.lower():
search_queries.append(f"{artist_name} {cleaned_name}".strip()) search_queries.append(f"{artist_name} {cleaned_name}".strip())
logger.info(f"🔍 YouTube mode: Searching with artist + track name: {search_queries}") logger.info(f"🔍 {download_mode.title()} mode: Searching with artist + track name: {search_queries}")
else: else:
# Soulseek mode: Track name only to avoid keyword filtering # Soulseek mode: Track name only to avoid keyword filtering
# Primary query: Full track name # Primary query: Full track name
@ -4519,7 +4540,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_youtube = username == 'youtube' is_streaming_source = username in ('youtube', 'tidal')
with matched_context_lock: with matched_context_lock:
matched_downloads_context[context_key] = { matched_downloads_context[context_key] = {
'search_result': { 'search_result': {
@ -4535,7 +4556,8 @@ def start_download():
'spotify_album': None, 'spotify_album': None,
'track_info': None 'track_info': None
} }
logger.info(f"{'[YouTube]' if is_youtube else '[Soulseek]'} Registered simple download for post-processing: {context_key}") source_label = username.title() if is_streaming_source else 'Soulseek'
logger.info(f"[{source_label}] Registered simple download for post-processing: {context_key}")
# Extract track name from filename for activity # Extract track name from filename for activity
track_name = filename.split('/')[-1] if '/' in filename else filename.split('\\')[-1] if '\\' in filename else filename track_name = filename.split('/')[-1] if '/' in filename else filename.split('\\')[-1] if '\\' in filename else filename
@ -4574,7 +4596,7 @@ def _find_completed_file_robust(download_dir, api_filename, transfer_dir=None):
from difflib import SequenceMatcher from difflib import SequenceMatcher
from unidecode import unidecode from unidecode import unidecode
# YOUTUBE SUPPORT: Handle encoded filename format "video_id||title" # YOUTUBE/TIDAL SUPPORT: Handle encoded filename format "id||title"
# Extract just the title part for file matching # Extract just the title part for file matching
if '||' in api_filename: if '||' in api_filename:
_, title = api_filename.split('||', 1) _, title = api_filename.split('||', 1)
@ -4715,7 +4737,7 @@ def get_download_status():
global _processed_download_ids global _processed_download_ids
transfers_data = run_async(soulseek_client._make_request('GET', 'transfers/downloads')) transfers_data = run_async(soulseek_client._make_request('GET', 'transfers/downloads'))
# Don't return early if no Soulseek transfers - YouTube downloads need to be checked too! # Don't return early if no Soulseek transfers - YouTube/Tidal downloads need to be checked too!
all_transfers = [] all_transfers = []
completed_matched_downloads = [] completed_matched_downloads = []
# Track files already claimed this poll cycle to prevent two contexts from # Track files already claimed this poll cycle to prevent two contexts from
@ -4724,7 +4746,7 @@ def get_download_status():
_files_claimed_this_cycle = set() _files_claimed_this_cycle = set()
if not transfers_data: if not transfers_data:
# No Soulseek transfers, but continue to check YouTube downloads below # No Soulseek transfers, but continue to check YouTube/Tidal downloads below
pass pass
else: else:
# This logic now correctly processes the nested structure from the slskd API # This logic now correctly processes the nested structure from the slskd API
@ -4882,17 +4904,18 @@ def get_download_status():
processing_thread.daemon = True processing_thread.daemon = True
processing_thread.start() processing_thread.start()
# Also include YouTube downloads in the response # Also include YouTube/Tidal downloads in the response
try: try:
all_youtube_downloads = run_async(soulseek_client.get_all_downloads()) all_streaming_downloads = run_async(soulseek_client.get_all_downloads())
for download in all_youtube_downloads: for download in all_streaming_downloads:
if download.username == 'youtube': if download.username in ('youtube', 'tidal'):
source_label = download.username.title()
# Convert DownloadStatus to transfer format that frontend expects # Convert DownloadStatus to transfer format that frontend expects
youtube_transfer = { streaming_transfer = {
'id': download.id, 'id': download.id,
'filename': download.filename, 'filename': download.filename,
'username': 'youtube', 'username': download.username,
'state': download.state, 'state': download.state,
'percentComplete': download.progress, 'percentComplete': download.progress,
'size': download.size, 'size': download.size,
@ -4900,12 +4923,12 @@ def get_download_status():
'averageSpeed': download.speed, 'averageSpeed': download.speed,
'direction': 'Download', # Required by frontend 'direction': 'Download', # Required by frontend
} }
all_transfers.append(youtube_transfer) all_transfers.append(streaming_transfer)
# Check if YouTube download is completed and needs post-processing # Check if download is completed and needs post-processing
# Verify bytes match before trusting state string # Verify bytes match before trusting state string
_yt_bytes_ok = download.size <= 0 or download.transferred >= download.size _st_bytes_ok = download.size <= 0 or download.transferred >= download.size
if _yt_bytes_ok and download.state and ('succeeded' in download.state.lower() or 'completed' in download.state.lower()): if _st_bytes_ok and download.state and ('succeeded' in download.state.lower() or 'completed' in download.state.lower()):
context_key = _make_context_key(download.username, download.filename) context_key = _make_context_key(download.username, download.filename)
with matched_context_lock: with matched_context_lock:
@ -4918,34 +4941,34 @@ def get_download_status():
if found_path: if found_path:
# Prevent two contexts from claiming the same physical file # Prevent two contexts from claiming the same physical file
_yt_norm = os.path.normpath(found_path) _st_norm = os.path.normpath(found_path)
if _yt_norm in _files_claimed_this_cycle: if _st_norm in _files_claimed_this_cycle:
print(f"⚠️ [YouTube] File already claimed this cycle: {os.path.basename(found_path)} — deferring") print(f"⚠️ [{source_label}] File already claimed this cycle: {os.path.basename(found_path)} — deferring")
continue continue
_files_claimed_this_cycle.add(_yt_norm) _files_claimed_this_cycle.add(_st_norm)
print(f"🎯 [YouTube] Found completed matched file on disk: {found_path}") print(f"🎯 [{source_label}] Found completed matched file on disk: {found_path}")
# Start post-processing thread # Start post-processing thread
def process_youtube_download(): def process_streaming_download(_ctx_key=context_key, _ctx=context, _path=found_path, _label=source_label):
try: try:
print(f"🚀 [YouTube] Starting post-processing thread for: {context_key}") print(f"🚀 [{_label}] Starting post-processing thread for: {_ctx_key}")
thread = threading.Thread(target=_post_process_matched_download, args=(context_key, context, found_path)) thread = threading.Thread(target=_post_process_matched_download, args=(_ctx_key, _ctx, _path))
thread.daemon = True thread.daemon = True
thread.start() thread.start()
_processed_download_ids.add(context_key) _processed_download_ids.add(_ctx_key)
print(f"✅ [YouTube] Marked as processed: {context_key}") print(f"✅ [{_label}] Marked as processed: {_ctx_key}")
except Exception as e: except Exception as e:
print(f"❌ [YouTube] Error starting post-processing thread for {context_key}: {e}") print(f"❌ [{_label}] Error starting post-processing thread for {_ctx_key}: {e}")
processing_thread = threading.Thread(target=process_youtube_download) processing_thread = threading.Thread(target=process_streaming_download)
processing_thread.daemon = True processing_thread.daemon = True
processing_thread.start() processing_thread.start()
else: else:
# File not found - likely already processed and moved to library # File not found - likely already processed and moved to library
# Mark as processed to prevent infinite checking # Mark as processed to prevent infinite checking
_processed_download_ids.add(context_key) _processed_download_ids.add(context_key)
except Exception as yt_error: except Exception as streaming_error:
import traceback import traceback
print(f"⚠️ Could not fetch YouTube downloads for status: {yt_error}") print(f"⚠️ Could not fetch YouTube/Tidal downloads for status: {streaming_error}")
traceback.print_exc() traceback.print_exc()
return jsonify({"transfers": all_transfers}) return jsonify({"transfers": all_transfers})
@ -14100,11 +14123,12 @@ def get_valid_candidates(results, spotify_track, query):
if not initial_candidates: if not initial_candidates:
return [] return []
# Skip quality filtering for YouTube results (always MP3 320kbps - no quality options) # Skip quality filtering for YouTube/Tidal results (quality is fixed by source, not user-selectable per-result)
is_youtube_source = initial_candidates[0].username == "youtube" if initial_candidates else False is_streaming_source = initial_candidates[0].username in ("youtube", "tidal") if initial_candidates else False
if is_youtube_source: if is_streaming_source:
print(f"🎵 [YouTube] Skipping quality filter - YouTube always provides MP3 320kbps") source_label = initial_candidates[0].username.title()
print(f"🎵 [{source_label}] Skipping quality filter - streaming source handles quality internally")
quality_filtered_candidates = initial_candidates quality_filtered_candidates = initial_candidates
else: else:
# Filter by user's quality profile before artist verification (Soulseek only) # Filter by user's quality profile before artist verification (Soulseek only)
@ -14132,8 +14156,8 @@ def get_valid_candidates(results, spotify_track, query):
artist_word_sets.append(words) artist_word_sets.append(words)
for candidate in quality_filtered_candidates: for candidate in quality_filtered_candidates:
# Skip artist check for YouTube results (title matching is sufficient as processed by matching engine) # Skip artist check for streaming results (title matching is sufficient as processed by matching engine)
if is_youtube_source: if is_streaming_source:
verified_candidates.append(candidate) verified_candidates.append(candidate)
continue continue
@ -16211,8 +16235,8 @@ 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 == 'youtube': if not batch_id or username in ('youtube', 'tidal'):
_sr.info(f"Skipped — no batch_id or youtube") _sr.info(f"Skipped — no batch_id or streaming source ({username})")
return return
with tasks_lock: with tasks_lock:
@ -18153,6 +18177,46 @@ def get_discover_album(source, album_id):
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
# ===================================================================
# TIDAL DOWNLOAD AUTH ENDPOINTS
# ===================================================================
@app.route('/api/tidal/download/auth/start', methods=['POST'])
def tidal_download_auth_start():
"""Start Tidal device-code OAuth flow for download client."""
try:
tidal_dl = soulseek_client.tidal
result = tidal_dl.start_device_auth()
if result:
return jsonify({"success": True, **result})
else:
return jsonify({"error": "Failed to start Tidal auth. Is tidalapi installed?"}), 500
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/api/tidal/download/auth/check', methods=['GET'])
def tidal_download_auth_check():
"""Check status of Tidal device-code OAuth flow."""
try:
tidal_dl = soulseek_client.tidal
result = tidal_dl.check_device_auth()
return jsonify(result)
except Exception as e:
return jsonify({"status": "error", "message": str(e)}), 500
@app.route('/api/tidal/download/auth/status', methods=['GET'])
def tidal_download_auth_status():
"""Check if Tidal download client is authenticated."""
try:
tidal_dl = soulseek_client.tidal
authenticated = tidal_dl.is_authenticated()
return jsonify({"authenticated": authenticated})
except Exception as e:
return jsonify({"authenticated": False, "error": str(e)})
# =================================================================== # ===================================================================
# TIDAL PLAYLIST API ENDPOINTS # TIDAL PLAYLIST API ENDPOINTS
# =================================================================== # ===================================================================

View file

@ -162,7 +162,7 @@
</div> </div>
<div class="status-indicator" id="soulseek-indicator"> <div class="status-indicator" id="soulseek-indicator">
<span class="status-dot disconnected"></span> <span class="status-dot disconnected"></span>
<span class="status-name">Soulseek</span> <span class="status-name" id="download-source-name">Soulseek</span>
</div> </div>
</div> </div>
</div> </div>
@ -372,7 +372,7 @@
</div> </div>
<div class="service-card" id="soulseek-service-card"> <div class="service-card" id="soulseek-service-card">
<div class="service-card-header"> <div class="service-card-header">
<span class="service-card-title">Soulseek</span> <span class="service-card-title" id="download-source-title">Soulseek</span>
<span class="service-card-indicator disconnected" <span class="service-card-indicator disconnected"
id="soulseek-status-indicator">●</span> id="soulseek-status-indicator">●</span>
</div> </div>
@ -3075,6 +3075,7 @@
onchange="updateDownloadSourceUI()"> onchange="updateDownloadSourceUI()">
<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="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">
@ -3090,6 +3091,7 @@
<select id="hybrid-primary-source" class="form-select"> <select id="hybrid-primary-source" class="form-select">
<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>
</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.
@ -3107,6 +3109,33 @@
</div> </div>
</div> </div>
<!-- Tidal Download Settings (shown only when tidal mode is selected) -->
<div id="tidal-download-settings-container" style="display: none;">
<div class="form-group">
<label>Tidal Download Quality:</label>
<select id="tidal-download-quality" class="form-select">
<option value="low">Low (AAC 96kbps)</option>
<option value="high">High (AAC 320kbps)</option>
<option value="lossless">Lossless (FLAC 16-bit/44.1kHz)</option>
<option value="hires">HiRes (FLAC 24-bit/96kHz)</option>
</select>
<div class="setting-help-text">
Audio quality for Tidal downloads. HiRes requires a Tidal HiFi Plus subscription.
</div>
</div>
<div class="form-group">
<label>Tidal Download Auth:</label>
<div class="form-actions" style="margin-top: 4px;">
<button class="auth-button" id="tidal-download-auth-btn" onclick="startTidalDownloadAuth()">
Link Tidal Account
</button>
<span id="tidal-download-auth-status" class="setting-help-text" style="margin-left: 8px;"></span>
</div>
<div id="tidal-download-auth-code" style="display: none; margin-top: 8px;" class="setting-help-text">
</div>
</div>
</div>
<div class="form-group"> <div class="form-group">
<label>Log Level:</label> <label>Log Level:</label>
<select id="log-level-select" class="form-select" onchange="changeLogLevel()"> <select id="log-level-select" class="form-select" onchange="changeLogLevel()">

View file

@ -1835,6 +1835,7 @@ async function loadSettingsData() {
document.getElementById('download-source-mode').value = settings.download_source?.mode || 'soulseek'; document.getElementById('download-source-mode').value = settings.download_source?.mode || 'soulseek';
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';
// Update UI based on download source mode // Update UI based on download source mode
updateDownloadSourceUI(); updateDownloadSourceUI();
@ -1985,12 +1986,17 @@ function toggleServer(serverType) {
function updateDownloadSourceUI() { 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');
// Show hybrid settings only when hybrid mode is selected // Show hybrid settings only when hybrid mode is selected
if (mode === 'hybrid') { hybridContainer.style.display = mode === 'hybrid' ? 'block' : 'none';
hybridContainer.style.display = 'block';
} else { // Show Tidal download settings when tidal mode is selected
hybridContainer.style.display = 'none'; tidalContainer.style.display = mode === 'tidal' ? 'block' : 'none';
// Check Tidal download auth status when switching to tidal mode
if (mode === 'tidal') {
checkTidalDownloadAuthStatus();
} }
} }
@ -2495,6 +2501,9 @@ async function saveSettings(quiet = false) {
hybrid_primary: document.getElementById('hybrid-primary-source').value, hybrid_primary: document.getElementById('hybrid-primary-source').value,
youtube_min_confidence: parseFloat(document.getElementById('youtube-min-confidence').value) || 0.65 youtube_min_confidence: parseFloat(document.getElementById('youtube-min-confidence').value) || 0.65
}, },
tidal_download: {
quality: document.getElementById('tidal-download-quality').value || 'lossless'
},
database: { database: {
max_workers: parseInt(document.getElementById('max-workers').value) max_workers: parseInt(document.getElementById('max-workers').value)
}, },
@ -2839,6 +2848,97 @@ async function authenticateTidal() {
} }
} }
// ===== Tidal Download Auth (Device Flow) =====
async function checkTidalDownloadAuthStatus() {
const statusEl = document.getElementById('tidal-download-auth-status');
const btn = document.getElementById('tidal-download-auth-btn');
try {
const resp = await fetch('/api/tidal/download/auth/status');
const data = await resp.json();
if (data.authenticated) {
statusEl.textContent = 'Authenticated';
statusEl.style.color = '#4caf50';
btn.textContent = 'Re-link Tidal Account';
} else {
statusEl.textContent = 'Not authenticated';
statusEl.style.color = '#ff9800';
btn.textContent = 'Link Tidal Account';
}
} catch (e) {
statusEl.textContent = '';
}
}
let _tidalAuthPollTimer = null;
async function startTidalDownloadAuth() {
const btn = document.getElementById('tidal-download-auth-btn');
const statusEl = document.getElementById('tidal-download-auth-status');
const codeEl = document.getElementById('tidal-download-auth-code');
btn.disabled = true;
btn.textContent = 'Starting...';
statusEl.textContent = '';
try {
const resp = await fetch('/api/tidal/download/auth/start', { method: 'POST' });
const data = await resp.json();
if (!resp.ok || !data.success) {
throw new Error(data.error || 'Failed to start auth');
}
// Show the link/code to the user
const uri = data.verification_uri || '';
const code = data.user_code || '';
codeEl.style.display = 'block';
codeEl.innerHTML = `Go to <a href="${uri}" target="_blank" style="color:#1db954;">${uri}</a> and enter code: <strong>${code}</strong>`;
btn.textContent = 'Waiting for approval...';
statusEl.textContent = 'Waiting...';
statusEl.style.color = '#ff9800';
// Poll for completion
if (_tidalAuthPollTimer) clearInterval(_tidalAuthPollTimer);
_tidalAuthPollTimer = setInterval(async () => {
try {
const checkResp = await fetch('/api/tidal/download/auth/check');
const checkData = await checkResp.json();
if (checkData.status === 'completed') {
clearInterval(_tidalAuthPollTimer);
_tidalAuthPollTimer = null;
codeEl.style.display = 'none';
statusEl.textContent = 'Authenticated';
statusEl.style.color = '#4caf50';
btn.disabled = false;
btn.textContent = 'Re-link Tidal Account';
showToast('Tidal download account linked successfully', 'success');
} else if (checkData.status === 'error') {
clearInterval(_tidalAuthPollTimer);
_tidalAuthPollTimer = null;
codeEl.style.display = 'none';
statusEl.textContent = 'Auth failed';
statusEl.style.color = '#f44336';
btn.disabled = false;
btn.textContent = 'Link Tidal Account';
showToast('Tidal auth failed: ' + (checkData.message || 'Unknown error'), 'error');
}
// status === 'pending' — keep polling
} catch (pollErr) {
console.error('Tidal auth poll error:', pollErr);
}
}, 3000);
} catch (error) {
console.error('Tidal download auth error:', error);
showToast('Failed to start Tidal auth: ' + error.message, 'error');
btn.disabled = false;
btn.textContent = 'Link Tidal Account';
codeEl.style.display = 'none';
}
}
const PATH_INPUT_IDS = { const PATH_INPUT_IDS = {
download: 'download-path', download: 'download-path',
transfer: 'transfer-path', transfer: 'transfer-path',
@ -3532,9 +3632,9 @@ function initializeSearchModeToggle() {
const slskdResult = data.result; const slskdResult = data.result;
// Check if audio format is supported (YouTube is always MP3, so skip check) // Check if audio format is supported (YouTube/Tidal use encoded filenames, skip check)
const isYouTube = slskdResult.username === 'youtube'; const isStreamingSource = slskdResult.username === 'youtube' || slskdResult.username === 'tidal';
if (!isYouTube && slskdResult.filename && !isAudioFormatSupported(slskdResult.filename)) { if (!isStreamingSource && slskdResult.filename && !isAudioFormatSupported(slskdResult.filename)) {
const format = getFileExtension(slskdResult.filename); const format = getFileExtension(slskdResult.filename);
hideLoadingOverlay(); hideLoadingOverlay();
showToast(`Sorry, ${format.toUpperCase()} format is not supported in your browser. Try downloading instead.`, 'error'); showToast(`Sorry, ${format.toUpperCase()} format is not supported in your browser. Try downloading instead.`, 'error');
@ -9199,7 +9299,7 @@ async function updateModalWithLiveDownloadProgress() {
// Extract display title from filename (handle YouTube encoding) // Extract display title from filename (handle YouTube encoding)
let downloadTitle = ''; let downloadTitle = '';
if (downloadInfo.filename) { if (downloadInfo.filename) {
if (downloadInfo.username === 'youtube' && downloadInfo.filename.includes('||')) { if ((downloadInfo.username === 'youtube' || downloadInfo.username === 'tidal') && downloadInfo.filename.includes('||')) {
const parts = downloadInfo.filename.split('||'); const parts = downloadInfo.filename.split('||');
downloadTitle = parts[1] || parts[0]; downloadTitle = parts[1] || parts[0];
} else { } else {
@ -9996,10 +10096,10 @@ function renderQueue(containerId, downloads, isActiveQueue) {
// Extract display title from filename // Extract display title from filename
let title = 'Unknown File'; let title = 'Unknown File';
if (item.filename) { if (item.filename) {
// YouTube filenames are encoded as "video_id||title" // YouTube/Tidal filenames are encoded as "id||title"
if (item.username === 'youtube' && item.filename.includes('||')) { if ((item.username === 'youtube' || item.username === 'tidal') && item.filename.includes('||')) {
const parts = item.filename.split('||'); const parts = item.filename.split('||');
title = parts[1] || parts[0]; // Use title part, fallback to video_id title = parts[1] || parts[0]; // Use title part, fallback to id
} else { } else {
// Regular Soulseek filename - extract last part of path // Regular Soulseek filename - extract last part of path
title = item.filename.split(/[\\/]/).pop(); title = item.filename.split(/[\\/]/).pop();
@ -10555,9 +10655,9 @@ async function streamTrack(index) {
const result = window.currentSearchResults[index]; const result = window.currentSearchResults[index];
console.log(`🎵 Streaming track:`, result); console.log(`🎵 Streaming track:`, result);
// Check for unsupported formats before streaming (YouTube is always MP3, so skip check) // Check for unsupported formats before streaming (YouTube/Tidal use encoded filenames, skip check)
const isYouTube = result.username === 'youtube'; const isStreamingSource = result.username === 'youtube' || result.username === 'tidal';
if (!isYouTube && result.filename) { if (!isStreamingSource && result.filename) {
const format = getFileExtension(result.filename); const format = getFileExtension(result.filename);
console.log(`🎵 [STREAM CHECK] File: ${result.filename}, Extension: ${format}`); console.log(`🎵 [STREAM CHECK] File: ${result.filename}, Extension: ${format}`);
@ -10594,13 +10694,13 @@ async function streamAlbumTrack(albumIndex, trackIndex) {
const album = window.currentSearchResults[albumIndex]; const album = window.currentSearchResults[albumIndex];
console.log(`🎵 Album data:`, album); console.log(`🎵 Album data:`, album);
// Surgical Fix: Handle YouTube results which are "flat" (no tracks array) // Surgical Fix: Handle YouTube/Tidal results which are "flat" (no tracks array)
if (album.username === 'youtube') { if (album.username === 'youtube' || album.username === 'tidal') {
// For YouTube results, the "album" is actually the track itself // For YouTube/Tidal results, the "album" is actually the track itself
const track = album; const track = album;
const trackData = { const trackData = {
...track, ...track,
username: 'youtube', username: track.username,
filename: track.filename, filename: track.filename,
artist: track.artist, artist: track.artist,
album: track.title, // Use title as album name for player album: track.title, // Use title as album name for player
@ -10631,9 +10731,9 @@ async function streamAlbumTrack(albumIndex, trackIndex) {
console.log(`🎵 Enhanced track data:`, trackData); console.log(`🎵 Enhanced track data:`, trackData);
// Check for unsupported formats before streaming (YouTube is always MP3, so skip check) // Check for unsupported formats before streaming (YouTube/Tidal use encoded filenames, skip check)
const isYouTube = trackData.username === 'youtube'; const isStreamingSource2 = trackData.username === 'youtube' || trackData.username === 'tidal';
if (!isYouTube && trackData.filename && !isAudioFormatSupported(trackData.filename)) { if (!isStreamingSource2 && trackData.filename && !isAudioFormatSupported(trackData.filename)) {
const format = getFileExtension(trackData.filename); const format = getFileExtension(trackData.filename);
showToast(`Sorry, ${format.toUpperCase()} format is not supported in web browsers. Try downloading instead.`, 'error'); showToast(`Sorry, ${format.toUpperCase()} format is not supported in web browsers. Try downloading instead.`, 'error');
return; return;
@ -25137,6 +25237,14 @@ function updateServiceStatus(service, statusData) {
disconnectBtn.style.display = statusData.source === 'spotify' ? '' : 'none'; disconnectBtn.style.display = statusData.source === 'spotify' ? '' : 'none';
} }
} }
// Update download source title on dashboard card
if (service === 'soulseek' && statusData.source) {
const sourceNames = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', hybrid: 'Hybrid' };
const displayName = sourceNames[statusData.source] || 'Soulseek';
const titleEl = document.getElementById('download-source-title');
if (titleEl) titleEl.textContent = displayName;
}
} }
function updateSidebarServiceStatus(service, statusData) { function updateSidebarServiceStatus(service, statusData) {
@ -25170,6 +25278,14 @@ function updateSidebarServiceStatus(service, statusData) {
musicSourceNameElement.textContent = sourceName; musicSourceNameElement.textContent = sourceName;
} }
} }
// Update download source name based on configured mode
if (service === 'soulseek' && statusData.source) {
const sourceNames = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', hybrid: 'Hybrid' };
const displayName = sourceNames[statusData.source] || 'Soulseek';
const sidebarName = document.getElementById('download-source-name');
if (sidebarName) sidebarName.textContent = displayName;
}
} }
} }