diff --git a/config/settings.py b/config/settings.py index a8375819..d5d14d72 100644 --- a/config/settings.py +++ b/config/settings.py @@ -188,10 +188,19 @@ class ConfigManager: "transfer_path": "./Transfer" }, "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 "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": { "token": "" }, diff --git a/core/download_orchestrator.py b/core/download_orchestrator.py index 4548d836..0bdf473c 100644 --- a/core/download_orchestrator.py +++ b/core/download_orchestrator.py @@ -1,10 +1,11 @@ """ 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 - YouTube Only: YouTube-exclusive downloads +- Tidal Only: Tidal-exclusive downloads - 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 core.soulseek_client import SoulseekClient, TrackResult, AlbumResult, DownloadStatus from core.youtube_client import YouTubeClient +from core.tidal_download_client import TidalDownloadClient logger = get_logger("download_orchestrator") @@ -29,9 +31,10 @@ class DownloadOrchestrator: """ def __init__(self): - """Initialize orchestrator with both clients""" + """Initialize orchestrator with all clients""" self.soulseek = SoulseekClient() self.youtube = YouTubeClient() + self.tidal = TidalDownloadClient() # Load mode from config self.mode = config_manager.get('download_source.mode', 'soulseek') @@ -64,9 +67,11 @@ class DownloadOrchestrator: return self.soulseek.is_configured() elif self.mode == 'youtube': return self.youtube.is_configured() + elif self.mode == 'tidal': + return self.tidal.is_configured() elif self.mode == 'hybrid': # In hybrid mode, at least one source must be configured - return self.soulseek.is_configured() or self.youtube.is_configured() + return self.soulseek.is_configured() or self.youtube.is_configured() or self.tidal.is_configured() return False @@ -80,15 +85,18 @@ class DownloadOrchestrator: return await self.soulseek.check_connection() elif self.mode == 'youtube': return await self.youtube.check_connection() + elif self.mode == 'tidal': + return await self.tidal.check_connection() elif self.mode == 'hybrid': - # In hybrid mode, check both sources + # In hybrid mode, check all sources soulseek_ok = await self.soulseek.check_connection() youtube_ok = await self.youtube.check_connection() + tidal_ok = await self.tidal.check_connection() - 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 - return soulseek_ok or youtube_ok + return soulseek_ok or youtube_ok or tidal_ok return False @@ -112,33 +120,38 @@ class DownloadOrchestrator: logger.info(f"🔍 Searching YouTube: {query}") 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': + # 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 - if self.hybrid_primary == 'soulseek': - logger.info(f"🔍 Hybrid search - trying Soulseek first: {query}") - tracks, albums = await self.soulseek.search(query, timeout, progress_callback) + logger.info(f"🔍 Hybrid search - trying {primary} first: {query}") + tracks, albums = await clients[primary].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: - logger.info(f"✅ Soulseek found {len(tracks)} tracks") + logger.info(f"✅ {fallback_name} found {len(tracks)} tracks") return (tracks, albums) - # Otherwise, try YouTube as fallback - logger.info(f"🔄 Soulseek found nothing, trying YouTube fallback") - return await self.youtube.search(query, timeout, progress_callback) - - 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) + # Nothing found from any source + logger.warning(f"❌ Hybrid search exhausted all sources for: {query}") + return ([], []) # Fallback: empty results return ([], []) @@ -203,6 +216,9 @@ class DownloadOrchestrator: if username == 'youtube': logger.info(f"📥 Downloading from YouTube: {filename}") 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: logger.info(f"📥 Downloading from Soulseek: {filename}") return await self.soulseek.download(username, filename, file_size) @@ -214,12 +230,13 @@ class DownloadOrchestrator: Returns: List of DownloadStatus objects """ - # Get downloads from both sources + # Get downloads from all sources soulseek_downloads = await self.soulseek.get_all_downloads() youtube_downloads = await self.youtube.get_all_downloads() + tidal_downloads = await self.tidal.get_all_downloads() # 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]: """ @@ -241,6 +258,11 @@ class DownloadOrchestrator: if status: return status + # Try Tidal + status = await self.tidal.get_download_status(download_id) + if status: + return status + return None async def cancel_download(self, download_id: str, username: str = None, remove: bool = False) -> bool: @@ -258,16 +280,22 @@ class DownloadOrchestrator: # If username is provided, route directly if username == 'youtube': 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: 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) if soulseek_cancelled: return True 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: """ @@ -292,10 +320,11 @@ class DownloadOrchestrator: True if successful """ 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() + 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) ===== # 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) async def cancel_all_downloads(self) -> bool: - """Cancel and remove all downloads from slskd.""" - return await self.soulseek.cancel_all_downloads() + """Cancel and remove all downloads from all sources.""" + soulseek_ok = await self.soulseek.cancel_all_downloads() + # Clear Tidal active downloads too + tidal_ok = await self.tidal.clear_all_completed_downloads() + return soulseek_ok diff --git a/core/tidal_download_client.py b/core/tidal_download_client.py new file mode 100644 index 00000000..7f431ece --- /dev/null +++ b/core/tidal_download_client.py @@ -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 diff --git a/requirements-webui.txt b/requirements-webui.txt index 6d07e134..e1f094e0 100644 --- a/requirements-webui.txt +++ b/requirements-webui.txt @@ -42,4 +42,7 @@ asyncio-mqtt>=0.16.0 pyacoustid>=1.3.0 # WebSocket client for Hydrabase connection -websocket-client>=1.7.0 \ No newline at end of file +websocket-client>=1.7.0 + +# Tidal download support +tidalapi>=0.7.6 \ No newline at end of file diff --git a/web_server.py b/web_server.py index 2a6bf953..517464f2 100644 --- a/web_server.py +++ b/web_server.py @@ -137,8 +137,8 @@ def extract_filename(full_path): 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. - Special handling for YouTube: If the filename contains '||' (YouTube encoding format), - treat it as a filename, not a path, to avoid splitting on '/' in video titles. + Special handling for YouTube/Tidal: If the filename contains '||' (encoded format), + treat it as a filename, not a path, to avoid splitting on '/' in titles. """ if not full_path: return "" @@ -177,11 +177,14 @@ try: matching_engine = MusicMatchingEngine() 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 if hasattr(soulseek_client, 'youtube'): soulseek_client.youtube.set_shutdown_check(lambda: IS_SHUTTING_DOWN) 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 media_clients = { @@ -382,12 +385,12 @@ def get_cached_transfer_data(): key = _make_context_key(transfer.get('username'), transfer.get('filename', '')) live_transfers_lookup[key] = transfer - # Also add YouTube downloads (through orchestrator) + # Also add YouTube/Tidal downloads (through orchestrator) try: all_downloads = run_async(soulseek_client.get_all_downloads()) for download in all_downloads: - # Only add YouTube downloads (Soulseek ones are already in the lookup) - if download.username == 'youtube': + # Only add YouTube/Tidal downloads (Soulseek ones are already in the lookup) + if download.username in ('youtube', 'tidal'): key = _make_context_key(download.username, download.filename) # Convert DownloadStatus to transfer dict format live_transfers_lookup[key] = { @@ -401,7 +404,7 @@ def get_cached_transfer_data(): 'averageSpeed': download.speed, } except Exception as e: - print(f"⚠️ Could not fetch YouTube downloads: {e}") + print(f"⚠️ Could not fetch YouTube/Tidal downloads: {e}") # Update cache transfer_data_cache['data'] = live_transfers_lookup @@ -706,12 +709,12 @@ class WebUIDownloadMonitor: key = _make_context_key(username, file_info.get('filename', '')) live_transfers[key] = file_info - # Also get YouTube downloads (through orchestrator) + # Also get YouTube/Tidal downloads (through orchestrator) try: all_downloads = run_async(soulseek_client.get_all_downloads()) for download in all_downloads: - # Only add YouTube downloads (Soulseek ones are already in the lookup) - if download.username == 'youtube': + # Only add YouTube/Tidal downloads (Soulseek ones are already in the lookup) + if download.username in ('youtube', 'tidal'): key = _make_context_key(download.username, download.filename) # Convert DownloadStatus to transfer dict format for monitor compatibility live_transfers[key] = { @@ -725,7 +728,7 @@ class WebUIDownloadMonitor: 'averageSpeed': download.speed, } 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 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): """ - 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. """ try: @@ -1600,26 +1603,34 @@ def _find_streaming_download_in_all_downloads(all_downloads, track_data): return None 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/) download_path = docker_resolve_path(download_path) audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'} target_filename = extract_filename(track_data.get('filename', '')) - # YOUTUBE SUPPORT: Handle encoded filename format "video_id||title" - # The file on disk will be "title.mp3", not "video_id||title" + # YOUTUBE/TIDAL SUPPORT: Handle encoded filename format "id||title" + # The file on disk will be "title.ext", not "id||title" is_youtube = track_data.get('username') == 'youtube' + is_tidal = track_data.get('username') == 'tidal' + is_streaming_source = is_youtube or is_tidal target_filename_youtube = None - if is_youtube and '||' in target_filename: + if is_streaming_source and '||' in target_filename: _, title = target_filename.split('||', 1) - # 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_youtube: - # Fallback: if YouTube but no encoded format, use as-is + if is_tidal: + # Tidal files can be flac or m4a — match any audio extension + safe_title = re.sub(r'[<>:"/\\|?*]', '_', title) + target_filename_youtube = safe_title # Extension-less for flexible matching + print(f"🎵 [Tidal Stream] Looking for file starting with: {target_filename_youtube}") + 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 - print(f"🎵 [YouTube Stream] Using direct filename: {target_filename_youtube}") + print(f"🎵 [Stream] Using direct filename: {target_filename_youtube}") try: # Walk through the downloads directory to find the file @@ -1642,15 +1653,18 @@ def _find_downloaded_file(download_path, track_data): continue # Check if this is our target file - if is_youtube and target_filename_youtube: - # For YouTube, use fuzzy matching (case-insensitive, flexible) - # Because yt-dlp might sanitize the filename differently + if is_streaming_source and target_filename_youtube: + # For YouTube/Tidal, use fuzzy matching (case-insensitive, flexible) from difflib import SequenceMatcher - similarity = SequenceMatcher(None, - file.lower(), - target_filename_youtube.lower()).ratio() + # For Tidal, compare without extension (file could be .flac or .m4a) + compare_target = target_filename_youtube.lower() + 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 if similarity > best_similarity: @@ -1667,13 +1681,14 @@ def _find_downloaded_file(download_path, track_data): print(f"✅ Found streaming file: {file_path}") return file_path - # For YouTube, if we found a good enough match (80%+), use it - if is_youtube and best_match and best_similarity >= 0.80: - print(f"✅ Found good match ({best_similarity:.2f}) for YouTube streaming file: {best_match}") + # For YouTube/Tidal, if we found a good enough match (80%+), use it + if is_streaming_source and best_match and best_similarity >= 0.80: + source_label = 'Tidal' if is_tidal else 'YouTube' + print(f"✅ Found good match ({best_similarity:.2f}) for {source_label} streaming file: {best_match}") return best_match 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" Best similarity: {best_similarity:.2f}") return None @@ -1798,6 +1813,7 @@ def run_service_test(service, test_config): mode_messages = { 'soulseek': "Successfully connected to Soulseek network via slskd.", 'youtube': "YouTube download source ready.", + 'tidal': "Tidal download source ready.", 'hybrid': "Download sources ready (Hybrid mode)." } message = mode_messages.get(download_mode, "Download source connected.") @@ -1807,6 +1823,7 @@ def run_service_test(service, test_config): mode_errors = { 'soulseek': "slskd is not connected to the Soulseek network. Check slskd status and credentials.", 'youtube': "YouTube download source not available.", + 'tidal': "Tidal download source not available. Check authentication.", 'hybrid': "Could not connect to download sources. Check configuration." } error = mode_errors.get(download_mode, "Download source connection failed.") @@ -2217,6 +2234,10 @@ def get_status(): } _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 = { 'spotify': _status_cache['spotify'], 'media_server': _status_cache['media_server'], @@ -2514,7 +2535,7 @@ def handle_settings(): if 'active_media_server' in new_settings: config_manager.set_active_media_server(new_settings['active_media_server']) - for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', '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: for key, value in new_settings[service].items(): config_manager.set(f'{service}.{key}', value) @@ -4346,8 +4367,8 @@ def stream_enhanced_search_track(): search_queries = [] import re - if download_mode == 'youtube' or (download_mode == 'hybrid' and config_manager.get('download_source.hybrid_primary') == 'youtube'): - # YouTube mode: Include artist for better context + if download_mode in ('youtube', 'tidal') or (download_mode == 'hybrid' and config_manager.get('download_source.hybrid_primary') in ('youtube', 'tidal')): + # YouTube/Tidal mode: Include artist for better context # Primary query: Artist + Track if artist_name and track_name: 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(): 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: # Soulseek mode: Track name only to avoid keyword filtering # Primary query: Full track name @@ -4519,7 +4540,7 @@ def start_download(): if download_id: # Register download for post-processing (simple transfer to /Transfer) context_key = _make_context_key(username, filename) - is_youtube = username == 'youtube' + is_streaming_source = username in ('youtube', 'tidal') with matched_context_lock: matched_downloads_context[context_key] = { 'search_result': { @@ -4535,7 +4556,8 @@ def start_download(): 'spotify_album': 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 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 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 if '||' in api_filename: _, title = api_filename.split('||', 1) @@ -4715,7 +4737,7 @@ def get_download_status(): global _processed_download_ids 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 = [] completed_matched_downloads = [] # 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() 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 else: # 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.start() - # Also include YouTube downloads in the response + # Also include YouTube/Tidal downloads in the response 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: - if download.username == 'youtube': + for download in all_streaming_downloads: + if download.username in ('youtube', 'tidal'): + source_label = download.username.title() # Convert DownloadStatus to transfer format that frontend expects - youtube_transfer = { + streaming_transfer = { 'id': download.id, 'filename': download.filename, - 'username': 'youtube', + 'username': download.username, 'state': download.state, 'percentComplete': download.progress, 'size': download.size, @@ -4900,12 +4923,12 @@ def get_download_status(): 'averageSpeed': download.speed, '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 - _yt_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()): + _st_bytes_ok = download.size <= 0 or download.transferred >= download.size + 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) with matched_context_lock: @@ -4918,34 +4941,34 @@ def get_download_status(): if found_path: # Prevent two contexts from claiming the same physical file - _yt_norm = os.path.normpath(found_path) - if _yt_norm in _files_claimed_this_cycle: - print(f"⚠️ [YouTube] File already claimed this cycle: {os.path.basename(found_path)} — deferring") + _st_norm = os.path.normpath(found_path) + if _st_norm in _files_claimed_this_cycle: + print(f"⚠️ [{source_label}] File already claimed this cycle: {os.path.basename(found_path)} — deferring") continue - _files_claimed_this_cycle.add(_yt_norm) - print(f"🎯 [YouTube] Found completed matched file on disk: {found_path}") + _files_claimed_this_cycle.add(_st_norm) + print(f"🎯 [{source_label}] Found completed matched file on disk: {found_path}") # 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: - print(f"🚀 [YouTube] Starting post-processing thread for: {context_key}") - thread = threading.Thread(target=_post_process_matched_download, args=(context_key, context, found_path)) + print(f"🚀 [{_label}] Starting post-processing thread for: {_ctx_key}") + thread = threading.Thread(target=_post_process_matched_download, args=(_ctx_key, _ctx, _path)) thread.daemon = True thread.start() - _processed_download_ids.add(context_key) - print(f"✅ [YouTube] Marked as processed: {context_key}") + _processed_download_ids.add(_ctx_key) + print(f"✅ [{_label}] Marked as processed: {_ctx_key}") 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.start() else: # File not found - likely already processed and moved to library # Mark as processed to prevent infinite checking _processed_download_ids.add(context_key) - except Exception as yt_error: + except Exception as streaming_error: 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() return jsonify({"transfers": all_transfers}) @@ -14100,11 +14123,12 @@ def get_valid_candidates(results, spotify_track, query): if not initial_candidates: return [] - # Skip quality filtering for YouTube results (always MP3 320kbps - no quality options) - is_youtube_source = initial_candidates[0].username == "youtube" if initial_candidates else False + # Skip quality filtering for YouTube/Tidal results (quality is fixed by source, not user-selectable per-result) + is_streaming_source = initial_candidates[0].username in ("youtube", "tidal") if initial_candidates else False - if is_youtube_source: - print(f"🎵 [YouTube] Skipping quality filter - YouTube always provides MP3 320kbps") + if is_streaming_source: + source_label = initial_candidates[0].username.title() + print(f"🎵 [{source_label}] Skipping quality filter - streaming source handles quality internally") quality_filtered_candidates = initial_candidates else: # 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) for candidate in quality_filtered_candidates: - # Skip artist check for YouTube results (title matching is sufficient as processed by matching engine) - if is_youtube_source: + # Skip artist check for streaming results (title matching is sufficient as processed by matching engine) + if is_streaming_source: verified_candidates.append(candidate) 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.""" _sr = source_reuse_logger _sr.info(f"_store_batch_source called: batch={batch_id}, user={username}, file={filename}") - if not batch_id or username == 'youtube': - _sr.info(f"Skipped — no batch_id or youtube") + if not batch_id or username in ('youtube', 'tidal'): + _sr.info(f"Skipped — no batch_id or streaming source ({username})") return with tasks_lock: @@ -18153,6 +18177,46 @@ def get_discover_album(source, album_id): 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 # =================================================================== diff --git a/webui/index.html b/webui/index.html index 44fef070..ced655db 100644 --- a/webui/index.html +++ b/webui/index.html @@ -162,7 +162,7 @@
- Soulseek + Soulseek
@@ -372,7 +372,7 @@
- Soulseek + Soulseek
@@ -3075,6 +3075,7 @@ onchange="updateDownloadSourceUI()"> +
@@ -3090,6 +3091,7 @@
Which source to try first when hybrid mode is enabled. @@ -3107,6 +3109,33 @@
+ + +