Merge pull request #393 from elmerohueso/hifi-fixes
fix HIFI downloads to get LOSSLESS and HI_RES again
This commit is contained in:
commit
e504099439
10 changed files with 1865 additions and 791 deletions
|
|
@ -12,20 +12,22 @@ Supports:
|
||||||
- Track search by title, artist, album
|
- Track search by title, artist, album
|
||||||
- Album lookup by ID
|
- Album lookup by ID
|
||||||
- Artist lookup by ID
|
- Artist lookup by ID
|
||||||
- Direct FLAC download URLs from Tidal CDN
|
- HLS manifest-based downloads via /trackManifests/ endpoint
|
||||||
- Quality selection: HI_RES_LOSSLESS, LOSSLESS, HIGH, LOW
|
- Quality selection: HIRES_LOSSLESS, LOSSLESS, HIGH, LOW
|
||||||
- Multiple API instance failover
|
- Multiple API instance failover
|
||||||
|
- FFmpeg demuxing for FLAC extraction from MP4 containers
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import json
|
|
||||||
import base64
|
|
||||||
import uuid
|
import uuid
|
||||||
import time
|
import time
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
from typing import List, Optional, Dict, Any, Tuple
|
from typing import List, Optional, Dict, Any, Tuple
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
import requests as http_requests
|
import requests as http_requests
|
||||||
|
|
||||||
|
|
@ -35,38 +37,44 @@ from core.soulseek_client import TrackResult, AlbumResult, DownloadStatus
|
||||||
|
|
||||||
logger = get_logger("hifi_client")
|
logger = get_logger("hifi_client")
|
||||||
|
|
||||||
# Quality tiers matching Tidal's internal quality labels
|
# HLS quality presets mapping to /trackManifests/ format parameters
|
||||||
HIFI_QUALITY_MAP = {
|
HLS_QUALITY_MAP = {
|
||||||
'hires': {
|
'hires': {
|
||||||
'api_value': 'HI_RES_LOSSLESS',
|
'formats': ['FLAC_HIRES'],
|
||||||
'label': 'FLAC 24-bit/96kHz',
|
'manifest_type': 'HLS',
|
||||||
'extension': 'flac',
|
'extension': 'flac',
|
||||||
|
'label': 'FLAC 24-bit/96kHz',
|
||||||
'bitrate': 9216,
|
'bitrate': 9216,
|
||||||
'codec': 'flac',
|
'codec': 'flac',
|
||||||
},
|
},
|
||||||
'lossless': {
|
'lossless': {
|
||||||
'api_value': 'LOSSLESS',
|
'formats': ['FLAC'],
|
||||||
'label': 'FLAC 16-bit/44.1kHz',
|
'manifest_type': 'HLS',
|
||||||
'extension': 'flac',
|
'extension': 'flac',
|
||||||
|
'label': 'FLAC 16-bit/44.1kHz',
|
||||||
'bitrate': 1411,
|
'bitrate': 1411,
|
||||||
'codec': 'flac',
|
'codec': 'flac',
|
||||||
},
|
},
|
||||||
'high': {
|
'high': {
|
||||||
'api_value': 'HIGH',
|
'formats': ['AACLC'],
|
||||||
'label': 'AAC 320kbps',
|
'manifest_type': 'HLS',
|
||||||
'extension': 'm4a',
|
'extension': 'm4a',
|
||||||
|
'label': 'AAC 320kbps',
|
||||||
'bitrate': 320,
|
'bitrate': 320,
|
||||||
'codec': 'aac',
|
'codec': 'aac',
|
||||||
},
|
},
|
||||||
'low': {
|
'low': {
|
||||||
'api_value': 'LOW',
|
'formats': ['HEAACV1'],
|
||||||
'label': 'AAC 96kbps',
|
'manifest_type': 'HLS',
|
||||||
'extension': 'm4a',
|
'extension': 'm4a',
|
||||||
|
'label': 'AAC 96kbps',
|
||||||
'bitrate': 96,
|
'bitrate': 96,
|
||||||
'codec': 'aac',
|
'codec': 'aac',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HLS_MAP_TAG_RE = re.compile(r'#EXT-X-MAP:.*URI="([^"]+)"')
|
||||||
|
|
||||||
# Default public hifi-api instances (ordered by preference)
|
# Default public hifi-api instances (ordered by preference)
|
||||||
DEFAULT_INSTANCES = [
|
DEFAULT_INSTANCES = [
|
||||||
'https://triton.squid.wtf',
|
'https://triton.squid.wtf',
|
||||||
|
|
@ -85,56 +93,68 @@ class HiFiClient:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, download_path: str = None, base_url: str = None):
|
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:
|
if download_path is None:
|
||||||
download_path = config_manager.get('soulseek.download_path', './downloads')
|
download_path = config_manager.get('soulseek.download_path', './downloads')
|
||||||
self.download_path = Path(download_path)
|
self.download_path = Path(download_path)
|
||||||
self.download_path.mkdir(parents=True, exist_ok=True)
|
self.download_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# API instance management
|
self._instances = []
|
||||||
self._instances = list(DEFAULT_INSTANCES)
|
self._instance_lock = threading.Lock()
|
||||||
if base_url:
|
self._load_instances_from_db()
|
||||||
# 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._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 = http_requests.Session()
|
||||||
self.session.headers.update({
|
self.session.headers.update({
|
||||||
'User-Agent': 'SoulSync/1.0',
|
'User-Agent': 'SoulSync/1.0',
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
})
|
})
|
||||||
|
|
||||||
# Download tracking (mirrors TidalDownloadClient pattern)
|
|
||||||
self.active_downloads: Dict[str, Dict[str, Any]] = {}
|
self.active_downloads: Dict[str, Dict[str, Any]] = {}
|
||||||
self._download_lock = threading.Lock()
|
self._download_lock = threading.Lock()
|
||||||
|
|
||||||
# Shutdown check callback
|
|
||||||
self.shutdown_check = None
|
self.shutdown_check = None
|
||||||
|
|
||||||
# Rate limiting
|
|
||||||
self._last_api_call = 0
|
self._last_api_call = 0
|
||||||
self._api_lock = threading.Lock()
|
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}, "
|
logger.info(f"HiFi client initialized (instance: {self._current_instance}, "
|
||||||
f"download path: {self.download_path})")
|
f"download path: {self.download_path})")
|
||||||
|
|
||||||
def set_shutdown_check(self, check_callable):
|
def set_shutdown_check(self, check_callable):
|
||||||
"""Set a callback function to check for system shutdown."""
|
|
||||||
self.shutdown_check = check_callable
|
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]:
|
def _get_instance(self) -> Optional[str]:
|
||||||
"""Get the current active API instance URL."""
|
|
||||||
with self._instance_lock:
|
with self._instance_lock:
|
||||||
return self._current_instance
|
return self._current_instance
|
||||||
|
|
||||||
def _rotate_instance(self, failed_url: str):
|
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:
|
with self._instance_lock:
|
||||||
if failed_url in self._instances:
|
if failed_url in self._instances:
|
||||||
self._instances.remove(failed_url)
|
self._instances.remove(failed_url)
|
||||||
|
|
@ -146,7 +166,6 @@ class HiFiClient:
|
||||||
self._current_instance = None
|
self._current_instance = None
|
||||||
|
|
||||||
def _rate_limit(self):
|
def _rate_limit(self):
|
||||||
"""Enforce minimum interval between API calls."""
|
|
||||||
with self._api_lock:
|
with self._api_lock:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
elapsed = now - self._last_api_call
|
elapsed = now - self._last_api_call
|
||||||
|
|
@ -155,10 +174,6 @@ class HiFiClient:
|
||||||
self._last_api_call = time.time()
|
self._last_api_call = time.time()
|
||||||
|
|
||||||
def _api_get(self, path: str, params: dict = None, timeout: int = 15) -> Optional[dict]:
|
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()
|
tried = set()
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
|
|
@ -176,7 +191,6 @@ class HiFiClient:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
# Check for API-level errors
|
|
||||||
if isinstance(data, dict) and data.get('error'):
|
if isinstance(data, dict) and data.get('error'):
|
||||||
logger.warning(f"HiFi API error from {instance}: {data['error']}")
|
logger.warning(f"HiFi API error from {instance}: {data['error']}")
|
||||||
return None
|
return None
|
||||||
|
|
@ -201,10 +215,7 @@ class HiFiClient:
|
||||||
logger.error(f"HiFi API unexpected error: {e}")
|
logger.error(f"HiFi API unexpected error: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# ===================== Availability =====================
|
|
||||||
|
|
||||||
def is_available(self) -> bool:
|
def is_available(self) -> bool:
|
||||||
"""Check if the HiFi API is reachable."""
|
|
||||||
try:
|
try:
|
||||||
data = self._api_get('/', timeout=5)
|
data = self._api_get('/', timeout=5)
|
||||||
return data is not None
|
return data is not None
|
||||||
|
|
@ -212,11 +223,9 @@ class HiFiClient:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def is_configured(self) -> bool:
|
def is_configured(self) -> bool:
|
||||||
"""Check if HiFi client is configured and ready (matches Soulseek interface)."""
|
|
||||||
return self._current_instance is not None
|
return self._current_instance is not None
|
||||||
|
|
||||||
async def check_connection(self) -> bool:
|
async def check_connection(self) -> bool:
|
||||||
"""Test if HiFi API is accessible (async, Soulseek-compatible)."""
|
|
||||||
try:
|
try:
|
||||||
import asyncio
|
import asyncio
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
|
@ -226,28 +235,13 @@ class HiFiClient:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_version(self) -> Optional[str]:
|
def get_version(self) -> Optional[str]:
|
||||||
"""Get the API version of the current instance."""
|
|
||||||
data = self._api_get('/')
|
data = self._api_get('/')
|
||||||
if data and isinstance(data, dict):
|
if data and isinstance(data, dict):
|
||||||
return data.get('version') or data.get('data', {}).get('version')
|
return data.get('version') or data.get('data', {}).get('version')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# ===================== Search =====================
|
|
||||||
|
|
||||||
def search_tracks(self, title: str = None, artist: str = None,
|
def search_tracks(self, title: str = None, artist: str = None,
|
||||||
album: str = None, limit: int = 20) -> List[Dict]:
|
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}
|
params = {'limit': limit}
|
||||||
if title:
|
if title:
|
||||||
params['s'] = title
|
params['s'] = title
|
||||||
|
|
@ -264,7 +258,6 @@ class HiFiClient:
|
||||||
if not data:
|
if not data:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Handle response format: {data: {items: [...]}} or {data: [...]}
|
|
||||||
items = []
|
items = []
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
inner = data.get('data', data)
|
inner = data.get('data', data)
|
||||||
|
|
@ -285,15 +278,9 @@ class HiFiClient:
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def search_raw(self, query: str, limit: int = 20) -> List[Dict]:
|
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)
|
return self.search_tracks(title=query, limit=limit)
|
||||||
|
|
||||||
def _parse_track(self, item: dict) -> Dict:
|
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'
|
artist_name = 'Unknown Artist'
|
||||||
artists_raw = item.get('artists', item.get('artist'))
|
artists_raw = item.get('artists', item.get('artist'))
|
||||||
if isinstance(artists_raw, list):
|
if isinstance(artists_raw, list):
|
||||||
|
|
@ -309,7 +296,6 @@ class HiFiClient:
|
||||||
elif isinstance(artists_raw, str):
|
elif isinstance(artists_raw, str):
|
||||||
artist_name = artists_raw
|
artist_name = artists_raw
|
||||||
|
|
||||||
# Album
|
|
||||||
album_raw = item.get('album', {})
|
album_raw = item.get('album', {})
|
||||||
album_name = ''
|
album_name = ''
|
||||||
if isinstance(album_raw, dict):
|
if isinstance(album_raw, dict):
|
||||||
|
|
@ -317,7 +303,6 @@ class HiFiClient:
|
||||||
elif isinstance(album_raw, str):
|
elif isinstance(album_raw, str):
|
||||||
album_name = album_raw
|
album_name = album_raw
|
||||||
|
|
||||||
# Duration
|
|
||||||
duration_s = item.get('duration', 0)
|
duration_s = item.get('duration', 0)
|
||||||
duration_ms = duration_s * 1000 if duration_s and duration_s < 100000 else duration_s
|
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', '')),
|
'quality': item.get('audioQuality', item.get('quality', '')),
|
||||||
}
|
}
|
||||||
|
|
||||||
# ===================== Track Info & Stream URL =====================
|
|
||||||
|
|
||||||
def get_track_info(self, track_id: int) -> Optional[Dict]:
|
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})
|
data = self._api_get('/info/', params={'id': track_id})
|
||||||
if not data:
|
if not data:
|
||||||
return None
|
return None
|
||||||
|
|
@ -346,57 +328,7 @@ class HiFiClient:
|
||||||
return self._parse_track(inner)
|
return self._parse_track(inner)
|
||||||
return None
|
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]:
|
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})
|
data = self._api_get('/album/', params={'id': album_id, 'limit': limit})
|
||||||
if not data:
|
if not data:
|
||||||
return None
|
return None
|
||||||
|
|
@ -405,7 +337,6 @@ class HiFiClient:
|
||||||
if not isinstance(inner, dict):
|
if not isinstance(inner, dict):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Parse tracks within album
|
|
||||||
tracks_raw = inner.get('items', inner.get('tracks', []))
|
tracks_raw = inner.get('items', inner.get('tracks', []))
|
||||||
tracks = []
|
tracks = []
|
||||||
for item in tracks_raw:
|
for item in tracks_raw:
|
||||||
|
|
@ -426,7 +357,6 @@ class HiFiClient:
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_artist(self, artist_id: int) -> Optional[Dict]:
|
def get_artist(self, artist_id: int) -> Optional[Dict]:
|
||||||
"""Get artist info and top tracks."""
|
|
||||||
data = self._api_get('/artist/', params={'id': artist_id})
|
data = self._api_get('/artist/', params={'id': artist_id})
|
||||||
if not data:
|
if not data:
|
||||||
return None
|
return None
|
||||||
|
|
@ -434,14 +364,150 @@ class HiFiClient:
|
||||||
inner = data.get('data', data) if isinstance(data, dict) else data
|
inner = data.get('data', data) if isinstance(data, dict) else data
|
||||||
return inner if isinstance(inner, dict) else None
|
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,
|
async def search(self, query: str, timeout: int = None,
|
||||||
progress_callback=None) -> Tuple[List[TrackResult], List[AlbumResult]]:
|
progress_callback=None) -> Tuple[List[TrackResult], List[AlbumResult]]:
|
||||||
"""
|
|
||||||
Search with Soulseek-compatible return format (TrackResult, AlbumResult).
|
|
||||||
Matches the interface expected by DownloadOrchestrator.
|
|
||||||
"""
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -449,7 +515,7 @@ class HiFiClient:
|
||||||
tracks = await loop.run_in_executor(None, lambda: self.search_raw(query))
|
tracks = await loop.run_in_executor(None, lambda: self.search_raw(query))
|
||||||
|
|
||||||
quality_key = config_manager.get('hifi_download.quality', 'lossless')
|
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 = []
|
results = []
|
||||||
for t in tracks:
|
for t in tracks:
|
||||||
|
|
@ -466,7 +532,6 @@ class HiFiClient:
|
||||||
return ([], [])
|
return ([], [])
|
||||||
|
|
||||||
def _to_track_result(self, track: Dict, quality_info: Dict) -> TrackResult:
|
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']}"
|
display_name = f"{track['artist']} - {track['title']}"
|
||||||
filename = f"{track['id']}||{display_name}"
|
filename = f"{track['id']}||{display_name}"
|
||||||
|
|
||||||
|
|
@ -486,13 +551,7 @@ class HiFiClient:
|
||||||
track_number=track.get('track_number'),
|
track_number=track.get('track_number'),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ===================== Download =====================
|
|
||||||
|
|
||||||
async def download(self, username: str, filename: str, file_size: int = 0) -> Optional[str]:
|
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:
|
try:
|
||||||
if '||' not in filename:
|
if '||' not in filename:
|
||||||
logger.error(f"Invalid filename format: {filename}")
|
logger.error(f"Invalid filename format: {filename}")
|
||||||
|
|
@ -537,7 +596,6 @@ class HiFiClient:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _download_worker(self, download_id: str, track_id: int, display_name: str):
|
def _download_worker(self, download_id: str, track_id: int, display_name: str):
|
||||||
"""Background download thread."""
|
|
||||||
try:
|
try:
|
||||||
with self._download_lock:
|
with self._download_lock:
|
||||||
if download_id in self.active_downloads:
|
if download_id in self.active_downloads:
|
||||||
|
|
@ -561,111 +619,168 @@ class HiFiClient:
|
||||||
self.active_downloads[download_id]['state'] = 'Errored'
|
self.active_downloads[download_id]['state'] = 'Errored'
|
||||||
|
|
||||||
def _download_sync(self, download_id: str, track_id: int, display_name: str) -> Optional[str]:
|
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')
|
quality_key = config_manager.get('hifi_download.quality', 'lossless')
|
||||||
chain = ['hires', 'lossless', 'high', 'low']
|
chain = ['hires', 'lossless', 'high', 'low']
|
||||||
start = chain.index(quality_key) if quality_key in chain else 1
|
start = chain.index(quality_key) if quality_key in chain else 1
|
||||||
allow_fallback = config_manager.get('hifi_download.allow_fallback', True)
|
allow_fallback = config_manager.get('hifi_download.allow_fallback', True)
|
||||||
chain = chain[start:] if allow_fallback else [quality_key]
|
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:
|
for q_key in chain:
|
||||||
if self.shutdown_check and self.shutdown_check():
|
if self.shutdown_check and self.shutdown_check():
|
||||||
logger.info("Shutdown detected, aborting HiFi download")
|
logger.info("Shutdown detected, aborting HiFi download")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
stream_info = self.get_stream_url(track_id, quality=q_key)
|
manifest_info = self._get_hls_manifest(track_id, quality=q_key)
|
||||||
if not stream_info or not stream_info.get('url'):
|
if not manifest_info or not manifest_info.get('segment_uris'):
|
||||||
logger.warning(f"No stream URL at quality {q_key}, trying next")
|
logger.warning(f"No HLS manifest at quality {q_key}, trying next")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
download_url = stream_info['url']
|
extension = manifest_info['extension']
|
||||||
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)
|
safe_name = re.sub(r'[<>:"/\\|?*]', '_', display_name)
|
||||||
out_filename = f"{safe_name}.{extension}"
|
out_filename = f"{safe_name}.{extension}"
|
||||||
out_path = self.download_path / out_filename
|
out_path = self.download_path / out_filename
|
||||||
|
|
||||||
try:
|
is_flac = q_key in ('hires', 'lossless')
|
||||||
logger.info(f"Downloading from HiFi ({q_key}): {out_filename}")
|
intermediate_path = out_path.with_suffix('.m4a') if is_flac else out_path
|
||||||
response = http_requests.get(download_url, stream=True, timeout=120)
|
|
||||||
response.raise_for_status()
|
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
|
downloaded = 0
|
||||||
chunk_size = 64 * 1024
|
|
||||||
speed_start = time.time()
|
speed_start = time.time()
|
||||||
last_speed_update = speed_start
|
segments_completed = 0
|
||||||
|
|
||||||
with self._download_lock:
|
with self._download_lock:
|
||||||
if download_id in self.active_downloads:
|
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:
|
with intermediate_path.open('wb') as output_file:
|
||||||
for chunk in response.iter_content(chunk_size=chunk_size):
|
if init_uri:
|
||||||
if not chunk:
|
|
||||||
continue
|
|
||||||
if self.shutdown_check and self.shutdown_check():
|
if self.shutdown_check and self.shutdown_check():
|
||||||
f.close()
|
logger.info("Shutdown detected, aborting HiFi download")
|
||||||
out_path.unlink(missing_ok=True)
|
intermediate_path.unlink(missing_ok=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
f.write(chunk)
|
logger.debug(f"Downloading init segment: {init_uri}")
|
||||||
downloaded += len(chunk)
|
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:
|
self._update_download_progress(download_id, downloaded,
|
||||||
progress = (downloaded / total_size) * 100
|
segments_completed, total_segments, speed_start)
|
||||||
else:
|
|
||||||
progress = 0
|
|
||||||
|
|
||||||
# Calculate speed every 0.5s
|
for segment_url in segment_uris:
|
||||||
now = time.time()
|
if self.shutdown_check and self.shutdown_check():
|
||||||
elapsed_total = now - speed_start
|
logger.info("Shutdown detected, aborting HiFi download")
|
||||||
speed = int(downloaded / elapsed_total) if elapsed_total > 0 else 0
|
intermediate_path.unlink(missing_ok=True)
|
||||||
time_remaining = int((total_size - downloaded) / speed) if speed > 0 and total_size > 0 else None
|
return None
|
||||||
|
|
||||||
with self._download_lock:
|
segment_data = self._download_segment_with_retry(segment_url)
|
||||||
if download_id in self.active_downloads:
|
output_file.write(segment_data)
|
||||||
self.active_downloads[download_id]['transferred'] = downloaded
|
downloaded += len(segment_data)
|
||||||
self.active_downloads[download_id]['progress'] = round(progress, 1)
|
segments_completed += 1
|
||||||
self.active_downloads[download_id]['speed'] = speed
|
|
||||||
self.active_downloads[download_id]['time_remaining'] = time_remaining
|
self._update_download_progress(download_id, downloaded,
|
||||||
|
segments_completed, total_segments, speed_start)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Download failed at quality {q_key}: {e}")
|
logger.warning(f"Download failed at quality {q_key}: {e}")
|
||||||
out_path.unlink(missing_ok=True)
|
intermediate_path.unlink(missing_ok=True)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Validate file size
|
|
||||||
if downloaded < MIN_AUDIO_SIZE:
|
if downloaded < MIN_AUDIO_SIZE:
|
||||||
logger.warning(f"File too small at {q_key} ({downloaded} bytes), trying next")
|
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
|
continue
|
||||||
|
|
||||||
logger.info(f"HiFi download complete ({q_key}): {out_path} "
|
try:
|
||||||
f"({downloaded / (1024*1024):.1f} MB)")
|
if is_flac:
|
||||||
return str(out_path)
|
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}'")
|
logger.error(f"All quality tiers exhausted for '{display_name}'")
|
||||||
return None
|
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]:
|
async def get_all_downloads(self) -> List[DownloadStatus]:
|
||||||
"""Get all active downloads (Soulseek-compatible)."""
|
|
||||||
statuses = []
|
statuses = []
|
||||||
with self._download_lock:
|
with self._download_lock:
|
||||||
for _dl_id, info in self.active_downloads.items():
|
for _dl_id, info in self.active_downloads.items():
|
||||||
|
|
@ -684,7 +799,6 @@ class HiFiClient:
|
||||||
return statuses
|
return statuses
|
||||||
|
|
||||||
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
|
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
|
||||||
"""Get status of a specific download."""
|
|
||||||
with self._download_lock:
|
with self._download_lock:
|
||||||
info = self.active_downloads.get(download_id)
|
info = self.active_downloads.get(download_id)
|
||||||
if not info:
|
if not info:
|
||||||
|
|
@ -704,7 +818,6 @@ class HiFiClient:
|
||||||
|
|
||||||
async def cancel_download(self, download_id: str, username: str = None,
|
async def cancel_download(self, download_id: str, username: str = None,
|
||||||
remove: bool = False) -> bool:
|
remove: bool = False) -> bool:
|
||||||
"""Cancel an active download."""
|
|
||||||
with self._download_lock:
|
with self._download_lock:
|
||||||
if download_id not in self.active_downloads:
|
if download_id not in self.active_downloads:
|
||||||
return False
|
return False
|
||||||
|
|
@ -714,7 +827,6 @@ class HiFiClient:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def clear_all_completed_downloads(self) -> bool:
|
async def clear_all_completed_downloads(self) -> bool:
|
||||||
"""Clear all terminal downloads."""
|
|
||||||
with self._download_lock:
|
with self._download_lock:
|
||||||
to_remove = [
|
to_remove = [
|
||||||
did for did, info in self.active_downloads.items()
|
did for did, info in self.active_downloads.items()
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -723,6 +723,17 @@ class MusicDatabase:
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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()
|
conn.commit()
|
||||||
logger.info("Database initialized successfully")
|
logger.info("Database initialized successfully")
|
||||||
|
|
||||||
|
|
@ -11594,6 +11605,86 @@ class MusicDatabase:
|
||||||
logger.error(f"Error getting issue counts: {e}")
|
logger.error(f"Error getting issue counts: {e}")
|
||||||
return {'open': 0, 'in_progress': 0, 'resolved': 0, 'dismissed': 0, 'total': 0}
|
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
|
# Thread-safe singleton pattern for database access
|
||||||
_database_instances: Dict[int, MusicDatabase] = {} # Thread ID -> Database instance
|
_database_instances: Dict[int, MusicDatabase] = {} # Thread ID -> Database instance
|
||||||
_database_lock = threading.Lock()
|
_database_lock = threading.Lock()
|
||||||
|
|
|
||||||
368
tests/test_hifi_instance_methods.py
Normal file
368
tests/test_hifi_instance_methods.py
Normal file
|
|
@ -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"])
|
||||||
384
tests/test_hls_parsing.py
Normal file
384
tests/test_hls_parsing.py
Normal file
|
|
@ -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")
|
||||||
|
|
@ -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
|
|
||||||
111
web_server.py
111
web_server.py
|
|
@ -5071,7 +5071,7 @@ def get_status():
|
||||||
(download_mode == 'hybrid' and 'soulseek' in hybrid_order))
|
(download_mode == 'hybrid' and 'soulseek' in hybrid_order))
|
||||||
|
|
||||||
# Serverless sources (YouTube, HiFi, Qobuz) are always available
|
# 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
|
is_serverless = (download_mode in serverless_sources or
|
||||||
(download_mode == 'hybrid' and
|
(download_mode == 'hybrid' and
|
||||||
hybrid_order and any(s in serverless_sources for s in hybrid_order)))
|
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
|
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
|
# DEEZER DOWNLOAD ENDPOINTS
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
|
|
|
||||||
|
|
@ -4622,8 +4622,13 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>API Instances:</label>
|
<label>API Instances:</label>
|
||||||
|
<div id="hifi-instances-list" style="margin-bottom: 8px;"></div>
|
||||||
|
<div style="display:flex;gap:8px;margin-bottom:8px;">
|
||||||
|
<input type="url" id="hifi-new-instance" class="form-input" placeholder="https://example.com" style="flex:1;">
|
||||||
|
<button class="test-button" onclick="addHiFiInstance()">Add</button>
|
||||||
|
</div>
|
||||||
<button class="test-button" onclick="checkHiFiInstances()" id="hifi-instances-check-btn" style="margin-bottom: 8px;">Check All Instances</button>
|
<button class="test-button" onclick="checkHiFiInstances()" id="hifi-instances-check-btn" style="margin-bottom: 8px;">Check All Instances</button>
|
||||||
<div id="hifi-instances-panel" style="display: none;"></div>
|
<div id="hifi-instances-status-panel" style="display: none;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -797,6 +797,7 @@ async function loadSettingsData() {
|
||||||
document.getElementById('qobuz-allow-fallback').checked = settings.qobuz?.allow_fallback !== false;
|
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-download-quality').value = settings.hifi_download?.quality || 'lossless';
|
||||||
document.getElementById('hifi-allow-fallback').checked = settings.hifi_download?.allow_fallback !== false;
|
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-download-quality').value = settings.deezer_download?.quality || 'flac';
|
||||||
document.getElementById('deezer-allow-fallback').checked = settings.deezer_download?.allow_fallback !== false;
|
document.getElementById('deezer-allow-fallback').checked = settings.deezer_download?.allow_fallback !== false;
|
||||||
document.getElementById('deezer-download-arl').value = settings.deezer_download?.arl || '';
|
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 searchVal = (document.getElementById('genre-whitelist-search')?.value || '').toLowerCase();
|
||||||
const filtered = searchVal ? _genreWhitelistCache.filter(g => g.toLowerCase().includes(searchVal)) : _genreWhitelistCache;
|
const filtered = searchVal ? _genreWhitelistCache.filter(g => g.toLowerCase().includes(searchVal)) : _genreWhitelistCache;
|
||||||
container.innerHTML = filtered.map(g =>
|
container.innerHTML = filtered.map(g =>
|
||||||
`<span class="genre-chip">${escapeHtml(g)}<button class="genre-chip-x" onclick="_genreWhitelistRemove('${escapeHtml(g.replace(/'/g, "\\'"))}')">×</button></span>`
|
`<span class="genre-chip">${escapeHtml(g)}<button class="genre-chip-x" data-genre="${escapeHtml(g)}">×</button></span>`
|
||||||
).join('');
|
).join('');
|
||||||
if (countEl) countEl.textContent = `${_genreWhitelistCache.length} genres`;
|
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) {
|
function _genreWhitelistRemove(genre) {
|
||||||
|
|
@ -2786,19 +2800,32 @@ function renderApiKeys(keys) {
|
||||||
container.innerHTML = keys.map(k => `
|
container.innerHTML = keys.map(k => `
|
||||||
<div style="display: flex; align-items: center; justify-content: space-between; padding: 8px 10px; margin-bottom: 4px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 6px;">
|
<div style="display: flex; align-items: center; justify-content: space-between; padding: 8px 10px; margin-bottom: 4px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 6px;">
|
||||||
<div style="flex: 1; min-width: 0;">
|
<div style="flex: 1; min-width: 0;">
|
||||||
<div style="font-size: 13px; color: #e0e0e0; font-weight: 500;">${k.label || 'Unnamed'}</div>
|
<div style="font-size: 13px; color: #e0e0e0; font-weight: 500;">${escapeHtml(k.label || 'Unnamed')}</div>
|
||||||
<div style="font-size: 11px; color: #666; margin-top: 2px;">
|
<div style="font-size: 11px; color: #666; margin-top: 2px;">
|
||||||
<code>${k.key_prefix || 'sk_...'}...</code>
|
<code>${escapeHtml(k.key_prefix || 'sk_...')}...</code>
|
||||||
· Created ${k.created_at ? new Date(k.created_at).toLocaleDateString() : 'unknown'}
|
· Created ${k.created_at ? new Date(k.created_at).toLocaleDateString() : 'unknown'}
|
||||||
${k.last_used_at ? '· Last used ' + new Date(k.last_used_at).toLocaleDateString() : ''}
|
${k.last_used_at ? '· Last used ' + new Date(k.last_used_at).toLocaleDateString() : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="revokeApiKey('${k.id}', '${(k.label || 'this key').replace(/'/g, "\\'")}')"
|
<button class="revoke-api-key-btn" data-key-id="${escapeHtml(k.id)}" data-key-label="${escapeHtml(k.label || 'this key')}"
|
||||||
style="padding: 4px 10px; background: rgba(255,82,82,0.1); border: 1px solid rgba(255,82,82,0.2); color: #ff5252; border-radius: 4px; cursor: pointer; font-size: 11px; white-space: nowrap;">
|
style="padding: 4px 10px; background: rgba(255,82,82,0.1); border: 1px solid rgba(255,82,82,0.2); color: #ff5252; border-radius: 4px; cursor: pointer; font-size: 11px; white-space: nowrap;">
|
||||||
Revoke
|
Revoke
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).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() {
|
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 = '<div style="color: rgba(255,255,255,0.4); font-size: 0.85em;">No instances configured.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listEl.innerHTML = data.instances.map((inst, i) => {
|
||||||
|
const enabledClass = inst.enabled ? '' : 'hifi-instance-disabled';
|
||||||
|
const checkHtml = inst.enabled
|
||||||
|
? `<span class="hifi-instance-toggle on" data-url="${escapeHtml(inst.url)}" title="Click to disable">✔</span>`
|
||||||
|
: `<span class="hifi-instance-toggle off" data-url="${escapeHtml(inst.url)}" title="Click to enable">✘</span>`;
|
||||||
|
return `<div class="hifi-instance-item${inst.enabled ? '' : ' disabled'}" draggable="true" data-url="${escapeHtml(inst.url)}">
|
||||||
|
<span class="hifi-instance-grip">☰</span>
|
||||||
|
<span class="hifi-instance-url">${escapeHtml(inst.url)}</span>
|
||||||
|
${checkHtml}
|
||||||
|
<span class="hifi-instance-remove" data-url="${escapeHtml(inst.url)}" title="Remove instance">✖</span>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
_initHiFiDragDrop();
|
||||||
|
_initHiFiClickHandlers();
|
||||||
|
} catch (e) {
|
||||||
|
listEl.innerHTML = `<div style="color:#f44336;font-size:0.85em;">Error loading instances: ${escapeHtml(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
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');
|
const btn = document.getElementById('hifi-instances-check-btn');
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
panel.style.display = 'block';
|
panel.style.display = 'block';
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
#artist-detail-page .release-card.album-card .mb-card-icon:hover {
|
||||||
opacity: 1;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue