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:
Broque Thomas 2026-03-15 22:53:34 -07:00
parent 483e45cbc0
commit ec389c5ae8
8 changed files with 2155 additions and 36 deletions

View file

@ -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": ""

View file

@ -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
View 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

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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
# ===================================================================

View file

@ -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">

View file

@ -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');