You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SoulSync/core/download_orchestrator.py

483 lines
19 KiB

"""
Download Orchestrator
Routes downloads between Soulseek, YouTube, Tidal, Qobuz, and HiFi based on configuration.
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
"""
import asyncio
from typing import List, Optional, Tuple
from pathlib import Path
from utils.logging_config import get_logger
from config.settings import config_manager
from core.soulseek_client import SoulseekClient, TrackResult, AlbumResult, DownloadStatus
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, 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.
"""
def __init__(self):
"""Initialize orchestrator with all clients"""
self.soulseek = SoulseekClient()
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')
self.hybrid_primary = config_manager.get('download_source.hybrid_primary', 'soulseek')
self.hybrid_secondary = config_manager.get('download_source.hybrid_secondary', 'youtube')
self.hybrid_order = config_manager.get('download_source.hybrid_order', [])
logger.info(f"🎛️ Download Orchestrator initialized - Mode: {self.mode}")
if self.mode == 'hybrid':
if self.hybrid_order:
logger.info(f" Source priority: {''.join(self.hybrid_order)}")
else:
logger.info(f" Primary: {self.hybrid_primary}, Fallback: {self.hybrid_secondary}")
def reload_settings(self):
"""Reload settings from config (call after settings change)"""
self.mode = config_manager.get('download_source.mode', 'soulseek')
self.hybrid_primary = config_manager.get('download_source.hybrid_primary', 'soulseek')
self.hybrid_secondary = config_manager.get('download_source.hybrid_secondary', 'youtube')
self.hybrid_order = config_manager.get('download_source.hybrid_order', [])
# Reload underlying client configs (SLSKD URL, API key, etc.)
self.soulseek._setup_client()
logger.info(f"🔄 Soulseek client config reloaded")
logger.info(f"🔄 Download Orchestrator settings reloaded - Mode: {self.mode}")
def is_configured(self) -> bool:
"""
Check if orchestrator is configured and ready to use.
Returns True if at least one download source is configured.
"""
if self.mode == 'soulseek':
return self.soulseek.is_configured()
elif self.mode == 'youtube':
return self.youtube.is_configured()
elif self.mode == 'tidal':
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':
clients = {'soulseek': self.soulseek, 'youtube': self.youtube, 'tidal': self.tidal, 'qobuz': self.qobuz, 'hifi': self.hifi}
sources = self.hybrid_order if self.hybrid_order else [self.hybrid_primary, self.hybrid_secondary]
return any(clients[s].is_configured() for s in sources if s in clients)
return False
def get_source_status(self) -> dict:
"""Return configured status for each download source."""
clients = {
'soulseek': self.soulseek,
'youtube': self.youtube,
'tidal': self.tidal,
'qobuz': self.qobuz,
'hifi': self.hifi,
}
return {name: client.is_configured() for name, client in clients.items()}
async def check_connection(self) -> bool:
"""
Test if download sources are accessible.
Returns True if the configured source(s) are reachable.
"""
if self.mode == 'soulseek':
return await self.soulseek.check_connection()
elif self.mode == 'youtube':
return await self.youtube.check_connection()
elif self.mode == 'tidal':
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 ''} | HiFi: {'' if hifi_ok else ''}")
return soulseek_ok or youtube_ok or tidal_ok or qobuz_ok or hifi_ok
return False
async def search(self, query: str, timeout: int = None, progress_callback=None) -> Tuple[List[TrackResult], List[AlbumResult]]:
"""
Search for tracks using configured source(s).
Args:
query: Search query
timeout: Search timeout (for Soulseek)
progress_callback: Progress callback (for Soulseek)
Returns:
Tuple of (track_results, album_results)
"""
if self.mode == 'soulseek':
logger.info(f"🔍 Searching Soulseek: {query}")
return await self.soulseek.search(query, timeout, progress_callback)
elif self.mode == 'youtube':
logger.info(f"🔍 Searching YouTube: {query}")
return await self.youtube.search(query, timeout, progress_callback)
elif self.mode == 'tidal':
logger.info(f"🔍 Searching Tidal: {query}")
return await self.tidal.search(query, timeout, progress_callback)
elif self.mode == 'qobuz':
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,
}
# Build ordered source list: prefer hybrid_order, fall back to legacy primary/secondary
if self.hybrid_order:
source_order = [s for s in self.hybrid_order if s in clients]
else:
primary = self.hybrid_primary if self.hybrid_primary in clients else 'soulseek'
secondary = self.hybrid_secondary if self.hybrid_secondary in clients else 'soulseek'
if secondary == primary:
secondary = next((name for name in clients if name != primary), 'soulseek')
source_order = [primary, secondary]
if not source_order:
source_order = ['soulseek']
logger.info(f"🔍 Hybrid search ({''.join(source_order)}): {query}")
# Try each source in priority order (skip unconfigured ones)
for i, source_name in enumerate(source_order):
client = clients[source_name]
if hasattr(client, 'is_configured') and not client.is_configured():
logger.info(f"⏭️ Skipping {source_name} (not configured)")
continue
try:
if i == 0:
logger.info(f"🔍 Trying {source_name} (priority {i+1}): {query}")
else:
logger.info(f"🔄 Trying {source_name} (priority {i+1}): {query}")
tracks, albums = await client.search(query, timeout, progress_callback)
if tracks:
logger.info(f"{source_name} found {len(tracks)} tracks")
return (tracks, albums)
except Exception as e:
logger.warning(f"⚠️ {source_name} search failed: {e}")
# Nothing found from any source
logger.warning(f"❌ Hybrid search: all sources ({', '.join(source_order)}) found nothing for: {query}")
return ([], [])
# Fallback: empty results
return ([], [])
async def search_and_download_best(self, query: str) -> Optional[str]:
"""
Search and automatically download the best result.
Supports Hybrid mode (uses configured source priority).
Args:
query: Search query string
Returns:
Download ID (str) or None if failed
"""
# 1. Search using configured mode/hybrid logic
results = await self.search(query)
# Unpack tuple (tracks, albums) - defensive handling
if isinstance(results, tuple):
tracks = results[0]
else:
tracks = results # Should not happen based on search() return type, but safe
if not tracks:
logger.warning(f"No results found for: {query}")
return None
# 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', 'hifi') if tracks else False
if is_streaming:
filtered_results = tracks
else:
filtered_results = self.soulseek.filter_results_by_quality_preference(tracks)
if not filtered_results:
logger.warning(f"No suitable quality results found for: {query}")
return None
# 3. Download the best match
best_result = filtered_results[0]
quality_info = f"{best_result.quality.upper()}"
if best_result.bitrate:
quality_info += f" {best_result.bitrate}kbps"
logger.info(f"Downloading best match: {best_result.filename} ({quality_info}) from {best_result.username}")
# Use orchestrator's download method to route correctly
return await self.download(best_result.username, best_result.filename, best_result.size)
async def download(self, username: str, filename: str, file_size: int = 0) -> Optional[str]:
"""
Download a track using the appropriate client.
Args:
username: Username (or "youtube" for YouTube)
filename: Filename or YouTube video ID
file_size: File size estimate
Returns:
download_id: Unique download ID for tracking
"""
# Detect which client to use based on username
if username == 'youtube':
logger.info(f"📥 Downloading from YouTube: {filename}")
return await self.youtube.download(username, filename, file_size)
elif username == 'tidal':
logger.info(f"📥 Downloading from Tidal: {filename}")
return await self.tidal.download(username, filename, file_size)
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)
async def get_all_downloads(self) -> List[DownloadStatus]:
"""
Get all active downloads from all sources.
Returns:
List of DownloadStatus objects
"""
# Get downloads from all sources
soulseek_downloads = await self.soulseek.get_all_downloads()
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 + hifi_downloads
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
"""
Get status of a specific download.
Args:
download_id: Download ID to query
Returns:
DownloadStatus object or None if not found
"""
# Try Soulseek first
status = await self.soulseek.get_download_status(download_id)
if status:
return status
# Try YouTube
status = await self.youtube.get_download_status(download_id)
if status:
return status
# Try Tidal
status = await self.tidal.get_download_status(download_id)
if status:
return status
# Try Qobuz
status = await self.qobuz.get_download_status(download_id)
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:
"""
Cancel an active download.
Args:
download_id: Download ID to cancel
username: Username hint (optional)
remove: Whether to remove from active downloads
Returns:
True if cancelled successfully
"""
# If username is provided, route directly
if username == 'youtube':
return await self.youtube.cancel_download(download_id, username, remove)
elif username == 'tidal':
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)
# Otherwise, try all sources
soulseek_cancelled = await self.soulseek.cancel_download(download_id, username, remove)
if soulseek_cancelled:
return True
youtube_cancelled = await self.youtube.cancel_download(download_id, username, remove)
if youtube_cancelled:
return True
tidal_cancelled = await self.tidal.cancel_download(download_id, username, remove)
if tidal_cancelled:
return True
qobuz_cancelled = await self.qobuz.cancel_download(download_id, username, remove)
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:
"""
Signal that a download has completed (Soulseek only).
Args:
download_id: Download ID
username: Username
remove: Whether to remove from active downloads
Returns:
True if successful
"""
# This is Soulseek-specific, so only call on Soulseek client
return await self.soulseek.signal_download_completion(download_id, username, remove)
async def clear_all_completed_downloads(self) -> bool:
"""
Clear all completed downloads from both sources.
Returns:
True if successful
"""
soulseek_cleared = await self.soulseek.clear_all_completed_downloads()
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 and hifi_cleared
# ===== Soulseek-specific methods (for backwards compatibility) =====
# These are internal methods that some parts of the codebase use directly
async def _make_request(self, method: str, endpoint: str, **kwargs):
"""
Proxy to SoulseekClient._make_request for backwards compatibility.
This is a Soulseek-specific internal method.
Args:
method: HTTP method
endpoint: API endpoint
**kwargs: Additional request parameters
Returns:
API response
"""
return await self.soulseek._make_request(method, endpoint, **kwargs)
async def _make_direct_request(self, method: str, endpoint: str, **kwargs):
"""
Proxy to SoulseekClient._make_direct_request for backwards compatibility.
This is a Soulseek-specific internal method.
Args:
method: HTTP method
endpoint: API endpoint
**kwargs: Additional request parameters
Returns:
API response
"""
return await self.soulseek._make_direct_request(method, endpoint, **kwargs)
async def clear_all_searches(self) -> bool:
"""
Clear all searches (Soulseek-specific).
Returns:
True if successful
"""
return await self.soulseek.clear_all_searches()
async def maintain_search_history_with_buffer(self, keep_searches: int = 50, trigger_threshold: int = 200) -> bool:
"""
Maintain search history (Soulseek-specific).
Args:
keep_searches: Number of searches to keep
trigger_threshold: Threshold to trigger cleanup
Returns:
True if successful
"""
return await self.soulseek.maintain_search_history_with_buffer(keep_searches, trigger_threshold)
async def cancel_all_downloads(self) -> bool:
"""Cancel and remove all downloads from all sources."""
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