From ec389c5ae8b92d58818060ad7de39d923cc2954c Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:53:34 -0700 Subject: [PATCH] Add HiFi as free lossless download source via public hifi-api instances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New download mode alongside Soulseek, YouTube, Tidal, and Qobuz. Uses community-run REST API instances (no auth required) that serve Tidal CDN FLAC streams. Features quality fallback chain (hires→lossless→high→low), automatic instance rotation on failure, and full hybrid mode support. Also fixes 6 missing streaming source checks for HiFi and Qobuz in the frontend that were blocking playback with "format not supported" errors. --- config/settings.py | 5 +- core/download_orchestrator.py | 50 +- core/hifi_client.py | 724 +++++++++++++++++++ core/matching_engine.py | 2 +- test_hifi_client.py | 1271 +++++++++++++++++++++++++++++++++ web_server.py | 60 +- webui/index.html | 31 + webui/static/script.js | 48 +- 8 files changed, 2155 insertions(+), 36 deletions(-) create mode 100644 core/hifi_client.py create mode 100644 test_hifi_client.py diff --git a/config/settings.py b/config/settings.py index 339baf4c..44eb9516 100644 --- a/config/settings.py +++ b/config/settings.py @@ -363,7 +363,7 @@ class ConfigManager: "transfer_path": "./Transfer" }, "download_source": { - "mode": "soulseek", # Options: "soulseek", "youtube", "tidal", "qobuz", "hybrid" + "mode": "soulseek", # Options: "soulseek", "youtube", "tidal", "qobuz", "hifi", "hybrid" "hybrid_primary": "soulseek", # Which source to try first in hybrid mode "hybrid_secondary": "youtube", # Fallback source if primary finds nothing }, @@ -384,6 +384,9 @@ class ConfigManager: "user_auth_token": "" } }, + "hifi_download": { + "quality": "lossless", # Options: "low", "high", "lossless", "hires" + }, "listenbrainz": { "base_url": "", "token": "" diff --git a/core/download_orchestrator.py b/core/download_orchestrator.py index c4b6081c..afe475d8 100644 --- a/core/download_orchestrator.py +++ b/core/download_orchestrator.py @@ -1,12 +1,13 @@ """ Download Orchestrator -Routes downloads between Soulseek, YouTube, Tidal, and Qobuz based on configuration. +Routes downloads between Soulseek, YouTube, Tidal, Qobuz, and HiFi based on configuration. -Supports five modes: +Supports six modes: - Soulseek Only: Traditional behavior - YouTube Only: YouTube-exclusive downloads - Tidal Only: Tidal-exclusive downloads - Qobuz Only: Qobuz-exclusive downloads +- HiFi Only: Free lossless downloads via public hifi-api instances - Hybrid: Try primary source first, fallback to others """ @@ -20,13 +21,14 @@ from core.soulseek_client import SoulseekClient, TrackResult, AlbumResult, Downl from core.youtube_client import YouTubeClient from core.tidal_download_client import TidalDownloadClient from core.qobuz_client import QobuzClient +from core.hifi_client import HiFiClient logger = get_logger("download_orchestrator") class DownloadOrchestrator: """ - Orchestrates downloads between Soulseek, YouTube, Tidal, and Qobuz based on user preferences. + Orchestrates downloads between Soulseek, YouTube, Tidal, Qobuz, and HiFi based on user preferences. Acts as a drop-in replacement for SoulseekClient by exposing the same async interface. Routes requests to the appropriate client(s) based on configured mode. @@ -38,6 +40,7 @@ class DownloadOrchestrator: self.youtube = YouTubeClient() self.tidal = TidalDownloadClient() self.qobuz = QobuzClient() + self.hifi = HiFiClient() # Load mode from config self.mode = config_manager.get('download_source.mode', 'soulseek') @@ -74,8 +77,10 @@ class DownloadOrchestrator: return self.tidal.is_configured() elif self.mode == 'qobuz': return self.qobuz.is_configured() + elif self.mode == 'hifi': + return self.hifi.is_configured() elif self.mode == 'hybrid': - return self.soulseek.is_configured() or self.youtube.is_configured() or self.tidal.is_configured() or self.qobuz.is_configured() + return self.soulseek.is_configured() or self.youtube.is_configured() or self.tidal.is_configured() or self.qobuz.is_configured() or self.hifi.is_configured() return False @@ -93,15 +98,18 @@ class DownloadOrchestrator: return await self.tidal.check_connection() elif self.mode == 'qobuz': return await self.qobuz.check_connection() + elif self.mode == 'hifi': + return await self.hifi.check_connection() elif self.mode == 'hybrid': soulseek_ok = await self.soulseek.check_connection() youtube_ok = await self.youtube.check_connection() tidal_ok = await self.tidal.check_connection() qobuz_ok = await self.qobuz.check_connection() + hifi_ok = await self.hifi.check_connection() - logger.info(f" Soulseek: {'✅' if soulseek_ok else '❌'} | YouTube: {'✅' if youtube_ok else '❌'} | Tidal: {'✅' if tidal_ok else '❌'} | Qobuz: {'✅' if qobuz_ok else '❌'}") + logger.info(f" Soulseek: {'✅' if soulseek_ok else '❌'} | YouTube: {'✅' if youtube_ok else '❌'} | Tidal: {'✅' if tidal_ok else '❌'} | Qobuz: {'✅' if qobuz_ok else '❌'} | HiFi: {'✅' if hifi_ok else '❌'}") - return soulseek_ok or youtube_ok or tidal_ok or qobuz_ok + return soulseek_ok or youtube_ok or tidal_ok or qobuz_ok or hifi_ok return False @@ -133,12 +141,17 @@ class DownloadOrchestrator: logger.info(f"🔍 Searching Qobuz: {query}") return await self.qobuz.search(query, timeout, progress_callback) + elif self.mode == 'hifi': + logger.info(f"🔍 Searching HiFi: {query}") + return await self.hifi.search(query, timeout, progress_callback) + elif self.mode == 'hybrid': clients = { 'soulseek': self.soulseek, 'youtube': self.youtube, 'tidal': self.tidal, 'qobuz': self.qobuz, + 'hifi': self.hifi, } primary = self.hybrid_primary if self.hybrid_primary in clients else 'soulseek' secondary = self.hybrid_secondary if self.hybrid_secondary in clients else 'soulseek' @@ -195,7 +208,7 @@ class DownloadOrchestrator: # 2. Filter using Soulseek's quality preferences (Soulseek only) # Streaming sources (YouTube/Tidal/Qobuz) handle quality internally - is_streaming = tracks[0].username in ('youtube', 'tidal', 'qobuz') if tracks else False + is_streaming = tracks[0].username in ('youtube', 'tidal', 'qobuz', 'hifi') if tracks else False if is_streaming: filtered_results = tracks else: @@ -239,6 +252,9 @@ class DownloadOrchestrator: elif username == 'qobuz': logger.info(f"📥 Downloading from Qobuz: {filename}") return await self.qobuz.download(username, filename, file_size) + elif username == 'hifi': + logger.info(f"📥 Downloading from HiFi: {filename}") + return await self.hifi.download(username, filename, file_size) else: logger.info(f"📥 Downloading from Soulseek: {filename}") return await self.soulseek.download(username, filename, file_size) @@ -255,8 +271,9 @@ class DownloadOrchestrator: youtube_downloads = await self.youtube.get_all_downloads() tidal_downloads = await self.tidal.get_all_downloads() qobuz_downloads = await self.qobuz.get_all_downloads() + hifi_downloads = await self.hifi.get_all_downloads() - return soulseek_downloads + youtube_downloads + tidal_downloads + qobuz_downloads + return soulseek_downloads + youtube_downloads + tidal_downloads + qobuz_downloads + hifi_downloads async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]: """ @@ -288,6 +305,11 @@ class DownloadOrchestrator: if status: return status + # Try HiFi + status = await self.hifi.get_download_status(download_id) + if status: + return status + return None async def cancel_download(self, download_id: str, username: str = None, remove: bool = False) -> bool: @@ -309,6 +331,8 @@ class DownloadOrchestrator: return await self.tidal.cancel_download(download_id, username, remove) elif username == 'qobuz': return await self.qobuz.cancel_download(download_id, username, remove) + elif username == 'hifi': + return await self.hifi.cancel_download(download_id, username, remove) elif username: return await self.soulseek.cancel_download(download_id, username, remove) @@ -326,7 +350,11 @@ class DownloadOrchestrator: return True qobuz_cancelled = await self.qobuz.cancel_download(download_id, username, remove) - return qobuz_cancelled + if qobuz_cancelled: + return True + + hifi_cancelled = await self.hifi.cancel_download(download_id, username, remove) + return hifi_cancelled async def signal_download_completion(self, download_id: str, username: str, remove: bool = True) -> bool: """ @@ -354,8 +382,9 @@ class DownloadOrchestrator: youtube_cleared = await self.youtube.clear_all_completed_downloads() tidal_cleared = await self.tidal.clear_all_completed_downloads() qobuz_cleared = await self.qobuz.clear_all_completed_downloads() + hifi_cleared = await self.hifi.clear_all_completed_downloads() - return soulseek_cleared and youtube_cleared and tidal_cleared and qobuz_cleared + return soulseek_cleared and youtube_cleared and tidal_cleared and qobuz_cleared and hifi_cleared # ===== Soulseek-specific methods (for backwards compatibility) ===== # These are internal methods that some parts of the codebase use directly @@ -417,4 +446,5 @@ class DownloadOrchestrator: soulseek_ok = await self.soulseek.cancel_all_downloads() await self.tidal.clear_all_completed_downloads() await self.qobuz.clear_all_completed_downloads() + await self.hifi.clear_all_completed_downloads() return soulseek_ok diff --git a/core/hifi_client.py b/core/hifi_client.py new file mode 100644 index 00000000..8048be0e --- /dev/null +++ b/core/hifi_client.py @@ -0,0 +1,724 @@ +""" +HiFi API Client — Alternative lossless download source via public hifi-api instances. + +Provides Tidal-sourced FLAC downloads (16-bit and 24-bit) through the open hifi-api +project. No authentication required from the client — the API instances handle +Tidal credentials internally. + +Interface follows the same patterns as TidalDownloadClient for drop-in compatibility +with the existing download infrastructure (TrackResult, DownloadStatus, etc). + +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 +- Multiple API instance failover +""" + +import os +import re +import json +import base64 +import uuid +import time +import threading +from typing import List, Optional, Dict, Any, Tuple +from pathlib import Path + +import requests as http_requests + +from utils.logging_config import get_logger +from config.settings import config_manager +from core.soulseek_client import TrackResult, AlbumResult, DownloadStatus + +logger = get_logger("hifi_client") + +# Quality tiers matching Tidal's internal quality labels +HIFI_QUALITY_MAP = { + 'hires': { + 'api_value': 'HI_RES_LOSSLESS', + 'label': 'FLAC 24-bit/96kHz', + 'extension': 'flac', + 'bitrate': 9216, + 'codec': 'flac', + }, + 'lossless': { + 'api_value': 'LOSSLESS', + 'label': 'FLAC 16-bit/44.1kHz', + 'extension': 'flac', + 'bitrate': 1411, + 'codec': 'flac', + }, + 'high': { + 'api_value': 'HIGH', + 'label': 'AAC 320kbps', + 'extension': 'm4a', + 'bitrate': 320, + 'codec': 'aac', + }, + 'low': { + 'api_value': 'LOW', + 'label': 'AAC 96kbps', + 'extension': 'm4a', + 'bitrate': 96, + 'codec': 'aac', + }, +} + +# Default public hifi-api instances (ordered by preference) +DEFAULT_INSTANCES = [ + 'https://triton.squid.wtf', + 'https://hifi-one.spotisaver.net', + 'https://hifi-two.spotisaver.net', + 'https://hund.qqdl.site', + 'https://katze.qqdl.site', + 'https://arran.monochrome.tf', +] + + +class HiFiClient: + """ + HiFi API client for searching and downloading lossless music. + Uses public hifi-api instances (Tidal backend) — no auth required. + """ + + 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._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 + + 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 _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) + self._instances.append(failed_url) + if self._instances: + self._current_instance = self._instances[0] + logger.info(f"Rotated to HiFi instance: {self._current_instance}") + else: + 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 + if elapsed < self._min_interval: + time.sleep(self._min_interval - elapsed) + 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: + instance = self._get_instance() + if not instance or instance in tried: + logger.error("All HiFi API instances exhausted") + return None + + tried.add(instance) + url = f"{instance}{path}" + self._rate_limit() + + try: + response = self.session.get(url, params=params, timeout=timeout) + 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 + + return data + + except http_requests.exceptions.Timeout: + logger.warning(f"HiFi API timeout: {instance}") + self._rotate_instance(instance) + except http_requests.exceptions.ConnectionError: + logger.warning(f"HiFi API connection error: {instance}") + self._rotate_instance(instance) + except http_requests.exceptions.HTTPError as e: + status = e.response.status_code if e.response is not None else 0 + if status in (502, 503, 504): + logger.warning(f"HiFi API server error ({status}): {instance}") + self._rotate_instance(instance) + else: + logger.error(f"HiFi API HTTP error ({status}): {e}") + return None + except Exception as e: + 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 + except Exception: + 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() + return await loop.run_in_executor(None, self.is_available) + except Exception as e: + logger.error(f"HiFi connection check failed: {e}") + 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 + if artist: + params['a'] = artist + if album: + params['al'] = album + + if not any(k in params for k in ('s', 'a', 'al')): + logger.warning("search_tracks called with no search terms") + return [] + + data = self._api_get('/search/', params=params) + if not data: + return [] + + # Handle response format: {data: {items: [...]}} or {data: [...]} + items = [] + if isinstance(data, dict): + inner = data.get('data', data) + if isinstance(inner, dict): + items = inner.get('items', inner.get('tracks', [])) + elif isinstance(inner, list): + items = inner + + results = [] + for item in items: + try: + results.append(self._parse_track(item)) + except Exception as e: + logger.debug(f"Skipping unparseable track: {e}") + + logger.info(f"HiFi search: {len(results)} tracks found " + f"(title={title}, artist={artist}, album={album})") + 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): + names = [] + for a in artists_raw: + if isinstance(a, dict): + names.append(a.get('name', '')) + elif isinstance(a, str): + names.append(a) + artist_name = ', '.join(n for n in names if n) or 'Unknown Artist' + elif isinstance(artists_raw, dict): + artist_name = artists_raw.get('name', 'Unknown Artist') + elif isinstance(artists_raw, str): + artist_name = artists_raw + + # Album + album_raw = item.get('album', {}) + album_name = '' + if isinstance(album_raw, dict): + album_name = album_raw.get('title', album_raw.get('name', '')) + 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 + + return { + 'id': item.get('id'), + 'title': item.get('title', item.get('name', 'Unknown')), + 'artist': artist_name, + 'album': album_name, + 'duration_ms': int(duration_ms) if duration_ms else 0, + 'track_number': item.get('trackNumber', item.get('track_number')), + 'isrc': item.get('isrc'), + 'explicit': item.get('explicit', False), + '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 + + inner = data.get('data', data) if isinstance(data, dict) else data + if isinstance(inner, dict): + 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 + + inner = data.get('data', data) if isinstance(data, dict) else data + 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: + try: + tracks.append(self._parse_track(item)) + except Exception as e: + logger.debug(f"Skipping album track: {e}") + + return { + 'id': inner.get('id', album_id), + 'title': inner.get('title', inner.get('name', 'Unknown Album')), + 'artist': inner.get('artist', {}).get('name', '') if isinstance(inner.get('artist'), dict) else str(inner.get('artist', '')), + 'tracks': tracks, + 'track_count': inner.get('numberOfTracks', len(tracks)), + 'duration_s': inner.get('duration', 0), + 'release_date': inner.get('releaseDate', ''), + 'cover_id': inner.get('cover', ''), + } + + 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 + + inner = data.get('data', data) if isinstance(data, dict) else data + return inner if isinstance(inner, dict) else None + + # ===================== Soulseek-Compatible Search ===================== + + 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: + loop = asyncio.get_event_loop() + 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']) + + results = [] + for t in tracks: + try: + tr = self._to_track_result(t, q_info) + results.append(tr) + except Exception as e: + logger.debug(f"Skipping track result conversion: {e}") + + return (results, []) + + except Exception as e: + logger.error(f"HiFi compatible search failed: {e}") + 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}" + + return TrackResult( + username='hifi', + filename=filename, + size=0, + bitrate=quality_info.get('bitrate'), + duration=track.get('duration_ms'), + quality=quality_info.get('codec', 'flac'), + free_upload_slots=999, + upload_speed=999999, + queue_length=0, + artist=track.get('artist'), + title=track.get('title'), + album=track.get('album'), + 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}") + return None + + track_id_str, display_name = filename.split('||', 1) + try: + track_id = int(track_id_str) + except ValueError: + logger.error(f"Invalid track ID: {track_id_str}") + return None + + download_id = str(uuid.uuid4()) + + with self._download_lock: + self.active_downloads[download_id] = { + 'id': download_id, + 'filename': filename, + 'username': 'hifi', + 'state': 'Initializing', + 'progress': 0.0, + 'size': 0, + 'transferred': 0, + 'speed': 0, + 'time_remaining': None, + 'track_id': track_id, + 'display_name': display_name, + 'file_path': None, + } + + thread = threading.Thread( + target=self._download_worker, + args=(download_id, track_id, display_name), + daemon=True, + ) + thread.start() + + return download_id + + except Exception as e: + logger.error(f"Failed to start HiFi download: {e}") + 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: + self.active_downloads[download_id]['state'] = 'InProgress, Downloading' + + file_path = self._download_sync(download_id, track_id, display_name) + + with self._download_lock: + if download_id in self.active_downloads: + if file_path: + self.active_downloads[download_id]['state'] = 'Completed, Succeeded' + self.active_downloads[download_id]['progress'] = 100.0 + self.active_downloads[download_id]['file_path'] = file_path + else: + self.active_downloads[download_id]['state'] = 'Errored' + + except Exception as e: + logger.error(f"HiFi download worker failed for {download_id}: {e}") + with self._download_lock: + if download_id in self.active_downloads: + self.active_downloads[download_id]['state'] = 'Errored' + + def _download_sync(self, download_id: str, track_id: int, display_name: str) -> Optional[str]: + """ + Synchronous download 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 + chain = chain[start:] + + MIN_AUDIO_SIZE = 100 * 1024 # 100KB + + 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") + 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 + 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() + + total_size = int(response.headers.get('content-length', 0)) + downloaded = 0 + chunk_size = 64 * 1024 + speed_start = time.time() + last_speed_update = speed_start + + 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(): + 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 + + # 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 + + 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 + + except Exception as e: + logger.warning(f"Download failed at quality {q_key}: {e}") + out_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) + continue + + logger.info(f"HiFi download complete ({q_key}): {out_path} " + f"({downloaded / (1024*1024):.1f} MB)") + return str(out_path) + + logger.error(f"All quality tiers exhausted for '{display_name}'") + return None + + # ===================== Status / Cancel / Clear ===================== + + 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(): + statuses.append(DownloadStatus( + id=info['id'], + filename=info['filename'], + username=info['username'], + state=info['state'], + progress=info['progress'], + size=info['size'], + transferred=info['transferred'], + speed=info['speed'], + time_remaining=info.get('time_remaining'), + file_path=info.get('file_path'), + )) + 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: + return None + return DownloadStatus( + id=info['id'], + filename=info['filename'], + username=info['username'], + state=info['state'], + progress=info['progress'], + size=info['size'], + transferred=info['transferred'], + speed=info['speed'], + time_remaining=info.get('time_remaining'), + file_path=info.get('file_path'), + ) + + async def cancel_download(self, download_id: str, username: str = None, + remove: bool = False) -> bool: + """Cancel an active download.""" + with self._download_lock: + if download_id not in self.active_downloads: + return False + self.active_downloads[download_id]['state'] = 'Cancelled' + if remove: + del self.active_downloads[download_id] + 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() + if info.get('state', '') in ('Completed, Succeeded', 'Cancelled', 'Errored', 'Aborted') + ] + for did in to_remove: + del self.active_downloads[did] + return True diff --git a/core/matching_engine.py b/core/matching_engine.py index eaa21002..905a1d7a 100644 --- a/core/matching_engine.py +++ b/core/matching_engine.py @@ -440,7 +440,7 @@ class MusicMatchingEngine: # Often YouTube videos are titled "Artist - Album - Title" or similar # Only include if mode is youtube or hybrid (safe for Soulseek default) download_mode = config_manager.get('download_source.mode', 'soulseek') - if download_mode in ['youtube', 'hybrid'] and album_name and album_name.lower() not in ['single', 'ep', 'greatest hits']: + if download_mode in ['youtube', 'tidal', 'qobuz', 'hifi', 'hybrid'] and album_name and album_name.lower() not in ['single', 'ep', 'greatest hits']: album_clean = self.clean_album_name(album_name) if album_clean: # Standard query: Artist Album Title diff --git a/test_hifi_client.py b/test_hifi_client.py new file mode 100644 index 00000000..8cba1f2a --- /dev/null +++ b/test_hifi_client.py @@ -0,0 +1,1271 @@ +""" +Comprehensive tests for HiFi API Client (core/hifi_client.py). + +Tests the client against live public hifi-api instances to validate: +- Instance availability and API versioning +- Track search (by title, artist, album, combined queries) +- Stream URL retrieval and base64 manifest decoding +- Album lookup and track listing +- Artist lookup +- Quality tier selection and fallback chain +- Instance failover when one goes down +- TrackResult / DownloadStatus compatibility with Soulseek interfaces +- Actual file download (small test track) +- Rate limiting behavior +- Error handling (bad IDs, empty queries, malformed data) +- Download lifecycle: start → progress → complete/cancel/error + +Usage: + python test_hifi_client.py # Run all tests + python test_hifi_client.py -k search # Run only search tests + python test_hifi_client.py -v # Verbose output +""" + +import os +import sys +import json +import time +import asyncio +import tempfile +import shutil +import threading +from pathlib import Path +from unittest.mock import patch, MagicMock +from dataclasses import dataclass + +import pytest + +# Add project root to path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from core.hifi_client import ( + HiFiClient, + HIFI_QUALITY_MAP, + DEFAULT_INSTANCES, +) +from core.soulseek_client import TrackResult, AlbumResult, DownloadStatus + + +# ===================== Fixtures ===================== + +@pytest.fixture(scope="session") +def temp_download_dir(): + """Create a temporary download directory for the entire test session.""" + d = tempfile.mkdtemp(prefix="hifi_test_") + yield d + shutil.rmtree(d, ignore_errors=True) + + +@pytest.fixture +def client(temp_download_dir): + """Create a fresh HiFiClient for each test.""" + c = HiFiClient(download_path=temp_download_dir) + return c + + +@pytest.fixture(scope="session") +def shared_client(temp_download_dir): + """Shared client for read-only tests (avoids excessive instance creation).""" + return HiFiClient(download_path=temp_download_dir) + + +@pytest.fixture +def event_loop(): + """Create an event loop for async tests.""" + loop = asyncio.new_event_loop() + yield loop + loop.close() + + +def run_async(coro): + """Helper to run an async function synchronously.""" + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +# ===================== 1. Instance & Availability Tests ===================== + +class TestInstanceManagement: + """Tests for API instance management and availability.""" + + def test_01_default_instances_populated(self, client): + """Client should have default instances loaded.""" + assert len(client._instances) >= 1 + assert client._current_instance is not None + + def test_02_custom_instance_priority(self, temp_download_dir): + """Custom base_url should be first in instance list.""" + custom = "https://my-custom-instance.example.com" + c = HiFiClient(download_path=temp_download_dir, base_url=custom) + assert c._instances[0] == custom + assert c._current_instance == custom + + def test_03_custom_instance_trailing_slash(self, temp_download_dir): + """Trailing slash should be stripped from custom instance URL.""" + custom = "https://my-custom-instance.example.com/" + c = HiFiClient(download_path=temp_download_dir, base_url=custom) + assert c._instances[0] == "https://my-custom-instance.example.com" + + def test_04_is_available(self, shared_client): + """At least one public instance should be reachable.""" + assert shared_client.is_available(), "No HiFi API instance is reachable" + + def test_05_get_version(self, shared_client): + """Should return a version string from the API.""" + version = shared_client.get_version() + # version may be None if endpoint doesn't expose it, that's okay + # but the call shouldn't crash + if version: + assert isinstance(version, str) + + def test_06_instance_rotation(self, temp_download_dir): + """Rotating an instance should move it to the back.""" + c = HiFiClient(download_path=temp_download_dir) + first = c._instances[0] + second = c._instances[1] if len(c._instances) > 1 else None + + c._rotate_instance(first) + + assert c._instances[-1] == first + if second: + assert c._current_instance == second + + def test_07_rotate_nonexistent_instance(self, client): + """Rotating a URL not in the list shouldn't crash.""" + original_first = client._current_instance + client._rotate_instance("https://nonexistent.example.com") + assert client._current_instance == original_first + + def test_08_all_instances_exhausted(self, temp_download_dir): + """_api_get should return None when all instances fail.""" + c = HiFiClient(download_path=temp_download_dir) + c._instances = ["https://definitely-not-real-1.invalid", "https://definitely-not-real-2.invalid"] + c._current_instance = c._instances[0] + c._min_interval = 0 # No rate limiting in test + + result = c._api_get("/", timeout=3) + assert result is None + + +# ===================== 2. Rate Limiting Tests ===================== + +class TestRateLimiting: + """Tests for rate limiting between API calls.""" + + def test_09_rate_limit_enforces_interval(self, temp_download_dir): + """Rate limiting should enforce minimum interval between calls.""" + c = HiFiClient(download_path=temp_download_dir) + c._min_interval = 0.3 + + start = time.time() + c._rate_limit() + c._rate_limit() + elapsed = time.time() - start + + assert elapsed >= 0.25, f"Rate limiting should enforce ~0.3s gap, got {elapsed:.3f}s" + + def test_10_rate_limit_first_call_no_delay(self, temp_download_dir): + """First API call shouldn't have artificial delay.""" + c = HiFiClient(download_path=temp_download_dir) + c._min_interval = 1.0 + c._last_api_call = 0 # Reset + + start = time.time() + c._rate_limit() + elapsed = time.time() - start + + assert elapsed < 0.1, f"First call should be instant, took {elapsed:.3f}s" + + +# ===================== 3. Search Tests ===================== + +class TestSearch: + """Tests for track search functionality.""" + + def test_11_search_by_title(self, shared_client): + """Search by title should return results.""" + results = shared_client.search_tracks(title="Bohemian Rhapsody") + assert len(results) > 0, "Expected results for 'Bohemian Rhapsody'" + assert results[0]['title'], "Track should have a title" + + def test_12_search_by_artist(self, shared_client): + """Search by artist alone may return empty (API returns artist objects, not tracks). + This test validates it doesn't crash.""" + results = shared_client.search_tracks(artist="Queen") + # Artist-only search hits /search/?a=Queen which returns artist objects, + # not tracks — so 0 results is expected behavior. + assert isinstance(results, list) + + def test_13_search_by_title_and_artist(self, shared_client): + """Combined title + artist search should return relevant results.""" + results = shared_client.search_tracks(title="Stairway to Heaven", artist="Led Zeppelin") + assert len(results) > 0, "Expected results for 'Stairway to Heaven' by Led Zeppelin" + + # Check that at least one result mentions the artist + found_artist = False + for r in results: + if 'led zeppelin' in r.get('artist', '').lower(): + found_artist = True + break + assert found_artist, "Expected at least one result from Led Zeppelin" + + def test_14_search_by_album(self, shared_client): + """Search by album alone may return empty (API returns album objects, not tracks). + This test validates it doesn't crash.""" + results = shared_client.search_tracks(album="Dark Side of the Moon") + # Album-only search hits /search/?al=... which returns album objects, + # not tracks — so 0 results is expected behavior. + assert isinstance(results, list) + + def test_15_search_limit(self, shared_client): + """Search should respect the limit parameter.""" + results = shared_client.search_tracks(title="Love", limit=5) + assert len(results) <= 5, f"Expected ≤5 results, got {len(results)}" + + def test_16_search_no_terms(self, client): + """Search with no terms should return empty list.""" + results = client.search_tracks() + assert results == [] + + def test_17_search_gibberish(self, shared_client): + """Search for gibberish should return empty or handle gracefully.""" + results = shared_client.search_tracks(title="xzqwkjhgf9876zzz") + assert isinstance(results, list) + + def test_18_search_generic(self, shared_client): + """Generic search_raw() should call search_tracks with title.""" + results = shared_client.search_raw("Never Gonna Give You Up") + assert len(results) > 0 + + def test_19_search_result_fields(self, shared_client): + """Search results should have all expected fields.""" + results = shared_client.search_tracks(title="Yesterday", artist="Beatles") + assert len(results) > 0 + + track = results[0] + required_fields = ['id', 'title', 'artist', 'album', 'duration_ms'] + for field in required_fields: + assert field in track, f"Missing field: {field}" + + assert track['id'] is not None, "Track ID should not be None" + assert isinstance(track['title'], str) + assert isinstance(track['artist'], str) + + def test_20_search_duration_is_milliseconds(self, shared_client): + """Duration should be in milliseconds (typically > 30000 for a normal song).""" + results = shared_client.search_tracks(title="Bohemian Rhapsody", artist="Queen") + if results: + track = results[0] + duration = track.get('duration_ms', 0) + # Bohemian Rhapsody is ~6 minutes = ~360000ms + if duration > 0: + assert duration > 10000, f"Duration {duration}ms seems too low — might be in seconds" + + def test_21_search_special_characters(self, shared_client): + """Search should handle special characters.""" + results = shared_client.search_tracks(title="What's Going On", artist="Marvin Gaye") + assert isinstance(results, list) + + def test_22_search_unicode(self, shared_client): + """Search should handle unicode characters.""" + results = shared_client.search_tracks(title="Für Elise") + assert isinstance(results, list) + + def test_23_search_very_long_query(self, shared_client): + """Very long query should not crash.""" + results = shared_client.search_tracks(title="A" * 500) + assert isinstance(results, list) + + +# ===================== 4. Track Info Tests ===================== + +class TestTrackInfo: + """Tests for individual track info retrieval.""" + + def _get_test_track_id(self, client): + """Helper: search for a track and return its ID.""" + results = client.search_tracks(title="Bohemian Rhapsody", artist="Queen") + if results: + return results[0]['id'] + return None + + def test_24_get_track_info(self, shared_client): + """Should return track info for a valid ID.""" + track_id = self._get_test_track_id(shared_client) + if not track_id: + pytest.skip("No search results to get a track ID") + + info = shared_client.get_track_info(track_id) + assert info is not None, f"Expected track info for ID {track_id}" + assert info.get('title'), "Track info should have a title" + assert info.get('artist'), "Track info should have an artist" + + def test_25_get_track_info_invalid_id(self, shared_client): + """Invalid track ID should return None, not crash.""" + info = shared_client.get_track_info(99999999999) + # May return None or an error — just shouldn't raise + assert info is None or isinstance(info, dict) + + def test_26_get_track_info_zero_id(self, shared_client): + """Zero ID should handle gracefully.""" + info = shared_client.get_track_info(0) + assert info is None or isinstance(info, dict) + + +# ===================== 5. Stream URL / Manifest Tests ===================== + +class TestStreamURL: + """Tests for stream URL retrieval and manifest decoding.""" + + def _get_test_track_id(self, client): + results = client.search_tracks(title="Bohemian Rhapsody", artist="Queen") + return results[0]['id'] if results else None + + def test_27_get_stream_url_lossless(self, shared_client): + """Should return a stream URL for lossless quality.""" + track_id = self._get_test_track_id(shared_client) + if not track_id: + pytest.skip("No search results") + + stream = shared_client.get_stream_url(track_id, quality='lossless') + if stream is None: + pytest.skip("Stream URL not available (may be geo-restricted)") + + assert 'url' in stream, "Stream info should contain 'url'" + assert stream['url'].startswith('http'), f"URL should be HTTP(S): {stream['url'][:100]}" + assert stream['quality'] == 'lossless' + + def test_28_get_stream_url_hires(self, shared_client): + """Should try to get hi-res stream URL.""" + track_id = self._get_test_track_id(shared_client) + if not track_id: + pytest.skip("No search results") + + stream = shared_client.get_stream_url(track_id, quality='hires') + # Hi-res may not be available for all tracks — just shouldn't crash + if stream: + assert 'url' in stream + + def test_29_get_stream_url_high(self, shared_client): + """Should get AAC stream URL.""" + track_id = self._get_test_track_id(shared_client) + if not track_id: + pytest.skip("No search results") + + stream = shared_client.get_stream_url(track_id, quality='high') + if stream: + assert 'url' in stream + assert stream['quality'] == 'high' + + def test_30_get_stream_url_invalid_track(self, shared_client): + """Invalid track ID should return None.""" + stream = shared_client.get_stream_url(99999999999, quality='lossless') + assert stream is None + + def test_31_get_stream_url_invalid_quality(self, shared_client): + """Invalid quality key should fall back to lossless.""" + track_id = self._get_test_track_id(shared_client) + if not track_id: + pytest.skip("No search results") + + # 'nonexistent' isn't in HIFI_QUALITY_MAP, should fall back + stream = shared_client.get_stream_url(track_id, quality='nonexistent') + # Should not crash — either returns data with lossless fallback or None + + def test_32_stream_url_has_encryption_info(self, shared_client): + """Stream info should include encryption field.""" + track_id = self._get_test_track_id(shared_client) + if not track_id: + pytest.skip("No search results") + + stream = shared_client.get_stream_url(track_id, quality='lossless') + if stream: + assert 'encryption' in stream + # Most should be 'NONE' for public instances + assert stream['encryption'] in ('NONE', 'OLD_AES', ''), \ + f"Unexpected encryption: {stream['encryption']}" + + +# ===================== 6. Album Tests ===================== + +class TestAlbum: + """Tests for album lookup.""" + + def _get_test_album_id(self, client): + """Search for a track and extract its album ID if available.""" + results = client.search_tracks(title="Bohemian Rhapsody", artist="Queen", limit=5) + for r in results: + # Album ID may be embedded in album info + if r.get('album'): + # We need an actual album ID — search the album endpoint + break + return None + + def test_33_get_album_known_id(self, shared_client): + """Should return album data for a known Tidal album ID.""" + # "A Night at the Opera" by Queen — Tidal album ID 36393265 + album = shared_client.get_album(36393265) + if album is None: + pytest.skip("Album endpoint not available or ID changed") + + assert album.get('title'), "Album should have a title" + assert isinstance(album.get('tracks', []), list) + + def test_34_get_album_tracks_have_metadata(self, shared_client): + """Album tracks should have proper metadata.""" + album = shared_client.get_album(36393265) + if not album or not album.get('tracks'): + pytest.skip("Album data not available") + + for track in album['tracks'][:3]: + assert track.get('title'), f"Track missing title: {track}" + assert track.get('id'), f"Track missing ID: {track}" + + def test_35_get_album_invalid_id(self, shared_client): + """Invalid album ID should return None.""" + album = shared_client.get_album(99999999999) + assert album is None or (isinstance(album, dict) and len(album.get('tracks', [])) == 0) + + def test_36_get_album_track_count(self, shared_client): + """Album track_count should match actual tracks returned.""" + album = shared_client.get_album(36393265) + if not album: + pytest.skip("Album data not available") + + tracks = album.get('tracks', []) + count = album.get('track_count', 0) + # These should be close (may differ if some tracks are unavailable) + if tracks and count: + assert abs(len(tracks) - count) <= 2, \ + f"Track count mismatch: {len(tracks)} tracks vs reported {count}" + + +# ===================== 7. Artist Tests ===================== + +class TestArtist: + """Tests for artist lookup.""" + + def test_37_get_artist_known_id(self, shared_client): + """Should return artist data for Queen (Tidal artist ID 30157).""" + artist = shared_client.get_artist(30157) + if artist is None: + pytest.skip("Artist endpoint not available") + + assert isinstance(artist, dict) + + def test_38_get_artist_invalid_id(self, shared_client): + """Invalid artist ID should return None.""" + artist = shared_client.get_artist(99999999999) + assert artist is None or isinstance(artist, dict) + + +# ===================== 8. Quality Map Tests ===================== + +class TestQualityMap: + """Tests for quality tier configuration.""" + + def test_39_all_quality_tiers_present(self): + """All four quality tiers should be defined.""" + assert 'hires' in HIFI_QUALITY_MAP + assert 'lossless' in HIFI_QUALITY_MAP + assert 'high' in HIFI_QUALITY_MAP + assert 'low' in HIFI_QUALITY_MAP + + def test_40_quality_tiers_have_required_fields(self): + """Each quality tier should have api_value, label, extension, bitrate, codec.""" + for key, tier in HIFI_QUALITY_MAP.items(): + assert 'api_value' in tier, f"{key} missing api_value" + assert 'label' in tier, f"{key} missing label" + assert 'extension' in tier, f"{key} missing extension" + assert 'bitrate' in tier, f"{key} missing bitrate" + assert 'codec' in tier, f"{key} missing codec" + + def test_41_flac_tiers_have_flac_extension(self): + """Lossless tiers should produce .flac files.""" + assert HIFI_QUALITY_MAP['hires']['extension'] == 'flac' + assert HIFI_QUALITY_MAP['lossless']['extension'] == 'flac' + + def test_42_aac_tiers_have_m4a_extension(self): + """Lossy tiers should produce .m4a files.""" + assert HIFI_QUALITY_MAP['high']['extension'] == 'm4a' + assert HIFI_QUALITY_MAP['low']['extension'] == 'm4a' + + def test_43_bitrate_ordering(self): + """Bitrates should descend: hires > lossless > high > low.""" + assert HIFI_QUALITY_MAP['hires']['bitrate'] > HIFI_QUALITY_MAP['lossless']['bitrate'] + assert HIFI_QUALITY_MAP['lossless']['bitrate'] > HIFI_QUALITY_MAP['high']['bitrate'] + assert HIFI_QUALITY_MAP['high']['bitrate'] > HIFI_QUALITY_MAP['low']['bitrate'] + + +# ===================== 9. Parse Track Tests ===================== + +class TestParseTrack: + """Tests for _parse_track internal method.""" + + def test_44_parse_track_basic(self, client): + """Should parse a simple track dict.""" + item = { + 'id': 12345, + 'title': 'Test Song', + 'artists': [{'name': 'Test Artist'}], + 'album': {'title': 'Test Album'}, + 'duration': 240, + 'trackNumber': 3, + 'isrc': 'USTEST0001', + } + result = client._parse_track(item) + assert result['id'] == 12345 + assert result['title'] == 'Test Song' + assert result['artist'] == 'Test Artist' + assert result['album'] == 'Test Album' + assert result['duration_ms'] == 240000 # 240s → 240000ms + assert result['track_number'] == 3 + assert result['isrc'] == 'USTEST0001' + + def test_45_parse_track_multiple_artists(self, client): + """Should join multiple artists with commas.""" + item = { + 'id': 1, + 'title': 'Collab', + 'artists': [{'name': 'Artist A'}, {'name': 'Artist B'}, {'name': 'Artist C'}], + 'duration': 180, + } + result = client._parse_track(item) + assert result['artist'] == 'Artist A, Artist B, Artist C' + + def test_46_parse_track_string_artist(self, client): + """Should handle artist as a plain string.""" + item = {'id': 1, 'title': 'Song', 'artist': 'Solo Artist', 'duration': 100} + result = client._parse_track(item) + assert result['artist'] == 'Solo Artist' + + def test_47_parse_track_dict_artist(self, client): + """Should handle artist as a dict with name key.""" + item = {'id': 1, 'title': 'Song', 'artist': {'name': 'Dict Artist'}, 'duration': 100} + result = client._parse_track(item) + assert result['artist'] == 'Dict Artist' + + def test_48_parse_track_missing_artists(self, client): + """Missing artist should default to 'Unknown Artist'.""" + item = {'id': 1, 'title': 'Orphan Song', 'duration': 100} + result = client._parse_track(item) + assert result['artist'] == 'Unknown Artist' + + def test_49_parse_track_string_album(self, client): + """Should handle album as a plain string.""" + item = {'id': 1, 'title': 'Song', 'album': 'String Album', 'duration': 100} + result = client._parse_track(item) + assert result['album'] == 'String Album' + + def test_50_parse_track_missing_album(self, client): + """Missing album should be empty string.""" + item = {'id': 1, 'title': 'Song', 'duration': 100} + result = client._parse_track(item) + assert result['album'] == '' + + def test_51_parse_track_duration_already_ms(self, client): + """Duration >= 100000 should be treated as already in milliseconds.""" + item = {'id': 1, 'title': 'Song', 'duration': 240000} + result = client._parse_track(item) + assert result['duration_ms'] == 240000 + + def test_52_parse_track_duration_seconds(self, client): + """Duration < 100000 should be converted from seconds to ms.""" + item = {'id': 1, 'title': 'Song', 'duration': 240} + result = client._parse_track(item) + assert result['duration_ms'] == 240000 + + def test_53_parse_track_zero_duration(self, client): + """Zero duration should stay zero.""" + item = {'id': 1, 'title': 'Song', 'duration': 0} + result = client._parse_track(item) + assert result['duration_ms'] == 0 + + def test_54_parse_track_name_fallback(self, client): + """Should fall back to 'name' key if 'title' missing.""" + item = {'id': 1, 'name': 'Fallback Name', 'duration': 100} + result = client._parse_track(item) + assert result['title'] == 'Fallback Name' + + def test_55_parse_track_explicit_flag(self, client): + """Should preserve explicit flag.""" + item = {'id': 1, 'title': 'Song', 'duration': 100, 'explicit': True} + result = client._parse_track(item) + assert result['explicit'] is True + + def test_56_parse_track_quality_field(self, client): + """Should parse audioQuality field.""" + item = {'id': 1, 'title': 'Song', 'duration': 100, 'audioQuality': 'LOSSLESS'} + result = client._parse_track(item) + assert result['quality'] == 'LOSSLESS' + + def test_57_parse_track_artists_with_strings(self, client): + """Should handle artists list containing plain strings.""" + item = {'id': 1, 'title': 'Song', 'artists': ['Artist A', 'Artist B'], 'duration': 100} + result = client._parse_track(item) + assert result['artist'] == 'Artist A, Artist B' + + def test_58_parse_track_empty_artist_names(self, client): + """Should skip empty artist names.""" + item = {'id': 1, 'title': 'Song', 'artists': [{'name': ''}, {'name': 'Real Artist'}], 'duration': 100} + result = client._parse_track(item) + assert result['artist'] == 'Real Artist' + + +# ===================== 10. TrackResult Compatibility Tests ===================== + +class TestTrackResultCompatibility: + """Tests for Soulseek-compatible TrackResult conversion.""" + + def test_59_to_track_result_basic(self, client): + """Should convert track dict to TrackResult.""" + track = { + 'id': 12345, + 'title': 'Test Song', + 'artist': 'Test Artist', + 'album': 'Test Album', + 'duration_ms': 240000, + 'track_number': 3, + } + q_info = HIFI_QUALITY_MAP['lossless'] + result = client._to_track_result(track, q_info) + + assert isinstance(result, TrackResult) + assert result.username == 'hifi' + assert result.artist == 'Test Artist' + assert result.title == 'Test Song' + assert result.album == 'Test Album' + assert result.bitrate == 1411 + assert result.duration == 240000 + assert result.track_number == 3 + + def test_60_track_result_filename_format(self, client): + """Filename should be 'track_id||display_name'.""" + track = {'id': 999, 'title': 'My Song', 'artist': 'My Artist'} + q_info = HIFI_QUALITY_MAP['lossless'] + result = client._to_track_result(track, q_info) + + assert '||' in result.filename + parts = result.filename.split('||', 1) + assert parts[0] == '999' + assert 'My Artist' in parts[1] + assert 'My Song' in parts[1] + + def test_61_track_result_hifi_username(self, client): + """All HiFi results should use 'hifi' username.""" + track = {'id': 1, 'title': 'S', 'artist': 'A'} + result = client._to_track_result(track, HIFI_QUALITY_MAP['lossless']) + assert result.username == 'hifi' + + def test_62_track_result_high_slots(self, client): + """HiFi results should have high slot count (always available).""" + track = {'id': 1, 'title': 'S', 'artist': 'A'} + result = client._to_track_result(track, HIFI_QUALITY_MAP['lossless']) + assert result.free_upload_slots >= 99 + + def test_63_track_result_different_qualities(self, client): + """Different quality tiers should produce different bitrates.""" + track = {'id': 1, 'title': 'S', 'artist': 'A'} + + hires = client._to_track_result(track, HIFI_QUALITY_MAP['hires']) + lossless = client._to_track_result(track, HIFI_QUALITY_MAP['lossless']) + high = client._to_track_result(track, HIFI_QUALITY_MAP['high']) + + assert hires.bitrate > lossless.bitrate > high.bitrate + + +# ===================== 11. Async Search Compatible Tests ===================== + +class TestSearchCompatible: + """Tests for the async Soulseek-compatible search interface.""" + + def test_64_search_returns_tuple(self, shared_client): + """search should return (tracks, albums) tuple.""" + tracks, albums = run_async(shared_client.search("Bohemian Rhapsody")) + assert isinstance(tracks, list) + assert isinstance(albums, list) + + def test_65_search_track_results(self, shared_client): + """Results should be TrackResult instances.""" + tracks, _ = run_async(shared_client.search("Yesterday Beatles")) + if tracks: + assert isinstance(tracks[0], TrackResult) + + def test_66_search_empty_query(self, client): + """Empty query should return empty lists.""" + tracks, albums = run_async(client.search("")) + assert tracks == [] or isinstance(tracks, list) + + def test_67_search_albums_always_empty(self, shared_client): + """Albums list should always be empty (HiFi doesn't return album results from search).""" + _, albums = run_async(shared_client.search("Dark Side of the Moon")) + assert albums == [] + + +# ===================== 12. Download Lifecycle Tests ===================== + +class TestDownloadLifecycle: + """Tests for download start, tracking, cancel, and cleanup.""" + + def test_68_download_creates_tracking_entry(self, client): + """Starting a download should create an entry in active_downloads.""" + # Use a valid-ish filename format but don't care if it actually downloads + download_id = run_async(client.download('hifi', '12345||Test Artist - Test Song')) + + if download_id: + assert download_id in client.active_downloads + info = client.active_downloads[download_id] + assert info['state'] in ('Initializing', 'InProgress, Downloading', 'Errored') + assert info['username'] == 'hifi' + + def test_69_download_invalid_filename_format(self, client): + """Filename without || separator should fail gracefully.""" + download_id = run_async(client.download('hifi', 'invalid_no_separator')) + assert download_id is None + + def test_70_download_invalid_track_id(self, client): + """Non-numeric track ID should fail gracefully.""" + download_id = run_async(client.download('hifi', 'abc||Artist - Song')) + assert download_id is None + + def test_71_cancel_download(self, client): + """Should be able to cancel a download.""" + # Start a download + download_id = run_async(client.download('hifi', '99999999||Fake - Track')) + if not download_id: + pytest.skip("Download didn't start") + + result = run_async(client.cancel_download(download_id)) + assert result is True + + info = client.active_downloads.get(download_id) + if info: + assert info['state'] == 'Cancelled' + + def test_72_cancel_nonexistent_download(self, client): + """Cancelling a non-existent download should return False.""" + result = run_async(client.cancel_download('fake-uuid-12345')) + assert result is False + + def test_73_cancel_with_remove(self, client): + """Cancel with remove=True should delete from active_downloads.""" + download_id = run_async(client.download('hifi', '99999999||Fake - Track')) + if not download_id: + pytest.skip("Download didn't start") + + run_async(client.cancel_download(download_id, remove=True)) + assert download_id not in client.active_downloads + + def test_74_get_all_downloads(self, client): + """get_all_downloads should return DownloadStatus list.""" + statuses = run_async(client.get_all_downloads()) + assert isinstance(statuses, list) + for s in statuses: + assert isinstance(s, DownloadStatus) + + def test_75_get_download_status(self, client): + """Should return status for a known download.""" + download_id = run_async(client.download('hifi', '99999999||Test - Track')) + if not download_id: + pytest.skip("Download didn't start") + + status = run_async(client.get_download_status(download_id)) + assert status is not None + assert isinstance(status, DownloadStatus) + assert status.id == download_id + + def test_76_get_download_status_unknown(self, client): + """Should return None for unknown download ID.""" + status = run_async(client.get_download_status('nonexistent-uuid')) + assert status is None + + def test_77_clear_completed_downloads(self, client): + """Should clear terminal downloads but keep active ones.""" + # Add a fake completed download + with client._download_lock: + client.active_downloads['fake-completed'] = { + 'id': 'fake-completed', + 'filename': 'test', + 'username': 'hifi', + 'state': 'Completed, Succeeded', + 'progress': 100.0, + 'size': 1000, + 'transferred': 1000, + 'speed': 0, + } + client.active_downloads['fake-active'] = { + 'id': 'fake-active', + 'filename': 'test2', + 'username': 'hifi', + 'state': 'InProgress, Downloading', + 'progress': 50.0, + 'size': 2000, + 'transferred': 1000, + 'speed': 100, + } + + run_async(client.clear_all_completed_downloads()) + + assert 'fake-completed' not in client.active_downloads + assert 'fake-active' in client.active_downloads + + def test_78_clear_errored_downloads(self, client): + """Should clear errored downloads.""" + with client._download_lock: + client.active_downloads['fake-errored'] = { + 'id': 'fake-errored', + 'filename': 'err', + 'username': 'hifi', + 'state': 'Errored', + 'progress': 0, + 'size': 0, + 'transferred': 0, + 'speed': 0, + } + + run_async(client.clear_all_completed_downloads()) + assert 'fake-errored' not in client.active_downloads + + def test_79_clear_cancelled_downloads(self, client): + """Should clear cancelled downloads.""" + with client._download_lock: + client.active_downloads['fake-cancelled'] = { + 'id': 'fake-cancelled', + 'filename': 'can', + 'username': 'hifi', + 'state': 'Cancelled', + 'progress': 0, + 'size': 0, + 'transferred': 0, + 'speed': 0, + } + + run_async(client.clear_all_completed_downloads()) + assert 'fake-cancelled' not in client.active_downloads + + +# ===================== 13. Download Sync (Quality Fallback) Tests ===================== + +class TestDownloadSync: + """Tests for the synchronous download with quality fallback chain.""" + + def test_80_quality_chain_starts_at_configured_quality(self, client): + """Quality fallback chain should start from configured quality.""" + with patch('core.hifi_client.config_manager') as mock_config: + mock_config.get.return_value = 'high' + chain = ['hires', 'lossless', 'high', 'low'] + start = chain.index('high') + assert chain[start:] == ['high', 'low'] + + def test_81_quality_chain_starts_at_hires(self, client): + """If configured quality is hires, chain starts from the top.""" + with patch('core.hifi_client.config_manager') as mock_config: + mock_config.get.return_value = 'hires' + chain = ['hires', 'lossless', 'high', 'low'] + start = chain.index('hires') + assert chain[start:] == ['hires', 'lossless', 'high', 'low'] + + def test_82_download_sync_shutdown_check(self, temp_download_dir): + """Download should abort if shutdown_check returns True.""" + c = HiFiClient(download_path=temp_download_dir) + c.set_shutdown_check(lambda: True) + + result = c._download_sync('test-id', 12345, 'Test - Track') + assert result is None + + def test_83_safe_filename_generation(self, client): + """Filenames with special chars should be sanitized.""" + import re + display = 'Artist: "Song" / ' + safe = re.sub(r'[<>:"/\\|?*]', '_', display) + assert '<' not in safe + assert '>' not in safe + assert '"' not in safe + assert ':' not in safe + assert '/' not in safe + assert '\\' not in safe + assert '|' not in safe + assert '?' not in safe + assert '*' not in safe + + +# ===================== 14. Shutdown Check Tests ===================== + +class TestShutdownCheck: + """Tests for shutdown check callback.""" + + def test_84_set_shutdown_check(self, client): + """Should store the shutdown check callback.""" + check = lambda: False + client.set_shutdown_check(check) + assert client.shutdown_check is check + + def test_85_shutdown_check_default_none(self, temp_download_dir): + """Default shutdown check should be None.""" + c = HiFiClient(download_path=temp_download_dir) + assert c.shutdown_check is None + + +# ===================== 15. Download Path Tests ===================== + +class TestDownloadPath: + """Tests for download path configuration.""" + + def test_86_custom_download_path(self, temp_download_dir): + """Should use provided download path.""" + c = HiFiClient(download_path=temp_download_dir) + assert str(c.download_path) == temp_download_dir + + def test_87_download_path_created(self): + """Should create download directory if it doesn't exist.""" + test_path = tempfile.mktemp(prefix="hifi_mkdir_test_") + try: + c = HiFiClient(download_path=test_path) + assert Path(test_path).exists() + finally: + shutil.rmtree(test_path, ignore_errors=True) + + def test_88_default_download_path(self): + """Default path should come from config_manager or './downloads'.""" + with patch('core.hifi_client.config_manager') as mock_config: + mock_config.get.return_value = './test_default_downloads' + c = HiFiClient() + expected = Path('./test_default_downloads').resolve() + # Just verify it doesn't crash; actual path depends on config + assert c.download_path is not None + # Cleanup + shutil.rmtree(str(c.download_path), ignore_errors=True) + + +# ===================== 16. Instance Failover Integration Tests ===================== + +class TestInstanceFailover: + """Tests for multi-instance failover behavior.""" + + def test_89_failover_on_connection_error(self, temp_download_dir): + """Should failover to next instance on connection error.""" + c = HiFiClient(download_path=temp_download_dir) + c._instances = [ + "https://definitely-not-real.invalid", # Will fail + c._instances[0] if c._instances else "https://triton.squid.wtf", # Should succeed + ] + c._current_instance = c._instances[0] + c._min_interval = 0 + + # After failing on first, should try second + result = c._api_get("/", timeout=3) + # If second instance is up, we should get data + # If not, at least verify no crash and first was rotated + assert c._instances[-1] == "https://definitely-not-real.invalid" + + def test_90_failover_preserves_all_instances(self, temp_download_dir): + """Failover should move failed instance to back, not remove it.""" + c = HiFiClient(download_path=temp_download_dir) + original_count = len(c._instances) + first = c._instances[0] + + c._rotate_instance(first) + + assert len(c._instances) == original_count + assert c._instances[-1] == first + + +# ===================== 17. HTTP Session Tests ===================== + +class TestHTTPSession: + """Tests for HTTP session configuration.""" + + def test_91_session_user_agent(self, client): + """Session should have SoulSync user agent.""" + ua = client.session.headers.get('User-Agent') + assert 'SoulSync' in ua + + def test_92_session_accept_json(self, client): + """Session should accept JSON.""" + accept = client.session.headers.get('Accept') + assert 'json' in accept + + +# ===================== 18. Thread Safety Tests ===================== + +class TestThreadSafety: + """Tests for thread-safe operations.""" + + def test_93_concurrent_download_tracking(self, client): + """Multiple threads should safely add/read downloads.""" + errors = [] + + def add_download(i): + try: + with client._download_lock: + client.active_downloads[f'test-{i}'] = { + 'id': f'test-{i}', + 'filename': f'track-{i}', + 'username': 'hifi', + 'state': 'InProgress, Downloading', + 'progress': 0, + 'size': 0, + 'transferred': 0, + 'speed': 0, + } + except Exception as e: + errors.append(e) + + threads = [threading.Thread(target=add_download, args=(i,)) for i in range(20)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert len(errors) == 0 + assert len([k for k in client.active_downloads if k.startswith('test-')]) == 20 + + def test_94_concurrent_instance_rotation(self, temp_download_dir): + """Multiple threads rotating instances shouldn't crash.""" + c = HiFiClient(download_path=temp_download_dir) + errors = [] + + def rotate(i): + try: + if c._instances: + c._rotate_instance(c._instances[0]) + except Exception as e: + errors.append(e) + + threads = [threading.Thread(target=rotate, args=(i,)) for i in range(10)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert len(errors) == 0 + + +# ===================== 19. DownloadStatus Field Tests ===================== + +class TestDownloadStatusFields: + """Tests for DownloadStatus dataclass compatibility.""" + + def test_95_download_status_all_fields(self, client): + """DownloadStatus should have all required fields.""" + with client._download_lock: + client.active_downloads['field-test'] = { + 'id': 'field-test', + 'filename': '123||Artist - Title', + 'username': 'hifi', + 'state': 'InProgress, Downloading', + 'progress': 45.5, + 'size': 50_000_000, + 'transferred': 22_750_000, + 'speed': 1_000_000, + 'time_remaining': 27, + 'file_path': '/tmp/test.flac', + } + + status = run_async(client.get_download_status('field-test')) + assert status.id == 'field-test' + assert status.filename == '123||Artist - Title' + assert status.username == 'hifi' + assert status.state == 'InProgress, Downloading' + assert status.progress == 45.5 + assert status.size == 50_000_000 + assert status.transferred == 22_750_000 + assert status.speed == 1_000_000 + assert status.time_remaining == 27 + assert status.file_path == '/tmp/test.flac' + + def test_96_download_status_optional_fields_default(self, client): + """Optional fields should default to None.""" + with client._download_lock: + client.active_downloads['optional-test'] = { + 'id': 'optional-test', + 'filename': 'test', + 'username': 'hifi', + 'state': 'Initializing', + 'progress': 0, + 'size': 0, + 'transferred': 0, + 'speed': 0, + } + + status = run_async(client.get_download_status('optional-test')) + assert status.time_remaining is None + assert status.file_path is None + + +# ===================== 20. Live Download Test ===================== + +class TestLiveDownload: + """Live download test — actually downloads a track from HiFi API.""" + + def test_97_live_search_and_get_stream(self, shared_client): + """Full flow: search → get track info → get stream URL.""" + results = shared_client.search_tracks(title="Yesterday", artist="Beatles", limit=3) + if not results: + pytest.skip("No search results available") + + track = results[0] + track_id = track['id'] + + # Get track info + info = shared_client.get_track_info(track_id) + # info might be None if endpoint not available + + # Get stream URL + stream = shared_client.get_stream_url(track_id, quality='lossless') + if not stream: + pytest.skip("Stream URL not available") + + assert stream['url'].startswith('http') + assert stream['quality'] == 'lossless' + + def test_98_live_download_small_segment(self, shared_client, temp_download_dir): + """Download the first 100KB of a track to verify the URL works.""" + results = shared_client.search_tracks(title="Yesterday", artist="Beatles", limit=1) + if not results: + pytest.skip("No search results") + + stream = shared_client.get_stream_url(results[0]['id'], quality='lossless') + if not stream: + pytest.skip("No stream URL") + + import requests + try: + resp = requests.get(stream['url'], stream=True, timeout=10, + headers={'Range': 'bytes=0-102400'}) + data = resp.content + assert len(data) > 1000, f"Expected >1KB of audio data, got {len(data)} bytes" + + # Check for FLAC magic bytes + if stream.get('codec', '') and 'flac' in stream['codec'].lower(): + assert data[:4] == b'fLaC', "FLAC file should start with 'fLaC' magic bytes" + except requests.exceptions.RequestException as e: + pytest.skip(f"CDN request failed: {e}") + + def test_99_live_full_download_lifecycle(self, temp_download_dir): + """Full download lifecycle: search → download → verify file.""" + client = HiFiClient(download_path=temp_download_dir) + + results = client.search_tracks(title="Yesterday", artist="Beatles", limit=1) + if not results: + pytest.skip("No search results") + + track = results[0] + filename = f"{track['id']}||{track['artist']} - {track['title']}" + + download_id = run_async(client.download('hifi', filename)) + if not download_id: + pytest.skip("Download failed to start") + + # Wait for download to complete (max 60s) + for _ in range(120): + time.sleep(0.5) + status = run_async(client.get_download_status(download_id)) + if not status: + break + if status.state in ('Completed, Succeeded', 'Errored', 'Cancelled'): + break + + status = run_async(client.get_download_status(download_id)) + if status and status.state == 'Completed, Succeeded': + assert status.file_path is not None + assert os.path.exists(status.file_path) + file_size = os.path.getsize(status.file_path) + assert file_size > 100 * 1024, f"Downloaded file too small: {file_size} bytes" + # Cleanup + os.unlink(status.file_path) + else: + # Download may have failed due to geo/availability — not a test failure + state = status.state if status else 'unknown' + pytest.skip(f"Download did not complete successfully (state: {state})") + + def test_100_live_download_cancel_cleans_up(self, temp_download_dir): + """Cancelling a live download should update state.""" + client = HiFiClient(download_path=temp_download_dir) + + results = client.search_tracks(title="Stairway to Heaven", artist="Led Zeppelin", limit=1) + if not results: + pytest.skip("No search results") + + track = results[0] + filename = f"{track['id']}||{track['artist']} - {track['title']}" + + download_id = run_async(client.download('hifi', filename)) + if not download_id: + pytest.skip("Download failed to start") + + # Brief pause then cancel + time.sleep(1) + result = run_async(client.cancel_download(download_id)) + assert result is True + + status = run_async(client.get_download_status(download_id)) + if status: + assert status.state == 'Cancelled' + + +# ===================== Bonus: Edge Case & Regression Tests ===================== + +class TestEdgeCases: + """Additional edge case and regression tests.""" + + def test_101_is_configured_with_instances(self, client): + """Client with instances should report as configured.""" + assert client.is_configured() is True + + def test_102_is_configured_no_instances(self, temp_download_dir): + """Client with no current instance should report not configured.""" + c = HiFiClient(download_path=temp_download_dir) + c._current_instance = None + assert c.is_configured() is False + + def test_103_check_connection_async(self, shared_client): + """check_connection should return True when instances are up.""" + result = run_async(shared_client.check_connection()) + assert result is True + + def test_104_empty_instances_list(self, temp_download_dir): + """Client with no instances should handle gracefully.""" + c = HiFiClient(download_path=temp_download_dir) + c._instances = [] + c._current_instance = None + + assert c.is_available() is False + assert c.search_tracks(title="test") == [] + + def test_105_api_get_error_response(self, client): + """API returning error dict should return None.""" + with patch.object(client.session, 'get') as mock_get: + mock_response = MagicMock() + mock_response.json.return_value = {'error': 'Something went wrong'} + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + result = client._api_get('/test') + assert result is None + + def test_106_parse_track_with_track_number_alt_key(self, client): + """Should handle 'track_number' key (underscore variant).""" + item = {'id': 1, 'title': 'Song', 'track_number': 7, 'duration': 100} + result = client._parse_track(item) + assert result['track_number'] == 7 + + def test_107_download_path_type(self, client): + """Download path should be a Path object.""" + assert isinstance(client.download_path, Path) + + def test_108_download_returns_unique_ids(self, client): + """Each download should get a unique ID.""" + ids = set() + for i in range(5): + did = run_async(client.download('hifi', f'{i}||Test - Track {i}')) + if did: + ids.add(did) + assert len(ids) == 5 or len(ids) == 0 # All unique or all failed + + +if __name__ == '__main__': + pytest.main([__file__, '-v', '--tb=short', '-x']) diff --git a/web_server.py b/web_server.py index 66dbee04..6548ff37 100644 --- a/web_server.py +++ b/web_server.py @@ -327,6 +327,9 @@ if soulseek_client: if hasattr(soulseek_client, 'qobuz'): soulseek_client.qobuz.set_shutdown_check(lambda: IS_SHUTTING_DOWN) print(" ✅ Configured Qobuz client shutdown callback") + if hasattr(soulseek_client, 'hifi'): + soulseek_client.hifi.set_shutdown_check(lambda: IS_SHUTTING_DOWN) + print(" ✅ Configured HiFi client shutdown callback") # Initialize web scan manager for automatic post-download scanning try: @@ -1619,7 +1622,7 @@ def get_cached_transfer_data(): all_downloads = run_async(soulseek_client.get_all_downloads()) for download in all_downloads: # Only add streaming source downloads (Soulseek ones are already in the lookup) - if download.username in ('youtube', 'tidal', 'qobuz'): + if download.username in ('youtube', 'tidal', 'qobuz', 'hifi'): key = _make_context_key(download.username, download.filename) # Convert DownloadStatus to transfer dict format live_transfers_lookup[key] = { @@ -1943,7 +1946,7 @@ class WebUIDownloadMonitor: all_downloads = run_async(soulseek_client.get_all_downloads()) for download in all_downloads: # Only add streaming source downloads (Soulseek ones are already in the lookup from slskd API) - if download.username in ('youtube', 'tidal', 'qobuz'): + if download.username in ('youtube', 'tidal', 'qobuz', 'hifi'): key = _make_context_key(download.username, download.filename) # Convert DownloadStatus to transfer dict format for monitor compatibility live_transfers[key] = { @@ -2973,20 +2976,21 @@ def _find_downloaded_file(download_path, track_data): audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'} target_filename = extract_filename(track_data.get('filename', '')) - # YOUTUBE/TIDAL/QOBUZ SUPPORT: Handle encoded filename format "id||title" + # YOUTUBE/TIDAL/QOBUZ/HIFI SUPPORT: Handle encoded filename format "id||title" # The file on disk will be "title.ext", not "id||title" is_youtube = track_data.get('username') == 'youtube' is_tidal = track_data.get('username') == 'tidal' is_qobuz = track_data.get('username') == 'qobuz' - is_streaming_source = is_youtube or is_tidal or is_qobuz + is_hifi = track_data.get('username') == 'hifi' + is_streaming_source = is_youtube or is_tidal or is_qobuz or is_hifi target_filename_youtube = None if is_streaming_source and '||' in target_filename: _, title = target_filename.split('||', 1) - if is_tidal or is_qobuz: - # Tidal/Qobuz files can be flac or m4a or mp3 — match any audio extension + if is_tidal or is_qobuz or is_hifi: + # Tidal/Qobuz/HiFi files can be flac or m4a — match any audio extension safe_title = re.sub(r'[<>:"/\\|?*]', '_', title) target_filename_youtube = safe_title # Extension-less for flexible matching - source_name = 'Qobuz' if is_qobuz else 'Tidal' + source_name = 'HiFi' if is_hifi else ('Qobuz' if is_qobuz else 'Tidal') print(f"🎵 [{source_name} Stream] Looking for file starting with: {target_filename_youtube}") else: # yt-dlp will create "Title.mp3" from "Title" @@ -3024,11 +3028,11 @@ def _find_downloaded_file(download_path, track_data): # For Tidal, compare without extension (file could be .flac or .m4a) compare_target = target_filename_youtube.lower() compare_file = file.lower() - if is_tidal or is_qobuz: + if is_tidal or is_qobuz or is_hifi: compare_file = os.path.splitext(compare_file)[0] similarity = SequenceMatcher(None, compare_file, compare_target).ratio() - source_label = 'Qobuz' if is_qobuz else ('Tidal' if is_tidal else 'YouTube') + source_label = 'HiFi' if is_hifi else ('Qobuz' if is_qobuz else ('Tidal' if is_tidal else 'YouTube')) print(f"🔍 [{source_label} Stream] Comparing: '{file}' vs '{target_filename_youtube}' = {similarity:.2f}") # Keep track of best match @@ -3179,6 +3183,8 @@ def run_service_test(service, test_config): 'soulseek': "Successfully connected to Soulseek network via slskd.", 'youtube': "YouTube download source ready.", 'tidal': "Tidal download source ready.", + 'qobuz': "Qobuz download source ready.", + 'hifi': "HiFi download source ready.", 'hybrid': "Download sources ready (Hybrid mode)." } message = mode_messages.get(download_mode, "Download source connected.") @@ -3189,6 +3195,8 @@ def run_service_test(service, test_config): 'soulseek': "slskd is not connected to the Soulseek network. Check slskd status and credentials.", 'youtube': "YouTube download source not available.", 'tidal': "Tidal download source not available. Check authentication.", + 'qobuz': "Qobuz download source not available. Check authentication.", + 'hifi': "HiFi download source not available. Public API instances may be down.", 'hybrid': "Could not connect to download sources. Check configuration." } error = mode_errors.get(download_mode, "Download source connection failed.") @@ -4198,7 +4206,7 @@ def handle_settings(): if 'active_media_server' in new_settings: config_manager.set_active_media_server(new_settings['active_media_server']) - for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb']: + for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb']: if service in new_settings: for key, value in new_settings[service].items(): config_manager.set(f'{service}.{key}', value) @@ -6643,7 +6651,7 @@ def stream_enhanced_search_track(): search_queries = [] import re - if download_mode in ('youtube', 'tidal', 'qobuz') or (download_mode == 'hybrid' and config_manager.get('download_source.hybrid_primary') in ('youtube', 'tidal', 'qobuz')): + if download_mode in ('youtube', 'tidal', 'qobuz', 'hifi') or (download_mode == 'hybrid' and config_manager.get('download_source.hybrid_primary') in ('youtube', 'tidal', 'qobuz', 'hifi')): # YouTube/Tidal mode: Include artist for better context # Primary query: Artist + Track if artist_name and track_name: @@ -6819,7 +6827,7 @@ def start_download(): if download_id: # Register download for post-processing (simple transfer to /Transfer) context_key = _make_context_key(username, filename) - is_streaming_source = username in ('youtube', 'tidal', 'qobuz') + is_streaming_source = username in ('youtube', 'tidal', 'qobuz', 'hifi') with matched_context_lock: matched_downloads_context[context_key] = { 'search_result': { @@ -7197,7 +7205,7 @@ def get_download_status(): all_streaming_downloads = run_async(soulseek_client.get_all_downloads()) for download in all_streaming_downloads: - if download.username in ('youtube', 'tidal', 'qobuz'): + if download.username in ('youtube', 'tidal', 'qobuz', 'hifi'): source_label = download.username.title() # Convert DownloadStatus to transfer format that frontend expects streaming_transfer = { @@ -19939,7 +19947,7 @@ def get_valid_candidates(results, spotify_track, query): return [] # Skip quality filtering for YouTube/Tidal/Qobuz results (quality is fixed by source, not user-selectable per-result) - is_streaming_source = initial_candidates[0].username in ("youtube", "tidal", "qobuz") if initial_candidates else False + is_streaming_source = initial_candidates[0].username in ("youtube", "tidal", "qobuz", "hifi") if initial_candidates else False if is_streaming_source: source_label = initial_candidates[0].username.title() @@ -22178,7 +22186,7 @@ def _try_source_reuse(task_id, batch_id, track): if not source_tracks or not last_source: _sr.info(f"Skipped — no source_tracks or no last_source") return False - if last_source.get('username') in ('youtube', 'tidal', 'qobuz'): + if last_source.get('username') in ('youtube', 'tidal', 'qobuz', 'hifi'): _sr.info(f"Skipped — {last_source.get('username')} source (no folder-based reuse)") return False @@ -22280,7 +22288,7 @@ def _store_batch_source(batch_id, username, filename): """Browse the successful download's folder and store results on the batch for reuse.""" _sr = source_reuse_logger _sr.info(f"_store_batch_source called: batch={batch_id}, user={username}, file={filename}") - if not batch_id or username in ('youtube', 'tidal', 'qobuz'): + if not batch_id or username in ('youtube', 'tidal', 'qobuz', 'hifi'): _sr.info(f"Skipped — no batch_id or streaming source ({username})") return @@ -24298,6 +24306,26 @@ def get_discover_album(source, album_id): return jsonify({"error": str(e)}), 500 +# =================================================================== +# HIFI DOWNLOAD ENDPOINTS +# =================================================================== + +@app.route('/api/hifi/status', methods=['GET']) +def hifi_status(): + """Check if HiFi API instances are reachable.""" + try: + hifi = soulseek_client.hifi + available = hifi.is_available() + version = hifi.get_version() if available else None + return jsonify({ + "available": available, + "version": version, + "instance": hifi._get_instance(), + }) + except Exception as e: + return jsonify({"available": False, "error": str(e)}) + + # =================================================================== # TIDAL DOWNLOAD AUTH ENDPOINTS # =================================================================== diff --git a/webui/index.html b/webui/index.html index 0fa55d21..2826395b 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3807,6 +3807,7 @@ +
@@ -3824,6 +3825,7 @@ +
First source to try for downloads. @@ -3836,6 +3838,7 @@ +
If the primary source finds nothing, try this source next. @@ -3946,6 +3949,34 @@
+ + +