Improve YouTube download handling and shutdown safety
Adjusts matching weights for YouTube sources to rely more on title and duration, adds a shutdown callback to the YouTube client to prevent new downloads during shutdown, and enhances post-processing to reliably resolve actual YouTube file paths. Improves error handling for file removal, ensures no new batch downloads start during shutdown, and refines download monitoring to trigger post-processing on completed YouTube downloads. Also increases YouTube download retries and improves logging for debugging.
This commit is contained in:
parent
af2265a68a
commit
ddd2ebdb13
4 changed files with 155 additions and 15 deletions
|
|
@ -572,13 +572,21 @@ class MusicMatchingEngine:
|
||||||
is_short_title = len(spotify_cleaned_title) <= 5 or len(title_words) == 1
|
is_short_title = len(spotify_cleaned_title) <= 5 or len(title_words) == 1
|
||||||
|
|
||||||
# --- Final Weighted Score ---
|
# --- Final Weighted Score ---
|
||||||
# Rebalanced weights: Artist matching is now more important to prevent false positives
|
is_youtube = slskd_track.username == 'youtube'
|
||||||
final_confidence = (title_score * 0.45) + (artist_score * 0.40) + (duration_score * 0.15)
|
|
||||||
|
if is_youtube:
|
||||||
|
# For YouTube, rely more on Title and Duration since Artist is often missing from video titles
|
||||||
|
# and the search query already filtered by artist to some extent.
|
||||||
|
# New weights: Title 70%, Artist 10%, Duration 20%
|
||||||
|
final_confidence = (title_score * 0.70) + (artist_score * 0.10) + (duration_score * 0.20)
|
||||||
|
else:
|
||||||
|
# Standard weights for Soulseek (Artist is critical for correctness)
|
||||||
|
# Rebalanced weights: Artist matching is now more important to prevent false positives
|
||||||
|
final_confidence = (title_score * 0.45) + (artist_score * 0.40) + (duration_score * 0.15)
|
||||||
|
|
||||||
# Apply short title penalty AFTER calculating base confidence
|
# Apply short title penalty AFTER calculating base confidence
|
||||||
# This allows perfect matches to still pass, but penalizes weak artist matches
|
# This allows perfect matches to still pass, but penalizes weak artist matches
|
||||||
# For YouTube, skip penalty since artist matching is less reliable (searches are track-name-only)
|
# For YouTube, skip penalty since artist matching is less reliable (searches are track-name-only)
|
||||||
is_youtube = slskd_track.username == 'youtube'
|
|
||||||
if is_short_title and artist_score < 0.5 and not is_youtube:
|
if is_short_title and artist_score < 0.5 and not is_youtube:
|
||||||
# Heavy penalty but not complete rejection
|
# Heavy penalty but not complete rejection
|
||||||
# Multiply by 0.4 (60% penalty) - still possible to pass if title+duration are perfect
|
# Multiply by 0.4 (60% penalty) - still possible to pass if title+duration are perfect
|
||||||
|
|
@ -596,13 +604,13 @@ class MusicMatchingEngine:
|
||||||
# Debug logging to track matching decisions
|
# Debug logging to track matching decisions
|
||||||
if final_confidence > 0.3: # Only log potential matches
|
if final_confidence > 0.3: # Only log potential matches
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Match scoring: '{spotify_track.name}' by {spotify_track.artists[0] if spotify_track.artists else 'Unknown'} "
|
f"Match scoring ({'YT' if is_youtube else 'SLSK'}): '{spotify_track.name}' by {spotify_track.artists[0] if spotify_track.artists else 'Unknown'} "
|
||||||
f"vs '{slskd_track.filename[:60]}...' | "
|
f"vs '{slskd_track.filename[:60]}...' | "
|
||||||
f"Title: {title_score:.2f} (ratio: {title_ratio:.2f}, boundary: {has_word_boundary}), "
|
f"Title: {title_score:.2f} (ratio: {title_ratio:.2f}, boundary: {has_word_boundary}), "
|
||||||
f"Artist: {artist_score:.2f}, Duration: {duration_score:.2f}, "
|
f"Artist: {artist_score:.2f}, Duration: {duration_score:.2f}, "
|
||||||
f"Final: {final_confidence:.2f} {'✅ PASS' if final_confidence > 0.58 else '❌ FAIL'}"
|
f"Final: {final_confidence:.2f} {'✅ PASS' if final_confidence > 0.63 else '❌ FAIL'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ensure the final score doesn't exceed 1.0
|
# Ensure the final score doesn't exceed 1.0
|
||||||
return min(final_confidence, 1.0)
|
return min(final_confidence, 1.0)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,7 @@ class DownloadStatus:
|
||||||
transferred: int
|
transferred: int
|
||||||
speed: int
|
speed: int
|
||||||
time_remaining: Optional[int] = None
|
time_remaining: Optional[int] = None
|
||||||
|
file_path: Optional[str] = None
|
||||||
|
|
||||||
class SoulseekClient:
|
class SoulseekClient:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
|
@ -330,8 +331,9 @@ class SoulseekClient:
|
||||||
error_detail = response_text if response_text.strip() else "No error details provided"
|
error_detail = response_text if response_text.strip() else "No error details provided"
|
||||||
|
|
||||||
# Reduce noise for expected 404s during search cleanup
|
# Reduce noise for expected 404s during search cleanup
|
||||||
if response.status == 404 and 'searches/' in url and method == 'DELETE':
|
# Reduce noise for expected 404s (e.g. status checks for YouTube downloads)
|
||||||
logger.debug(f"Search not found for deletion (expected): {url}")
|
if response.status == 404:
|
||||||
|
logger.debug(f"API request returned 404 (Not Found) for {url}")
|
||||||
else:
|
else:
|
||||||
logger.error(f"API request failed: HTTP {response.status} ({response.reason}) - {error_detail}")
|
logger.error(f"API request failed: HTTP {response.status} ({response.reason}) - {error_detail}")
|
||||||
logger.debug(f"Failed request: {method} {url}")
|
logger.debug(f"Failed request: {method} {url}")
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,13 @@ class YouTubeClient:
|
||||||
|
|
||||||
logger.info(f"📁 YouTube client using download path: {self.download_path}")
|
logger.info(f"📁 YouTube client using download path: {self.download_path}")
|
||||||
|
|
||||||
|
# Callback for shutdown check (avoids circular imports)
|
||||||
|
self.shutdown_check = None
|
||||||
|
|
||||||
|
def set_shutdown_check(self, check_callable):
|
||||||
|
"""Set a callback function to check for system shutdown"""
|
||||||
|
self.shutdown_check = check_callable
|
||||||
|
|
||||||
# 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")
|
||||||
|
|
@ -893,21 +900,35 @@ class YouTubeClient:
|
||||||
File path if successful, None otherwise
|
File path if successful, None otherwise
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
max_retries = 2
|
max_retries = 3
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
|
# Check for server shutdown using callback
|
||||||
|
if self.shutdown_check and self.shutdown_check():
|
||||||
|
logger.info(f"🛑 Server shutting down, aborting download attempt {attempt + 1}")
|
||||||
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Use default download options
|
# Use default download options
|
||||||
download_opts = self.download_opts.copy()
|
download_opts = self.download_opts.copy()
|
||||||
|
|
||||||
|
# Force best audio format to prevent 'Requested format not available' errors
|
||||||
|
download_opts['format'] = 'bestaudio/best'
|
||||||
|
download_opts['noplaylist'] = True
|
||||||
|
|
||||||
# On retry, try different player client
|
# On retry, try different player client
|
||||||
if attempt > 0:
|
if attempt == 1:
|
||||||
logger.info(f"🔄 Retry {attempt + 1}/{max_retries} with different settings")
|
logger.info(f"🔄 Retry {attempt + 1}/{max_retries} with different player client")
|
||||||
download_opts['extractor_args'] = {
|
download_opts['extractor_args'] = {
|
||||||
'youtube': {
|
'youtube': {
|
||||||
'player_client': ['web'], # Try web-only on retry
|
'player_client': ['web'], # Try web-only on retry
|
||||||
'skip': ['hls', 'dash'],
|
'skip': ['hls', 'dash'],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
elif attempt >= 2:
|
||||||
|
logger.info(f"🔄 Retry {attempt + 1}/{max_retries} with 'best' format (video fallback)")
|
||||||
|
download_opts['format'] = 'best' # Fallback to best available (including video)
|
||||||
|
download_opts.pop('extractor_args', None) # Reset extractor args
|
||||||
|
|
||||||
|
|
||||||
# Perform download
|
# Perform download
|
||||||
with yt_dlp.YoutubeDL(download_opts) as ydl:
|
with yt_dlp.YoutubeDL(download_opts) as ydl:
|
||||||
|
|
@ -1004,7 +1025,8 @@ class YouTubeClient:
|
||||||
size=download_info['size'],
|
size=download_info['size'],
|
||||||
transferred=download_info['transferred'],
|
transferred=download_info['transferred'],
|
||||||
speed=download_info['speed'],
|
speed=download_info['speed'],
|
||||||
time_remaining=download_info.get('time_remaining')
|
time_remaining=download_info.get('time_remaining'),
|
||||||
|
file_path=download_info.get('file_path')
|
||||||
)
|
)
|
||||||
|
|
||||||
async def cancel_download(self, download_id: str, username: str = None, remove: bool = False) -> bool:
|
async def cancel_download(self, download_id: str, username: str = None, remove: bool = False) -> bool:
|
||||||
|
|
|
||||||
114
web_server.py
114
web_server.py
|
|
@ -120,6 +120,12 @@ try:
|
||||||
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)
|
||||||
|
|
||||||
|
# Inject shutdown check callback into YouTube client (avoids circular imports)
|
||||||
|
# The callback uses the global IS_SHUTTING_DOWN flag from this module
|
||||||
|
if hasattr(soulseek_client, 'youtube'):
|
||||||
|
soulseek_client.youtube.set_shutdown_check(lambda: IS_SHUTTING_DOWN)
|
||||||
|
print("✅ Configured YouTube client shutdown callback")
|
||||||
|
|
||||||
# Initialize web scan manager for automatic post-download scanning
|
# Initialize web scan manager for automatic post-download scanning
|
||||||
media_clients = {
|
media_clients = {
|
||||||
|
|
@ -499,6 +505,22 @@ class WebUIDownloadMonitor:
|
||||||
|
|
||||||
# Check for timeouts and errors - retries handled directly in _should_retry_task
|
# Check for timeouts and errors - retries handled directly in _should_retry_task
|
||||||
self._should_retry_task(task_id, task, live_transfers_lookup, current_time)
|
self._should_retry_task(task_id, task, live_transfers_lookup, current_time)
|
||||||
|
|
||||||
|
# ENHANCED: Check for successful completions (especially YouTube)
|
||||||
|
task_filename = task.get('filename') or task.get('track_info', {}).get('filename')
|
||||||
|
task_username = task.get('username') or task.get('track_info', {}).get('username')
|
||||||
|
|
||||||
|
if task_filename and task_username:
|
||||||
|
lookup_key = f"{task_username}::{extract_filename(task_filename)}"
|
||||||
|
live_info = live_transfers_lookup.get(lookup_key)
|
||||||
|
|
||||||
|
if live_info:
|
||||||
|
state = live_info.get('state', '')
|
||||||
|
# Trigger post-processing if download is completed but still marked as downloading locally
|
||||||
|
# 'Completed' is used by YouTubeClient, 'Succeeded' by Soulseek
|
||||||
|
if state in ['Completed', 'Succeeded'] and task['status'] == 'downloading':
|
||||||
|
print(f"✅ Monitor detected completed download for {task_id} ({state}) - triggering post-processing")
|
||||||
|
_on_download_completed(task_id, success=True)
|
||||||
|
|
||||||
# ENHANCED: Add worker count validation to detect ghost workers
|
# ENHANCED: Add worker count validation to detect ghost workers
|
||||||
self._validate_worker_counts()
|
self._validate_worker_counts()
|
||||||
|
|
@ -1008,10 +1030,23 @@ def cleanup_monitor():
|
||||||
batch_locks.clear()
|
batch_locks.clear()
|
||||||
print("🧹 Cleaned up batch locks")
|
print("🧹 Cleaned up batch locks")
|
||||||
|
|
||||||
|
# Global shutdown flag
|
||||||
|
IS_SHUTTING_DOWN = False
|
||||||
|
|
||||||
def signal_handler(signum, frame):
|
def signal_handler(signum, frame):
|
||||||
"""Handle SIGINT (Ctrl+C) and SIGTERM"""
|
"""Handle SIGINT (Ctrl+C) and SIGTERM"""
|
||||||
|
global IS_SHUTTING_DOWN
|
||||||
print(f"🛑 Signal {signum} received, cleaning up...")
|
print(f"🛑 Signal {signum} received, cleaning up...")
|
||||||
|
IS_SHUTTING_DOWN = True
|
||||||
cleanup_monitor()
|
cleanup_monitor()
|
||||||
|
|
||||||
|
# Shutdown executor to prevent new tasks
|
||||||
|
try:
|
||||||
|
print("🛑 Shutting down missing_download_executor...")
|
||||||
|
missing_download_executor.shutdown(wait=False, cancel_futures=True)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Error shutting down executor: {e}")
|
||||||
|
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
# Register cleanup handlers
|
# Register cleanup handlers
|
||||||
|
|
@ -8212,14 +8247,26 @@ def _post_process_matched_download(context_key, context, file_path):
|
||||||
if has_metadata:
|
if has_metadata:
|
||||||
print(f"⚠️ [Protection] Existing file already has metadata enhancement - skipping overwrite: {os.path.basename(final_path)}")
|
print(f"⚠️ [Protection] Existing file already has metadata enhancement - skipping overwrite: {os.path.basename(final_path)}")
|
||||||
print(f"🗑️ [Protection] Removing redundant download file: {os.path.basename(file_path)}")
|
print(f"🗑️ [Protection] Removing redundant download file: {os.path.basename(file_path)}")
|
||||||
os.remove(file_path) # Remove the redundant file
|
try:
|
||||||
|
os.remove(file_path) # Remove the redundant file
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"⚠️ [Protection] Could not remove redundant file (already gone): {file_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ [Protection] Error removing redundant file: {e}")
|
||||||
return # Don't overwrite the good file
|
return # Don't overwrite the good file
|
||||||
else:
|
else:
|
||||||
print(f"🔄 [Protection] Existing file lacks metadata - safe to overwrite: {os.path.basename(final_path)}")
|
print(f"🔄 [Protection] Existing file lacks metadata - safe to overwrite: {os.path.basename(final_path)}")
|
||||||
os.remove(final_path)
|
try:
|
||||||
|
os.remove(final_path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass # It was just there, but now gone?
|
||||||
except Exception as check_error:
|
except Exception as check_error:
|
||||||
print(f"⚠️ [Protection] Error checking existing file metadata, proceeding with overwrite: {check_error}")
|
print(f"⚠️ [Protection] Error checking existing file metadata, proceeding with overwrite: {check_error}")
|
||||||
os.remove(final_path)
|
try:
|
||||||
|
if os.path.exists(final_path):
|
||||||
|
os.remove(final_path)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ [Protection] Failed to remove existing file for overwrite: {e}")
|
||||||
|
|
||||||
_safe_move_file(file_path, final_path)
|
_safe_move_file(file_path, final_path)
|
||||||
|
|
||||||
|
|
@ -10684,6 +10731,11 @@ def get_valid_candidates(results, spotify_track, query):
|
||||||
normalized_spotify_artist = re.sub(r'[^a-zA-Z0-9]', '', spotify_artist_name).lower()
|
normalized_spotify_artist = re.sub(r'[^a-zA-Z0-9]', '', spotify_artist_name).lower()
|
||||||
|
|
||||||
for candidate in quality_filtered_candidates:
|
for candidate in quality_filtered_candidates:
|
||||||
|
# Skip artist check for YouTube results (title matching is sufficient as processed by matching engine)
|
||||||
|
if is_youtube_source:
|
||||||
|
verified_candidates.append(candidate)
|
||||||
|
continue
|
||||||
|
|
||||||
# This check is critical: it ensures the artist's name is in the file path,
|
# This check is critical: it ensures the artist's name is in the file path,
|
||||||
# preventing downloads from the wrong artist.
|
# preventing downloads from the wrong artist.
|
||||||
normalized_slskd_path = re.sub(r'[^a-zA-Z0-9]', '', candidate.filename).lower()
|
normalized_slskd_path = re.sub(r'[^a-zA-Z0-9]', '', candidate.filename).lower()
|
||||||
|
|
@ -10757,6 +10809,11 @@ def _start_next_batch_of_downloads(batch_id):
|
||||||
batch_lock = _get_batch_lock(batch_id)
|
batch_lock = _get_batch_lock(batch_id)
|
||||||
|
|
||||||
with batch_lock:
|
with batch_lock:
|
||||||
|
# Prevent starting new tasks if shutting down
|
||||||
|
if IS_SHUTTING_DOWN:
|
||||||
|
print(f"🛑 [Batch Manager] Server shutting down - skipping new tasks for batch {batch_id}")
|
||||||
|
return
|
||||||
|
|
||||||
with tasks_lock:
|
with tasks_lock:
|
||||||
if batch_id not in download_batches:
|
if batch_id not in download_batches:
|
||||||
return
|
return
|
||||||
|
|
@ -11640,7 +11697,58 @@ def _run_post_processing_worker(task_id, batch_id):
|
||||||
# RESILIENT FILE-FINDING LOOP: Try up to 3 times with delays
|
# RESILIENT FILE-FINDING LOOP: Try up to 3 times with delays
|
||||||
found_file = None
|
found_file = None
|
||||||
file_location = None
|
file_location = None
|
||||||
|
|
||||||
|
# CRITICAL FIX: For YouTube downloads, the filename in task is 'id||title' (metadata),
|
||||||
|
# but the actual file on disk is 'Title.mp3'. We must ask the client for the real path.
|
||||||
|
if (task.get('username') == 'youtube' or '||' in str(task_filename)) and not found_file:
|
||||||
|
logger.info(f"🔧 [Post-Processing] Detected YouTube download task: {task_id}")
|
||||||
|
try:
|
||||||
|
# Query the download orchestrator for the status which contains the real file path
|
||||||
|
# CRITICAL FIX: Use the actual download_id designated by the client, not the internal task_id
|
||||||
|
actual_download_id = task.get('download_id') or task_id
|
||||||
|
status = asyncio.run(soulseek_client.get_download_status(actual_download_id))
|
||||||
|
if status and status.file_path:
|
||||||
|
real_path = status.file_path
|
||||||
|
if os.path.exists(real_path):
|
||||||
|
# Determine if it's in download or transfer directory
|
||||||
|
real_path_obj = Path(real_path)
|
||||||
|
download_dir_obj = Path(download_dir)
|
||||||
|
transfer_dir_obj = Path(transfer_dir)
|
||||||
|
|
||||||
|
# Use absolute path comparison
|
||||||
|
try:
|
||||||
|
if download_dir_obj.resolve() in real_path_obj.resolve().parents:
|
||||||
|
file_location = 'download'
|
||||||
|
elif transfer_dir_obj.resolve() in real_path_obj.resolve().parents:
|
||||||
|
file_location = 'transfer'
|
||||||
|
else:
|
||||||
|
file_location = 'absolute'
|
||||||
|
except:
|
||||||
|
# Fallback if resolve fails (e.g. permission or path issues)
|
||||||
|
file_location = 'absolute'
|
||||||
|
|
||||||
|
if file_location:
|
||||||
|
# We found the file! Use the absolute path if it confuses the joining logic,
|
||||||
|
# but usually we want just the filename if location is 'download'/'transfer'
|
||||||
|
# CRITICAL FIX: Always use the absolute real_path.
|
||||||
|
# Stripping to basename causes FileNotFoundError because post-processing
|
||||||
|
# runs with CWD as project root, not download dir.
|
||||||
|
found_file = real_path
|
||||||
|
|
||||||
|
logger.info(f"✅ [Post-Processing] Resolved actual YouTube filename: {found_file} (Location: {file_location})")
|
||||||
|
else:
|
||||||
|
logger.warning(f"⚠️ [Post-Processing] YouTube status reported path but file missing: {real_path}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"⚠️ [Post-Processing] YouTube status returned no file_path for task {task_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"⚠️ [Post-Processing] Failed to retrieve YouTube task status: {e}")
|
||||||
|
|
||||||
for retry_count in range(3):
|
for retry_count in range(3):
|
||||||
|
# If we already resolved the file (e.g. via YouTube status), skip searching
|
||||||
|
if found_file:
|
||||||
|
print(f"🎯 [Post-Processing] Skipping search loop, file already resolved: {found_file}")
|
||||||
|
break
|
||||||
|
|
||||||
print(f"🔍 [Post-Processing] Attempt {retry_count + 1}/3 to find file")
|
print(f"🔍 [Post-Processing] Attempt {retry_count + 1}/3 to find file")
|
||||||
print(f"🔍 [Post-Processing] Original filename: {task_basename}")
|
print(f"🔍 [Post-Processing] Original filename: {task_basename}")
|
||||||
if expected_final_filename:
|
if expected_final_filename:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue