diff --git a/core/hifi_client.py b/core/hifi_client.py index 6da7ddf0..7b3a8823 100644 --- a/core/hifi_client.py +++ b/core/hifi_client.py @@ -12,20 +12,22 @@ Supports: - Track search by title, artist, album - Album lookup by ID - Artist lookup by ID -- Direct FLAC download URLs from Tidal CDN -- Quality selection: HI_RES_LOSSLESS, LOSSLESS, HIGH, LOW +- HLS manifest-based downloads via /trackManifests/ endpoint +- Quality selection: HIRES_LOSSLESS, LOSSLESS, HIGH, LOW - Multiple API instance failover +- FFmpeg demuxing for FLAC extraction from MP4 containers """ import os import re -import json -import base64 import uuid import time +import shutil +import subprocess import threading from typing import List, Optional, Dict, Any, Tuple from pathlib import Path +from urllib.parse import urljoin import requests as http_requests @@ -35,38 +37,44 @@ from core.soulseek_client import TrackResult, AlbumResult, DownloadStatus logger = get_logger("hifi_client") -# Quality tiers matching Tidal's internal quality labels -HIFI_QUALITY_MAP = { +# HLS quality presets mapping to /trackManifests/ format parameters +HLS_QUALITY_MAP = { 'hires': { - 'api_value': 'HI_RES_LOSSLESS', - 'label': 'FLAC 24-bit/96kHz', + 'formats': ['FLAC_HIRES'], + 'manifest_type': 'HLS', 'extension': 'flac', + 'label': 'FLAC 24-bit/96kHz', 'bitrate': 9216, 'codec': 'flac', }, 'lossless': { - 'api_value': 'LOSSLESS', - 'label': 'FLAC 16-bit/44.1kHz', + 'formats': ['FLAC'], + 'manifest_type': 'HLS', 'extension': 'flac', + 'label': 'FLAC 16-bit/44.1kHz', 'bitrate': 1411, 'codec': 'flac', }, 'high': { - 'api_value': 'HIGH', - 'label': 'AAC 320kbps', + 'formats': ['AACLC'], + 'manifest_type': 'HLS', 'extension': 'm4a', + 'label': 'AAC 320kbps', 'bitrate': 320, 'codec': 'aac', }, 'low': { - 'api_value': 'LOW', - 'label': 'AAC 96kbps', + 'formats': ['HEAACV1'], + 'manifest_type': 'HLS', 'extension': 'm4a', + 'label': 'AAC 96kbps', 'bitrate': 96, 'codec': 'aac', }, } +HLS_MAP_TAG_RE = re.compile(r'#EXT-X-MAP:.*URI="([^"]+)"') + # Default public hifi-api instances (ordered by preference) DEFAULT_INSTANCES = [ 'https://triton.squid.wtf', @@ -85,56 +93,68 @@ class HiFiClient: """ def __init__(self, download_path: str = None, base_url: str = None): - # Download path (use Soulseek path for consistency with post-processing) 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) - # API instance management - self._instances = list(DEFAULT_INSTANCES) - if base_url: - # User-provided instance gets top priority - self._instances.insert(0, base_url.rstrip('/')) + self._instances = [] + self._instance_lock = threading.Lock() + self._load_instances_from_db() self._current_instance = self._instances[0] if self._instances else None - self._instance_lock = threading.Lock() - # HTTP session with retry-friendly settings self.session = http_requests.Session() self.session.headers.update({ 'User-Agent': 'SoulSync/1.0', 'Accept': 'application/json', }) - # Download tracking (mirrors TidalDownloadClient pattern) self.active_downloads: Dict[str, Dict[str, Any]] = {} self._download_lock = threading.Lock() - # Shutdown check callback self.shutdown_check = None - # Rate limiting self._last_api_call = 0 self._api_lock = threading.Lock() - self._min_interval = 0.5 # 500ms between calls + self._min_interval = 0.5 logger.info(f"HiFi client initialized (instance: {self._current_instance}, " f"download path: {self.download_path})") def set_shutdown_check(self, check_callable): - """Set a callback function to check for system shutdown.""" self.shutdown_check = check_callable - # ===================== Instance Management ===================== + def _load_instances_from_db(self): + try: + from database.music_database import get_database + db = get_database() + db.seed_hifi_instances(DEFAULT_INSTANCES) + rows = db.get_hifi_instances() + urls = [r['url'] for r in rows if r['enabled']] + if urls: + self._instances = urls + else: + self._instances = list(DEFAULT_INSTANCES) + except Exception as e: + logger.warning(f"Failed to load HiFi instances from DB, using defaults: {e}") + self._instances = list(DEFAULT_INSTANCES) + + def reload_instances(self): + with self._instance_lock: + old_current = self._current_instance + self._load_instances_from_db() + self._current_instance = self._instances[0] if self._instances else None + if self._current_instance != old_current: + logger.info(f"HiFi instances reloaded, active: {self._current_instance}") + else: + logger.info("HiFi instances reloaded") def _get_instance(self) -> Optional[str]: - """Get the current active API instance URL.""" with self._instance_lock: return self._current_instance def _rotate_instance(self, failed_url: str): - """Move a failed instance to the back of the list and switch to next.""" with self._instance_lock: if failed_url in self._instances: self._instances.remove(failed_url) @@ -146,7 +166,6 @@ class HiFiClient: self._current_instance = None def _rate_limit(self): - """Enforce minimum interval between API calls.""" with self._api_lock: now = time.time() elapsed = now - self._last_api_call @@ -155,10 +174,6 @@ class HiFiClient: self._last_api_call = time.time() def _api_get(self, path: str, params: dict = None, timeout: int = 15) -> Optional[dict]: - """ - Make a GET request to the hifi-api, with instance failover. - Tries each instance up to once before giving up. - """ tried = set() while True: @@ -176,7 +191,6 @@ class HiFiClient: response.raise_for_status() data = response.json() - # Check for API-level errors if isinstance(data, dict) and data.get('error'): logger.warning(f"HiFi API error from {instance}: {data['error']}") return None @@ -201,10 +215,7 @@ class HiFiClient: logger.error(f"HiFi API unexpected error: {e}") return None - # ===================== Availability ===================== - def is_available(self) -> bool: - """Check if the HiFi API is reachable.""" try: data = self._api_get('/', timeout=5) return data is not None @@ -212,11 +223,9 @@ class HiFiClient: return False def is_configured(self) -> bool: - """Check if HiFi client is configured and ready (matches Soulseek interface).""" return self._current_instance is not None async def check_connection(self) -> bool: - """Test if HiFi API is accessible (async, Soulseek-compatible).""" try: import asyncio loop = asyncio.get_event_loop() @@ -226,28 +235,13 @@ class HiFiClient: return False def get_version(self) -> Optional[str]: - """Get the API version of the current instance.""" data = self._api_get('/') if data and isinstance(data, dict): return data.get('version') or data.get('data', {}).get('version') return None - # ===================== Search ===================== - def search_tracks(self, title: str = None, artist: str = None, album: str = None, limit: int = 20) -> List[Dict]: - """ - Search for tracks on Tidal via hifi-api. - - Args: - title: Track title to search for - artist: Artist name to search for - album: Album name to search for - limit: Max results to return - - Returns: - List of track dicts with id, title, artist, album, duration, etc. - """ params = {'limit': limit} if title: params['s'] = title @@ -264,7 +258,6 @@ class HiFiClient: if not data: return [] - # Handle response format: {data: {items: [...]}} or {data: [...]} items = [] if isinstance(data, dict): inner = data.get('data', data) @@ -285,15 +278,9 @@ class HiFiClient: return results def search_raw(self, query: str, limit: int = 20) -> List[Dict]: - """ - Generic search (free-text query). Maps to title search. - Returns raw dicts (not TrackResult). - """ return self.search_tracks(title=query, limit=limit) def _parse_track(self, item: dict) -> Dict: - """Parse a track item from hifi-api response into a normalized dict.""" - # Artist can be a dict with 'name' or a list of artists artist_name = 'Unknown Artist' artists_raw = item.get('artists', item.get('artist')) if isinstance(artists_raw, list): @@ -309,7 +296,6 @@ class HiFiClient: elif isinstance(artists_raw, str): artist_name = artists_raw - # Album album_raw = item.get('album', {}) album_name = '' if isinstance(album_raw, dict): @@ -317,7 +303,6 @@ class HiFiClient: elif isinstance(album_raw, str): album_name = album_raw - # Duration duration_s = item.get('duration', 0) duration_ms = duration_s * 1000 if duration_s and duration_s < 100000 else duration_s @@ -333,10 +318,7 @@ class HiFiClient: 'quality': item.get('audioQuality', item.get('quality', '')), } - # ===================== Track Info & Stream URL ===================== - def get_track_info(self, track_id: int) -> Optional[Dict]: - """Get detailed metadata for a specific track.""" data = self._api_get('/info/', params={'id': track_id}) if not data: return None @@ -346,57 +328,7 @@ class HiFiClient: return self._parse_track(inner) return None - def get_stream_url(self, track_id: int, quality: str = 'lossless') -> Optional[Dict]: - """ - Get the direct download URL for a track. - - Args: - track_id: Tidal track ID - quality: One of 'hires', 'lossless', 'high', 'low' - - Returns: - Dict with 'url', 'mime_type', 'codec', 'quality' or None on failure. - """ - q_info = HIFI_QUALITY_MAP.get(quality, HIFI_QUALITY_MAP['lossless']) - api_quality = q_info['api_value'] - - data = self._api_get('/track/', params={'id': track_id, 'quality': api_quality}) - if not data: - return None - - # Extract manifest from response - inner = data.get('data', data) if isinstance(data, dict) else data - if not isinstance(inner, dict): - return None - - manifest_b64 = inner.get('manifest') - if not manifest_b64: - logger.warning(f"No manifest in track response for {track_id}") - return None - - try: - manifest = json.loads(base64.b64decode(manifest_b64)) - except Exception as e: - logger.error(f"Failed to decode manifest for track {track_id}: {e}") - return None - - urls = manifest.get('urls', []) - if not urls: - logger.warning(f"No URLs in manifest for track {track_id}") - return None - - return { - 'url': urls[0], - 'mime_type': manifest.get('mimeType', ''), - 'codec': manifest.get('codecs', ''), - 'encryption': manifest.get('encryptionType', 'NONE'), - 'quality': quality, - } - - # ===================== Album & Artist ===================== - def get_album(self, album_id: int, limit: int = 100) -> Optional[Dict]: - """Get album metadata and track list.""" data = self._api_get('/album/', params={'id': album_id, 'limit': limit}) if not data: return None @@ -405,7 +337,6 @@ class HiFiClient: if not isinstance(inner, dict): return None - # Parse tracks within album tracks_raw = inner.get('items', inner.get('tracks', [])) tracks = [] for item in tracks_raw: @@ -426,7 +357,6 @@ class HiFiClient: } def get_artist(self, artist_id: int) -> Optional[Dict]: - """Get artist info and top tracks.""" data = self._api_get('/artist/', params={'id': artist_id}) if not data: return None @@ -434,14 +364,150 @@ class HiFiClient: inner = data.get('data', data) if isinstance(data, dict) else data return inner if isinstance(inner, dict) else None - # ===================== Soulseek-Compatible Search ===================== + def _parse_hls_playlist(self, text: str, playlist_url: str): + init_uri = None + segment_uris = [] + variant_uri = None + + lines = [line.strip() for line in text.splitlines() if line.strip()] + + for index, line in enumerate(lines): + if line.startswith('#EXTM3U'): + continue + + if line.startswith('#EXT-X-STREAM-INF'): + for next_line in lines[index + 1:]: + if not next_line.startswith('#'): + variant_uri = urljoin(playlist_url, next_line) + break + break + + if line.startswith('#EXT-X-MAP'): + match = HLS_MAP_TAG_RE.search(line) + if match: + init_uri = match.group(1) + continue + + if line.startswith('#'): + continue + + segment_uris.append(urljoin(playlist_url, line)) + + if variant_uri: + return None, [variant_uri] + + if not segment_uris: + raise ValueError('No segment URIs found in the HLS playlist') + + if init_uri: + init_uri = urljoin(playlist_url, init_uri) + + return init_uri, segment_uris + + def _get_hls_manifest(self, track_id: int, quality: str = 'lossless') -> Optional[Dict]: + q_info = HLS_QUALITY_MAP.get(quality, HLS_QUALITY_MAP['lossless']) + formats = q_info['formats'] + + params = [ + ('id', str(track_id)), + ('formats', ','.join(formats)), + ('usage', 'DOWNLOAD'), + ('manifestType', 'HLS'), + ('adaptive', 'true'), + ('uriScheme', 'HTTPS'), + ] + + data = self._api_get('/trackManifests/', params=params, timeout=20) + if not data: + return None + + try: + inner = data.get('data', data) if isinstance(data, dict) else data + attrs = inner.get('data', {}).get('attributes', {}) + uri = attrs.get('uri') + except (AttributeError, KeyError) as e: + logger.warning(f"Failed to extract playlist URI from manifest response: {e}") + return None + + if not uri: + logger.warning(f"No playlist URI in manifest for track {track_id}") + return None + + try: + playlist_resp = self.session.get(uri, allow_redirects=True, timeout=30) + playlist_resp.raise_for_status() + playlist_text = playlist_resp.text + except Exception as e: + logger.warning(f"Failed to fetch HLS playlist for track {track_id}: {e}") + return None + + try: + init_uri, segment_uris = self._parse_hls_playlist(playlist_text, uri) + except ValueError as e: + logger.warning(f"Failed to parse HLS playlist for track {track_id}: {e}") + return None + + if '#EXT-X-STREAM-INF' in playlist_text and segment_uris: + playlist_uri = segment_uris[0] + try: + logger.debug(f"Detected master HLS playlist, following variant: {playlist_uri}") + variant_resp = self.session.get(playlist_uri, allow_redirects=True, timeout=30) + variant_resp.raise_for_status() + variant_text = variant_resp.text + init_uri, segment_uris = self._parse_hls_playlist(variant_text, playlist_uri) + except Exception as e: + logger.warning(f"Failed to fetch variant playlist for track {track_id}: {e}") + return None + + if init_uri: + logger.info(f"HiFi HLS manifest for track {track_id}: " + f"init segment + {len(segment_uris)} segments ({quality})") + else: + logger.info(f"HiFi HLS manifest for track {track_id}: " + f"{len(segment_uris)} segments ({quality})") + + return { + 'init_uri': init_uri, + 'segment_uris': segment_uris, + 'extension': q_info['extension'], + 'codec': q_info['codec'], + 'quality': quality, + } + + def _demux_flac(self, input_path: Path, output_path: Path) -> None: + ffmpeg = shutil.which('ffmpeg') + if not ffmpeg: + 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: + raise RuntimeError('ffmpeg is required to demux FLAC from MP4. Install ffmpeg and retry.') + + try: + result = subprocess.run( + [ + ffmpeg, + '-y', + '-hide_banner', + '-loglevel', 'error', + '-i', str(input_path), + '-map', '0:a:0', + '-c', 'copy', + str(output_path), + ], + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError as exc: + raise RuntimeError( + f'ffmpeg failed while demuxing {input_path} -> {output_path}: ' + f'{exc.returncode}\n{exc.stderr}' + ) from exc async def search(self, query: str, timeout: int = None, progress_callback=None) -> Tuple[List[TrackResult], List[AlbumResult]]: - """ - Search with Soulseek-compatible return format (TrackResult, AlbumResult). - Matches the interface expected by DownloadOrchestrator. - """ import asyncio try: @@ -449,7 +515,7 @@ class HiFiClient: tracks = await loop.run_in_executor(None, lambda: self.search_raw(query)) quality_key = config_manager.get('hifi_download.quality', 'lossless') - q_info = HIFI_QUALITY_MAP.get(quality_key, HIFI_QUALITY_MAP['lossless']) + q_info = HLS_QUALITY_MAP.get(quality_key, HLS_QUALITY_MAP['lossless']) results = [] for t in tracks: @@ -466,7 +532,6 @@ class HiFiClient: return ([], []) def _to_track_result(self, track: Dict, quality_info: Dict) -> TrackResult: - """Convert a hifi track dict to a TrackResult.""" display_name = f"{track['artist']} - {track['title']}" filename = f"{track['id']}||{display_name}" @@ -486,13 +551,7 @@ class HiFiClient: track_number=track.get('track_number'), ) - # ===================== Download ===================== - async def download(self, username: str, filename: str, file_size: int = 0) -> Optional[str]: - """ - Download a track (async, Soulseek-compatible interface). - Filename format: "track_id||display_name" - """ try: if '||' not in filename: logger.error(f"Invalid filename format: {filename}") @@ -537,7 +596,6 @@ class HiFiClient: return None def _download_worker(self, download_id: str, track_id: int, display_name: str): - """Background download thread.""" try: with self._download_lock: if download_id in self.active_downloads: @@ -561,111 +619,168 @@ class HiFiClient: self.active_downloads[download_id]['state'] = 'Errored' def _download_sync(self, download_id: str, track_id: int, display_name: str) -> Optional[str]: - """ - Synchronous download with quality fallback chain. - Returns file path on success, None on failure. - """ quality_key = config_manager.get('hifi_download.quality', 'lossless') chain = ['hires', 'lossless', 'high', 'low'] start = chain.index(quality_key) if quality_key in chain else 1 allow_fallback = config_manager.get('hifi_download.allow_fallback', True) chain = chain[start:] if allow_fallback else [quality_key] - MIN_AUDIO_SIZE = 100 * 1024 # 100KB + MIN_AUDIO_SIZE = 100 * 1024 for q_key in chain: if self.shutdown_check and self.shutdown_check(): logger.info("Shutdown detected, aborting HiFi download") return None - stream_info = self.get_stream_url(track_id, quality=q_key) - if not stream_info or not stream_info.get('url'): - logger.warning(f"No stream URL at quality {q_key}, trying next") + manifest_info = self._get_hls_manifest(track_id, quality=q_key) + if not manifest_info or not manifest_info.get('segment_uris'): + logger.warning(f"No HLS manifest at quality {q_key}, trying next") continue - download_url = stream_info['url'] - codec = stream_info.get('codec', '') - - # Determine extension - if 'flac' in codec.lower(): - extension = 'flac' - elif 'mp4a' in codec.lower() or 'aac' in codec.lower(): - extension = 'm4a' - else: - extension = HIFI_QUALITY_MAP.get(q_key, {}).get('extension', 'flac') - - # Build output path + extension = manifest_info['extension'] safe_name = re.sub(r'[<>:"/\\|?*]', '_', display_name) out_filename = f"{safe_name}.{extension}" out_path = self.download_path / out_filename - try: - logger.info(f"Downloading from HiFi ({q_key}): {out_filename}") - response = http_requests.get(download_url, stream=True, timeout=120) - response.raise_for_status() + is_flac = q_key in ('hires', 'lossless') + intermediate_path = out_path.with_suffix('.m4a') if is_flac else out_path + + try: + init_uri = manifest_info.get('init_uri') + segment_uris = manifest_info['segment_uris'] + total_segments = len(segment_uris) + (1 if init_uri else 0) + + logger.info(f"Downloading from HiFi ({q_key}): {out_filename} " + f"({total_segments} segments)") - total_size = int(response.headers.get('content-length', 0)) downloaded = 0 - chunk_size = 64 * 1024 speed_start = time.time() - last_speed_update = speed_start + segments_completed = 0 with self._download_lock: if download_id in self.active_downloads: - self.active_downloads[download_id]['size'] = total_size + self.active_downloads[download_id]['size'] = 0 - with open(out_path, 'wb') as f: - for chunk in response.iter_content(chunk_size=chunk_size): - if not chunk: - continue + with intermediate_path.open('wb') as output_file: + if init_uri: if self.shutdown_check and self.shutdown_check(): - f.close() - out_path.unlink(missing_ok=True) + logger.info("Shutdown detected, aborting HiFi download") + intermediate_path.unlink(missing_ok=True) return None - f.write(chunk) - downloaded += len(chunk) + logger.debug(f"Downloading init segment: {init_uri}") + init_data = self._download_segment_with_retry(init_uri) + output_file.write(init_data) + downloaded += len(init_data) + segments_completed += 1 - if total_size > 0: - progress = (downloaded / total_size) * 100 - else: - progress = 0 + self._update_download_progress(download_id, downloaded, + segments_completed, total_segments, speed_start) - # Calculate speed every 0.5s - now = time.time() - elapsed_total = now - speed_start - speed = int(downloaded / elapsed_total) if elapsed_total > 0 else 0 - time_remaining = int((total_size - downloaded) / speed) if speed > 0 and total_size > 0 else None + for segment_url in segment_uris: + if self.shutdown_check and self.shutdown_check(): + logger.info("Shutdown detected, aborting HiFi download") + intermediate_path.unlink(missing_ok=True) + return None - with self._download_lock: - if download_id in self.active_downloads: - self.active_downloads[download_id]['transferred'] = downloaded - self.active_downloads[download_id]['progress'] = round(progress, 1) - self.active_downloads[download_id]['speed'] = speed - self.active_downloads[download_id]['time_remaining'] = time_remaining + segment_data = self._download_segment_with_retry(segment_url) + output_file.write(segment_data) + downloaded += len(segment_data) + segments_completed += 1 + + self._update_download_progress(download_id, downloaded, + segments_completed, total_segments, speed_start) except Exception as e: logger.warning(f"Download failed at quality {q_key}: {e}") - out_path.unlink(missing_ok=True) + intermediate_path.unlink(missing_ok=True) continue - # Validate file size if downloaded < MIN_AUDIO_SIZE: logger.warning(f"File too small at {q_key} ({downloaded} bytes), trying next") - out_path.unlink(missing_ok=True) + intermediate_path.unlink(missing_ok=True) continue - logger.info(f"HiFi download complete ({q_key}): {out_path} " - f"({downloaded / (1024*1024):.1f} MB)") - return str(out_path) + try: + if is_flac: + logger.info(f"Demuxing FLAC from MP4 container: {intermediate_path} -> {out_path}") + self._demux_flac(intermediate_path, out_path) + intermediate_path.unlink(missing_ok=True) + final_size = out_path.stat().st_size if out_path.exists() else 0 + else: + final_size = intermediate_path.stat().st_size if intermediate_path.exists() else 0 + + if final_size < MIN_AUDIO_SIZE: + logger.warning(f"Final file too small after processing at {q_key} " + f"({final_size} bytes), trying next") + out_path.unlink(missing_ok=True) + continue + + logger.info(f"HiFi download complete ({q_key}): {out_path} " + f"({final_size / (1024*1024):.1f} MB)") + return str(out_path) + + except Exception as e: + logger.warning(f"Post-processing failed at quality {q_key}: {e}") + out_path.unlink(missing_ok=True) + intermediate_path.unlink(missing_ok=True) + continue logger.error(f"All quality tiers exhausted for '{display_name}'") return None - # ===================== Status / Cancel / Clear ===================== + def _download_segment_with_retry(self, url: str) -> bytes: + """Download a single HLS segment with 3 retries and 2s fixed backoff.""" + last_error = None + for attempt in range(4): + try: + resp = self.session.get(url, allow_redirects=True, timeout=30) + resp.raise_for_status() + return resp.content + except http_requests.exceptions.HTTPError as e: + status = e.response.status_code if e.response is not None else 0 + if 400 <= status < 500: + raise + last_error = e + except (http_requests.exceptions.Timeout, + http_requests.exceptions.ConnectionError) as e: + last_error = e + + if attempt < 3: + if self.shutdown_check and self.shutdown_check(): + raise RuntimeError("Shutdown requested") + logger.warning(f"Segment download failed (attempt {attempt + 1}/4), " + f"retrying in 2s: {url}") + time.sleep(2) + + raise last_error + + def _update_download_progress(self, download_id: str, downloaded: int, + segments_completed: int, total_segments: int, + speed_start: float): + with self._download_lock: + if download_id not in self.active_downloads: + return + info = self.active_downloads[download_id] + info['transferred'] = downloaded + + now = time.time() + elapsed_total = now - speed_start + speed = int(downloaded / elapsed_total) if elapsed_total > 0 else 0 + info['speed'] = speed + + if total_segments > 0: + progress = (segments_completed / total_segments) * 100 + info['progress'] = round(min(progress, 99.9), 1) + + time_remaining = None + if speed > 0: + remaining_bytes = downloaded * (total_segments / max(segments_completed, 1)) - downloaded + if remaining_bytes > 0: + time_remaining = int(remaining_bytes / speed) + info['time_remaining'] = time_remaining async def get_all_downloads(self) -> List[DownloadStatus]: - """Get all active downloads (Soulseek-compatible).""" statuses = [] with self._download_lock: for _dl_id, info in self.active_downloads.items(): @@ -684,7 +799,6 @@ class HiFiClient: return statuses async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]: - """Get status of a specific download.""" with self._download_lock: info = self.active_downloads.get(download_id) if not info: @@ -704,7 +818,6 @@ class HiFiClient: async def cancel_download(self, download_id: str, username: str = None, remove: bool = False) -> bool: - """Cancel an active download.""" with self._download_lock: if download_id not in self.active_downloads: return False @@ -714,7 +827,6 @@ class HiFiClient: return True async def clear_all_completed_downloads(self) -> bool: - """Clear all terminal downloads.""" with self._download_lock: to_remove = [ did for did, info in self.active_downloads.items() diff --git a/core/tidal_download_client.py b/core/tidal_download_client.py index 0823e56d..ca76805d 100644 --- a/core/tidal_download_client.py +++ b/core/tidal_download_client.py @@ -5,7 +5,7 @@ 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 +- HiRes/Lossless/High quality audio downloads via Tidal v2 trackManifests endpoint - Drop-in replacement compatible with Soulseek interface """ @@ -13,12 +13,14 @@ import os import re import asyncio import uuid +import time import threading import shutil import subprocess from typing import List, Optional, Dict, Any, Tuple from pathlib import Path from datetime import datetime, timezone +from urllib.parse import urljoin try: import tidalapi @@ -36,31 +38,27 @@ from core.soulseek_client import TrackResult, AlbumResult, DownloadStatus logger = get_logger("tidal_download_client") -# Quality tier definitions +# Quality tiers definitions (used for display in search results) 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, @@ -68,92 +66,31 @@ QUALITY_MAP = { }, } -# 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 - - -# Ordering of Tidal's audioQuality values, worst to best. Used to accept -# tier upgrades (Tidal serving higher than the user asked) while still -# rejecting downgrades. Values are the strings tidalapi's `Quality` enum -# exposes — and the strings Tidal's API returns in the `audioQuality` -# field. `HI_RES` (legacy MQA) isn't in the modern `Quality` enum but -# may still come back for old catalog tracks; we rank it below -# `HI_RES_LOSSLESS` so it's treated as a downgrade when the user asked -# for true HiRes lossless. -_QUALITY_RANK = { - 'LOW': 1, - 'HIGH': 2, - 'LOSSLESS': 3, - 'HI_RES': 4, - 'HI_RES_LOSSLESS': 5, +# HLS-specific format mapping for v2 trackManifests endpoint +HLS_QUALITY_MAP = { + 'hires': { + 'formats': ['FLAC_HIRES'], + 'manifest_type': 'HLS', + 'extension': 'flac', + }, + 'lossless': { + 'formats': ['FLAC'], + 'manifest_type': 'HLS', + 'extension': 'flac', + }, + 'high': { + 'formats': ['AACLC'], + 'manifest_type': 'HLS', + 'extension': 'm4a', + }, + 'low': { + 'formats': ['HEAACV1'], + 'manifest_type': 'HLS', + 'extension': 'm4a', + }, } - -def _verify_stream_tier(stream, q_info: dict, q_key: str) -> Tuple[bool, Optional[str]]: - """Return ``(True, None)`` when the tier Tidal actually served is - acceptable (same as requested, or a higher tier), ``(False, reason)`` - when Tidal silently downgraded. - - Tidal's API degrades quality without raising: ask for HI_RES_LOSSLESS - on a track that's only in LOW_320K and you get LOW_320K back with no - error. The downloader used to accept that and write the resulting - AAC file, which defeated "HiRes only" with no fallback and made the - worker's fallback chain ineffective (every tier "succeeded" at the - first one that returned anything). - - We accept upgrades because Tidal occasionally serves a higher tier - than requested on tracks flagged as such in its catalog — rejecting - a higher quality than asked for would be user-hostile. - - Defensive paths: - - No ``audio_quality`` on the stream (older tidalapi builds): pass - through, let the pre-existing codec / file-size guards decide. - - QUALITY_MAP entry without ``tidal_quality`` (tidalapi wasn't - importable at module load): pass through for the same reason. - - Unrecognized served quality value (new Tidal tier we haven't - mapped yet): reject, surfacing a "can't verify" reason so the - next tier gets a chance or the final diagnostic names the - unknown value. - """ - served = getattr(stream, 'audio_quality', None) - expected = q_info.get('tidal_quality') - if served is None or expected is None: - return True, None - - # Both sides may be enum instances (str subclass) or plain strings; - # coerce to str to compare values only. - served_str = str(served) - expected_str = str(expected) - - if served_str == expected_str: - return True, None - - served_rank = _QUALITY_RANK.get(served_str) - expected_rank = _QUALITY_RANK.get(expected_str) - - if expected_rank is None: - # Shouldn't happen — every entry in QUALITY_MAP resolves to a - # known tier. If it does, don't reject valid downloads. - return True, None - - if served_rank is None: - return False, ( - f"{q_key}: Tidal returned unrecognized audioQuality " - f"'{served_str}' — can't verify the tier matches '{expected_str}'" - ) - - if served_rank >= expected_rank: - return True, None - - return False, ( - f"{q_key}: Tidal served '{served_str}' instead of " - f"'{expected_str}' — account tier, track licensing, " - f"or region doesn't permit {q_key} for this track" - ) +HLS_MAP_TAG_RE = re.compile(r'#EXT-X-MAP:.*URI="([^"]+)"') class TidalDownloadClient: @@ -166,7 +103,6 @@ class TidalDownloadClient: 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') @@ -175,35 +111,26 @@ class TidalDownloadClient: 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', '') @@ -212,10 +139,8 @@ class TidalDownloadClient: 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, @@ -224,7 +149,7 @@ class TidalDownloadClient: ) if restored and self.session.check_login(): logger.info("Restored Tidal download session from saved tokens") - self._save_session() # refresh may have rotated tokens + self._save_session() return else: logger.warning("Saved Tidal session tokens are invalid/expired") @@ -232,7 +157,6 @@ class TidalDownloadClient: 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', { @@ -243,7 +167,6 @@ class TidalDownloadClient: }) def is_authenticated(self) -> bool: - """Check if we have a valid Tidal session.""" if not self.session: return False try: @@ -252,10 +175,6 @@ class TidalDownloadClient: 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 @@ -265,11 +184,6 @@ class TidalDownloadClient: login, future = self.session.login_oauth() self._device_auth_future = future - # tidalapi returns `verification_uri_complete` as a schemeless - # string like `link.tidal.com/ABCDE`. Passing that straight to - # an makes the browser treat it as a relative URL and - # route it back to the SoulSync origin, so normalize to a - # full https:// URL here. raw_uri = login.verification_uri_complete or f"link.tidal.com/{login.user_code}" if not raw_uri.startswith(('http://', 'https://')): raw_uri = f"https://{raw_uri}" @@ -285,10 +199,6 @@ class TidalDownloadClient: 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'} @@ -300,7 +210,6 @@ class TidalDownloadClient: '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() @@ -313,18 +222,13 @@ class TidalDownloadClient: 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) @@ -332,11 +236,6 @@ class TidalDownloadClient: logger.error(f"Tidal connection check failed: {e}") return False - # Words that distinguish a specific audio variant from the original track. - # If any of these appear in a query, the fallback-retry results must also - # contain them — otherwise we'd silently downgrade a "(Live)" or - # "(Acoustic)" search to the studio version when shortened queries match - # too broadly. _QUALIFIER_KEYWORDS = frozenset({ 'remix', 'mix', 'edit', 'version', 'dub', 'rmx', 'vip', 'cut', 'rework', 'bootleg', 'flip', @@ -347,9 +246,6 @@ class TidalDownloadClient: @classmethod def _extract_qualifiers(cls, query: str) -> List[str]: - """Return the qualifier keywords that appear as whole words in the - query (case-insensitive). Word-boundary match prevents false hits like - "edit" matching "edition" or "mix" matching "remix".""" if not query: return [] found = [] @@ -361,7 +257,6 @@ class TidalDownloadClient: @staticmethod def _track_name_contains_qualifiers(track_name: str, qualifiers: List[str]) -> bool: - """True iff the track name contains every qualifier as a whole word.""" if not qualifiers: return True if not track_name: @@ -374,17 +269,6 @@ class TidalDownloadClient: @staticmethod def _generate_shortened_queries(original: str) -> List[str]: - """Generate progressively-shorter variants of a search query. - - Tidal's search engine chokes on long queries with lots of qualifiers - (remix credits, edit labels, bonus-disc markers). When the original - returns 0 results, we retry with shortened variants in order of - conservativeness — the first variant that returns results wins. - - Variants are returned in priority order. Dedupes against the original - and against previously-added variants so we never issue duplicate - requests. - """ variants: List[str] = [] seen = {original.strip().lower()} @@ -394,80 +278,45 @@ class TidalDownloadClient: variants.append(candidate) seen.add(candidate.lower()) - # 1. Strip a trailing parenthetical/bracketed suffix. - # "Song (Radio Edit)" → "Song" _add(re.sub(r'\s*[\(\[][^\)\]]*[\)\]]\s*$', '', original)) - - # 2. Strip ALL parentheticals/brackets (mid-string too). - # "Song (feat X) [Remix]" → "Song" _add(re.sub(r'\s*[\(\[][^\)\]]*[\)\]]', ' ', original)) tokens = original.split() - # 3. Drop the last token — covers trailing 1-word modifiers - # (e.g. "… Remix", "… Extended"). if len(tokens) >= 3: _add(' '.join(tokens[:-1])) - # 4. Drop the last two tokens. if len(tokens) >= 4: _add(' '.join(tokens[:-2])) - # 5. Drop the last three tokens — covers "fred v remix" style - # 3-word modifiers common in remix/bonus track names. if len(tokens) >= 5: _add(' '.join(tokens[:-3])) - # 6. Aggressive: keep roughly the first half (rounded up). if len(tokens) >= 7: _add(' '.join(tokens[:len(tokens) // 2 + 1])) return variants 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 ([], []) - # Defensive guard — None/empty query would blow up the shortener's - # .strip() call. Match the original behaviour (log + empty tuple). if not query or not isinstance(query, str): logger.warning(f"Invalid Tidal search query: {query!r}") return ([], []) logger.info(f"Searching Tidal for: {query}") - # Outer try/except preserves the original contract: any unexpected - # error returns ([], []) so the caller can fall back to other sources - # instead of crashing. Traceback is logged once, not per-attempt. try: - # Build the retry ladder: original query, then progressively-shortened - # variants. Capped at 5 total requests to avoid hammering Tidal on - # genuinely-missing tracks, while still allowing enough variants to - # cover multi-word trailing modifiers like remix credits. queries_to_try = [query] + self._generate_shortened_queries(query) queries_to_try = queries_to_try[:5] - # Qualifier-aware safety net: if the original query contains variant - # keywords (Live, Remix, Acoustic, Extended, etc.), fallback results - # MUST still contain those qualifiers in their track names. Otherwise - # a shortened query could silently downgrade "Song (Live)" to the - # studio "Song" and the caller would download the wrong variant. required_qualifiers = self._extract_qualifiers(query) tidal_tracks: list = [] successful_query: Optional[str] = None last_error: Optional[Exception] = None - # Tracks whether ANY fallback attempt returned broader matches that - # got rejected by the qualifier filter — used to give an accurate - # "no qualifier-matching variant" log message at the end instead of - # a generic "0 results". any_fallback_filtered_out = False loop = asyncio.get_event_loop() @@ -482,12 +331,6 @@ class TidalDownloadClient: found = await loop.run_in_executor(None, _search) if found: - # Fallback attempts get qualifier-filtered. We trust the - # original query to return only appropriate matches, but - # shortened queries are more permissive and can return - # wrong-variant tracks (e.g. studio when Live was asked - # for). Drop any result whose title doesn't carry all - # original qualifier words. is_fallback = attempt_idx > 0 if is_fallback and required_qualifiers: filtered = [ @@ -519,7 +362,6 @@ class TidalDownloadClient: if attempt_idx < len(queries_to_try) - 1: logger.debug(f"Tidal returned 0 results for '{attempt_query}' — trying shortened variant") - # Small pause so we're not hammering Tidal with rapid retries await asyncio.sleep(0.1) except Exception as e: last_error = e @@ -546,7 +388,6 @@ class TidalDownloadClient: if successful_query and successful_query != query: logger.info(f"Tidal fallback query succeeded: '{successful_query}' (original: '{query}')") - # Get configured quality for display quality_key = config_manager.get('tidal_download.quality', 'lossless') quality_info = QUALITY_MAP.get(quality_key, QUALITY_MAP['lossless']) @@ -562,32 +403,25 @@ class TidalDownloadClient: return (track_results, []) except Exception as e: - # Unhandled error in the retry orchestration itself (not in an - # individual attempt, which is already caught above). Preserves - # the original contract of returning ([], []) on any failure so - # the caller's fallback chain isn't broken. logger.error(f"Tidal search orchestration 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 + size=0, bitrate=quality_info.get('bitrate'), duration=duration_ms, quality=quality_info.get('codec', 'flac'), @@ -602,19 +436,166 @@ class TidalDownloadClient: return track_result - # ===================== Download ===================== + def _parse_hls_playlist(self, text: str, playlist_url: str): + init_uri = None + segment_uris = [] + variant_uri = None + + lines = [line.strip() for line in text.splitlines() if line.strip()] + + for index, line in enumerate(lines): + if line.startswith('#EXTM3U'): + continue + + if line.startswith('#EXT-X-STREAM-INF'): + for next_line in lines[index + 1:]: + if not next_line.startswith('#'): + variant_uri = urljoin(playlist_url, next_line) + break + break + + if line.startswith('#EXT-X-MAP'): + match = HLS_MAP_TAG_RE.search(line) + if match: + init_uri = match.group(1) + continue + + if line.startswith('#'): + continue + + segment_uris.append(urljoin(playlist_url, line)) + + if variant_uri: + return None, [variant_uri] + + if not segment_uris: + raise ValueError('No segment URIs found in the HLS playlist') + + if init_uri: + init_uri = urljoin(playlist_url, init_uri) + + return init_uri, segment_uris + + def _get_hls_manifest(self, track_id: int, quality: str = 'lossless') -> Optional[Dict]: + q_info = HLS_QUALITY_MAP.get(quality, HLS_QUALITY_MAP['lossless']) + formats = q_info['formats'] + + access_token = self.session.access_token + if not access_token: + logger.error("No Tidal access token available") + return None + + url = f"https://openapi.tidal.com/v2/trackManifests/{track_id}" + params = [ + ('adaptive', 'true'), + ('manifestType', 'HLS'), + ('uriScheme', 'HTTPS'), + ('usage', 'DOWNLOAD'), + ] + for fmt in formats: + params.append(('formats', fmt)) + + headers = { + 'Authorization': f'Bearer {access_token}', + 'Accept': 'application/vnd.api+json', + } + + try: + response = http_requests.get(url, params=params, headers=headers, timeout=20) + response.raise_for_status() + data = response.json() + except http_requests.HTTPError as e: + logger.warning(f"Failed to fetch HLS manifest for track {track_id}: {e}") + return None + except Exception as e: + logger.warning(f"Failed to fetch HLS manifest for track {track_id}: {e}") + return None + + try: + attrs = data.get('data', {}).get('attributes', {}) + uri = attrs.get('uri') + except (AttributeError, KeyError) as e: + logger.warning(f"Failed to extract playlist URI from manifest response for track {track_id}: {e}") + return None + + if not uri: + logger.warning(f"No playlist URI in manifest for track {track_id}") + return None + + try: + playlist_resp = http_requests.get(uri, allow_redirects=True, timeout=30) + playlist_resp.raise_for_status() + playlist_text = playlist_resp.text + except Exception as e: + logger.warning(f"Failed to fetch HLS playlist for track {track_id}: {e}") + return None + + try: + init_uri, segment_uris = self._parse_hls_playlist(playlist_text, uri) + except ValueError as e: + logger.warning(f"Failed to parse HLS playlist for track {track_id}: {e}") + return None + + if '#EXT-X-STREAM-INF' in playlist_text and segment_uris: + playlist_uri = segment_uris[0] + try: + logger.debug(f"Detected master HLS playlist, following variant: {playlist_uri}") + variant_resp = http_requests.get(playlist_uri, allow_redirects=True, timeout=30) + variant_resp.raise_for_status() + variant_text = variant_resp.text + init_uri, segment_uris = self._parse_hls_playlist(variant_text, playlist_uri) + except Exception as e: + logger.warning(f"Failed to fetch variant playlist for track {track_id}: {e}") + return None + + if init_uri: + logger.info(f"Tidal HLS manifest for track {track_id}: " + f"init segment + {len(segment_uris)} segments ({quality})") + else: + logger.info(f"Tidal HLS manifest for track {track_id}: " + f"{len(segment_uris)} segments ({quality})") + + return { + 'init_uri': init_uri, + 'segment_uris': segment_uris, + 'extension': QUALITY_MAP.get(quality, {}).get('extension', 'flac'), + 'codec': QUALITY_MAP.get(quality, {}).get('codec', 'flac'), + 'quality': quality, + } + + def _demux_flac(self, input_path: Path, output_path: Path) -> None: + ffmpeg = shutil.which('ffmpeg') + if not ffmpeg: + 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: + raise RuntimeError('ffmpeg is required to demux FLAC from MP4. Install ffmpeg and retry.') + + try: + result = subprocess.run( + [ + ffmpeg, + '-y', + '-hide_banner', + '-loglevel', 'error', + '-i', str(input_path), + '-map', '0:a:0', + '-c', 'copy', + str(output_path), + ], + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError as exc: + raise RuntimeError( + f'ffmpeg failed while demuxing {input_path} -> {output_path}: ' + f'{exc.returncode}\n{exc.stderr}' + ) from exc 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}") @@ -634,7 +615,7 @@ class TidalDownloadClient: with self._download_lock: self.active_downloads[download_id] = { 'id': download_id, - 'filename': filename, # Keep original encoded format for context matching + 'filename': filename, 'username': 'tidal', 'state': 'Initializing', 'progress': 0.0, @@ -647,7 +628,6 @@ class TidalDownloadClient: '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), @@ -665,7 +645,6 @@ class TidalDownloadClient: 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: @@ -698,254 +677,172 @@ class TidalDownloadClient: 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}") + quality_key = config_manager.get('tidal_download.quality', 'lossless') + chain = ['hires', 'lossless', 'high', 'low'] + start = chain.index(quality_key) if quality_key in chain else 1 + allow_fallback = config_manager.get('tidal_download.allow_fallback', True) + chain = chain[start:] if allow_fallback else [quality_key] + + MIN_AUDIO_SIZE = 100 * 1024 + + for q_key in chain: + if self.shutdown_check and self.shutdown_check(): + logger.info("Shutdown detected, aborting Tidal download") return None - # Determine quality - quality_key = config_manager.get('tidal_download.quality', 'lossless') - quality_info = QUALITY_MAP.get(quality_key, QUALITY_MAP['lossless']) + manifest_info = self._get_hls_manifest(track_id, quality=q_key) + if not manifest_info or not manifest_info.get('segment_uris'): + logger.warning(f"No HLS manifest at quality {q_key}, trying next") + continue - # Try quality fallback chain: hires → lossless → high → low - # The entire download+validation is inside the loop so that garbage - # files (stubs, empty HiRes responses) trigger a retry at the next tier. - quality_chain = ['hires', 'lossless', 'high', 'low'] - start_idx = quality_chain.index(quality_key) if quality_key in quality_chain else 1 - allow_fallback = config_manager.get('tidal_download.allow_fallback', True) - chain = quality_chain[start_idx:] if allow_fallback else [quality_key] + extension = manifest_info['extension'] + safe_name = re.sub(r'[<>:"/\\|?*]', '_', display_name) + out_filename = f"{safe_name}.{extension}" + out_path = self.download_path / out_filename - MIN_AUDIO_SIZE = 100 * 1024 # 100KB + is_flac = q_key in ('hires', 'lossless') + intermediate_path = out_path.with_suffix('.m4a') if is_flac else out_path - quality_error_reasons = [] + try: + init_uri = manifest_info.get('init_uri') + segment_uris = manifest_info['segment_uris'] + total_segments = len(segment_uris) + (1 if init_uri else 0) - for q_key in chain: - q_info = QUALITY_MAP[q_key] + logger.info(f"Downloading from Tidal ({q_key}): {out_filename} " + f"({total_segments} segments)") - # --- Step 1: Get stream --- - try: - self.session.audio_quality = q_info['tidal_quality'] - stream = track.get_stream() - if not stream or not stream.manifest_mime_type: - reason = f"{q_key}: no stream returned" - logger.warning(f"Quality {q_key} returned no stream, trying next") - quality_error_reasons.append(reason) - continue + downloaded = 0 + speed_start = time.time() + segments_completed = 0 - ok, reason = _verify_stream_tier(stream, q_info, q_key) - if not ok: - logger.warning(reason) - quality_error_reasons.append(reason) - continue + with self._download_lock: + if download_id in self.active_downloads: + self.active_downloads[download_id]['size'] = 0 - logger.info(f"Got Tidal stream at quality: {q_key}") - except Exception as e: - reason = f"{q_key}: {type(e).__name__}: {e}" - logger.warning(f"Quality {q_key} unavailable: {e}") - quality_error_reasons.append(reason) - continue + with intermediate_path.open('wb') as output_file: + if init_uri: + if self.shutdown_check and self.shutdown_check(): + logger.info("Shutdown detected, aborting Tidal download") + intermediate_path.unlink(missing_ok=True) + return None - # --- Step 2: Parse manifest --- - manifest = stream.get_stream_manifest() - urls = manifest.get_urls() - if not urls: - reason = f"{q_key}: manifest returned no URLs" - logger.warning(f"No download URLs for quality {q_key}, trying next") - quality_error_reasons.append(reason) - continue + logger.debug(f"Downloading init segment: {init_uri}") + init_data = self._download_segment_with_retry(init_uri) + output_file.write(init_data) + downloaded += len(init_data) + segments_completed += 1 - download_url = urls[0] + self._update_download_progress(download_id, downloaded, + segments_completed, total_segments, speed_start) - # Determine file extension from manifest codec (HiRes FLAC - # can arrive wrapped in MP4 — unwrapped at Step 4). - 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' + for segment_url in segment_uris: + if self.shutdown_check and self.shutdown_check(): + logger.info("Shutdown detected, aborting Tidal download") + intermediate_path.unlink(missing_ok=True) + return None + + segment_data = self._download_segment_with_retry(segment_url) + output_file.write(segment_data) + downloaded += len(segment_data) + segments_completed += 1 + + self._update_download_progress(download_id, downloaded, + segments_completed, total_segments, speed_start) + + except Exception as e: + logger.warning(f"Download failed at quality {q_key}: {e}") + intermediate_path.unlink(missing_ok=True) + continue + + if downloaded < MIN_AUDIO_SIZE: + logger.warning(f"File too small at {q_key} ({downloaded} bytes), trying next") + intermediate_path.unlink(missing_ok=True) + continue + + try: + if is_flac: + logger.info(f"Demuxing FLAC from MP4 container: {intermediate_path} -> {out_path}") + self._demux_flac(intermediate_path, out_path) + intermediate_path.unlink(missing_ok=True) + final_size = out_path.stat().st_size if out_path.exists() else 0 else: - extension = q_info.get('extension', 'flac') + final_size = intermediate_path.stat().st_size if intermediate_path.exists() else 0 - # Build output filename - safe_name = re.sub(r'[<>:"/\\|?*]', '_', display_name) - out_filename = f"{safe_name}.{extension}" - out_path = self.download_path / out_filename - - # Check for shutdown before downloading - if self.shutdown_check and self.shutdown_check(): - logger.info("Server shutting down, aborting Tidal download") - return None - - # --- Step 3: Download --- - try: - logger.info(f"Downloading from Tidal ({q_key}): {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 - - 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) - - 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) - - except Exception as dl_err: - logger.warning(f"Download failed at quality {q_key}: {dl_err}") - quality_error_reasons.append(f"{q_key}: download error: {type(dl_err).__name__}: {dl_err}") - out_path.unlink(missing_ok=True) - continue - - # --- Step 4: Validate --- - if downloaded < MIN_AUDIO_SIZE: - logger.warning( - f"Tidal download too small at {q_key} ({downloaded} bytes) — " - f"likely a stub/preview for '{display_name}'. Trying next quality." - ) - quality_error_reasons.append(f"{q_key}: file too small ({downloaded} bytes), likely a stub") - out_path.unlink(missing_ok=True) - continue - - # HiRes FLAC in MP4 container: extract raw FLAC with FFmpeg - if extension == 'flac' and self._is_mp4_container(out_path): - extracted = self._extract_flac_from_mp4(out_path) - if extracted: - out_path = Path(extracted) - else: - logger.warning( - f"Cannot extract FLAC from MP4 container at {q_key} — " - f"deleting and trying next quality" - ) - quality_error_reasons.append(f"{q_key}: FLAC extraction from MP4 container failed") - out_path.unlink(missing_ok=True) - continue - - # Final size check after any extraction - final_size = out_path.stat().st_size if out_path.exists() else 0 if final_size < MIN_AUDIO_SIZE: - logger.warning( - f"Final file too small after processing at {q_key} " - f"({final_size} bytes) — trying next quality" - ) - quality_error_reasons.append(f"{q_key}: final file too small after extraction ({final_size} bytes)") + logger.warning(f"Final file too small after processing at {q_key} " + f"({final_size} bytes), trying next") out_path.unlink(missing_ok=True) continue - # Success — file is valid - logger.info(f"Tidal download complete ({q_key}): {out_path} ({final_size / (1024*1024):.1f} MB)") + logger.info(f"Tidal download complete ({q_key}): {out_path} " + f"({final_size / (1024*1024):.1f} MB)") return str(out_path) - # All quality tiers exhausted — build a diagnostic message - # Re-use quality_key/allow_fallback already read above to stay consistent - # with how the chain was built (avoids config-change-mid-download inconsistency). - reasons_str = '; '.join(quality_error_reasons) if quality_error_reasons else 'unknown' - if quality_key == 'hires' and not allow_fallback: - hint = ( - " HiRes quality is unavailable for this track on your account or in your region. " - "Enable 'Quality Fallback' in Tidal settings to fall back to Lossless automatically." - ) - else: - hint = "" - logger.error( - f"No Tidal quality tier produced a valid download for '{display_name}'." - f"{hint} Failure reasons: [{reasons_str}]" - ) - return None + except Exception as e: + logger.warning(f"Post-processing failed at quality {q_key}: {e}") + out_path.unlink(missing_ok=True) + intermediate_path.unlink(missing_ok=True) + continue - except Exception as e: - logger.error(f"Tidal download failed: {e}") - import traceback - traceback.print_exc() - return None + logger.error(f"All quality tiers exhausted for '{display_name}'") + 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 _download_segment_with_retry(self, url: str) -> bytes: + """Download a single HLS segment with 3 retries and 2s fixed backoff.""" + last_error = None + for attempt in range(4): + try: + resp = http_requests.get(url, allow_redirects=True, timeout=30) + resp.raise_for_status() + return resp.content + except http_requests.exceptions.HTTPError as e: + status = e.response.status_code if e.response is not None else 0 + if 400 <= status < 500: + raise + last_error = e + except (http_requests.exceptions.Timeout, + http_requests.exceptions.ConnectionError) as e: + last_error = e - 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 + if attempt < 3: + if self.shutdown_check and self.shutdown_check(): + raise RuntimeError("Shutdown requested") + logger.warning(f"Tidal segment download failed (attempt {attempt + 1}/4), " + f"retrying in 2s: {url}") + time.sleep(2) - flac_path = mp4_path.with_suffix('.flac') - temp_path = mp4_path.with_suffix('.tmp.flac') + raise last_error - try: - result = subprocess.run( - [ffmpeg, '-i', str(mp4_path), '-vn', '-acodec', 'copy', str(temp_path), '-y'], - capture_output=True, text=True, timeout=120, - ) + def _update_download_progress(self, download_id: str, downloaded: int, + segments_completed: int, total_segments: int, + speed_start: float): + with self._download_lock: + if download_id not in self.active_downloads: + return + info = self.active_downloads[download_id] + info['transferred'] = downloaded - 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 + now = time.time() + elapsed_total = now - speed_start + speed = int(downloaded / elapsed_total) if elapsed_total > 0 else 0 + info['speed'] = speed - except Exception as e: - logger.warning(f"FFmpeg extraction error: {e}") - temp_path.unlink(missing_ok=True) - return None + if total_segments > 0: + progress = (segments_completed / total_segments) * 100 + info['progress'] = round(min(progress, 99.9), 1) - # ===================== Status / Cancel / Clear ===================== + time_remaining = None + if speed > 0: + remaining_bytes = downloaded * (total_segments / max(segments_completed, 1)) - downloaded + if remaining_bytes > 0: + time_remaining = int(remaining_bytes / speed) + info['time_remaining'] = time_remaining async def get_all_downloads(self) -> List[DownloadStatus]: - """Get all active downloads (matches Soulseek interface).""" download_statuses = [] with self._download_lock: @@ -967,7 +864,6 @@ class TidalDownloadClient: 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 @@ -987,7 +883,6 @@ class TidalDownloadClient: ) 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: @@ -1007,7 +902,6 @@ class TidalDownloadClient: 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 = [ diff --git a/database/music_database.py b/database/music_database.py index d4f68f0a..a2c9df7c 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -723,6 +723,17 @@ class MusicDatabase: except Exception: pass + # HiFi API instances table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS hifi_instances ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL UNIQUE, + priority INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + conn.commit() logger.info("Database initialized successfully") @@ -11594,6 +11605,86 @@ class MusicDatabase: logger.error(f"Error getting issue counts: {e}") return {'open': 0, 'in_progress': 0, 'resolved': 0, 'dismissed': 0, 'total': 0} + # ===================== HiFi Instances ===================== + + def get_hifi_instances(self) -> List[Dict[str, Any]]: + """Get all enabled HiFi instances ordered by priority.""" + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute("SELECT url, priority, enabled FROM hifi_instances WHERE enabled = 1 ORDER BY priority ASC, id ASC") + return [dict(row) for row in cursor.fetchall()] + + def get_all_hifi_instances(self) -> List[Dict[str, Any]]: + """Get all HiFi instances (including disabled) ordered by priority.""" + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute("SELECT url, priority, enabled FROM hifi_instances ORDER BY priority ASC, id ASC") + return [dict(row) for row in cursor.fetchall()] + + def add_hifi_instance(self, url: str, priority: int = 0) -> bool: + """Add a new HiFi instance.""" + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute( + "INSERT OR IGNORE INTO hifi_instances (url, priority, enabled) VALUES (?, ?, 1)", + (url, priority) + ) + conn.commit() + return cursor.rowcount > 0 + + def remove_hifi_instance(self, url: str) -> bool: + """Remove a HiFi instance.""" + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute("DELETE FROM hifi_instances WHERE url = ?", (url,)) + conn.commit() + return cursor.rowcount > 0 + + def toggle_hifi_instance(self, url: str, enabled: bool) -> bool: + """Enable or disable a HiFi instance.""" + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute("UPDATE hifi_instances SET enabled = ? WHERE url = ?", (1 if enabled else 0, url)) + conn.commit() + return cursor.rowcount > 0 + + def reorder_hifi_instances(self, urls: List[str]) -> bool: + """Update priorities based on the given URL order. + Returns False if any URL does not exist in the database. + """ + if not urls: + return True + conn = self._get_connection() + cursor = conn.cursor() + placeholders = ",".join("?" for _ in urls) + cursor.execute( + f"SELECT url FROM hifi_instances WHERE url IN ({placeholders})", + urls + ) + existing = {row["url"] for row in cursor.fetchall()} + missing = [u for u in urls if u not in existing] + if missing: + return False + for i, url in enumerate(urls): + cursor.execute("UPDATE hifi_instances SET priority = ? WHERE url = ?", (i, url)) + conn.commit() + return True + + def seed_hifi_instances(self, default_urls: List[str]) -> None: + """Insert default instances if the table is empty.""" + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute("SELECT COUNT(*) as cnt FROM hifi_instances") + count = cursor.fetchone()['cnt'] + if count == 0: + for i, url in enumerate(default_urls): + cursor.execute( + "INSERT OR IGNORE INTO hifi_instances (url, priority, enabled) VALUES (?, ?, 1)", + (url, i) + ) + conn.commit() + logger.info(f"Seeded {len(default_urls)} default HiFi instances") + # Thread-safe singleton pattern for database access _database_instances: Dict[int, MusicDatabase] = {} # Thread ID -> Database instance _database_lock = threading.Lock() diff --git a/tests/test_hifi_instance_methods.py b/tests/test_hifi_instance_methods.py new file mode 100644 index 00000000..d300591c --- /dev/null +++ b/tests/test_hifi_instance_methods.py @@ -0,0 +1,368 @@ +"""Tests for the HiFi instance CRUD helpers on ``MusicDatabase``: + +- ``get_hifi_instances()`` — returns enabled instances ordered by priority +- ``get_all_hifi_instances()`` — returns all instances (enabled + disabled) +- ``add_hifi_instance(url, priority)`` — inserts a new instance +- ``remove_hifi_instance(url)`` — deletes an instance by URL +- ``toggle_hifi_instance(url, enabled)`` — enables/disables an instance +- ``reorder_hifi_instances(urls)`` — updates priority ordering +- ``seed_hifi_instances(default_urls)`` — seeds defaults when table is empty + +These are isolated DB-method tests so the SQL itself is verified +without spinning up Flask or any HiFi client. +""" + +import sqlite3 +import sys +import types + +import pytest + + +# ── stubs (same shape used elsewhere in the test suite) ─────────────────── +if "spotipy" not in sys.modules: + spotipy = types.ModuleType("spotipy") + spotipy.Spotify = object + oauth2 = types.ModuleType("spotipy.oauth2") + oauth2.SpotifyOAuth = object + oauth2.SpotifyClientCredentials = object + spotipy.oauth2 = oauth2 + sys.modules["spotipy"] = spotipy + sys.modules["spotipy.oauth2"] = oauth2 + +if "config.settings" not in sys.modules: + config_pkg = types.ModuleType("config") + settings_mod = types.ModuleType("config.settings") + + class _DummyConfigManager: + def get(self, key, default=None): + return default + + def get_active_media_server(self): + return "primary" + + settings_mod.config_manager = _DummyConfigManager() + config_pkg.settings = settings_mod + sys.modules["config"] = config_pkg + sys.modules["config.settings"] = settings_mod + + +from database.music_database import MusicDatabase # noqa: E402 + + +# ── helpers ─────────────────────────────────────────────────────────────── + +class _InMemoryDB(MusicDatabase): + """MusicDatabase that uses an in-memory sqlite that survives across + `_get_connection()` calls.""" + + def __init__(self): + self._conn = sqlite3.connect(":memory:") + self._conn.row_factory = sqlite3.Row + self._conn.execute(""" + CREATE TABLE hifi_instances ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL UNIQUE, + priority INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + self._conn.commit() + + def _get_connection(self): + return _NonClosingConn(self._conn) + + +class _NonClosingConn: + """Wraps the shared sqlite connection so `with db._get_connection() + as conn:` doesn't close the underlying handle between calls.""" + def __init__(self, real): + self._real = real + + def cursor(self): + return self._real.cursor() + + def commit(self): + return self._real.commit() + + def close(self): + pass + + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + +@pytest.fixture +def db(): + return _InMemoryDB() + + +def _seed(db, *, instances=()): + """Seed hifi_instances rows. Each tuple: (url, priority, enabled).""" + cur = db._conn.cursor() + for url, priority, enabled in instances: + cur.execute( + "INSERT INTO hifi_instances (url, priority, enabled) VALUES (?, ?, ?)", + (url, priority, enabled), + ) + db._conn.commit() + + +# ── get_hifi_instances ──────────────────────────────────────────────────── + + +def test_get_hifi_instances_returns_enabled_ordered_by_priority(db): + _seed(db, instances=[ + ("http://b.com", 10, 1), + ("http://a.com", 5, 1), + ("http://c.com", 1, 1), + ]) + rows = db.get_hifi_instances() + assert [r["url"] for r in rows] == ["http://c.com", "http://a.com", "http://b.com"] + assert [r["priority"] for r in rows] == [1, 5, 10] + + +def test_get_hifi_instances_excludes_disabled(db): + _seed(db, instances=[ + ("http://a.com", 0, 1), + ("http://b.com", 1, 0), + ("http://c.com", 2, 1), + ]) + rows = db.get_hifi_instances() + assert {r["url"] for r in rows} == {"http://a.com", "http://c.com"} + + +def test_get_hifi_instances_returns_empty_when_no_rows(db): + assert db.get_hifi_instances() == [] + + +def test_get_hifi_instances_tiebreaks_on_id(db): + """Same priority → ordered by insertion order (autoincrement id).""" + _seed(db, instances=[ + ("http://first.com", 0, 1), + ("http://second.com", 0, 1), + ("http://third.com", 0, 1), + ]) + rows = db.get_hifi_instances() + assert [r["url"] for r in rows] == ["http://first.com", "http://second.com", "http://third.com"] + + +# ── get_all_hifi_instances ──────────────────────────────────────────────── + + +def test_get_all_hifi_instances_returns_all_including_disabled(db): + _seed(db, instances=[ + ("http://a.com", 0, 1), + ("http://b.com", 1, 0), + ]) + rows = db.get_all_hifi_instances() + assert {r["url"] for r in rows} == {"http://a.com", "http://b.com"} + + +def test_get_all_hifi_instances_ordered_by_priority(db): + _seed(db, instances=[ + ("http://c.com", 20, 0), + ("http://a.com", 0, 1), + ("http://b.com", 10, 1), + ]) + rows = db.get_all_hifi_instances() + assert [r["url"] for r in rows] == ["http://a.com", "http://b.com", "http://c.com"] + + +def test_get_all_hifi_instances_returns_empty_when_no_rows(db): + assert db.get_all_hifi_instances() == [] + + +# ── add_hifi_instance ───────────────────────────────────────────────────── + + +def test_add_hifi_instance_returns_true_on_insert(db): + assert db.add_hifi_instance("http://new.com", priority=3) is True + rows = db.get_all_hifi_instances() + assert len(rows) == 1 + assert rows[0]["url"] == "http://new.com" + assert rows[0]["priority"] == 3 + assert rows[0]["enabled"] == 1 + + +def test_add_hifi_instance_returns_false_on_duplicate(db): + _seed(db, instances=[("http://dup.com", 0, 1)]) + # INSERT OR IGNORE — should not raise, but return False (rowcount == 0) + assert db.add_hifi_instance("http://dup.com", priority=5) is False + rows = db.get_all_hifi_instances() + assert len(rows) == 1 + + +def test_add_hifi_instance_default_priority(db): + db.add_hifi_instance("http://x.com") + row = db.get_all_hifi_instances()[0] + assert row["priority"] == 0 + + +# ── remove_hifi_instance ────────────────────────────────────────────────── + + +def test_remove_hifi_instance_returns_true_on_delete(db): + _seed(db, instances=[("http://go.com", 0, 1)]) + assert db.remove_hifi_instance("http://go.com") is True + assert db.get_all_hifi_instances() == [] + + +def test_remove_hifi_instance_returns_false_when_not_found(db): + assert db.remove_hifi_instance("http://missing.com") is False + + +def test_remove_hifi_instance_only_removes_matching_url(db): + _seed(db, instances=[ + ("http://keep.com", 0, 1), + ("http://delete.com", 1, 1), + ]) + db.remove_hifi_instance("http://delete.com") + rows = db.get_all_hifi_instances() + assert len(rows) == 1 + assert rows[0]["url"] == "http://keep.com" + + +# ── toggle_hifi_instance ────────────────────────────────────────────────── + + +def test_toggle_hifi_instance_disable(db): + _seed(db, instances=[("http://x.com", 0, 1)]) + assert db.toggle_hifi_instance("http://x.com", enabled=False) is True + row = db.get_all_hifi_instances()[0] + assert row["enabled"] == 0 + + +def test_toggle_hifi_instance_enable(db): + _seed(db, instances=[("http://x.com", 0, 0)]) + assert db.toggle_hifi_instance("http://x.com", enabled=True) is True + row = db.get_all_hifi_instances()[0] + assert row["enabled"] == 1 + + +def test_toggle_hifi_instance_returns_false_when_not_found(db): + assert db.toggle_hifi_instance("http://missing.com", enabled=True) is False + + +def test_toggle_hifi_instance_noop_when_already_set(db): + """Toggling to the same value should still return True (row matched).""" + _seed(db, instances=[("http://x.com", 0, 1)]) + # SQLite rowcount for UPDATE is 1 even if value didn't change + assert db.toggle_hifi_instance("http://x.com", enabled=True) is True + + +# ── reorder_hifi_instances ──────────────────────────────────────────────── + + +def test_reorder_hifi_instances_updates_priorities(db): + _seed(db, instances=[ + ("http://a.com", 0, 1), + ("http://b.com", 1, 1), + ("http://c.com", 2, 1), + ]) + db.reorder_hifi_instances(["http://c.com", "http://a.com", "http://b.com"]) + rows = db.get_all_hifi_instances() + by_url = {r["url"]: r["priority"] for r in rows} + assert by_url == {"http://c.com": 0, "http://a.com": 1, "http://b.com": 2} + + +def test_reorder_hifi_instances_returns_true_on_empty_list(db): + assert db.reorder_hifi_instances([]) is True + + +def test_reorder_hifi_instances_returns_false_with_unknown_urls(db): + """Reorder should fail when any URL doesn't exist.""" + _seed(db, instances=[("http://a.com", 0, 1)]) + assert db.reorder_hifi_instances(["http://a.com", "http://phantom.com"]) is False + + +# ── seed_hifi_instances ─────────────────────────────────────────────────── + + +def test_seed_hifi_instances_inserts_when_empty(db): + db.seed_hifi_instances(["http://a.com", "http://b.com"]) + rows = db.get_all_hifi_instances() + assert len(rows) == 2 + by_url = {r["url"]: r["priority"] for r in rows} + assert by_url == {"http://a.com": 0, "http://b.com": 1} + + +def test_seed_hifi_instances_does_nothing_when_table_has_rows(db): + _seed(db, instances=[("http://existing.com", 0, 1)]) + db.seed_hifi_instances(["http://new.com"]) + rows = db.get_all_hifi_instances() + assert len(rows) == 1 + assert rows[0]["url"] == "http://existing.com" + + +def test_seed_hifi_instances_does_not_duplicate_on_reseed(db): + db.seed_hifi_instances(["http://a.com"]) + db.seed_hifi_instances(["http://a.com"]) + rows = db.get_all_hifi_instances() + assert len(rows) == 1 + + +# ── error propagation ──────────────────────────────────────────────────── +# These methods now let DB errors bubble up so the route layer turns them +# into a 500 — the user sees a real failure instead of a phantom empty state. + + +def _db_without_hifi_table(): + """Returns a MusicDatabase with NO hifi_instances table.""" + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + + class _NoTableDB(MusicDatabase): + def __init__(self): + self._conn = conn + + def _get_connection(self): + return _NonClosingConn(self._conn) + + return _NoTableDB() + + +def test_get_hifi_instances_propagates_db_errors(): + db = _db_without_hifi_table() + with pytest.raises(sqlite3.OperationalError): + db.get_hifi_instances() + + +def test_get_all_hifi_instances_propagates_db_errors(): + db = _db_without_hifi_table() + with pytest.raises(sqlite3.OperationalError): + db.get_all_hifi_instances() + + +def test_add_hifi_instance_propagates_db_errors(): + db = _db_without_hifi_table() + with pytest.raises(sqlite3.OperationalError): + db.add_hifi_instance("http://x.com") + + +def test_remove_hifi_instance_propagates_db_errors(): + db = _db_without_hifi_table() + with pytest.raises(sqlite3.OperationalError): + db.remove_hifi_instance("http://x.com") + + +def test_toggle_hifi_instance_propagates_db_errors(): + db = _db_without_hifi_table() + with pytest.raises(sqlite3.OperationalError): + db.toggle_hifi_instance("http://x.com", enabled=True) + + +def test_reorder_hifi_instances_propagates_db_errors(): + db = _db_without_hifi_table() + with pytest.raises(sqlite3.OperationalError): + db.reorder_hifi_instances(["http://x.com"]) + + +def test_seed_hifi_instances_propagates_db_errors(): + db = _db_without_hifi_table() + with pytest.raises(sqlite3.OperationalError): + db.seed_hifi_instances(["http://x.com"]) diff --git a/tests/test_hls_parsing.py b/tests/test_hls_parsing.py new file mode 100644 index 00000000..489509ad --- /dev/null +++ b/tests/test_hls_parsing.py @@ -0,0 +1,384 @@ +"""Tests for HLS-related methods on ``HiFiClient``: + +- ``_parse_hls_playlist(text, playlist_url)`` — parses m3u8 playlists +- ``_demux_flac(input_path, output_path)`` — ffmpeg demuxing error paths + +The parsing logic is identical in ``tidal_download_client.py``; these tests +cover the shared behavior via ``hifi_client.HiFiClient``. +""" + +import shutil +import subprocess +import sys +import types +from pathlib import Path +from unittest.mock import patch + +import pytest + + +# ── stubs for module-level imports ──────────────────────────────────────── + +if "spotipy" not in sys.modules: + spotipy = types.ModuleType("spotipy") + spotipy.Spotify = object + oauth2 = types.ModuleType("spotipy.oauth2") + oauth2.SpotifyOAuth = object + oauth2.SpotifyClientCredentials = object + spotipy.oauth2 = oauth2 + sys.modules["spotipy"] = spotipy + sys.modules["spotipy.oauth2"] = oauth2 + +if "config.settings" not in sys.modules: + config_pkg = types.ModuleType("config") + settings_mod = types.ModuleType("config.settings") + + class _DummyConfigManager: + def get(self, key, default=None): + return default + + def get_active_media_server(self): + return "primary" + + settings_mod.config_manager = _DummyConfigManager() + config_pkg.settings = settings_mod + sys.modules["config"] = config_pkg + sys.modules["config.settings"] = settings_mod + + +from core.hifi_client import HiFiClient # noqa: E402 + + +# ── fixture ─────────────────────────────────────────────────────────────── + +@pytest.fixture +def client(tmp_path): + """HiFiClient with a temp download dir and no DB dependency.""" + return HiFiClient(download_path=str(tmp_path / "downloads")) + + +# ── _parse_hls_playlist: master playlist ───────────────────────────────── + +MASTER_PLAYLIST = """\ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-STREAM-INF:BANDWIDTH=256000,CODECS="mp4a.40.2" +stream/low.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=512000,CODECS="mp4a.40.5" +stream/high.m3u8 +""" + + +def test_parse_master_playlist_returns_variant_uri(client): + init, segments = client._parse_hls_playlist( + MASTER_PLAYLIST, "https://cdn.example.com/master.m3u8" + ) + assert init is None + assert segments == ["https://cdn.example.com/stream/low.m3u8"] + + +def test_parse_master_playlist_picks_first_variant(client): + """Master playlists should return only the first variant URI.""" + init, segments = client._parse_hls_playlist( + MASTER_PLAYLIST, "https://cdn.example.com/master.m3u8" + ) + assert len(segments) == 1 + + +def test_parse_master_playlist_resolves_relative_uri(client): + init, segments = client._parse_hls_playlist( + MASTER_PLAYLIST, "https://cdn.example.com/playlists/master.m3u8" + ) + assert segments[0] == "https://cdn.example.com/playlists/stream/low.m3u8" + + +# ── _parse_hls_playlist: variant playlist with init segment ────────────── + +VARIANT_WITH_INIT = """\ +#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-TARGETDURATION:4 +#EXT-X-MAP:URI="init.mp4" +#EXTINF:3.840000, +seg001.m4s +#EXTINF:3.840000, +seg002.m4s +#EXTINF:2.560000, +seg003.m4s +""" + + +def test_parse_variant_with_init_returns_init_and_segments(client): + init, segments = client._parse_hls_playlist( + VARIANT_WITH_INIT, "https://cdn.example.com/variant.m3u8" + ) + assert init == "https://cdn.example.com/init.mp4" + assert segments == [ + "https://cdn.example.com/seg001.m4s", + "https://cdn.example.com/seg002.m4s", + "https://cdn.example.com/seg003.m4s", + ] + + +def test_parse_variant_with_init_resolves_relative_uris(client): + init, segments = client._parse_hls_playlist( + VARIANT_WITH_INIT, "https://cdn.example.com/audio/variant.m3u8" + ) + assert init == "https://cdn.example.com/audio/init.mp4" + assert segments[0] == "https://cdn.example.com/audio/seg001.m4s" + + +def test_parse_variant_with_absolute_init_uri(client): + playlist = """\ +#EXTM3U +#EXT-X-MAP:URI="https://other.cdn/init.mp4" +#EXTINF:3.0, +https://cdn.example.com/seg001.m4s +""" + init, segments = client._parse_hls_playlist( + playlist, "https://cdn.example.com/variant.m3u8" + ) + assert init == "https://other.cdn/init.mp4" + assert segments == ["https://cdn.example.com/seg001.m4s"] + + +# ── _parse_hls_playlist: variant playlist without init segment ─────────── + +VARIANT_NO_INIT = """\ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:10 +#EXTINF:9.984000, +seg001.ts +#EXTINF:9.984000, +seg002.ts +#EXT-X-ENDLIST +""" + + +def test_parse_variant_without_init_returns_none_init(client): + init, segments = client._parse_hls_playlist( + VARIANT_NO_INIT, "https://cdn.example.com/variant.m3u8" + ) + assert init is None + assert segments == [ + "https://cdn.example.com/seg001.ts", + "https://cdn.example.com/seg002.ts", + ] + + +# ── _parse_hls_playlist: error cases ───────────────────────────────────── + + +def test_parse_empty_playlist_raises_value_error(client): + with pytest.raises(ValueError, match="No segment URIs"): + client._parse_hls_playlist("#EXTM3U", "https://cdn.example.com/x.m3u8") + + +def test_parse_only_tags_raises_value_error(client): + """Playlist with only header and MAP tag but no segment URIs.""" + playlist = """\ +#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-MAP:URI="init.mp4" +""" + with pytest.raises(ValueError, match="No segment URIs"): + client._parse_hls_playlist(playlist, "https://cdn.example.com/x.m3u8") + + +def test_parse_only_extm3u_raises_value_error(client): + with pytest.raises(ValueError, match="No segment URIs"): + client._parse_hls_playlist("#EXTM3U\n", "https://cdn.example.com/x.m3u8") + + +# ── _parse_hls_playlist: edge cases ────────────────────────────────────── + + +def test_parse_skips_blank_lines(client): + playlist = """\ +#EXTM3U + +#EXT-X-MAP:URI="init.mp4" + +#EXTINF:3.0, + +seg001.m4s + +""" + init, segments = client._parse_hls_playlist( + playlist, "https://cdn.example.com/x.m3u8" + ) + assert init == "https://cdn.example.com/init.mp4" + assert segments == ["https://cdn.example.com/seg001.m4s"] + + +def test_parse_skips_unknown_tags(client): + """Tags like #EXT-X-VERSION, #EXT-X-TARGETDURATION should be ignored.""" + playlist = """\ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:10 +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-PLAYLIST-TYPE:VOD +#EXTINF:5.0, +seg001.ts +""" + init, segments = client._parse_hls_playlist( + playlist, "https://cdn.example.com/x.m3u8" + ) + assert init is None + assert segments == ["https://cdn.example.com/seg001.ts"] + + +def test_parse_captures_last_map_tag(client): + """If multiple EXT-X-MAP tags appear, the last one wins (overwrites init_uri).""" + playlist = """\ +#EXTM3U +#EXT-X-MAP:URI="init-first.mp4" +#EXTINF:3.0, +seg001.m4s +#EXT-X-MAP:URI="init-second.mp4" +#EXTINF:3.0, +seg002.m4s +""" + init, segments = client._parse_hls_playlist( + playlist, "https://cdn.example.com/x.m3u8" + ) + assert init == "https://cdn.example.com/init-second.mp4" + assert len(segments) == 2 + + +def test_parse_master_breaks_on_first_variant(client): + """Parser should stop after finding the first variant URI.""" + playlist = """\ +#EXTM3U +#EXT-X-STREAM-INF:BANDWIDTH=256000 +variant-low.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=512000 +variant-high.m3u8 +#EXTINF:3.0, +should-not-appear.ts +""" + init, segments = client._parse_hls_playlist( + playlist, "https://cdn.example.com/master.m3u8" + ) + assert init is None + assert segments == ["https://cdn.example.com/variant-low.m3u8"] + + +def test_parse_master_skips_comment_after_stream_inf(client): + """The line immediately after #EXT-X-STREAM-INF must be a non-comment URI.""" + playlist = """\ +#EXTM3U +#EXT-X-STREAM-INF:BANDWIDTH=256000 +# some comment +variant.m3u8 +""" + init, segments = client._parse_hls_playlist( + playlist, "https://cdn.example.com/master.m3u8" + ) + assert segments == ["https://cdn.example.com/variant.m3u8"] + + +def test_parse_handles_mixed_absolute_and_relative_uris(client): + playlist = """\ +#EXTM3U +#EXTINF:3.0, +https://cdn-a.example.com/seg001.m4s +#EXTINF:3.0, +seg002.m4s +#EXTINF:3.0, +https://cdn-b.example.com/seg003.m4s +""" + init, segments = client._parse_hls_playlist( + playlist, "https://cdn.example.com/variant.m3u8" + ) + assert segments == [ + "https://cdn-a.example.com/seg001.m4s", + "https://cdn.example.com/seg002.m4s", + "https://cdn-b.example.com/seg003.m4s", + ] + + +def test_parse_single_segment(client): + playlist = """\ +#EXTM3U +#EXTINF:3.0, +only-segment.m4s +""" + init, segments = client._parse_hls_playlist( + playlist, "https://cdn.example.com/x.m3u8" + ) + assert init is None + assert segments == ["https://cdn.example.com/only-segment.m4s"] + + +# ── _demux_flac: error paths ───────────────────────────────────────────── + + +def test_demux_flac_raises_when_ffmpeg_not_found(client, tmp_path, monkeypatch): + """When ffmpeg is nowhere on PATH and not in tools/, RuntimeError is raised.""" + monkeypatch.setattr("shutil.which", lambda _: None) + inp = tmp_path / "input.mp4" + inp.touch() + out = tmp_path / "output.flac" + + with patch.object(Path, "exists", return_value=False): + with pytest.raises(RuntimeError, match="ffmpeg is required"): + client._demux_flac(inp, out) + + +def test_demux_flac_raises_on_ffmpeg_failure(client, tmp_path, monkeypatch): + """When ffmpeg exits non-zero, RuntimeError includes stderr.""" + monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/ffmpeg") + + inp = tmp_path / "input.mp4" + inp.touch() + out = tmp_path / "output.flac" + + fake_result = subprocess.CalledProcessError( + returncode=1, + cmd=["ffmpeg"], + stderr="Invalid data found when processing input", + ) + + with patch("subprocess.run", side_effect=fake_result): + with pytest.raises(RuntimeError, match="ffmpeg failed"): + client._demux_flac(inp, out) + + +def test_demux_flac_uses_tools_dir_fallback(client, tmp_path, monkeypatch): + """When shutil.which fails but tools/ffmpeg exists, it should be used.""" + monkeypatch.setattr("shutil.which", lambda _: None) + + inp = tmp_path / "input.mp4" + inp.touch() + out = tmp_path / "output.flac" + + tools_dir = Path(__file__).parent.parent / "tools" + + original_exists = Path.exists + original_which = shutil.which + + def fake_exists(self): + if str(self) == str(tools_dir / "ffmpeg"): + return True + return original_exists(self) + + def fake_which(name): + if name == "ffmpeg": + return None + return original_which(name) + + monkeypatch.setattr(shutil, "which", fake_which) + monkeypatch.setattr(Path, "exists", fake_exists) + + fake_result = subprocess.CalledProcessError( + returncode=1, cmd=["ffmpeg"], stderr="fail" + ) + with patch("subprocess.run", side_effect=fake_result) as mock_run: + with pytest.raises(RuntimeError, match="ffmpeg failed"): + client._demux_flac(inp, out) + + call_args = mock_run.call_args[0][0] + assert call_args[0] == str(tools_dir / "ffmpeg") diff --git a/tests/test_tidal_stream_tier_verification.py b/tests/test_tidal_stream_tier_verification.py deleted file mode 100644 index 73898a55..00000000 --- a/tests/test_tidal_stream_tier_verification.py +++ /dev/null @@ -1,162 +0,0 @@ -"""Tests for `_verify_stream_tier` — the guard that rejects silent Tidal -quality downgrades so the fallback chain (or "HiRes only" with fallback -disabled) behaves the way users configure it to. - -Without this check, a user with "HiRes only, no quality fallback" who -asks Tidal for a track that's only available in AAC 320kbps would -receive the 320kbps stream silently — Tidal never raises, it just -serves the highest tier available — and the downloader would accept -the m4a file and report success. Reported by Netti93. - -Tiers ranked worst-to-best: - LOW < HIGH < LOSSLESS < HI_RES < HI_RES_LOSSLESS - -Accepting matches and upgrades, rejecting downgrades, rejecting -unrecognized values. - -Note on the fake Quality values: tidalapi's real Quality enum has -VALUES that differ from the member names (e.g., `low_320k.value == -'HIGH'`, `high_lossless.value == 'LOSSLESS'`). The stub mirrors real -values so the tests catch case-sensitivity regressions. -""" - -import sys -import types - - -if 'tidalapi' not in sys.modules: - _fake = types.ModuleType('tidalapi') - - class _FakeQuality: - low_96k = 'LOW' - low_320k = 'HIGH' - high_lossless = 'LOSSLESS' - hi_res = 'HI_RES' - hi_res_lossless = 'HI_RES_LOSSLESS' - - _fake.Quality = _FakeQuality - _fake.media = types.SimpleNamespace(Track=object) - sys.modules['tidalapi'] = _fake - - -from core.tidal_download_client import QUALITY_MAP, _verify_stream_tier # noqa: E402 - - -class _FakeStream: - """Minimal stand-in for tidalapi.media.Stream.""" - - def __init__(self, audio_quality=None): - if audio_quality is not None: - self.audio_quality = audio_quality - - -# --------------------------------------------------------------------------- -# Match — served quality is exactly what was requested -# --------------------------------------------------------------------------- - -def test_served_quality_matches_request(): - stream = _FakeStream(audio_quality='HI_RES_LOSSLESS') - ok, reason = _verify_stream_tier(stream, QUALITY_MAP['hires'], 'hires') - assert ok is True - assert reason is None - - -# --------------------------------------------------------------------------- -# Upgrades — Tidal serving a higher tier than requested is accepted -# --------------------------------------------------------------------------- - -def test_lossless_request_upgraded_to_hires_is_accepted(): - """If Tidal serves HI_RES_LOSSLESS on a LOSSLESS-tier request (rare - but possible on tracks flagged as such in Tidal's catalog), we take - the upgrade — rejecting a better-than-asked tier would be user- - hostile.""" - stream = _FakeStream(audio_quality='HI_RES_LOSSLESS') - ok, reason = _verify_stream_tier(stream, QUALITY_MAP['lossless'], 'lossless') - assert ok is True - assert reason is None - - -def test_lossless_request_upgraded_to_mqa_hires_is_accepted(): - stream = _FakeStream(audio_quality='HI_RES') - ok, reason = _verify_stream_tier(stream, QUALITY_MAP['lossless'], 'lossless') - assert ok is True - assert reason is None - - -def test_low_request_upgraded_to_any_higher_tier_is_accepted(): - stream = _FakeStream(audio_quality='LOSSLESS') - ok, reason = _verify_stream_tier(stream, QUALITY_MAP['low'], 'low') - assert ok is True - assert reason is None - - -# --------------------------------------------------------------------------- -# Downgrades — the reported bug -# --------------------------------------------------------------------------- - -def test_hires_downgraded_to_aac_is_rejected(): - """The exact case Netti93 reported: asked HiRes, Tidal served - AAC 320kbps (`'HIGH'` in Tidal's API vocabulary).""" - stream = _FakeStream(audio_quality='HIGH') - ok, reason = _verify_stream_tier(stream, QUALITY_MAP['hires'], 'hires') - assert ok is False - assert 'HIGH' in reason - assert 'HI_RES_LOSSLESS' in reason - - -def test_hires_lossless_downgraded_to_mqa_hires_is_rejected(): - """User explicitly asked for HI_RES_LOSSLESS (true lossless HiRes). - Getting MQA-encoded HI_RES is a downgrade even though both are - "HiRes tier" marketing-wise — MQA is lossy.""" - stream = _FakeStream(audio_quality='HI_RES') - ok, reason = _verify_stream_tier(stream, QUALITY_MAP['hires'], 'hires') - assert ok is False - assert 'HI_RES_LOSSLESS' in reason - - -def test_lossless_downgraded_to_aac_is_rejected(): - stream = _FakeStream(audio_quality='HIGH') - ok, reason = _verify_stream_tier(stream, QUALITY_MAP['lossless'], 'lossless') - assert ok is False - assert 'LOSSLESS' in reason - - -# --------------------------------------------------------------------------- -# Unknown quality strings — reject conservatively -# --------------------------------------------------------------------------- - -def test_unknown_served_quality_is_rejected(): - """If Tidal introduces a new tier we haven't mapped yet, we can't - prove it's acceptable — reject rather than silently pass through, - so the next fallback tier gets a chance and the final diagnostic - log names the unknown value.""" - stream = _FakeStream(audio_quality='SPATIAL_360_DREAM_TIER') - ok, reason = _verify_stream_tier(stream, QUALITY_MAP['hires'], 'hires') - assert ok is False - assert 'SPATIAL_360_DREAM_TIER' in reason - assert 'unrecognized' in reason.lower() or 'can\'t verify' in reason.lower() - - -# --------------------------------------------------------------------------- -# Defensive — missing attributes must not spuriously fail downloads -# --------------------------------------------------------------------------- - -def test_stream_without_audio_quality_attr_is_accepted(): - """Older tidalapi versions may not expose audio_quality — treat as - "can't verify" and let pre-existing codec / file-size guards decide. - Better to miss a downgrade than break every Tidal download after a - library upgrade.""" - stream = _FakeStream() - assert not hasattr(stream, 'audio_quality') - ok, reason = _verify_stream_tier(stream, QUALITY_MAP['hires'], 'hires') - assert ok is True - assert reason is None - - -def test_quality_info_without_tidal_quality_is_accepted(): - """If QUALITY_MAP somehow lacks 'tidal_quality' (tidalapi failed to - import at module load), don't spuriously reject streams.""" - stream = _FakeStream(audio_quality='HI_RES_LOSSLESS') - ok, reason = _verify_stream_tier(stream, {'label': 'x'}, 'hires') - assert ok is True - assert reason is None diff --git a/web_server.py b/web_server.py index b118a7d2..58e88f0d 100644 --- a/web_server.py +++ b/web_server.py @@ -5071,7 +5071,7 @@ def get_status(): (download_mode == 'hybrid' and 'soulseek' in hybrid_order)) # Serverless sources (YouTube, HiFi, Qobuz) are always available - serverless_sources = ('youtube', 'hifi', 'qobuz') + serverless_sources = ('youtube', 'hifi', 'qobuz', 'tidal', 'deezer_dl') is_serverless = (download_mode in serverless_sources or (download_mode == 'hybrid' and hybrid_order and any(s in serverless_sources for s in hybrid_order))) @@ -23425,6 +23425,115 @@ def hifi_instances(): return jsonify({'error': str(e)}), 500 +@app.route('/api/hifi/instances', methods=['POST']) +@admin_only +def hifi_add_instance(): + """Add a new HiFi API instance.""" + try: + data = request.get_json() or {} + url = data.get('url', '').strip().rstrip('/') + if not url: + return jsonify({'success': False, 'error': 'URL is required'}), 400 + if not url.startswith(('http://', 'https://')): + return jsonify({'success': False, 'error': 'URL must start with http:// or https://'}), 400 + from database.music_database import get_database + db = get_database() + # Get current count to assign next priority + existing = db.get_all_hifi_instances() + priority = len(existing) + added = db.add_hifi_instance(url, priority) + if not added: + return jsonify({'success': False, 'error': 'Instance already exists'}), 400 + # Reload the client + if soulseek_client and hasattr(soulseek_client, 'hifi') and soulseek_client.hifi: + soulseek_client.hifi.reload_instances() + return jsonify({'success': True, 'url': url}) + except Exception as e: + logger.error(f"Error adding HiFi instance: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/hifi/instances', methods=['DELETE']) +@admin_only +def hifi_remove_instance(): + """Remove a HiFi API instance.""" + try: + url = (request.args.get('url') or '').strip().rstrip('/') + if not url: + return jsonify({'success': False, 'error': 'URL is required'}), 400 + from database.music_database import get_database + db = get_database() + removed = db.remove_hifi_instance(url) + if not removed: + return jsonify({'success': False, 'error': 'Instance not found'}), 404 + # Reload the client + if soulseek_client and hasattr(soulseek_client, 'hifi') and soulseek_client.hifi: + soulseek_client.hifi.reload_instances() + return jsonify({'success': True, 'url': url}) + except Exception as e: + logger.error(f"Error removing HiFi instance: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/hifi/instances/toggle', methods=['POST']) +@admin_only +def hifi_toggle_instance(): + """Toggle enabled state of a HiFi API instance.""" + try: + data = request.get_json() or {} + url = data.get('url', '').strip().rstrip('/') + enabled = data.get('enabled', True) + if not url: + return jsonify({'success': False, 'error': 'URL is required'}), 400 + from database.music_database import get_database + db = get_database() + db.toggle_hifi_instance(url, enabled) + if soulseek_client and hasattr(soulseek_client, 'hifi') and soulseek_client.hifi: + soulseek_client.hifi.reload_instances() + return jsonify({'success': True}) + except Exception as e: + logger.error(f"Error toggling HiFi instance: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/hifi/instances/reorder', methods=['POST']) +@admin_only +def hifi_reorder_instances(): + """Reorder HiFi API instances.""" + try: + data = request.get_json() or {} + urls = data.get('urls', []) + if not urls: + return jsonify({'success': False, 'error': 'URL list is required'}), 400 + from database.music_database import get_database + db = get_database() + if not db.reorder_hifi_instances(urls): + return jsonify({'success': False, 'error': 'One or more URLs not found'}), 400 + # Reload the client + if soulseek_client and hasattr(soulseek_client, 'hifi') and soulseek_client.hifi: + soulseek_client.hifi.reload_instances() + return jsonify({'success': True}) + except Exception as e: + logger.error(f"Error reordering HiFi instances: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/hifi/instances/list', methods=['GET']) +@admin_only +def hifi_list_instances(): + """Get editable list of HiFi API instances.""" + try: + from database.music_database import get_database + from core.hifi_client import DEFAULT_INSTANCES + db = get_database() + db.seed_hifi_instances(DEFAULT_INSTANCES) + instances = db.get_all_hifi_instances() + return jsonify({'instances': instances}) + except Exception as e: + logger.error(f"Error listing HiFi instances: {e}") + return jsonify({'error': str(e)}), 500 + + # =================================================================== # DEEZER DOWNLOAD ENDPOINTS # =================================================================== diff --git a/webui/index.html b/webui/index.html index 7bcab176..8721bde0 100644 --- a/webui/index.html +++ b/webui/index.html @@ -4622,8 +4622,13 @@
+
+
+ + +
- +
diff --git a/webui/static/settings.js b/webui/static/settings.js index fb47a9ec..4f2ade67 100644 --- a/webui/static/settings.js +++ b/webui/static/settings.js @@ -797,6 +797,7 @@ async function loadSettingsData() { document.getElementById('qobuz-allow-fallback').checked = settings.qobuz?.allow_fallback !== false; document.getElementById('hifi-download-quality').value = settings.hifi_download?.quality || 'lossless'; document.getElementById('hifi-allow-fallback').checked = settings.hifi_download?.allow_fallback !== false; + loadHiFiInstances(); document.getElementById('deezer-download-quality').value = settings.deezer_download?.quality || 'flac'; document.getElementById('deezer-allow-fallback').checked = settings.deezer_download?.allow_fallback !== false; document.getElementById('deezer-download-arl').value = settings.deezer_download?.arl || ''; @@ -1833,9 +1834,22 @@ function _genreWhitelistRender(genres) { const searchVal = (document.getElementById('genre-whitelist-search')?.value || '').toLowerCase(); const filtered = searchVal ? _genreWhitelistCache.filter(g => g.toLowerCase().includes(searchVal)) : _genreWhitelistCache; container.innerHTML = filtered.map(g => - `${escapeHtml(g)}` + `${escapeHtml(g)}` ).join(''); if (countEl) countEl.textContent = `${_genreWhitelistCache.length} genres`; + _initGenreChipClickHandler(); +} + +function _initGenreChipClickHandler() { + const container = document.getElementById('genre-whitelist-chips'); + if (!container) return; + container.onclick = (e) => { + const btn = e.target.closest('.genre-chip-x'); + if (btn) { + e.preventDefault(); + _genreWhitelistRemove(btn.dataset.genre); + } + }; } function _genreWhitelistRemove(genre) { @@ -2786,19 +2800,32 @@ function renderApiKeys(keys) { container.innerHTML = keys.map(k => `
-
${k.label || 'Unnamed'}
+
${escapeHtml(k.label || 'Unnamed')}
- ${k.key_prefix || 'sk_...'}... + ${escapeHtml(k.key_prefix || 'sk_...')}... · Created ${k.created_at ? new Date(k.created_at).toLocaleDateString() : 'unknown'} ${k.last_used_at ? '· Last used ' + new Date(k.last_used_at).toLocaleDateString() : ''}
-
`).join(''); + _initApiKeyClickHandler(); +} + +function _initApiKeyClickHandler() { + const container = document.getElementById('api-keys-list'); + if (!container) return; + container.onclick = (e) => { + const btn = e.target.closest('.revoke-api-key-btn'); + if (btn) { + e.preventDefault(); + revokeApiKey(btn.dataset.keyId, btn.dataset.keyLabel); + } + }; } async function generateApiKey() { @@ -3277,8 +3304,177 @@ async function testLidarrConnection() { } } +async function loadHiFiInstances() { + const listEl = document.getElementById('hifi-instances-list'); + if (!listEl) return; + try { + const resp = await fetch('/api/hifi/instances/list'); + const data = await resp.json(); + if (!data.instances || data.instances.length === 0) { + listEl.innerHTML = '
No instances configured.
'; + return; + } + listEl.innerHTML = data.instances.map((inst, i) => { + const enabledClass = inst.enabled ? '' : 'hifi-instance-disabled'; + const checkHtml = inst.enabled + ? `` + : ``; + return `
+ + ${escapeHtml(inst.url)} + ${checkHtml} + +
`; + }).join(''); + _initHiFiDragDrop(); + _initHiFiClickHandlers(); + } catch (e) { + listEl.innerHTML = `
Error loading instances: ${escapeHtml(e.message)}
`; + } +} + +function _initHiFiDragDrop() { + const listEl = document.getElementById('hifi-instances-list'); + if (!listEl) return; + let dragIdx = null; + + listEl.querySelectorAll('.hifi-instance-item').forEach((item, idx) => { + item.addEventListener('dragstart', (e) => { + dragIdx = idx; + item.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + }); + item.addEventListener('dragend', () => { + item.classList.remove('dragging'); + dragIdx = null; + listEl.querySelectorAll('.hifi-instance-item').forEach(i => i.classList.remove('drag-over')); + }); + item.addEventListener('dragover', (e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + item.classList.add('drag-over'); + }); + item.addEventListener('dragleave', () => { + item.classList.remove('drag-over'); + }); + item.addEventListener('drop', async (e) => { + e.preventDefault(); + item.classList.remove('drag-over'); + if (dragIdx === null) return; + const items = [...listEl.querySelectorAll('.hifi-instance-item')]; + const dragged = items[dragIdx]; + if (dragIdx !== idx) { + if (dragIdx < idx) { + item.after(dragged); + } else { + item.before(dragged); + } + const urls = [...listEl.querySelectorAll('.hifi-instance-item')].map(el => el.dataset.url); + await _saveHiFiInstanceOrder(urls); + } + }); + }); +} + +function _initHiFiClickHandlers() { + const listEl = document.getElementById('hifi-instances-list'); + if (!listEl) return; + listEl.onclick = (e) => { + const toggle = e.target.closest('.hifi-instance-toggle'); + if (toggle) { + e.preventDefault(); + toggleHiFiInstance(toggle.dataset.url); + return; + } + const remove = e.target.closest('.hifi-instance-remove'); + if (remove) { + e.preventDefault(); + removeHiFiInstance(remove.dataset.url); + } + }; +} + +async function _saveHiFiInstanceOrder(urls) { + try { + await fetch('/api/hifi/instances/reorder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ urls }) + }); + } catch (e) { + console.error('Failed to save HiFi instance order:', e); + } +} + +async function toggleHiFiInstance(url) { + const listEl = document.getElementById('hifi-instances-list'); + if (!listEl) return; + const item = listEl.querySelector(`.hifi-instance-item[data-url="${url}"]`); + const toggle = item?.querySelector('.hifi-instance-toggle'); + const currentlyEnabled = toggle?.classList.contains('on'); + const newEnabled = !currentlyEnabled; + try { + const resp = await fetch('/api/hifi/instances/toggle', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url, enabled: newEnabled }) + }); + const data = await resp.json(); + if (data.success) { + loadHiFiInstances(); + } else { + alert(data.error || 'Failed to toggle instance'); + } + } catch (e) { + alert(`Error: ${e.message}`); + } +} + +async function addHiFiInstance() { + const input = document.getElementById('hifi-new-instance'); + if (!input) return; + const url = input.value.trim(); + if (!url) return; + if (!url.startsWith('http://') && !url.startsWith('https://')) { + alert('URL must start with http:// or https://'); + return; + } + try { + const resp = await fetch('/api/hifi/instances', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }) + }); + const data = await resp.json(); + if (data.success) { + input.value = ''; + loadHiFiInstances(); + } else { + alert(data.error || 'Failed to add instance'); + } + } catch (e) { + alert(`Error: ${e.message}`); + } +} + +async function removeHiFiInstance(url) { + try { + const resp = await fetch(`/api/hifi/instances?url=${encodeURIComponent(url)}`, { + method: 'DELETE' + }); + const data = await resp.json(); + if (data.success) { + loadHiFiInstances(); + } else { + alert(data.error || 'Failed to remove instance'); + } + } catch (e) { + alert(`Error: ${e.message}`); + } +} + async function checkHiFiInstances() { - const panel = document.getElementById('hifi-instances-panel'); + const panel = document.getElementById('hifi-instances-status-panel'); const btn = document.getElementById('hifi-instances-check-btn'); if (!panel) return; panel.style.display = 'block'; diff --git a/webui/static/style.css b/webui/static/style.css index 054e9d94..a25df5ed 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -60262,3 +60262,80 @@ body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-bt #artist-detail-page .release-card.album-card .mb-card-icon:hover { opacity: 1; } + +/* ── HiFi API instances list (drag and drop) ── */ +#hifi-instances-list { + display: flex; + flex-direction: column; + gap: 4px; +} +.hifi-instance-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 8px; + font-size: 0.82em; + user-select: none; + transition: all 0.2s; + cursor: grab; +} +.hifi-instance-item:hover { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.1); +} +.hifi-instance-item.dragging { + opacity: 0.4; + border-color: rgb(var(--accent-rgb)); +} +.hifi-instance-item.drag-over { + border-color: rgb(var(--accent-rgb)); + background: rgba(var(--accent-rgb), 0.08); +} +.hifi-instance-item.disabled { + opacity: 0.4; +} +.hifi-instance-grip { + color: rgba(255, 255, 255, 0.3); + cursor: grab; + flex-shrink: 0; + font-size: 1em; +} +.hifi-instance-url { + flex: 1; + color: rgba(255, 255, 255, 0.7); + font-family: monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.hifi-instance-toggle { + flex-shrink: 0; + cursor: pointer; + font-size: 0.9em; + transition: color 0.15s; +} +.hifi-instance-toggle.on { + color: #4caf50; +} +.hifi-instance-toggle.on:hover { + color: #66bb6a; +} +.hifi-instance-toggle.off { + color: #666; +} +.hifi-instance-toggle.off:hover { + color: #999; +} +.hifi-instance-remove { + flex-shrink: 0; + color: #f44336; + cursor: pointer; + font-size: 0.9em; + transition: color 0.15s; +} +.hifi-instance-remove:hover { + color: #ef5350; +}