Add hybrid Soulseek/YouTube download orchestration - TESTING
Introduces a DownloadOrchestrator class to route downloads between Soulseek and YouTube based on user-configurable modes (Soulseek only, YouTube only, Hybrid with fallback). Updates web server and UI to support new download source settings, including hybrid mode options and YouTube confidence threshold. Refactors YouTube client for thread-safe download management and bot detection bypass. Ensures quality filtering is skipped for YouTube results and improves file matching and post-processing logic for YouTube downloads.
This commit is contained in:
parent
ce67e64ff7
commit
c94b1d2f5b
6 changed files with 648 additions and 125 deletions
|
|
@ -145,6 +145,11 @@ class ConfigManager:
|
||||||
"download_path": "./downloads",
|
"download_path": "./downloads",
|
||||||
"transfer_path": "./Transfer"
|
"transfer_path": "./Transfer"
|
||||||
},
|
},
|
||||||
|
"download_source": {
|
||||||
|
"mode": "soulseek", # Options: "soulseek", "youtube", "hybrid"
|
||||||
|
"hybrid_primary": "soulseek", # Which source to try first in hybrid mode
|
||||||
|
"youtube_min_confidence": 0.65 # Minimum confidence for YouTube matches
|
||||||
|
},
|
||||||
"listenbrainz": {
|
"listenbrainz": {
|
||||||
"token": ""
|
"token": ""
|
||||||
},
|
},
|
||||||
|
|
|
||||||
304
core/download_orchestrator.py
Normal file
304
core/download_orchestrator.py
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
"""
|
||||||
|
Download Orchestrator
|
||||||
|
Routes downloads between Soulseek and YouTube based on configuration.
|
||||||
|
|
||||||
|
Supports three modes:
|
||||||
|
- Soulseek Only: Traditional behavior
|
||||||
|
- YouTube Only: YouTube-exclusive downloads
|
||||||
|
- Hybrid: Try primary source first, fallback to secondary if it fails
|
||||||
|
"""
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
logger = get_logger("download_orchestrator")
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadOrchestrator:
|
||||||
|
"""
|
||||||
|
Orchestrates downloads between Soulseek and YouTube 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 both clients"""
|
||||||
|
self.soulseek = SoulseekClient()
|
||||||
|
self.youtube = YouTubeClient()
|
||||||
|
|
||||||
|
# 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.youtube_min_confidence = config_manager.get('download_source.youtube_min_confidence', 0.65)
|
||||||
|
|
||||||
|
logger.info(f"🎛️ Download Orchestrator initialized - Mode: {self.mode}")
|
||||||
|
if self.mode == 'hybrid':
|
||||||
|
logger.info(f" Primary source: {self.hybrid_primary}")
|
||||||
|
|
||||||
|
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.youtube_min_confidence = config_manager.get('download_source.youtube_min_confidence', 0.65)
|
||||||
|
|
||||||
|
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 == 'hybrid':
|
||||||
|
# In hybrid mode, at least one source must be configured
|
||||||
|
return self.soulseek.is_configured() or self.youtube.is_configured()
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
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 == 'hybrid':
|
||||||
|
# In hybrid mode, check both sources
|
||||||
|
soulseek_ok = await self.soulseek.check_connection()
|
||||||
|
youtube_ok = await self.youtube.check_connection()
|
||||||
|
|
||||||
|
logger.info(f" Soulseek: {'✅' if soulseek_ok else '❌'} | YouTube: {'✅' if youtube_ok else '❌'}")
|
||||||
|
|
||||||
|
# At least one must be available
|
||||||
|
return soulseek_ok or youtube_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 == 'hybrid':
|
||||||
|
# Try primary source first
|
||||||
|
if self.hybrid_primary == 'soulseek':
|
||||||
|
logger.info(f"🔍 Hybrid search - trying Soulseek first: {query}")
|
||||||
|
tracks, albums = await self.soulseek.search(query, timeout, progress_callback)
|
||||||
|
|
||||||
|
# If Soulseek found good results, return them
|
||||||
|
if tracks:
|
||||||
|
logger.info(f"✅ Soulseek found {len(tracks)} tracks")
|
||||||
|
return (tracks, albums)
|
||||||
|
|
||||||
|
# Otherwise, try YouTube as fallback
|
||||||
|
logger.info(f"🔄 Soulseek found nothing, trying YouTube fallback")
|
||||||
|
return await self.youtube.search(query, timeout, progress_callback)
|
||||||
|
|
||||||
|
else: # YouTube first
|
||||||
|
logger.info(f"🔍 Hybrid search - trying YouTube first: {query}")
|
||||||
|
tracks, albums = await self.youtube.search(query, timeout, progress_callback)
|
||||||
|
|
||||||
|
# If YouTube found good results, return them
|
||||||
|
if tracks:
|
||||||
|
logger.info(f"✅ YouTube found {len(tracks)} tracks")
|
||||||
|
return (tracks, albums)
|
||||||
|
|
||||||
|
# Otherwise, try Soulseek as fallback
|
||||||
|
logger.info(f"🔄 YouTube found nothing, trying Soulseek fallback")
|
||||||
|
return await self.soulseek.search(query, timeout, progress_callback)
|
||||||
|
|
||||||
|
# Fallback: empty results
|
||||||
|
return ([], [])
|
||||||
|
|
||||||
|
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)
|
||||||
|
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 both sources
|
||||||
|
soulseek_downloads = await self.soulseek.get_all_downloads()
|
||||||
|
youtube_downloads = await self.youtube.get_all_downloads()
|
||||||
|
|
||||||
|
# Combine and return
|
||||||
|
return soulseek_downloads + youtube_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
|
||||||
|
|
||||||
|
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:
|
||||||
|
return await self.soulseek.cancel_download(download_id, username, remove)
|
||||||
|
|
||||||
|
# Otherwise, try both 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)
|
||||||
|
return youtube_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 = True # YouTube auto-removes completed downloads
|
||||||
|
|
||||||
|
return soulseek_cleared and youtube_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)
|
||||||
|
|
@ -16,6 +16,7 @@ import re
|
||||||
import platform
|
import platform
|
||||||
import asyncio
|
import asyncio
|
||||||
import uuid
|
import uuid
|
||||||
|
import threading
|
||||||
from typing import List, Optional, Dict, Any, Tuple
|
from typing import List, Optional, Dict, Any, Tuple
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -96,10 +97,17 @@ class YouTubeClient:
|
||||||
Provides search, matching, and download capabilities with full Spotify metadata integration.
|
Provides search, matching, and download capabilities with full Spotify metadata integration.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, download_path: str = "./downloads/youtube"):
|
def __init__(self, download_path: str = None):
|
||||||
|
# Use Soulseek download path for consistency (post-processing expects files here)
|
||||||
|
from config.settings import config_manager
|
||||||
|
if download_path is None:
|
||||||
|
download_path = config_manager.get('soulseek.download_path', './downloads')
|
||||||
|
|
||||||
self.download_path = Path(download_path)
|
self.download_path = Path(download_path)
|
||||||
self.download_path.mkdir(parents=True, exist_ok=True)
|
self.download_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
logger.info(f"📁 YouTube client using download path: {self.download_path}")
|
||||||
|
|
||||||
# Initialize production matching engine for parity with Soulseek
|
# Initialize production matching engine for parity with Soulseek
|
||||||
self.matching_engine = MusicMatchingEngine()
|
self.matching_engine = MusicMatchingEngine()
|
||||||
logger.info("✅ Initialized production MusicMatchingEngine")
|
logger.info("✅ Initialized production MusicMatchingEngine")
|
||||||
|
|
@ -112,9 +120,9 @@ class YouTubeClient:
|
||||||
# Download queue management (mirrors Soulseek's download tracking)
|
# Download queue management (mirrors Soulseek's download tracking)
|
||||||
# Maps download_id -> download_info dict
|
# Maps download_id -> download_info dict
|
||||||
self.active_downloads: Dict[str, Dict[str, Any]] = {}
|
self.active_downloads: Dict[str, Dict[str, Any]] = {}
|
||||||
self._download_lock = asyncio.Lock()
|
self._download_lock = threading.Lock() # Use threading.Lock for thread safety
|
||||||
|
|
||||||
# Configure yt-dlp options
|
# Configure yt-dlp options with bot detection bypass
|
||||||
self.download_opts = {
|
self.download_opts = {
|
||||||
'format': 'bestaudio/best',
|
'format': 'bestaudio/best',
|
||||||
'outtmpl': str(self.download_path / '%(title)s.%(ext)s'),
|
'outtmpl': str(self.download_path / '%(title)s.%(ext)s'),
|
||||||
|
|
@ -127,6 +135,15 @@ class YouTubeClient:
|
||||||
'preferredquality': '320',
|
'preferredquality': '320',
|
||||||
}],
|
}],
|
||||||
'progress_hooks': [self._progress_hook], # Track download progress
|
'progress_hooks': [self._progress_hook], # Track download progress
|
||||||
|
# Bot detection bypass options
|
||||||
|
'extractor_args': {
|
||||||
|
'youtube': {
|
||||||
|
'player_client': ['android', 'web'], # Try multiple clients
|
||||||
|
'skip': ['hls', 'dash'], # Skip problematic formats
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
'age_limit': None, # Don't skip age-restricted
|
||||||
}
|
}
|
||||||
|
|
||||||
# Track current download progress (mirrors Soulseek transfer tracking)
|
# Track current download progress (mirrors Soulseek transfer tracking)
|
||||||
|
|
@ -179,6 +196,14 @@ class YouTubeClient:
|
||||||
'quiet': True,
|
'quiet': True,
|
||||||
'no_warnings': True,
|
'no_warnings': True,
|
||||||
'extract_flat': True, # Don't download, just extract info
|
'extract_flat': True, # Don't download, just extract info
|
||||||
|
# Bot detection bypass
|
||||||
|
'extractor_args': {
|
||||||
|
'youtube': {
|
||||||
|
'player_client': ['android', 'web'],
|
||||||
|
'skip': ['hls', 'dash'],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
}
|
}
|
||||||
|
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
|
@ -245,15 +270,16 @@ class YouTubeClient:
|
||||||
else:
|
else:
|
||||||
percent = 0
|
percent = 0
|
||||||
|
|
||||||
# Update active downloads dictionary (thread-safe update)
|
# Update active downloads dictionary (thread-safe update with lock)
|
||||||
if self.current_download_id in self.active_downloads:
|
with self._download_lock:
|
||||||
download_info = self.active_downloads[self.current_download_id]
|
if self.current_download_id in self.active_downloads:
|
||||||
download_info['state'] = 'Downloading'
|
download_info = self.active_downloads[self.current_download_id]
|
||||||
download_info['progress'] = round(percent, 1)
|
download_info['state'] = 'InProgress, Downloading' # Match Soulseek state format
|
||||||
download_info['transferred'] = downloaded
|
download_info['progress'] = round(percent, 1)
|
||||||
download_info['size'] = total
|
download_info['transferred'] = downloaded
|
||||||
download_info['speed'] = int(speed)
|
download_info['size'] = total
|
||||||
download_info['time_remaining'] = int(eta) if eta > 0 else None
|
download_info['speed'] = int(speed)
|
||||||
|
download_info['time_remaining'] = int(eta) if eta > 0 else None
|
||||||
|
|
||||||
# Also update current_download_progress for legacy compatibility
|
# Also update current_download_progress for legacy compatibility
|
||||||
self.current_download_progress = {
|
self.current_download_progress = {
|
||||||
|
|
@ -271,21 +297,23 @@ class YouTubeClient:
|
||||||
self.progress_callback(self.current_download_progress)
|
self.progress_callback(self.current_download_progress)
|
||||||
|
|
||||||
elif status == 'finished':
|
elif status == 'finished':
|
||||||
# Update to postprocessing state
|
# Download finished, ffmpeg is converting to MP3
|
||||||
if self.current_download_id in self.active_downloads:
|
# Keep state as 'InProgress, Downloading' - the download thread will set final state
|
||||||
self.active_downloads[self.current_download_id]['state'] = 'Postprocessing'
|
with self._download_lock:
|
||||||
self.active_downloads[self.current_download_id]['progress'] = 100.0
|
if self.current_download_id in self.active_downloads:
|
||||||
|
self.active_downloads[self.current_download_id]['progress'] = 95.0 # Almost done (converting)
|
||||||
|
|
||||||
self.current_download_progress['status'] = 'postprocessing'
|
self.current_download_progress['status'] = 'postprocessing'
|
||||||
self.current_download_progress['percent'] = 100.0
|
self.current_download_progress['percent'] = 95.0
|
||||||
|
|
||||||
if self.progress_callback:
|
if self.progress_callback:
|
||||||
self.progress_callback(self.current_download_progress)
|
self.progress_callback(self.current_download_progress)
|
||||||
|
|
||||||
elif status == 'error':
|
elif status == 'error':
|
||||||
# Mark as error
|
# Mark as error (thread-safe)
|
||||||
if self.current_download_id in self.active_downloads:
|
with self._download_lock:
|
||||||
self.active_downloads[self.current_download_id]['state'] = 'Errored'
|
if self.current_download_id in self.active_downloads:
|
||||||
|
self.active_downloads[self.current_download_id]['state'] = 'Errored'
|
||||||
|
|
||||||
self.current_download_progress['status'] = 'error'
|
self.current_download_progress['status'] = 'error'
|
||||||
if self.progress_callback:
|
if self.progress_callback:
|
||||||
|
|
@ -525,6 +553,14 @@ class YouTubeClient:
|
||||||
'no_warnings': True,
|
'no_warnings': True,
|
||||||
'extract_flat': False,
|
'extract_flat': False,
|
||||||
'default_search': 'ytsearch',
|
'default_search': 'ytsearch',
|
||||||
|
# Bot detection bypass (same as download options)
|
||||||
|
'extractor_args': {
|
||||||
|
'youtube': {
|
||||||
|
'player_client': ['android', 'web'],
|
||||||
|
'skip': ['hls', 'dash'],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
}
|
}
|
||||||
|
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
|
@ -716,13 +752,16 @@ class YouTubeClient:
|
||||||
"""
|
"""
|
||||||
Download YouTube video as audio (async, Soulseek-compatible interface).
|
Download YouTube video as audio (async, Soulseek-compatible interface).
|
||||||
|
|
||||||
|
Returns download_id immediately and runs download in background thread.
|
||||||
|
Monitor via get_download_status() or get_all_downloads().
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
username: Ignored for YouTube (always "youtube")
|
username: Ignored for YouTube (always "youtube")
|
||||||
filename: Encoded as "video_id||title" from search results
|
filename: Encoded as "video_id||title" from search results
|
||||||
file_size: Ignored for YouTube (kept for interface compatibility)
|
file_size: Ignored for YouTube (kept for interface compatibility)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
download_id: Unique ID for tracking this download, or None if failed to start
|
download_id: Unique ID for tracking this download
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Parse filename to extract video_id
|
# Parse filename to extract video_id
|
||||||
|
|
@ -740,12 +779,12 @@ class YouTubeClient:
|
||||||
download_id = str(uuid.uuid4())
|
download_id = str(uuid.uuid4())
|
||||||
|
|
||||||
# Initialize download info in active downloads
|
# Initialize download info in active downloads
|
||||||
async with self._download_lock:
|
with self._download_lock:
|
||||||
self.active_downloads[download_id] = {
|
self.active_downloads[download_id] = {
|
||||||
'id': download_id,
|
'id': download_id,
|
||||||
'filename': title,
|
'filename': filename, # Keep original encoded format for context matching!
|
||||||
'username': 'youtube',
|
'username': 'youtube',
|
||||||
'state': 'Initializing',
|
'state': 'Initializing', # Soulseek-style states
|
||||||
'progress': 0.0,
|
'progress': 0.0,
|
||||||
'size': file_size or 0,
|
'size': file_size or 0,
|
||||||
'transferred': 0,
|
'transferred': 0,
|
||||||
|
|
@ -755,52 +794,108 @@ class YouTubeClient:
|
||||||
'url': youtube_url,
|
'url': youtube_url,
|
||||||
'title': title,
|
'title': title,
|
||||||
'file_path': None, # Will be set when download completes
|
'file_path': None, # Will be set when download completes
|
||||||
'task': None # Will hold the background task
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Start download in background task
|
# Start download in background thread (returns immediately)
|
||||||
loop = asyncio.get_event_loop()
|
download_thread = threading.Thread(
|
||||||
task = loop.create_task(self._download_internal(download_id, youtube_url, title))
|
target=self._download_thread_worker,
|
||||||
|
args=(download_id, youtube_url, title, filename),
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
download_thread.start()
|
||||||
|
|
||||||
# Store task reference
|
logger.info(f"✅ YouTube download {download_id} started in background")
|
||||||
async with self._download_lock:
|
|
||||||
self.active_downloads[download_id]['task'] = task
|
|
||||||
|
|
||||||
logger.info(f"✅ Download started with ID: {download_id}")
|
|
||||||
return download_id
|
return download_id
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Failed to start download: {e}")
|
logger.error(f"❌ Failed to start YouTube download: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _download_internal(self, download_id: str, youtube_url: str, title: str):
|
def _download_thread_worker(self, download_id: str, youtube_url: str, title: str, original_filename: str):
|
||||||
"""
|
"""
|
||||||
Internal method to perform the actual YouTube download in the background.
|
Background thread worker for downloading YouTube videos.
|
||||||
|
Updates active_downloads dict with progress.
|
||||||
Args:
|
|
||||||
download_id: Unique download ID
|
|
||||||
youtube_url: YouTube video URL
|
|
||||||
title: Video title for display
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Update state to downloading
|
# Update state to downloading
|
||||||
async with self._download_lock:
|
with self._download_lock:
|
||||||
if download_id in self.active_downloads:
|
if download_id in self.active_downloads:
|
||||||
self.active_downloads[download_id]['state'] = 'Downloading'
|
self.active_downloads[download_id]['state'] = 'InProgress, Downloading' # Match Soulseek state
|
||||||
|
|
||||||
# Set current download ID for progress tracking
|
# Set current download ID for progress hook
|
||||||
self.current_download_id = download_id
|
self.current_download_id = download_id
|
||||||
|
|
||||||
# Run yt-dlp download in thread pool (to avoid blocking event loop)
|
# Perform actual download
|
||||||
loop = asyncio.get_event_loop()
|
file_path = self._download_sync(youtube_url, title)
|
||||||
|
|
||||||
def _download():
|
# Clear current download ID
|
||||||
|
self.current_download_id = None
|
||||||
|
|
||||||
|
if file_path:
|
||||||
|
# Mark as completed/succeeded (match Soulseek state)
|
||||||
|
with self._download_lock:
|
||||||
|
if download_id in self.active_downloads:
|
||||||
|
# IMPORTANT: Keep original filename for context lookup!
|
||||||
|
# The filename must match what was used to create the context entry
|
||||||
|
# We store the actual file path separately
|
||||||
|
self.active_downloads[download_id]['state'] = 'Completed, Succeeded' # Match Soulseek
|
||||||
|
self.active_downloads[download_id]['progress'] = 100.0
|
||||||
|
self.active_downloads[download_id]['file_path'] = file_path
|
||||||
|
# DO NOT update filename - keep original_filename for context matching
|
||||||
|
|
||||||
|
logger.info(f"✅ YouTube download {download_id} completed: {file_path}")
|
||||||
|
else:
|
||||||
|
# Mark as errored
|
||||||
|
with self._download_lock:
|
||||||
|
if download_id in self.active_downloads:
|
||||||
|
self.active_downloads[download_id]['state'] = 'Errored'
|
||||||
|
|
||||||
|
logger.error(f"❌ YouTube download {download_id} failed")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ YouTube download thread failed for {download_id}: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
# Mark as errored
|
||||||
|
with self._download_lock:
|
||||||
|
if download_id in self.active_downloads:
|
||||||
|
self.active_downloads[download_id]['state'] = 'Errored'
|
||||||
|
|
||||||
|
# Clear current download ID
|
||||||
|
if self.current_download_id == download_id:
|
||||||
|
self.current_download_id = None
|
||||||
|
|
||||||
|
def _download_sync(self, youtube_url: str, title: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Synchronous download method (runs in thread pool executor).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
youtube_url: YouTube video URL
|
||||||
|
title: Video title for display
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
File path if successful, None otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
max_retries = 2
|
||||||
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
# Use default download options
|
# Use default download options
|
||||||
download_opts = self.download_opts.copy()
|
download_opts = self.download_opts.copy()
|
||||||
|
|
||||||
|
# On retry, try different player client
|
||||||
|
if attempt > 0:
|
||||||
|
logger.info(f"🔄 Retry {attempt + 1}/{max_retries} with different settings")
|
||||||
|
download_opts['extractor_args'] = {
|
||||||
|
'youtube': {
|
||||||
|
'player_client': ['web'], # Try web-only on retry
|
||||||
|
'skip': ['hls', 'dash'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Perform download
|
# Perform download
|
||||||
with yt_dlp.YoutubeDL(download_opts) as ydl:
|
with yt_dlp.YoutubeDL(download_opts) as ydl:
|
||||||
info = ydl.extract_info(youtube_url, download=True)
|
info = ydl.extract_info(youtube_url, download=True)
|
||||||
|
|
@ -812,57 +907,38 @@ class YouTubeClient:
|
||||||
return str(filename)
|
return str(filename)
|
||||||
else:
|
else:
|
||||||
logger.error(f"❌ Download completed but file not found: {filename}")
|
logger.error(f"❌ Download completed but file not found: {filename}")
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
continue # Retry
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Download failed in thread: {e}")
|
error_msg = str(e)
|
||||||
import traceback
|
logger.error(f"❌ Download attempt {attempt + 1} failed: {error_msg}")
|
||||||
traceback.print_exc()
|
|
||||||
|
# Check if it's a 403 error
|
||||||
|
if '403' in error_msg or 'Forbidden' in error_msg:
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
logger.info(f"⏳ Waiting 2 seconds before retry...")
|
||||||
|
import time
|
||||||
|
time.sleep(2)
|
||||||
|
continue # Retry on 403
|
||||||
|
|
||||||
|
# For other errors or last retry, print traceback and return
|
||||||
|
if attempt == max_retries - 1:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
else:
|
||||||
|
continue # Retry
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Run download
|
return None # All retries failed
|
||||||
file_path = await loop.run_in_executor(None, _download)
|
|
||||||
|
|
||||||
if file_path:
|
|
||||||
# Mark download as completed
|
|
||||||
async with self._download_lock:
|
|
||||||
if download_id in self.active_downloads:
|
|
||||||
self.active_downloads[download_id]['state'] = 'Completed'
|
|
||||||
self.active_downloads[download_id]['progress'] = 100.0
|
|
||||||
self.active_downloads[download_id]['file_path'] = file_path
|
|
||||||
|
|
||||||
logger.info(f"✅ Download {download_id} completed: {file_path}")
|
|
||||||
else:
|
|
||||||
# Mark as error
|
|
||||||
async with self._download_lock:
|
|
||||||
if download_id in self.active_downloads:
|
|
||||||
self.active_downloads[download_id]['state'] = 'Errored'
|
|
||||||
|
|
||||||
logger.error(f"❌ Download {download_id} failed")
|
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
# Download was cancelled
|
|
||||||
async with self._download_lock:
|
|
||||||
if download_id in self.active_downloads:
|
|
||||||
self.active_downloads[download_id]['state'] = 'Cancelled'
|
|
||||||
|
|
||||||
logger.info(f"⚠️ Download {download_id} cancelled")
|
|
||||||
raise
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Download error
|
logger.error(f"❌ Download failed: {e}")
|
||||||
async with self._download_lock:
|
|
||||||
if download_id in self.active_downloads:
|
|
||||||
self.active_downloads[download_id]['state'] = 'Errored'
|
|
||||||
|
|
||||||
logger.error(f"❌ Download {download_id} failed: {e}")
|
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
return None
|
||||||
finally:
|
|
||||||
# Clear current download ID
|
|
||||||
if self.current_download_id == download_id:
|
|
||||||
self.current_download_id = None
|
|
||||||
|
|
||||||
async def get_all_downloads(self) -> List[DownloadStatus]:
|
async def get_all_downloads(self) -> List[DownloadStatus]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -873,7 +949,7 @@ class YouTubeClient:
|
||||||
"""
|
"""
|
||||||
download_statuses = []
|
download_statuses = []
|
||||||
|
|
||||||
async with self._download_lock:
|
with self._download_lock:
|
||||||
for download_id, download_info in self.active_downloads.items():
|
for download_id, download_info in self.active_downloads.items():
|
||||||
status = DownloadStatus(
|
status = DownloadStatus(
|
||||||
id=download_info['id'],
|
id=download_info['id'],
|
||||||
|
|
@ -900,7 +976,7 @@ class YouTubeClient:
|
||||||
Returns:
|
Returns:
|
||||||
DownloadStatus object or None if not found
|
DownloadStatus object or None if not found
|
||||||
"""
|
"""
|
||||||
async with self._download_lock:
|
with self._download_lock:
|
||||||
if download_id not in self.active_downloads:
|
if download_id not in self.active_downloads:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -922,6 +998,9 @@ class YouTubeClient:
|
||||||
"""
|
"""
|
||||||
Cancel an active download (matches Soulseek interface).
|
Cancel an active download (matches Soulseek interface).
|
||||||
|
|
||||||
|
NOTE: YouTube downloads cannot be truly cancelled mid-download,
|
||||||
|
but we mark them as cancelled for UI consistency.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
download_id: Download ID to cancel
|
download_id: Download ID to cancel
|
||||||
username: Ignored for YouTube (kept for interface compatibility)
|
username: Ignored for YouTube (kept for interface compatibility)
|
||||||
|
|
@ -931,26 +1010,19 @@ class YouTubeClient:
|
||||||
True if cancelled successfully, False otherwise
|
True if cancelled successfully, False otherwise
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with self._download_lock:
|
with self._download_lock:
|
||||||
if download_id not in self.active_downloads:
|
if download_id not in self.active_downloads:
|
||||||
logger.warning(f"⚠️ Download {download_id} not found")
|
logger.warning(f"⚠️ Download {download_id} not found")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
download_info = self.active_downloads[download_id]
|
# Update state to cancelled
|
||||||
task = download_info.get('task')
|
self.active_downloads[download_id]['state'] = 'Cancelled'
|
||||||
|
logger.info(f"⚠️ Marked YouTube download {download_id} as cancelled")
|
||||||
# Cancel the background task if it exists
|
|
||||||
if task and not task.done():
|
|
||||||
task.cancel()
|
|
||||||
logger.info(f"⚠️ Cancelled download {download_id}")
|
|
||||||
|
|
||||||
# Update state
|
|
||||||
download_info['state'] = 'Cancelled'
|
|
||||||
|
|
||||||
# Remove from active downloads if requested
|
# Remove from active downloads if requested
|
||||||
if remove:
|
if remove:
|
||||||
del self.active_downloads[download_id]
|
del self.active_downloads[download_id]
|
||||||
logger.info(f"🗑️ Removed download {download_id} from queue")
|
logger.info(f"🗑️ Removed YouTube download {download_id} from queue")
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
|
||||||
125
web_server.py
125
web_server.py
|
|
@ -31,6 +31,7 @@ from core.plex_client import PlexClient
|
||||||
from core.jellyfin_client import JellyfinClient
|
from core.jellyfin_client import JellyfinClient
|
||||||
from core.navidrome_client import NavidromeClient
|
from core.navidrome_client import NavidromeClient
|
||||||
from core.soulseek_client import SoulseekClient
|
from core.soulseek_client import SoulseekClient
|
||||||
|
from core.download_orchestrator import DownloadOrchestrator
|
||||||
from core.tidal_client import TidalClient # Added import for Tidal
|
from core.tidal_client import TidalClient # Added import for Tidal
|
||||||
from core.matching_engine import MusicMatchingEngine
|
from core.matching_engine import MusicMatchingEngine
|
||||||
from core.database_update_worker import DatabaseUpdateWorker, DatabaseStatsWorker
|
from core.database_update_worker import DatabaseUpdateWorker, DatabaseStatsWorker
|
||||||
|
|
@ -106,7 +107,8 @@ try:
|
||||||
plex_client = PlexClient()
|
plex_client = PlexClient()
|
||||||
jellyfin_client = JellyfinClient()
|
jellyfin_client = JellyfinClient()
|
||||||
navidrome_client = NavidromeClient()
|
navidrome_client = NavidromeClient()
|
||||||
soulseek_client = SoulseekClient()
|
# Use DownloadOrchestrator instead of SoulseekClient directly (routes between Soulseek/YouTube)
|
||||||
|
soulseek_client = DownloadOrchestrator()
|
||||||
tidal_client = TidalClient()
|
tidal_client = TidalClient()
|
||||||
matching_engine = MusicMatchingEngine()
|
matching_engine = MusicMatchingEngine()
|
||||||
sync_service = PlaylistSyncService(spotify_client, plex_client, soulseek_client, jellyfin_client, navidrome_client)
|
sync_service = PlaylistSyncService(spotify_client, plex_client, soulseek_client, jellyfin_client, navidrome_client)
|
||||||
|
|
@ -268,6 +270,7 @@ def get_cached_transfer_data():
|
||||||
# Cache expired or empty, fetch new data
|
# Cache expired or empty, fetch new data
|
||||||
live_transfers_lookup = {}
|
live_transfers_lookup = {}
|
||||||
try:
|
try:
|
||||||
|
# First, get Soulseek downloads from API
|
||||||
transfers_data = asyncio.run(soulseek_client._make_request('GET', 'transfers/downloads'))
|
transfers_data = asyncio.run(soulseek_client._make_request('GET', 'transfers/downloads'))
|
||||||
if transfers_data:
|
if transfers_data:
|
||||||
all_transfers = []
|
all_transfers = []
|
||||||
|
|
@ -282,7 +285,28 @@ def get_cached_transfer_data():
|
||||||
for transfer in all_transfers:
|
for transfer in all_transfers:
|
||||||
key = f"{transfer.get('username')}::{extract_filename(transfer.get('filename', ''))}"
|
key = f"{transfer.get('username')}::{extract_filename(transfer.get('filename', ''))}"
|
||||||
live_transfers_lookup[key] = transfer
|
live_transfers_lookup[key] = transfer
|
||||||
|
|
||||||
|
# Also add YouTube downloads (through orchestrator)
|
||||||
|
try:
|
||||||
|
all_downloads = asyncio.run(soulseek_client.get_all_downloads())
|
||||||
|
for download in all_downloads:
|
||||||
|
# Only add YouTube downloads (Soulseek ones are already in the lookup)
|
||||||
|
if download.username == 'youtube':
|
||||||
|
key = f"{download.username}::{extract_filename(download.filename)}"
|
||||||
|
# Convert DownloadStatus to transfer dict format
|
||||||
|
live_transfers_lookup[key] = {
|
||||||
|
'id': download.id,
|
||||||
|
'filename': download.filename,
|
||||||
|
'username': download.username,
|
||||||
|
'state': download.state,
|
||||||
|
'percentComplete': download.progress,
|
||||||
|
'size': download.size,
|
||||||
|
'bytesTransferred': download.transferred,
|
||||||
|
'averageSpeed': download.speed,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Could not fetch YouTube downloads: {e}")
|
||||||
|
|
||||||
# Update cache
|
# Update cache
|
||||||
transfer_data_cache['data'] = live_transfers_lookup
|
transfer_data_cache['data'] = live_transfers_lookup
|
||||||
transfer_data_cache['last_update'] = current_time
|
transfer_data_cache['last_update'] = current_time
|
||||||
|
|
@ -2058,7 +2082,7 @@ def handle_settings():
|
||||||
if 'active_media_server' in new_settings:
|
if 'active_media_server' in new_settings:
|
||||||
config_manager.set_active_media_server(new_settings['active_media_server'])
|
config_manager.set_active_media_server(new_settings['active_media_server'])
|
||||||
|
|
||||||
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'listenbrainz']:
|
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'listenbrainz']:
|
||||||
if service in new_settings:
|
if service in new_settings:
|
||||||
for key, value in new_settings[service].items():
|
for key, value in new_settings[service].items():
|
||||||
config_manager.set(f'{service}.{key}', value)
|
config_manager.set(f'{service}.{key}', value)
|
||||||
|
|
@ -2073,7 +2097,8 @@ def handle_settings():
|
||||||
spotify_client._setup_client()
|
spotify_client._setup_client()
|
||||||
plex_client.server = None
|
plex_client.server = None
|
||||||
jellyfin_client.server = None
|
jellyfin_client.server = None
|
||||||
soulseek_client._setup_client()
|
# Reload orchestrator settings (download source mode, hybrid_primary, etc.)
|
||||||
|
soulseek_client.reload_settings()
|
||||||
# FIX: Re-instantiate the global tidal_client to pick up new settings
|
# FIX: Re-instantiate the global tidal_client to pick up new settings
|
||||||
tidal_client = TidalClient()
|
tidal_client = TidalClient()
|
||||||
print("✅ Service clients re-initialized with new settings.")
|
print("✅ Service clients re-initialized with new settings.")
|
||||||
|
|
@ -3551,7 +3576,7 @@ def _find_completed_file_robust(download_dir, api_filename, transfer_dir=None):
|
||||||
Robustly finds a completed file on disk, accounting for name variations and
|
Robustly finds a completed file on disk, accounting for name variations and
|
||||||
unexpected subdirectories. This version uses the superior normalization logic
|
unexpected subdirectories. This version uses the superior normalization logic
|
||||||
from the GUI's matching_engine.py to ensure consistency.
|
from the GUI's matching_engine.py to ensure consistency.
|
||||||
|
|
||||||
First searches in download_dir, then optionally searches in transfer_dir if provided.
|
First searches in download_dir, then optionally searches in transfer_dir if provided.
|
||||||
Returns tuple (file_path, location) where location is 'downloads' or 'transfer'.
|
Returns tuple (file_path, location) where location is 'downloads' or 'transfer'.
|
||||||
"""
|
"""
|
||||||
|
|
@ -3560,6 +3585,14 @@ def _find_completed_file_robust(download_dir, api_filename, transfer_dir=None):
|
||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
from unidecode import unidecode
|
from unidecode import unidecode
|
||||||
|
|
||||||
|
# YOUTUBE SUPPORT: Handle encoded filename format "video_id||title"
|
||||||
|
# Extract just the title part for file matching
|
||||||
|
if '||' in api_filename:
|
||||||
|
print(f"🎵 Detected YouTube encoded filename: {api_filename}")
|
||||||
|
_, title = api_filename.split('||', 1)
|
||||||
|
api_filename = title # Use just the title for file searching
|
||||||
|
print(f"🎵 Extracted title for search: {api_filename}")
|
||||||
|
|
||||||
def normalize_for_finding(text: str) -> str:
|
def normalize_for_finding(text: str) -> str:
|
||||||
"""A powerful normalization function adapted from matching_engine.py."""
|
"""A powerful normalization function adapted from matching_engine.py."""
|
||||||
if not text: return ""
|
if not text: return ""
|
||||||
|
|
@ -6949,7 +6982,7 @@ def _build_final_path_for_track(context, spotify_artist, album_info, file_ext):
|
||||||
playlist_folder_mode = track_info.get("_playlist_folder_mode", False)
|
playlist_folder_mode = track_info.get("_playlist_folder_mode", False)
|
||||||
|
|
||||||
# Extract year from spotify_album for template use (safe for all modes)
|
# Extract year from spotify_album for template use (safe for all modes)
|
||||||
year = 'Unknown'
|
year = '' # Empty string instead of 'Unknown' to avoid "Unknown albumName"
|
||||||
spotify_album = context.get("spotify_album", {})
|
spotify_album = context.get("spotify_album", {})
|
||||||
if spotify_album and spotify_album.get('release_date'):
|
if spotify_album and spotify_album.get('release_date'):
|
||||||
release_date = spotify_album['release_date']
|
release_date = spotify_album['release_date']
|
||||||
|
|
@ -7087,7 +7120,13 @@ def _apply_path_template(template: str, context: dict) -> str:
|
||||||
result = result.replace('$album', context.get('album', 'Unknown Album'))
|
result = result.replace('$album', context.get('album', 'Unknown Album'))
|
||||||
result = result.replace('$title', context.get('title', 'Unknown Track'))
|
result = result.replace('$title', context.get('title', 'Unknown Track'))
|
||||||
result = result.replace('$track', f"{context.get('track_number', 1):02d}")
|
result = result.replace('$track', f"{context.get('track_number', 1):02d}")
|
||||||
result = result.replace('$year', str(context.get('year', 'Unknown')))
|
result = result.replace('$year', str(context.get('year', ''))) # Empty string instead of 'Unknown'
|
||||||
|
|
||||||
|
# Clean up extra spaces that might result from empty variables
|
||||||
|
import re
|
||||||
|
result = re.sub(r'\s+', ' ', result) # Multiple spaces to single space
|
||||||
|
result = re.sub(r'\s*-\s*-\s*', ' - ', result) # Clean up double dashes
|
||||||
|
result = result.strip() # Remove leading/trailing spaces
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
@ -7476,7 +7515,40 @@ def _post_process_matched_download_with_verification(context_key, context, file_
|
||||||
context['task_id'] = original_task_id
|
context['task_id'] = original_task_id
|
||||||
if original_batch_id:
|
if original_batch_id:
|
||||||
context['batch_id'] = original_batch_id
|
context['batch_id'] = original_batch_id
|
||||||
|
|
||||||
|
# Check if simple download handler already completed everything
|
||||||
|
if context.get('_simple_download_completed'):
|
||||||
|
print(f"✅ [Verification] Simple download handler already completed - verifying at custom path")
|
||||||
|
expected_final_path = context.get('_final_path')
|
||||||
|
|
||||||
|
if expected_final_path and os.path.exists(expected_final_path):
|
||||||
|
print(f"✅ [Verification] File verified at simple download path: {expected_final_path}")
|
||||||
|
with tasks_lock:
|
||||||
|
if task_id in download_tasks:
|
||||||
|
_mark_task_completed(task_id, context.get('track_info'))
|
||||||
|
print(f"✅ [Verification] Task {task_id} marked as completed (simple download)")
|
||||||
|
|
||||||
|
with matched_context_lock:
|
||||||
|
if context_key in matched_downloads_context:
|
||||||
|
del matched_downloads_context[context_key]
|
||||||
|
print(f"🗑️ [Verification] Cleaned up context after simple download completion: {context_key}")
|
||||||
|
|
||||||
|
_on_download_completed(batch_id, task_id, success=True)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
print(f"❌ [Verification] Simple download file not found at: {expected_final_path}")
|
||||||
|
with tasks_lock:
|
||||||
|
if task_id in download_tasks:
|
||||||
|
download_tasks[task_id]['status'] = 'failed'
|
||||||
|
download_tasks[task_id]['error_message'] = "Simple download file not found after processing."
|
||||||
|
|
||||||
|
with matched_context_lock:
|
||||||
|
if context_key in matched_downloads_context:
|
||||||
|
del matched_downloads_context[context_key]
|
||||||
|
|
||||||
|
_on_download_completed(batch_id, task_id, success=False)
|
||||||
|
return
|
||||||
|
|
||||||
# CRITICAL VERIFICATION STEP: Verify the final file exists
|
# CRITICAL VERIFICATION STEP: Verify the final file exists
|
||||||
spotify_artist = context.get("spotify_artist")
|
spotify_artist = context.get("spotify_artist")
|
||||||
if not spotify_artist:
|
if not spotify_artist:
|
||||||
|
|
@ -7702,6 +7774,10 @@ def _post_process_matched_download(context_key, context, file_path):
|
||||||
|
|
||||||
add_activity_item("✅", "Download Complete", f"{album_name}/{filename}", "Now")
|
add_activity_item("✅", "Download Complete", f"{album_name}/{filename}", "Now")
|
||||||
logger.info(f"✅ Simple download post-processing complete: {album_name}/{filename}")
|
logger.info(f"✅ Simple download post-processing complete: {album_name}/{filename}")
|
||||||
|
|
||||||
|
# Set flag in context so verification function knows this was fully handled
|
||||||
|
context['_simple_download_completed'] = True
|
||||||
|
context['_final_path'] = str(destination)
|
||||||
return
|
return
|
||||||
# --- END SIMPLE DOWNLOAD HANDLING ---
|
# --- END SIMPLE DOWNLOAD HANDLING ---
|
||||||
|
|
||||||
|
|
@ -7756,7 +7832,7 @@ def _post_process_matched_download(context_key, context, file_path):
|
||||||
|
|
||||||
is_album_download = context.get("is_album_download", False)
|
is_album_download = context.get("is_album_download", False)
|
||||||
has_clean_spotify_data = context.get("has_clean_spotify_data", False)
|
has_clean_spotify_data = context.get("has_clean_spotify_data", False)
|
||||||
|
|
||||||
if is_album_download and has_clean_spotify_data:
|
if is_album_download and has_clean_spotify_data:
|
||||||
# Build album_info directly from clean Spotify metadata (GUI PARITY)
|
# Build album_info directly from clean Spotify metadata (GUI PARITY)
|
||||||
print("✅ Album context with clean Spotify data found - using direct album info")
|
print("✅ Album context with clean Spotify data found - using direct album info")
|
||||||
|
|
@ -10383,19 +10459,26 @@ def get_valid_candidates(results, spotify_track, query):
|
||||||
if not initial_candidates:
|
if not initial_candidates:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Filter by user's quality profile before artist verification
|
# Skip quality filtering for YouTube results (always MP3 320kbps - no quality options)
|
||||||
# Use shared soulseek_client method for consistency
|
is_youtube_source = initial_candidates[0].username == "youtube" if initial_candidates else False
|
||||||
from core.soulseek_client import SoulseekClient
|
|
||||||
temp_client = SoulseekClient()
|
|
||||||
quality_filtered_candidates = temp_client.filter_results_by_quality_preference(initial_candidates)
|
|
||||||
|
|
||||||
# IMPORTANT: Respect empty results from quality filter
|
if is_youtube_source:
|
||||||
# If user has strict quality requirements (e.g., FLAC-only with fallback disabled),
|
print(f"🎵 [YouTube] Skipping quality filter - YouTube always provides MP3 320kbps")
|
||||||
# and no results match, we should fail the download rather than force a fallback.
|
quality_filtered_candidates = initial_candidates
|
||||||
# The quality filter already has its own fallback logic controlled by the user's settings.
|
else:
|
||||||
if not quality_filtered_candidates:
|
# Filter by user's quality profile before artist verification (Soulseek only)
|
||||||
print(f"⚠️ [Quality Filter] No candidates match quality profile - download will fail per user preferences")
|
# Use shared soulseek_client method for consistency
|
||||||
return []
|
from core.soulseek_client import SoulseekClient
|
||||||
|
temp_client = SoulseekClient()
|
||||||
|
quality_filtered_candidates = temp_client.filter_results_by_quality_preference(initial_candidates)
|
||||||
|
|
||||||
|
# IMPORTANT: Respect empty results from quality filter
|
||||||
|
# If user has strict quality requirements (e.g., FLAC-only with fallback disabled),
|
||||||
|
# and no results match, we should fail the download rather than force a fallback.
|
||||||
|
# The quality filter already has its own fallback logic controlled by the user's settings.
|
||||||
|
if not quality_filtered_candidates:
|
||||||
|
print(f"⚠️ [Quality Filter] No candidates match quality profile - download will fail per user preferences")
|
||||||
|
return []
|
||||||
|
|
||||||
verified_candidates = []
|
verified_candidates = []
|
||||||
spotify_artist_name = spotify_track.artists[0] if spotify_track.artists else ""
|
spotify_artist_name = spotify_track.artists[0] if spotify_track.artists else ""
|
||||||
|
|
|
||||||
|
|
@ -2547,6 +2547,40 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Download Source:</label>
|
||||||
|
<select id="download-source-mode" class="form-select" onchange="updateDownloadSourceUI()">
|
||||||
|
<option value="soulseek">Soulseek Only</option>
|
||||||
|
<option value="youtube">YouTube Only</option>
|
||||||
|
<option value="hybrid">Hybrid (Try both with fallback)</option>
|
||||||
|
</select>
|
||||||
|
<div class="setting-help-text">
|
||||||
|
Choose where to download music from. Hybrid mode tries primary source first, then falls back to secondary if it fails.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hybrid Mode Settings (shown only when hybrid is selected) -->
|
||||||
|
<div id="hybrid-settings-container" style="display: none;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Primary Source (Hybrid Mode):</label>
|
||||||
|
<select id="hybrid-primary-source" class="form-select">
|
||||||
|
<option value="soulseek">Try Soulseek First</option>
|
||||||
|
<option value="youtube">Try YouTube First</option>
|
||||||
|
</select>
|
||||||
|
<div class="setting-help-text">
|
||||||
|
Which source to try first when hybrid mode is enabled.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>YouTube Min Confidence:</label>
|
||||||
|
<input type="number" id="youtube-min-confidence" min="0.5" max="1.0" step="0.05" value="0.65" placeholder="0.65">
|
||||||
|
<div class="setting-help-text">
|
||||||
|
Minimum match confidence for YouTube downloads (0.5-1.0). Higher = stricter matching.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Log Level:</label>
|
<label>Log Level:</label>
|
||||||
<select id="log-level-select" class="form-select" onchange="changeLogLevel()">
|
<select id="log-level-select" class="form-select" onchange="changeLogLevel()">
|
||||||
|
|
|
||||||
|
|
@ -1678,7 +1678,15 @@ async function loadSettingsData() {
|
||||||
// Populate Download settings (right column)
|
// Populate Download settings (right column)
|
||||||
document.getElementById('download-path').value = settings.soulseek?.download_path || './downloads';
|
document.getElementById('download-path').value = settings.soulseek?.download_path || './downloads';
|
||||||
document.getElementById('transfer-path').value = settings.soulseek?.transfer_path || './Transfer';
|
document.getElementById('transfer-path').value = settings.soulseek?.transfer_path || './Transfer';
|
||||||
|
|
||||||
|
// Populate Download Source settings
|
||||||
|
document.getElementById('download-source-mode').value = settings.download_source?.mode || 'soulseek';
|
||||||
|
document.getElementById('hybrid-primary-source').value = settings.download_source?.hybrid_primary || 'soulseek';
|
||||||
|
document.getElementById('youtube-min-confidence').value = settings.download_source?.youtube_min_confidence || 0.65;
|
||||||
|
|
||||||
|
// Update UI based on download source mode
|
||||||
|
updateDownloadSourceUI();
|
||||||
|
|
||||||
// Populate Database settings
|
// Populate Database settings
|
||||||
document.getElementById('max-workers').value = settings.database?.max_workers || '5';
|
document.getElementById('max-workers').value = settings.database?.max_workers || '5';
|
||||||
|
|
||||||
|
|
@ -1789,6 +1797,18 @@ function toggleServer(serverType) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateDownloadSourceUI() {
|
||||||
|
const mode = document.getElementById('download-source-mode').value;
|
||||||
|
const hybridContainer = document.getElementById('hybrid-settings-container');
|
||||||
|
|
||||||
|
// Show hybrid settings only when hybrid mode is selected
|
||||||
|
if (mode === 'hybrid') {
|
||||||
|
hybridContainer.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
hybridContainer.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===============================
|
// ===============================
|
||||||
// QUALITY PROFILE FUNCTIONS
|
// QUALITY PROFILE FUNCTIONS
|
||||||
// ===============================
|
// ===============================
|
||||||
|
|
@ -2053,6 +2073,11 @@ async function saveSettings() {
|
||||||
listenbrainz: {
|
listenbrainz: {
|
||||||
token: document.getElementById('listenbrainz-token').value
|
token: document.getElementById('listenbrainz-token').value
|
||||||
},
|
},
|
||||||
|
download_source: {
|
||||||
|
mode: document.getElementById('download-source-mode').value,
|
||||||
|
hybrid_primary: document.getElementById('hybrid-primary-source').value,
|
||||||
|
youtube_min_confidence: parseFloat(document.getElementById('youtube-min-confidence').value) || 0.65
|
||||||
|
},
|
||||||
database: {
|
database: {
|
||||||
max_workers: parseInt(document.getElementById('max-workers').value)
|
max_workers: parseInt(document.getElementById('max-workers').value)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue