use trackManifest endpoint and rebuild tracks from HLS playlists (old track enpoint was changed by Tidal to no longer provide LOSSLESS or HI_RES)

This commit is contained in:
elmerohueso 2026-04-27 19:21:48 -06:00
parent 6ae1cb471e
commit 91b33b3dd2

View file

@ -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,47 +93,39 @@ 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 — loaded from database
self._instances = [] self._instances = []
self._instance_lock = threading.Lock() self._instance_lock = threading.Lock()
self._load_instances_from_db() self._load_instances_from_db()
self._current_instance = self._instances[0] if self._instances else None self._current_instance = self._instances[0] if self._instances else None
# 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
def _load_instances_from_db(self): def _load_instances_from_db(self):
"""Load instances from the database, seeding defaults if empty."""
try: try:
from database.music_database import get_database from database.music_database import get_database
db = get_database() db = get_database()
@ -141,7 +141,6 @@ class HiFiClient:
self._instances = list(DEFAULT_INSTANCES) self._instances = list(DEFAULT_INSTANCES)
def reload_instances(self): def reload_instances(self):
"""Reload instances from the database (called after settings change)."""
with self._instance_lock: with self._instance_lock:
old_current = self._current_instance old_current = self._current_instance
self._load_instances_from_db() self._load_instances_from_db()
@ -151,15 +150,11 @@ class HiFiClient:
else: else:
logger.info("HiFi instances reloaded") logger.info("HiFi instances reloaded")
# ===================== Instance Management =====================
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)
@ -171,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
@ -180,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:
@ -201,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
@ -226,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
@ -237,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()
@ -251,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
@ -289,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)
@ -310,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):
@ -334,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):
@ -342,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
@ -358,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
@ -371,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
@ -430,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:
@ -451,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
@ -459,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:
@ -474,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:
@ -491,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}"
@ -511,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}")
@ -562,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:
@ -586,111 +619,144 @@ 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.session.get(init_uri, allow_redirects=True, timeout=30)
init_data.raise_for_status()
output_file.write(init_data.content)
downloaded += len(init_data.content)
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: seg_resp = self.session.get(segment_url, allow_redirects=True, timeout=30)
if download_id in self.active_downloads: seg_resp.raise_for_status()
self.active_downloads[download_id]['transferred'] = downloaded output_file.write(seg_resp.content)
self.active_downloads[download_id]['progress'] = round(progress, 1) downloaded += len(seg_resp.content)
self.active_downloads[download_id]['speed'] = speed segments_completed += 1
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 _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():
@ -709,7 +775,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:
@ -729,7 +794,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
@ -739,7 +803,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()