Add HiFi as free lossless download source via public hifi-api instances
New download mode alongside Soulseek, YouTube, Tidal, and Qobuz. Uses community-run REST API instances (no auth required) that serve Tidal CDN FLAC streams. Features quality fallback chain (hires→lossless→high→low), automatic instance rotation on failure, and full hybrid mode support. Also fixes 6 missing streaming source checks for HiFi and Qobuz in the frontend that were blocking playback with "format not supported" errors.
This commit is contained in:
parent
483e45cbc0
commit
ec389c5ae8
8 changed files with 2155 additions and 36 deletions
|
|
@ -363,7 +363,7 @@ class ConfigManager:
|
|||
"transfer_path": "./Transfer"
|
||||
},
|
||||
"download_source": {
|
||||
"mode": "soulseek", # Options: "soulseek", "youtube", "tidal", "qobuz", "hybrid"
|
||||
"mode": "soulseek", # Options: "soulseek", "youtube", "tidal", "qobuz", "hifi", "hybrid"
|
||||
"hybrid_primary": "soulseek", # Which source to try first in hybrid mode
|
||||
"hybrid_secondary": "youtube", # Fallback source if primary finds nothing
|
||||
},
|
||||
|
|
@ -384,6 +384,9 @@ class ConfigManager:
|
|||
"user_auth_token": ""
|
||||
}
|
||||
},
|
||||
"hifi_download": {
|
||||
"quality": "lossless", # Options: "low", "high", "lossless", "hires"
|
||||
},
|
||||
"listenbrainz": {
|
||||
"base_url": "",
|
||||
"token": ""
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
"""
|
||||
Download Orchestrator
|
||||
Routes downloads between Soulseek, YouTube, Tidal, and Qobuz based on configuration.
|
||||
Routes downloads between Soulseek, YouTube, Tidal, Qobuz, and HiFi based on configuration.
|
||||
|
||||
Supports five modes:
|
||||
Supports six modes:
|
||||
- Soulseek Only: Traditional behavior
|
||||
- YouTube Only: YouTube-exclusive downloads
|
||||
- Tidal Only: Tidal-exclusive downloads
|
||||
- Qobuz Only: Qobuz-exclusive downloads
|
||||
- HiFi Only: Free lossless downloads via public hifi-api instances
|
||||
- Hybrid: Try primary source first, fallback to others
|
||||
"""
|
||||
|
||||
|
|
@ -20,13 +21,14 @@ from core.soulseek_client import SoulseekClient, TrackResult, AlbumResult, Downl
|
|||
from core.youtube_client import YouTubeClient
|
||||
from core.tidal_download_client import TidalDownloadClient
|
||||
from core.qobuz_client import QobuzClient
|
||||
from core.hifi_client import HiFiClient
|
||||
|
||||
logger = get_logger("download_orchestrator")
|
||||
|
||||
|
||||
class DownloadOrchestrator:
|
||||
"""
|
||||
Orchestrates downloads between Soulseek, YouTube, Tidal, and Qobuz based on user preferences.
|
||||
Orchestrates downloads between Soulseek, YouTube, Tidal, Qobuz, and HiFi based on user preferences.
|
||||
|
||||
Acts as a drop-in replacement for SoulseekClient by exposing the same async interface.
|
||||
Routes requests to the appropriate client(s) based on configured mode.
|
||||
|
|
@ -38,6 +40,7 @@ class DownloadOrchestrator:
|
|||
self.youtube = YouTubeClient()
|
||||
self.tidal = TidalDownloadClient()
|
||||
self.qobuz = QobuzClient()
|
||||
self.hifi = HiFiClient()
|
||||
|
||||
# Load mode from config
|
||||
self.mode = config_manager.get('download_source.mode', 'soulseek')
|
||||
|
|
@ -74,8 +77,10 @@ class DownloadOrchestrator:
|
|||
return self.tidal.is_configured()
|
||||
elif self.mode == 'qobuz':
|
||||
return self.qobuz.is_configured()
|
||||
elif self.mode == 'hifi':
|
||||
return self.hifi.is_configured()
|
||||
elif self.mode == 'hybrid':
|
||||
return self.soulseek.is_configured() or self.youtube.is_configured() or self.tidal.is_configured() or self.qobuz.is_configured()
|
||||
return self.soulseek.is_configured() or self.youtube.is_configured() or self.tidal.is_configured() or self.qobuz.is_configured() or self.hifi.is_configured()
|
||||
|
||||
return False
|
||||
|
||||
|
|
@ -93,15 +98,18 @@ class DownloadOrchestrator:
|
|||
return await self.tidal.check_connection()
|
||||
elif self.mode == 'qobuz':
|
||||
return await self.qobuz.check_connection()
|
||||
elif self.mode == 'hifi':
|
||||
return await self.hifi.check_connection()
|
||||
elif self.mode == 'hybrid':
|
||||
soulseek_ok = await self.soulseek.check_connection()
|
||||
youtube_ok = await self.youtube.check_connection()
|
||||
tidal_ok = await self.tidal.check_connection()
|
||||
qobuz_ok = await self.qobuz.check_connection()
|
||||
hifi_ok = await self.hifi.check_connection()
|
||||
|
||||
logger.info(f" Soulseek: {'✅' if soulseek_ok else '❌'} | YouTube: {'✅' if youtube_ok else '❌'} | Tidal: {'✅' if tidal_ok else '❌'} | Qobuz: {'✅' if qobuz_ok else '❌'}")
|
||||
logger.info(f" Soulseek: {'✅' if soulseek_ok else '❌'} | YouTube: {'✅' if youtube_ok else '❌'} | Tidal: {'✅' if tidal_ok else '❌'} | Qobuz: {'✅' if qobuz_ok else '❌'} | HiFi: {'✅' if hifi_ok else '❌'}")
|
||||
|
||||
return soulseek_ok or youtube_ok or tidal_ok or qobuz_ok
|
||||
return soulseek_ok or youtube_ok or tidal_ok or qobuz_ok or hifi_ok
|
||||
|
||||
return False
|
||||
|
||||
|
|
@ -133,12 +141,17 @@ class DownloadOrchestrator:
|
|||
logger.info(f"🔍 Searching Qobuz: {query}")
|
||||
return await self.qobuz.search(query, timeout, progress_callback)
|
||||
|
||||
elif self.mode == 'hifi':
|
||||
logger.info(f"🔍 Searching HiFi: {query}")
|
||||
return await self.hifi.search(query, timeout, progress_callback)
|
||||
|
||||
elif self.mode == 'hybrid':
|
||||
clients = {
|
||||
'soulseek': self.soulseek,
|
||||
'youtube': self.youtube,
|
||||
'tidal': self.tidal,
|
||||
'qobuz': self.qobuz,
|
||||
'hifi': self.hifi,
|
||||
}
|
||||
primary = self.hybrid_primary if self.hybrid_primary in clients else 'soulseek'
|
||||
secondary = self.hybrid_secondary if self.hybrid_secondary in clients else 'soulseek'
|
||||
|
|
@ -195,7 +208,7 @@ class DownloadOrchestrator:
|
|||
|
||||
# 2. Filter using Soulseek's quality preferences (Soulseek only)
|
||||
# Streaming sources (YouTube/Tidal/Qobuz) handle quality internally
|
||||
is_streaming = tracks[0].username in ('youtube', 'tidal', 'qobuz') if tracks else False
|
||||
is_streaming = tracks[0].username in ('youtube', 'tidal', 'qobuz', 'hifi') if tracks else False
|
||||
if is_streaming:
|
||||
filtered_results = tracks
|
||||
else:
|
||||
|
|
@ -239,6 +252,9 @@ class DownloadOrchestrator:
|
|||
elif username == 'qobuz':
|
||||
logger.info(f"📥 Downloading from Qobuz: {filename}")
|
||||
return await self.qobuz.download(username, filename, file_size)
|
||||
elif username == 'hifi':
|
||||
logger.info(f"📥 Downloading from HiFi: {filename}")
|
||||
return await self.hifi.download(username, filename, file_size)
|
||||
else:
|
||||
logger.info(f"📥 Downloading from Soulseek: {filename}")
|
||||
return await self.soulseek.download(username, filename, file_size)
|
||||
|
|
@ -255,8 +271,9 @@ class DownloadOrchestrator:
|
|||
youtube_downloads = await self.youtube.get_all_downloads()
|
||||
tidal_downloads = await self.tidal.get_all_downloads()
|
||||
qobuz_downloads = await self.qobuz.get_all_downloads()
|
||||
hifi_downloads = await self.hifi.get_all_downloads()
|
||||
|
||||
return soulseek_downloads + youtube_downloads + tidal_downloads + qobuz_downloads
|
||||
return soulseek_downloads + youtube_downloads + tidal_downloads + qobuz_downloads + hifi_downloads
|
||||
|
||||
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
|
||||
"""
|
||||
|
|
@ -288,6 +305,11 @@ class DownloadOrchestrator:
|
|||
if status:
|
||||
return status
|
||||
|
||||
# Try HiFi
|
||||
status = await self.hifi.get_download_status(download_id)
|
||||
if status:
|
||||
return status
|
||||
|
||||
return None
|
||||
|
||||
async def cancel_download(self, download_id: str, username: str = None, remove: bool = False) -> bool:
|
||||
|
|
@ -309,6 +331,8 @@ class DownloadOrchestrator:
|
|||
return await self.tidal.cancel_download(download_id, username, remove)
|
||||
elif username == 'qobuz':
|
||||
return await self.qobuz.cancel_download(download_id, username, remove)
|
||||
elif username == 'hifi':
|
||||
return await self.hifi.cancel_download(download_id, username, remove)
|
||||
elif username:
|
||||
return await self.soulseek.cancel_download(download_id, username, remove)
|
||||
|
||||
|
|
@ -326,7 +350,11 @@ class DownloadOrchestrator:
|
|||
return True
|
||||
|
||||
qobuz_cancelled = await self.qobuz.cancel_download(download_id, username, remove)
|
||||
return qobuz_cancelled
|
||||
if qobuz_cancelled:
|
||||
return True
|
||||
|
||||
hifi_cancelled = await self.hifi.cancel_download(download_id, username, remove)
|
||||
return hifi_cancelled
|
||||
|
||||
async def signal_download_completion(self, download_id: str, username: str, remove: bool = True) -> bool:
|
||||
"""
|
||||
|
|
@ -354,8 +382,9 @@ class DownloadOrchestrator:
|
|||
youtube_cleared = await self.youtube.clear_all_completed_downloads()
|
||||
tidal_cleared = await self.tidal.clear_all_completed_downloads()
|
||||
qobuz_cleared = await self.qobuz.clear_all_completed_downloads()
|
||||
hifi_cleared = await self.hifi.clear_all_completed_downloads()
|
||||
|
||||
return soulseek_cleared and youtube_cleared and tidal_cleared and qobuz_cleared
|
||||
return soulseek_cleared and youtube_cleared and tidal_cleared and qobuz_cleared and hifi_cleared
|
||||
|
||||
# ===== Soulseek-specific methods (for backwards compatibility) =====
|
||||
# These are internal methods that some parts of the codebase use directly
|
||||
|
|
@ -417,4 +446,5 @@ class DownloadOrchestrator:
|
|||
soulseek_ok = await self.soulseek.cancel_all_downloads()
|
||||
await self.tidal.clear_all_completed_downloads()
|
||||
await self.qobuz.clear_all_completed_downloads()
|
||||
await self.hifi.clear_all_completed_downloads()
|
||||
return soulseek_ok
|
||||
|
|
|
|||
724
core/hifi_client.py
Normal file
724
core/hifi_client.py
Normal file
|
|
@ -0,0 +1,724 @@
|
|||
"""
|
||||
HiFi API Client — Alternative lossless download source via public hifi-api instances.
|
||||
|
||||
Provides Tidal-sourced FLAC downloads (16-bit and 24-bit) through the open hifi-api
|
||||
project. No authentication required from the client — the API instances handle
|
||||
Tidal credentials internally.
|
||||
|
||||
Interface follows the same patterns as TidalDownloadClient for drop-in compatibility
|
||||
with the existing download infrastructure (TrackResult, DownloadStatus, etc).
|
||||
|
||||
Supports:
|
||||
- Track search by title, artist, album
|
||||
- Album lookup by ID
|
||||
- Artist lookup by ID
|
||||
- Direct FLAC download URLs from Tidal CDN
|
||||
- Quality selection: HI_RES_LOSSLESS, LOSSLESS, HIGH, LOW
|
||||
- Multiple API instance failover
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import base64
|
||||
import uuid
|
||||
import time
|
||||
import threading
|
||||
from typing import List, Optional, Dict, Any, Tuple
|
||||
from pathlib import Path
|
||||
|
||||
import requests as http_requests
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
from config.settings import config_manager
|
||||
from core.soulseek_client import TrackResult, AlbumResult, DownloadStatus
|
||||
|
||||
logger = get_logger("hifi_client")
|
||||
|
||||
# Quality tiers matching Tidal's internal quality labels
|
||||
HIFI_QUALITY_MAP = {
|
||||
'hires': {
|
||||
'api_value': 'HI_RES_LOSSLESS',
|
||||
'label': 'FLAC 24-bit/96kHz',
|
||||
'extension': 'flac',
|
||||
'bitrate': 9216,
|
||||
'codec': 'flac',
|
||||
},
|
||||
'lossless': {
|
||||
'api_value': 'LOSSLESS',
|
||||
'label': 'FLAC 16-bit/44.1kHz',
|
||||
'extension': 'flac',
|
||||
'bitrate': 1411,
|
||||
'codec': 'flac',
|
||||
},
|
||||
'high': {
|
||||
'api_value': 'HIGH',
|
||||
'label': 'AAC 320kbps',
|
||||
'extension': 'm4a',
|
||||
'bitrate': 320,
|
||||
'codec': 'aac',
|
||||
},
|
||||
'low': {
|
||||
'api_value': 'LOW',
|
||||
'label': 'AAC 96kbps',
|
||||
'extension': 'm4a',
|
||||
'bitrate': 96,
|
||||
'codec': 'aac',
|
||||
},
|
||||
}
|
||||
|
||||
# Default public hifi-api instances (ordered by preference)
|
||||
DEFAULT_INSTANCES = [
|
||||
'https://triton.squid.wtf',
|
||||
'https://hifi-one.spotisaver.net',
|
||||
'https://hifi-two.spotisaver.net',
|
||||
'https://hund.qqdl.site',
|
||||
'https://katze.qqdl.site',
|
||||
'https://arran.monochrome.tf',
|
||||
]
|
||||
|
||||
|
||||
class HiFiClient:
|
||||
"""
|
||||
HiFi API client for searching and downloading lossless music.
|
||||
Uses public hifi-api instances (Tidal backend) — no auth required.
|
||||
"""
|
||||
|
||||
def __init__(self, download_path: str = None, base_url: str = None):
|
||||
# Download path (use Soulseek path for consistency with post-processing)
|
||||
if download_path is None:
|
||||
download_path = config_manager.get('soulseek.download_path', './downloads')
|
||||
self.download_path = Path(download_path)
|
||||
self.download_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# API instance management
|
||||
self._instances = list(DEFAULT_INSTANCES)
|
||||
if base_url:
|
||||
# User-provided instance gets top priority
|
||||
self._instances.insert(0, base_url.rstrip('/'))
|
||||
|
||||
self._current_instance = self._instances[0] if self._instances else None
|
||||
self._instance_lock = threading.Lock()
|
||||
|
||||
# HTTP session with retry-friendly settings
|
||||
self.session = http_requests.Session()
|
||||
self.session.headers.update({
|
||||
'User-Agent': 'SoulSync/1.0',
|
||||
'Accept': 'application/json',
|
||||
})
|
||||
|
||||
# Download tracking (mirrors TidalDownloadClient pattern)
|
||||
self.active_downloads: Dict[str, Dict[str, Any]] = {}
|
||||
self._download_lock = threading.Lock()
|
||||
|
||||
# Shutdown check callback
|
||||
self.shutdown_check = None
|
||||
|
||||
# Rate limiting
|
||||
self._last_api_call = 0
|
||||
self._api_lock = threading.Lock()
|
||||
self._min_interval = 0.5 # 500ms between calls
|
||||
|
||||
logger.info(f"HiFi client initialized (instance: {self._current_instance}, "
|
||||
f"download path: {self.download_path})")
|
||||
|
||||
def set_shutdown_check(self, check_callable):
|
||||
"""Set a callback function to check for system shutdown."""
|
||||
self.shutdown_check = check_callable
|
||||
|
||||
# ===================== Instance Management =====================
|
||||
|
||||
def _get_instance(self) -> Optional[str]:
|
||||
"""Get the current active API instance URL."""
|
||||
with self._instance_lock:
|
||||
return self._current_instance
|
||||
|
||||
def _rotate_instance(self, failed_url: str):
|
||||
"""Move a failed instance to the back of the list and switch to next."""
|
||||
with self._instance_lock:
|
||||
if failed_url in self._instances:
|
||||
self._instances.remove(failed_url)
|
||||
self._instances.append(failed_url)
|
||||
if self._instances:
|
||||
self._current_instance = self._instances[0]
|
||||
logger.info(f"Rotated to HiFi instance: {self._current_instance}")
|
||||
else:
|
||||
self._current_instance = None
|
||||
|
||||
def _rate_limit(self):
|
||||
"""Enforce minimum interval between API calls."""
|
||||
with self._api_lock:
|
||||
now = time.time()
|
||||
elapsed = now - self._last_api_call
|
||||
if elapsed < self._min_interval:
|
||||
time.sleep(self._min_interval - elapsed)
|
||||
self._last_api_call = time.time()
|
||||
|
||||
def _api_get(self, path: str, params: dict = None, timeout: int = 15) -> Optional[dict]:
|
||||
"""
|
||||
Make a GET request to the hifi-api, with instance failover.
|
||||
Tries each instance up to once before giving up.
|
||||
"""
|
||||
tried = set()
|
||||
|
||||
while True:
|
||||
instance = self._get_instance()
|
||||
if not instance or instance in tried:
|
||||
logger.error("All HiFi API instances exhausted")
|
||||
return None
|
||||
|
||||
tried.add(instance)
|
||||
url = f"{instance}{path}"
|
||||
self._rate_limit()
|
||||
|
||||
try:
|
||||
response = self.session.get(url, params=params, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Check for API-level errors
|
||||
if isinstance(data, dict) and data.get('error'):
|
||||
logger.warning(f"HiFi API error from {instance}: {data['error']}")
|
||||
return None
|
||||
|
||||
return data
|
||||
|
||||
except http_requests.exceptions.Timeout:
|
||||
logger.warning(f"HiFi API timeout: {instance}")
|
||||
self._rotate_instance(instance)
|
||||
except http_requests.exceptions.ConnectionError:
|
||||
logger.warning(f"HiFi API connection error: {instance}")
|
||||
self._rotate_instance(instance)
|
||||
except http_requests.exceptions.HTTPError as e:
|
||||
status = e.response.status_code if e.response is not None else 0
|
||||
if status in (502, 503, 504):
|
||||
logger.warning(f"HiFi API server error ({status}): {instance}")
|
||||
self._rotate_instance(instance)
|
||||
else:
|
||||
logger.error(f"HiFi API HTTP error ({status}): {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"HiFi API unexpected error: {e}")
|
||||
return None
|
||||
|
||||
# ===================== Availability =====================
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if the HiFi API is reachable."""
|
||||
try:
|
||||
data = self._api_get('/', timeout=5)
|
||||
return data is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""Check if HiFi client is configured and ready (matches Soulseek interface)."""
|
||||
return self._current_instance is not None
|
||||
|
||||
async def check_connection(self) -> bool:
|
||||
"""Test if HiFi API is accessible (async, Soulseek-compatible)."""
|
||||
try:
|
||||
import asyncio
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, self.is_available)
|
||||
except Exception as e:
|
||||
logger.error(f"HiFi connection check failed: {e}")
|
||||
return False
|
||||
|
||||
def get_version(self) -> Optional[str]:
|
||||
"""Get the API version of the current instance."""
|
||||
data = self._api_get('/')
|
||||
if data and isinstance(data, dict):
|
||||
return data.get('version') or data.get('data', {}).get('version')
|
||||
return None
|
||||
|
||||
# ===================== Search =====================
|
||||
|
||||
def search_tracks(self, title: str = None, artist: str = None,
|
||||
album: str = None, limit: int = 20) -> List[Dict]:
|
||||
"""
|
||||
Search for tracks on Tidal via hifi-api.
|
||||
|
||||
Args:
|
||||
title: Track title to search for
|
||||
artist: Artist name to search for
|
||||
album: Album name to search for
|
||||
limit: Max results to return
|
||||
|
||||
Returns:
|
||||
List of track dicts with id, title, artist, album, duration, etc.
|
||||
"""
|
||||
params = {'limit': limit}
|
||||
if title:
|
||||
params['s'] = title
|
||||
if artist:
|
||||
params['a'] = artist
|
||||
if album:
|
||||
params['al'] = album
|
||||
|
||||
if not any(k in params for k in ('s', 'a', 'al')):
|
||||
logger.warning("search_tracks called with no search terms")
|
||||
return []
|
||||
|
||||
data = self._api_get('/search/', params=params)
|
||||
if not data:
|
||||
return []
|
||||
|
||||
# Handle response format: {data: {items: [...]}} or {data: [...]}
|
||||
items = []
|
||||
if isinstance(data, dict):
|
||||
inner = data.get('data', data)
|
||||
if isinstance(inner, dict):
|
||||
items = inner.get('items', inner.get('tracks', []))
|
||||
elif isinstance(inner, list):
|
||||
items = inner
|
||||
|
||||
results = []
|
||||
for item in items:
|
||||
try:
|
||||
results.append(self._parse_track(item))
|
||||
except Exception as e:
|
||||
logger.debug(f"Skipping unparseable track: {e}")
|
||||
|
||||
logger.info(f"HiFi search: {len(results)} tracks found "
|
||||
f"(title={title}, artist={artist}, album={album})")
|
||||
return results
|
||||
|
||||
def search_raw(self, query: str, limit: int = 20) -> List[Dict]:
|
||||
"""
|
||||
Generic search (free-text query). Maps to title search.
|
||||
Returns raw dicts (not TrackResult).
|
||||
"""
|
||||
return self.search_tracks(title=query, limit=limit)
|
||||
|
||||
def _parse_track(self, item: dict) -> Dict:
|
||||
"""Parse a track item from hifi-api response into a normalized dict."""
|
||||
# Artist can be a dict with 'name' or a list of artists
|
||||
artist_name = 'Unknown Artist'
|
||||
artists_raw = item.get('artists', item.get('artist'))
|
||||
if isinstance(artists_raw, list):
|
||||
names = []
|
||||
for a in artists_raw:
|
||||
if isinstance(a, dict):
|
||||
names.append(a.get('name', ''))
|
||||
elif isinstance(a, str):
|
||||
names.append(a)
|
||||
artist_name = ', '.join(n for n in names if n) or 'Unknown Artist'
|
||||
elif isinstance(artists_raw, dict):
|
||||
artist_name = artists_raw.get('name', 'Unknown Artist')
|
||||
elif isinstance(artists_raw, str):
|
||||
artist_name = artists_raw
|
||||
|
||||
# Album
|
||||
album_raw = item.get('album', {})
|
||||
album_name = ''
|
||||
if isinstance(album_raw, dict):
|
||||
album_name = album_raw.get('title', album_raw.get('name', ''))
|
||||
elif isinstance(album_raw, str):
|
||||
album_name = album_raw
|
||||
|
||||
# Duration
|
||||
duration_s = item.get('duration', 0)
|
||||
duration_ms = duration_s * 1000 if duration_s and duration_s < 100000 else duration_s
|
||||
|
||||
return {
|
||||
'id': item.get('id'),
|
||||
'title': item.get('title', item.get('name', 'Unknown')),
|
||||
'artist': artist_name,
|
||||
'album': album_name,
|
||||
'duration_ms': int(duration_ms) if duration_ms else 0,
|
||||
'track_number': item.get('trackNumber', item.get('track_number')),
|
||||
'isrc': item.get('isrc'),
|
||||
'explicit': item.get('explicit', False),
|
||||
'quality': item.get('audioQuality', item.get('quality', '')),
|
||||
}
|
||||
|
||||
# ===================== Track Info & Stream URL =====================
|
||||
|
||||
def get_track_info(self, track_id: int) -> Optional[Dict]:
|
||||
"""Get detailed metadata for a specific track."""
|
||||
data = self._api_get('/info/', params={'id': track_id})
|
||||
if not data:
|
||||
return None
|
||||
|
||||
inner = data.get('data', data) if isinstance(data, dict) else data
|
||||
if isinstance(inner, dict):
|
||||
return self._parse_track(inner)
|
||||
return None
|
||||
|
||||
def get_stream_url(self, track_id: int, quality: str = 'lossless') -> Optional[Dict]:
|
||||
"""
|
||||
Get the direct download URL for a track.
|
||||
|
||||
Args:
|
||||
track_id: Tidal track ID
|
||||
quality: One of 'hires', 'lossless', 'high', 'low'
|
||||
|
||||
Returns:
|
||||
Dict with 'url', 'mime_type', 'codec', 'quality' or None on failure.
|
||||
"""
|
||||
q_info = HIFI_QUALITY_MAP.get(quality, HIFI_QUALITY_MAP['lossless'])
|
||||
api_quality = q_info['api_value']
|
||||
|
||||
data = self._api_get('/track/', params={'id': track_id, 'quality': api_quality})
|
||||
if not data:
|
||||
return None
|
||||
|
||||
# Extract manifest from response
|
||||
inner = data.get('data', data) if isinstance(data, dict) else data
|
||||
if not isinstance(inner, dict):
|
||||
return None
|
||||
|
||||
manifest_b64 = inner.get('manifest')
|
||||
if not manifest_b64:
|
||||
logger.warning(f"No manifest in track response for {track_id}")
|
||||
return None
|
||||
|
||||
try:
|
||||
manifest = json.loads(base64.b64decode(manifest_b64))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to decode manifest for track {track_id}: {e}")
|
||||
return None
|
||||
|
||||
urls = manifest.get('urls', [])
|
||||
if not urls:
|
||||
logger.warning(f"No URLs in manifest for track {track_id}")
|
||||
return None
|
||||
|
||||
return {
|
||||
'url': urls[0],
|
||||
'mime_type': manifest.get('mimeType', ''),
|
||||
'codec': manifest.get('codecs', ''),
|
||||
'encryption': manifest.get('encryptionType', 'NONE'),
|
||||
'quality': quality,
|
||||
}
|
||||
|
||||
# ===================== Album & Artist =====================
|
||||
|
||||
def get_album(self, album_id: int, limit: int = 100) -> Optional[Dict]:
|
||||
"""Get album metadata and track list."""
|
||||
data = self._api_get('/album/', params={'id': album_id, 'limit': limit})
|
||||
if not data:
|
||||
return None
|
||||
|
||||
inner = data.get('data', data) if isinstance(data, dict) else data
|
||||
if not isinstance(inner, dict):
|
||||
return None
|
||||
|
||||
# Parse tracks within album
|
||||
tracks_raw = inner.get('items', inner.get('tracks', []))
|
||||
tracks = []
|
||||
for item in tracks_raw:
|
||||
try:
|
||||
tracks.append(self._parse_track(item))
|
||||
except Exception as e:
|
||||
logger.debug(f"Skipping album track: {e}")
|
||||
|
||||
return {
|
||||
'id': inner.get('id', album_id),
|
||||
'title': inner.get('title', inner.get('name', 'Unknown Album')),
|
||||
'artist': inner.get('artist', {}).get('name', '') if isinstance(inner.get('artist'), dict) else str(inner.get('artist', '')),
|
||||
'tracks': tracks,
|
||||
'track_count': inner.get('numberOfTracks', len(tracks)),
|
||||
'duration_s': inner.get('duration', 0),
|
||||
'release_date': inner.get('releaseDate', ''),
|
||||
'cover_id': inner.get('cover', ''),
|
||||
}
|
||||
|
||||
def get_artist(self, artist_id: int) -> Optional[Dict]:
|
||||
"""Get artist info and top tracks."""
|
||||
data = self._api_get('/artist/', params={'id': artist_id})
|
||||
if not data:
|
||||
return None
|
||||
|
||||
inner = data.get('data', data) if isinstance(data, dict) else data
|
||||
return inner if isinstance(inner, dict) else None
|
||||
|
||||
# ===================== Soulseek-Compatible Search =====================
|
||||
|
||||
async def search(self, query: str, timeout: int = None,
|
||||
progress_callback=None) -> Tuple[List[TrackResult], List[AlbumResult]]:
|
||||
"""
|
||||
Search with Soulseek-compatible return format (TrackResult, AlbumResult).
|
||||
Matches the interface expected by DownloadOrchestrator.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
tracks = await loop.run_in_executor(None, lambda: self.search_raw(query))
|
||||
|
||||
quality_key = config_manager.get('hifi_download.quality', 'lossless')
|
||||
q_info = HIFI_QUALITY_MAP.get(quality_key, HIFI_QUALITY_MAP['lossless'])
|
||||
|
||||
results = []
|
||||
for t in tracks:
|
||||
try:
|
||||
tr = self._to_track_result(t, q_info)
|
||||
results.append(tr)
|
||||
except Exception as e:
|
||||
logger.debug(f"Skipping track result conversion: {e}")
|
||||
|
||||
return (results, [])
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"HiFi compatible search failed: {e}")
|
||||
return ([], [])
|
||||
|
||||
def _to_track_result(self, track: Dict, quality_info: Dict) -> TrackResult:
|
||||
"""Convert a hifi track dict to a TrackResult."""
|
||||
display_name = f"{track['artist']} - {track['title']}"
|
||||
filename = f"{track['id']}||{display_name}"
|
||||
|
||||
return TrackResult(
|
||||
username='hifi',
|
||||
filename=filename,
|
||||
size=0,
|
||||
bitrate=quality_info.get('bitrate'),
|
||||
duration=track.get('duration_ms'),
|
||||
quality=quality_info.get('codec', 'flac'),
|
||||
free_upload_slots=999,
|
||||
upload_speed=999999,
|
||||
queue_length=0,
|
||||
artist=track.get('artist'),
|
||||
title=track.get('title'),
|
||||
album=track.get('album'),
|
||||
track_number=track.get('track_number'),
|
||||
)
|
||||
|
||||
# ===================== Download =====================
|
||||
|
||||
async def download(self, username: str, filename: str, file_size: int = 0) -> Optional[str]:
|
||||
"""
|
||||
Download a track (async, Soulseek-compatible interface).
|
||||
Filename format: "track_id||display_name"
|
||||
"""
|
||||
try:
|
||||
if '||' not in filename:
|
||||
logger.error(f"Invalid filename format: {filename}")
|
||||
return None
|
||||
|
||||
track_id_str, display_name = filename.split('||', 1)
|
||||
try:
|
||||
track_id = int(track_id_str)
|
||||
except ValueError:
|
||||
logger.error(f"Invalid track ID: {track_id_str}")
|
||||
return None
|
||||
|
||||
download_id = str(uuid.uuid4())
|
||||
|
||||
with self._download_lock:
|
||||
self.active_downloads[download_id] = {
|
||||
'id': download_id,
|
||||
'filename': filename,
|
||||
'username': 'hifi',
|
||||
'state': 'Initializing',
|
||||
'progress': 0.0,
|
||||
'size': 0,
|
||||
'transferred': 0,
|
||||
'speed': 0,
|
||||
'time_remaining': None,
|
||||
'track_id': track_id,
|
||||
'display_name': display_name,
|
||||
'file_path': None,
|
||||
}
|
||||
|
||||
thread = threading.Thread(
|
||||
target=self._download_worker,
|
||||
args=(download_id, track_id, display_name),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
return download_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start HiFi download: {e}")
|
||||
return None
|
||||
|
||||
def _download_worker(self, download_id: str, track_id: int, display_name: str):
|
||||
"""Background download thread."""
|
||||
try:
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['state'] = 'InProgress, Downloading'
|
||||
|
||||
file_path = self._download_sync(download_id, track_id, display_name)
|
||||
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
if file_path:
|
||||
self.active_downloads[download_id]['state'] = 'Completed, Succeeded'
|
||||
self.active_downloads[download_id]['progress'] = 100.0
|
||||
self.active_downloads[download_id]['file_path'] = file_path
|
||||
else:
|
||||
self.active_downloads[download_id]['state'] = 'Errored'
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"HiFi download worker failed for {download_id}: {e}")
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['state'] = 'Errored'
|
||||
|
||||
def _download_sync(self, download_id: str, track_id: int, display_name: str) -> Optional[str]:
|
||||
"""
|
||||
Synchronous download with quality fallback chain.
|
||||
Returns file path on success, None on failure.
|
||||
"""
|
||||
quality_key = config_manager.get('hifi_download.quality', 'lossless')
|
||||
chain = ['hires', 'lossless', 'high', 'low']
|
||||
start = chain.index(quality_key) if quality_key in chain else 1
|
||||
chain = chain[start:]
|
||||
|
||||
MIN_AUDIO_SIZE = 100 * 1024 # 100KB
|
||||
|
||||
for q_key in chain:
|
||||
if self.shutdown_check and self.shutdown_check():
|
||||
logger.info("Shutdown detected, aborting HiFi download")
|
||||
return None
|
||||
|
||||
stream_info = self.get_stream_url(track_id, quality=q_key)
|
||||
if not stream_info or not stream_info.get('url'):
|
||||
logger.warning(f"No stream URL at quality {q_key}, trying next")
|
||||
continue
|
||||
|
||||
download_url = stream_info['url']
|
||||
codec = stream_info.get('codec', '')
|
||||
|
||||
# Determine extension
|
||||
if 'flac' in codec.lower():
|
||||
extension = 'flac'
|
||||
elif 'mp4a' in codec.lower() or 'aac' in codec.lower():
|
||||
extension = 'm4a'
|
||||
else:
|
||||
extension = HIFI_QUALITY_MAP.get(q_key, {}).get('extension', 'flac')
|
||||
|
||||
# Build output path
|
||||
safe_name = re.sub(r'[<>:"/\\|?*]', '_', display_name)
|
||||
out_filename = f"{safe_name}.{extension}"
|
||||
out_path = self.download_path / out_filename
|
||||
|
||||
try:
|
||||
logger.info(f"Downloading from HiFi ({q_key}): {out_filename}")
|
||||
response = http_requests.get(download_url, stream=True, timeout=120)
|
||||
response.raise_for_status()
|
||||
|
||||
total_size = int(response.headers.get('content-length', 0))
|
||||
downloaded = 0
|
||||
chunk_size = 64 * 1024
|
||||
speed_start = time.time()
|
||||
last_speed_update = speed_start
|
||||
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['size'] = total_size
|
||||
|
||||
with open(out_path, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=chunk_size):
|
||||
if not chunk:
|
||||
continue
|
||||
if self.shutdown_check and self.shutdown_check():
|
||||
f.close()
|
||||
out_path.unlink(missing_ok=True)
|
||||
return None
|
||||
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
|
||||
if total_size > 0:
|
||||
progress = (downloaded / total_size) * 100
|
||||
else:
|
||||
progress = 0
|
||||
|
||||
# Calculate speed every 0.5s
|
||||
now = time.time()
|
||||
elapsed_total = now - speed_start
|
||||
speed = int(downloaded / elapsed_total) if elapsed_total > 0 else 0
|
||||
time_remaining = int((total_size - downloaded) / speed) if speed > 0 and total_size > 0 else None
|
||||
|
||||
with self._download_lock:
|
||||
if download_id in self.active_downloads:
|
||||
self.active_downloads[download_id]['transferred'] = downloaded
|
||||
self.active_downloads[download_id]['progress'] = round(progress, 1)
|
||||
self.active_downloads[download_id]['speed'] = speed
|
||||
self.active_downloads[download_id]['time_remaining'] = time_remaining
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Download failed at quality {q_key}: {e}")
|
||||
out_path.unlink(missing_ok=True)
|
||||
continue
|
||||
|
||||
# Validate file size
|
||||
if downloaded < MIN_AUDIO_SIZE:
|
||||
logger.warning(f"File too small at {q_key} ({downloaded} bytes), trying next")
|
||||
out_path.unlink(missing_ok=True)
|
||||
continue
|
||||
|
||||
logger.info(f"HiFi download complete ({q_key}): {out_path} "
|
||||
f"({downloaded / (1024*1024):.1f} MB)")
|
||||
return str(out_path)
|
||||
|
||||
logger.error(f"All quality tiers exhausted for '{display_name}'")
|
||||
return None
|
||||
|
||||
# ===================== Status / Cancel / Clear =====================
|
||||
|
||||
async def get_all_downloads(self) -> List[DownloadStatus]:
|
||||
"""Get all active downloads (Soulseek-compatible)."""
|
||||
statuses = []
|
||||
with self._download_lock:
|
||||
for dl_id, info in self.active_downloads.items():
|
||||
statuses.append(DownloadStatus(
|
||||
id=info['id'],
|
||||
filename=info['filename'],
|
||||
username=info['username'],
|
||||
state=info['state'],
|
||||
progress=info['progress'],
|
||||
size=info['size'],
|
||||
transferred=info['transferred'],
|
||||
speed=info['speed'],
|
||||
time_remaining=info.get('time_remaining'),
|
||||
file_path=info.get('file_path'),
|
||||
))
|
||||
return statuses
|
||||
|
||||
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
|
||||
"""Get status of a specific download."""
|
||||
with self._download_lock:
|
||||
info = self.active_downloads.get(download_id)
|
||||
if not info:
|
||||
return None
|
||||
return DownloadStatus(
|
||||
id=info['id'],
|
||||
filename=info['filename'],
|
||||
username=info['username'],
|
||||
state=info['state'],
|
||||
progress=info['progress'],
|
||||
size=info['size'],
|
||||
transferred=info['transferred'],
|
||||
speed=info['speed'],
|
||||
time_remaining=info.get('time_remaining'),
|
||||
file_path=info.get('file_path'),
|
||||
)
|
||||
|
||||
async def cancel_download(self, download_id: str, username: str = None,
|
||||
remove: bool = False) -> bool:
|
||||
"""Cancel an active download."""
|
||||
with self._download_lock:
|
||||
if download_id not in self.active_downloads:
|
||||
return False
|
||||
self.active_downloads[download_id]['state'] = 'Cancelled'
|
||||
if remove:
|
||||
del self.active_downloads[download_id]
|
||||
return True
|
||||
|
||||
async def clear_all_completed_downloads(self) -> bool:
|
||||
"""Clear all terminal downloads."""
|
||||
with self._download_lock:
|
||||
to_remove = [
|
||||
did for did, info in self.active_downloads.items()
|
||||
if info.get('state', '') in ('Completed, Succeeded', 'Cancelled', 'Errored', 'Aborted')
|
||||
]
|
||||
for did in to_remove:
|
||||
del self.active_downloads[did]
|
||||
return True
|
||||
|
|
@ -440,7 +440,7 @@ class MusicMatchingEngine:
|
|||
# Often YouTube videos are titled "Artist - Album - Title" or similar
|
||||
# Only include if mode is youtube or hybrid (safe for Soulseek default)
|
||||
download_mode = config_manager.get('download_source.mode', 'soulseek')
|
||||
if download_mode in ['youtube', 'hybrid'] and album_name and album_name.lower() not in ['single', 'ep', 'greatest hits']:
|
||||
if download_mode in ['youtube', 'tidal', 'qobuz', 'hifi', 'hybrid'] and album_name and album_name.lower() not in ['single', 'ep', 'greatest hits']:
|
||||
album_clean = self.clean_album_name(album_name)
|
||||
if album_clean:
|
||||
# Standard query: Artist Album Title
|
||||
|
|
|
|||
1271
test_hifi_client.py
Normal file
1271
test_hifi_client.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -327,6 +327,9 @@ if soulseek_client:
|
|||
if hasattr(soulseek_client, 'qobuz'):
|
||||
soulseek_client.qobuz.set_shutdown_check(lambda: IS_SHUTTING_DOWN)
|
||||
print(" ✅ Configured Qobuz client shutdown callback")
|
||||
if hasattr(soulseek_client, 'hifi'):
|
||||
soulseek_client.hifi.set_shutdown_check(lambda: IS_SHUTTING_DOWN)
|
||||
print(" ✅ Configured HiFi client shutdown callback")
|
||||
|
||||
# Initialize web scan manager for automatic post-download scanning
|
||||
try:
|
||||
|
|
@ -1619,7 +1622,7 @@ def get_cached_transfer_data():
|
|||
all_downloads = run_async(soulseek_client.get_all_downloads())
|
||||
for download in all_downloads:
|
||||
# Only add streaming source downloads (Soulseek ones are already in the lookup)
|
||||
if download.username in ('youtube', 'tidal', 'qobuz'):
|
||||
if download.username in ('youtube', 'tidal', 'qobuz', 'hifi'):
|
||||
key = _make_context_key(download.username, download.filename)
|
||||
# Convert DownloadStatus to transfer dict format
|
||||
live_transfers_lookup[key] = {
|
||||
|
|
@ -1943,7 +1946,7 @@ class WebUIDownloadMonitor:
|
|||
all_downloads = run_async(soulseek_client.get_all_downloads())
|
||||
for download in all_downloads:
|
||||
# Only add streaming source downloads (Soulseek ones are already in the lookup from slskd API)
|
||||
if download.username in ('youtube', 'tidal', 'qobuz'):
|
||||
if download.username in ('youtube', 'tidal', 'qobuz', 'hifi'):
|
||||
key = _make_context_key(download.username, download.filename)
|
||||
# Convert DownloadStatus to transfer dict format for monitor compatibility
|
||||
live_transfers[key] = {
|
||||
|
|
@ -2973,20 +2976,21 @@ def _find_downloaded_file(download_path, track_data):
|
|||
audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'}
|
||||
target_filename = extract_filename(track_data.get('filename', ''))
|
||||
|
||||
# YOUTUBE/TIDAL/QOBUZ SUPPORT: Handle encoded filename format "id||title"
|
||||
# YOUTUBE/TIDAL/QOBUZ/HIFI SUPPORT: Handle encoded filename format "id||title"
|
||||
# The file on disk will be "title.ext", not "id||title"
|
||||
is_youtube = track_data.get('username') == 'youtube'
|
||||
is_tidal = track_data.get('username') == 'tidal'
|
||||
is_qobuz = track_data.get('username') == 'qobuz'
|
||||
is_streaming_source = is_youtube or is_tidal or is_qobuz
|
||||
is_hifi = track_data.get('username') == 'hifi'
|
||||
is_streaming_source = is_youtube or is_tidal or is_qobuz or is_hifi
|
||||
target_filename_youtube = None
|
||||
if is_streaming_source and '||' in target_filename:
|
||||
_, title = target_filename.split('||', 1)
|
||||
if is_tidal or is_qobuz:
|
||||
# Tidal/Qobuz files can be flac or m4a or mp3 — match any audio extension
|
||||
if is_tidal or is_qobuz or is_hifi:
|
||||
# Tidal/Qobuz/HiFi files can be flac or m4a — match any audio extension
|
||||
safe_title = re.sub(r'[<>:"/\\|?*]', '_', title)
|
||||
target_filename_youtube = safe_title # Extension-less for flexible matching
|
||||
source_name = 'Qobuz' if is_qobuz else 'Tidal'
|
||||
source_name = 'HiFi' if is_hifi else ('Qobuz' if is_qobuz else 'Tidal')
|
||||
print(f"🎵 [{source_name} Stream] Looking for file starting with: {target_filename_youtube}")
|
||||
else:
|
||||
# yt-dlp will create "Title.mp3" from "Title"
|
||||
|
|
@ -3024,11 +3028,11 @@ def _find_downloaded_file(download_path, track_data):
|
|||
# For Tidal, compare without extension (file could be .flac or .m4a)
|
||||
compare_target = target_filename_youtube.lower()
|
||||
compare_file = file.lower()
|
||||
if is_tidal or is_qobuz:
|
||||
if is_tidal or is_qobuz or is_hifi:
|
||||
compare_file = os.path.splitext(compare_file)[0]
|
||||
similarity = SequenceMatcher(None, compare_file, compare_target).ratio()
|
||||
|
||||
source_label = 'Qobuz' if is_qobuz else ('Tidal' if is_tidal else 'YouTube')
|
||||
source_label = 'HiFi' if is_hifi else ('Qobuz' if is_qobuz else ('Tidal' if is_tidal else 'YouTube'))
|
||||
print(f"🔍 [{source_label} Stream] Comparing: '{file}' vs '{target_filename_youtube}' = {similarity:.2f}")
|
||||
|
||||
# Keep track of best match
|
||||
|
|
@ -3179,6 +3183,8 @@ def run_service_test(service, test_config):
|
|||
'soulseek': "Successfully connected to Soulseek network via slskd.",
|
||||
'youtube': "YouTube download source ready.",
|
||||
'tidal': "Tidal download source ready.",
|
||||
'qobuz': "Qobuz download source ready.",
|
||||
'hifi': "HiFi download source ready.",
|
||||
'hybrid': "Download sources ready (Hybrid mode)."
|
||||
}
|
||||
message = mode_messages.get(download_mode, "Download source connected.")
|
||||
|
|
@ -3189,6 +3195,8 @@ def run_service_test(service, test_config):
|
|||
'soulseek': "slskd is not connected to the Soulseek network. Check slskd status and credentials.",
|
||||
'youtube': "YouTube download source not available.",
|
||||
'tidal': "Tidal download source not available. Check authentication.",
|
||||
'qobuz': "Qobuz download source not available. Check authentication.",
|
||||
'hifi': "HiFi download source not available. Public API instances may be down.",
|
||||
'hybrid': "Could not connect to download sources. Check configuration."
|
||||
}
|
||||
error = mode_errors.get(download_mode, "Download source connection failed.")
|
||||
|
|
@ -4198,7 +4206,7 @@ def handle_settings():
|
|||
if 'active_media_server' in new_settings:
|
||||
config_manager.set_active_media_server(new_settings['active_media_server'])
|
||||
|
||||
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb']:
|
||||
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb']:
|
||||
if service in new_settings:
|
||||
for key, value in new_settings[service].items():
|
||||
config_manager.set(f'{service}.{key}', value)
|
||||
|
|
@ -6643,7 +6651,7 @@ def stream_enhanced_search_track():
|
|||
search_queries = []
|
||||
import re
|
||||
|
||||
if download_mode in ('youtube', 'tidal', 'qobuz') or (download_mode == 'hybrid' and config_manager.get('download_source.hybrid_primary') in ('youtube', 'tidal', 'qobuz')):
|
||||
if download_mode in ('youtube', 'tidal', 'qobuz', 'hifi') or (download_mode == 'hybrid' and config_manager.get('download_source.hybrid_primary') in ('youtube', 'tidal', 'qobuz', 'hifi')):
|
||||
# YouTube/Tidal mode: Include artist for better context
|
||||
# Primary query: Artist + Track
|
||||
if artist_name and track_name:
|
||||
|
|
@ -6819,7 +6827,7 @@ def start_download():
|
|||
if download_id:
|
||||
# Register download for post-processing (simple transfer to /Transfer)
|
||||
context_key = _make_context_key(username, filename)
|
||||
is_streaming_source = username in ('youtube', 'tidal', 'qobuz')
|
||||
is_streaming_source = username in ('youtube', 'tidal', 'qobuz', 'hifi')
|
||||
with matched_context_lock:
|
||||
matched_downloads_context[context_key] = {
|
||||
'search_result': {
|
||||
|
|
@ -7197,7 +7205,7 @@ def get_download_status():
|
|||
all_streaming_downloads = run_async(soulseek_client.get_all_downloads())
|
||||
|
||||
for download in all_streaming_downloads:
|
||||
if download.username in ('youtube', 'tidal', 'qobuz'):
|
||||
if download.username in ('youtube', 'tidal', 'qobuz', 'hifi'):
|
||||
source_label = download.username.title()
|
||||
# Convert DownloadStatus to transfer format that frontend expects
|
||||
streaming_transfer = {
|
||||
|
|
@ -19939,7 +19947,7 @@ def get_valid_candidates(results, spotify_track, query):
|
|||
return []
|
||||
|
||||
# Skip quality filtering for YouTube/Tidal/Qobuz results (quality is fixed by source, not user-selectable per-result)
|
||||
is_streaming_source = initial_candidates[0].username in ("youtube", "tidal", "qobuz") if initial_candidates else False
|
||||
is_streaming_source = initial_candidates[0].username in ("youtube", "tidal", "qobuz", "hifi") if initial_candidates else False
|
||||
|
||||
if is_streaming_source:
|
||||
source_label = initial_candidates[0].username.title()
|
||||
|
|
@ -22178,7 +22186,7 @@ def _try_source_reuse(task_id, batch_id, track):
|
|||
if not source_tracks or not last_source:
|
||||
_sr.info(f"Skipped — no source_tracks or no last_source")
|
||||
return False
|
||||
if last_source.get('username') in ('youtube', 'tidal', 'qobuz'):
|
||||
if last_source.get('username') in ('youtube', 'tidal', 'qobuz', 'hifi'):
|
||||
_sr.info(f"Skipped — {last_source.get('username')} source (no folder-based reuse)")
|
||||
return False
|
||||
|
||||
|
|
@ -22280,7 +22288,7 @@ def _store_batch_source(batch_id, username, filename):
|
|||
"""Browse the successful download's folder and store results on the batch for reuse."""
|
||||
_sr = source_reuse_logger
|
||||
_sr.info(f"_store_batch_source called: batch={batch_id}, user={username}, file={filename}")
|
||||
if not batch_id or username in ('youtube', 'tidal', 'qobuz'):
|
||||
if not batch_id or username in ('youtube', 'tidal', 'qobuz', 'hifi'):
|
||||
_sr.info(f"Skipped — no batch_id or streaming source ({username})")
|
||||
return
|
||||
|
||||
|
|
@ -24298,6 +24306,26 @@ def get_discover_album(source, album_id):
|
|||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# HIFI DOWNLOAD ENDPOINTS
|
||||
# ===================================================================
|
||||
|
||||
@app.route('/api/hifi/status', methods=['GET'])
|
||||
def hifi_status():
|
||||
"""Check if HiFi API instances are reachable."""
|
||||
try:
|
||||
hifi = soulseek_client.hifi
|
||||
available = hifi.is_available()
|
||||
version = hifi.get_version() if available else None
|
||||
return jsonify({
|
||||
"available": available,
|
||||
"version": version,
|
||||
"instance": hifi._get_instance(),
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({"available": False, "error": str(e)})
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# TIDAL DOWNLOAD AUTH ENDPOINTS
|
||||
# ===================================================================
|
||||
|
|
|
|||
|
|
@ -3807,6 +3807,7 @@
|
|||
<option value="youtube">YouTube Only</option>
|
||||
<option value="tidal">Tidal Only</option>
|
||||
<option value="qobuz">Qobuz Only</option>
|
||||
<option value="hifi">HiFi Only (Free Lossless)</option>
|
||||
<option value="hybrid">Hybrid (Primary + Fallback)</option>
|
||||
</select>
|
||||
<div class="setting-help-text">
|
||||
|
|
@ -3824,6 +3825,7 @@
|
|||
<option value="youtube">YouTube</option>
|
||||
<option value="tidal">Tidal</option>
|
||||
<option value="qobuz">Qobuz</option>
|
||||
<option value="hifi">HiFi</option>
|
||||
</select>
|
||||
<div class="setting-help-text">
|
||||
First source to try for downloads.
|
||||
|
|
@ -3836,6 +3838,7 @@
|
|||
<option value="youtube">YouTube</option>
|
||||
<option value="tidal">Tidal</option>
|
||||
<option value="qobuz">Qobuz</option>
|
||||
<option value="hifi">HiFi</option>
|
||||
</select>
|
||||
<div class="setting-help-text">
|
||||
If the primary source finds nothing, try this source next.
|
||||
|
|
@ -3946,6 +3949,34 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HiFi Download Settings (shown only when hifi mode is selected) -->
|
||||
<div id="hifi-download-settings-container" style="display: none;">
|
||||
<div class="form-group">
|
||||
<label>HiFi Download Quality:</label>
|
||||
<select id="hifi-download-quality" class="form-select">
|
||||
<option value="low">Low (AAC 96kbps)</option>
|
||||
<option value="high">High (AAC 320kbps)</option>
|
||||
<option value="lossless">Lossless (FLAC 16-bit/44.1kHz)</option>
|
||||
<option value="hires">HiRes (FLAC 24-bit/96kHz)</option>
|
||||
</select>
|
||||
<div class="setting-help-text">
|
||||
Audio quality for HiFi downloads. Uses public API instances — no account required.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>HiFi Status:</label>
|
||||
<div class="form-actions" style="margin-top: 4px;">
|
||||
<button class="test-button" id="hifi-test-btn" onclick="testHiFiConnection()">
|
||||
Test Connection
|
||||
</button>
|
||||
<span id="hifi-connection-status" class="setting-help-text" style="margin-left: 8px;"></span>
|
||||
</div>
|
||||
<div class="setting-help-text" style="margin-top: 6px;">
|
||||
Free lossless downloads via community-run hifi-api instances. No authentication or subscription needed.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- YouTube Settings (shown when youtube or hybrid mode) -->
|
||||
<div id="youtube-settings-container" style="display: none;">
|
||||
<div class="form-group">
|
||||
|
|
|
|||
|
|
@ -4817,6 +4817,7 @@ async function loadSettingsData() {
|
|||
updateHybridSecondaryOptions();
|
||||
document.getElementById('tidal-download-quality').value = settings.tidal_download?.quality || 'lossless';
|
||||
document.getElementById('qobuz-quality').value = settings.qobuz?.quality || 'lossless';
|
||||
document.getElementById('hifi-download-quality').value = settings.hifi_download?.quality || 'lossless';
|
||||
|
||||
// Populate YouTube settings
|
||||
document.getElementById('youtube-cookies-browser').value = settings.youtube?.cookies_browser || '';
|
||||
|
|
@ -5035,6 +5036,7 @@ function updateDownloadSourceUI() {
|
|||
const tidalContainer = document.getElementById('tidal-download-settings-container');
|
||||
const qobuzContainer = document.getElementById('qobuz-settings-container');
|
||||
const youtubeContainer = document.getElementById('youtube-settings-container');
|
||||
const hifiContainer = document.getElementById('hifi-download-settings-container');
|
||||
|
||||
hybridContainer.style.display = mode === 'hybrid' ? 'block' : 'none';
|
||||
|
||||
|
|
@ -5053,6 +5055,7 @@ function updateDownloadSourceUI() {
|
|||
tidalContainer.style.display = activeSources.has('tidal') ? 'block' : 'none';
|
||||
qobuzContainer.style.display = activeSources.has('qobuz') ? 'block' : 'none';
|
||||
youtubeContainer.style.display = activeSources.has('youtube') ? 'block' : 'none';
|
||||
hifiContainer.style.display = activeSources.has('hifi') ? 'block' : 'none';
|
||||
|
||||
// Quality profile is Soulseek-only (streaming sources handle quality via their own settings)
|
||||
const qualityProfileSection = document.getElementById('quality-profile-section');
|
||||
|
|
@ -5066,6 +5069,9 @@ function updateDownloadSourceUI() {
|
|||
if (activeSources.has('qobuz')) {
|
||||
checkQobuzAuthStatus();
|
||||
}
|
||||
if (activeSources.has('hifi')) {
|
||||
testHiFiConnection();
|
||||
}
|
||||
}
|
||||
|
||||
function updateHybridSecondaryOptions() {
|
||||
|
|
@ -5077,6 +5083,7 @@ function updateHybridSecondaryOptions() {
|
|||
{ value: 'youtube', label: 'YouTube' },
|
||||
{ value: 'tidal', label: 'Tidal' },
|
||||
{ value: 'qobuz', label: 'Qobuz' },
|
||||
{ value: 'hifi', label: 'HiFi' },
|
||||
];
|
||||
|
||||
secondary.innerHTML = '';
|
||||
|
|
@ -5645,6 +5652,9 @@ async function saveSettings(quiet = false) {
|
|||
tidal_download: {
|
||||
quality: document.getElementById('tidal-download-quality').value || 'lossless'
|
||||
},
|
||||
hifi_download: {
|
||||
quality: document.getElementById('hifi-download-quality').value || 'lossless'
|
||||
},
|
||||
qobuz: {
|
||||
quality: document.getElementById('qobuz-quality').value || 'lossless',
|
||||
embed_tags: document.getElementById('embed-qobuz').checked
|
||||
|
|
@ -6255,6 +6265,28 @@ async function authenticateTidal() {
|
|||
|
||||
// ===== Tidal Download Auth (Device Flow) =====
|
||||
|
||||
async function testHiFiConnection() {
|
||||
const statusEl = document.getElementById('hifi-connection-status');
|
||||
const btn = document.getElementById('hifi-test-btn');
|
||||
if (!statusEl) return;
|
||||
statusEl.textContent = 'Checking...';
|
||||
statusEl.style.color = '#aaa';
|
||||
try {
|
||||
const resp = await fetch('/api/hifi/status');
|
||||
const data = await resp.json();
|
||||
if (data.available) {
|
||||
statusEl.textContent = `Connected (v${data.version || '?'})`;
|
||||
statusEl.style.color = '#4caf50';
|
||||
} else {
|
||||
statusEl.textContent = 'No instances reachable';
|
||||
statusEl.style.color = '#ff9800';
|
||||
}
|
||||
} catch (e) {
|
||||
statusEl.textContent = 'Connection error';
|
||||
statusEl.style.color = '#f44336';
|
||||
}
|
||||
}
|
||||
|
||||
async function checkTidalDownloadAuthStatus() {
|
||||
const statusEl = document.getElementById('tidal-download-auth-status');
|
||||
const btn = document.getElementById('tidal-download-auth-btn');
|
||||
|
|
@ -7119,7 +7151,7 @@ function initializeSearchModeToggle() {
|
|||
const slskdResult = data.result;
|
||||
|
||||
// Check if audio format is supported (YouTube/Tidal use encoded filenames, skip check)
|
||||
const isStreamingSource = slskdResult.username === 'youtube' || slskdResult.username === 'tidal';
|
||||
const isStreamingSource = slskdResult.username === 'youtube' || slskdResult.username === 'tidal' || slskdResult.username === 'qobuz' || slskdResult.username === 'hifi';
|
||||
if (!isStreamingSource && slskdResult.filename && !isAudioFormatSupported(slskdResult.filename)) {
|
||||
const format = getFileExtension(slskdResult.filename);
|
||||
hideLoadingOverlay();
|
||||
|
|
@ -13026,7 +13058,7 @@ async function updateModalWithLiveDownloadProgress() {
|
|||
// Extract display title from filename (handle YouTube encoding)
|
||||
let downloadTitle = '';
|
||||
if (downloadInfo.filename) {
|
||||
if ((downloadInfo.username === 'youtube' || downloadInfo.username === 'tidal') && downloadInfo.filename.includes('||')) {
|
||||
if ((downloadInfo.username === 'youtube' || downloadInfo.username === 'tidal' || downloadInfo.username === 'qobuz' || downloadInfo.username === 'hifi') && downloadInfo.filename.includes('||')) {
|
||||
const parts = downloadInfo.filename.split('||');
|
||||
downloadTitle = parts[1] || parts[0];
|
||||
} else {
|
||||
|
|
@ -13868,7 +13900,7 @@ function renderQueue(containerId, downloads, isActiveQueue) {
|
|||
let title = 'Unknown File';
|
||||
if (item.filename) {
|
||||
// YouTube/Tidal filenames are encoded as "id||title"
|
||||
if ((item.username === 'youtube' || item.username === 'tidal') && item.filename.includes('||')) {
|
||||
if ((item.username === 'youtube' || item.username === 'tidal' || item.username === 'qobuz' || item.username === 'hifi') && item.filename.includes('||')) {
|
||||
const parts = item.filename.split('||');
|
||||
title = parts[1] || parts[0]; // Use title part, fallback to id
|
||||
} else {
|
||||
|
|
@ -14426,8 +14458,8 @@ async function streamTrack(index) {
|
|||
const result = window.currentSearchResults[index];
|
||||
console.log(`🎵 Streaming track:`, result);
|
||||
|
||||
// Check for unsupported formats before streaming (YouTube/Tidal use encoded filenames, skip check)
|
||||
const isStreamingSource = result.username === 'youtube' || result.username === 'tidal';
|
||||
// Check for unsupported formats before streaming (streaming sources use encoded filenames, skip check)
|
||||
const isStreamingSource = result.username === 'youtube' || result.username === 'tidal' || result.username === 'qobuz' || result.username === 'hifi';
|
||||
if (!isStreamingSource && result.filename) {
|
||||
const format = getFileExtension(result.filename);
|
||||
console.log(`🎵 [STREAM CHECK] File: ${result.filename}, Extension: ${format}`);
|
||||
|
|
@ -14466,7 +14498,7 @@ async function streamAlbumTrack(albumIndex, trackIndex) {
|
|||
console.log(`🎵 Album data:`, album);
|
||||
|
||||
// Surgical Fix: Handle YouTube/Tidal results which are "flat" (no tracks array)
|
||||
if (album.username === 'youtube' || album.username === 'tidal') {
|
||||
if (album.username === 'youtube' || album.username === 'tidal' || album.username === 'qobuz' || album.username === 'hifi') {
|
||||
// For YouTube/Tidal results, the "album" is actually the track itself
|
||||
const track = album;
|
||||
const trackData = {
|
||||
|
|
@ -14502,8 +14534,8 @@ async function streamAlbumTrack(albumIndex, trackIndex) {
|
|||
|
||||
console.log(`🎵 Enhanced track data:`, trackData);
|
||||
|
||||
// Check for unsupported formats before streaming (YouTube/Tidal use encoded filenames, skip check)
|
||||
const isStreamingSource2 = trackData.username === 'youtube' || trackData.username === 'tidal';
|
||||
// Check for unsupported formats before streaming (streaming sources use encoded filenames, skip check)
|
||||
const isStreamingSource2 = trackData.username === 'youtube' || trackData.username === 'tidal' || trackData.username === 'qobuz' || trackData.username === 'hifi';
|
||||
if (!isStreamingSource2 && trackData.filename && !isAudioFormatSupported(trackData.filename)) {
|
||||
const format = getFileExtension(trackData.filename);
|
||||
showToast(`Sorry, ${format.toUpperCase()} format is not supported in web browsers. Try downloading instead.`, 'error');
|
||||
|
|
|
|||
Loading…
Reference in a new issue