Merge pull request #443 from Nezreka/refactor/lift-process-failed-wishlist

Lift _process_failed_tracks_to_wishlist_exact to core/downloads/wishl…
This commit is contained in:
BoulderBadgeDad 2026-04-30 11:06:42 -07:00 committed by GitHub
commit eded0511e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 232 additions and 179 deletions

View file

@ -0,0 +1,222 @@
"""Failed-tracks wishlist processing — lifted from web_server.py.
Body is byte-identical to the original. Wishlist helpers are
direct imports from core.wishlist.*; runtime state comes from
core.runtime_state; automation_engine, soulseek_client, and the
sweep helper are injected via init() because they are constructed
in web_server.py.
"""
import logging
import time
from core.runtime_state import (
download_batches,
download_tasks,
tasks_lock,
)
from core.wishlist.processing import (
add_cancelled_tracks_to_failed_tracks as _add_cancelled_tracks_to_failed_tracks,
build_wishlist_source_context as _build_wishlist_source_context,
recover_uncaptured_failed_tracks as _recover_uncaptured_failed_tracks,
remove_completed_tracks_from_wishlist as _remove_completed_tracks_from_wishlist,
)
from core.wishlist.resolution import (
check_and_remove_from_wishlist as _check_and_remove_from_wishlist,
)
from utils.async_helpers import run_async
logger = logging.getLogger(__name__)
# Injected at runtime via init().
automation_engine = None
soulseek_client = None
_sweep_empty_download_directories = None
def init(engine, soulseek_client_obj, sweep_fn):
"""Bind shared singletons + the sweep helper from web_server."""
global automation_engine, soulseek_client, _sweep_empty_download_directories
automation_engine = engine
soulseek_client = soulseek_client_obj
_sweep_empty_download_directories = sweep_fn
def _process_failed_tracks_to_wishlist_exact(batch_id):
"""
Process failed and cancelled tracks to wishlist - EXACT replication of sync.py's on_all_downloads_complete() logic.
This matches sync.py's behavior precisely.
"""
try:
from core.wishlist_service import get_wishlist_service
logger.info(f"[Wishlist Processing] Starting wishlist processing for batch {batch_id}")
with tasks_lock:
if batch_id not in download_batches:
logger.warning(f"[Wishlist Processing] Batch {batch_id} not found")
return {'tracks_added': 0, 'errors': 0}
batch = download_batches[batch_id]
# Wing It mode — skip wishlist entirely for failed tracks
if batch.get('wing_it'):
failed_count = len(batch.get('permanently_failed_tracks', []))
logger.error(f"[Wing It] Skipping wishlist for {failed_count} failed tracks (wing it mode)")
return {'tracks_added': 0, 'errors': 0}
permanently_failed_tracks = batch.get('permanently_failed_tracks', [])
cancelled_tracks = batch.get('cancelled_tracks', set())
# STEP 0: Remove completed tracks from wishlist (THIS WAS MISSING!)
logger.info("[Wishlist Processing] Checking completed tracks for wishlist removal")
_remove_completed_tracks_from_wishlist(
batch,
download_tasks,
_check_and_remove_from_wishlist,
)
# STEP 1: Add cancelled tracks that were missing to permanently_failed_tracks (replicating sync.py)
# This matches sync.py's logic for adding cancelled missing tracks to the failed list
if cancelled_tracks:
logger.warning(f"[Wishlist Processing] Processing {len(cancelled_tracks)} cancelled tracks")
processed_count = _add_cancelled_tracks_to_failed_tracks(
batch,
download_tasks,
permanently_failed_tracks,
)
logger.warning(f"[Wishlist Processing] Processed {processed_count} cancelled tracks")
# STEP 1.5: Recover any failed/not_found tasks not captured in permanently_failed_tracks.
# Stuck detection (in _on_download_completed, _check_batch_completion_v2, and the Safety Valve)
# can force-mark tasks as not_found/failed without adding them to permanently_failed_tracks,
# causing them to silently skip wishlist processing.
recovered_count = _recover_uncaptured_failed_tracks(
batch,
download_tasks,
permanently_failed_tracks,
)
if recovered_count:
logger.warning(f"[Wishlist Processing] Recovered {recovered_count} uncaptured failed tracks for wishlist")
# STEP 2: Add permanently failed tracks to wishlist (exact sync.py logic)
failed_count = len(permanently_failed_tracks)
wishlist_added_count = 0
error_count = 0
logger.error(f"[Wishlist Processing] Processing {failed_count} failed tracks for wishlist")
if permanently_failed_tracks:
try:
wishlist_service = get_wishlist_service()
# Create source_context identical to sync.py
source_context = _build_wishlist_source_context(batch)
# Process each failed track (matching sync.py's loop) with safety limit
max_failed_tracks = min(len(permanently_failed_tracks), 50) # Safety limit
wing_it_skipped = 0
for i, failed_track_info in enumerate(permanently_failed_tracks[:max_failed_tracks]):
try:
track_name = failed_track_info.get('track_name', f'Track {i+1}')
# Skip wing-it fallback tracks — they had no real metadata match,
# so adding them to wishlist would just retry with the same raw data.
# Check the track ID prefix since the wishlist payload helper overwrites source.
track_data = failed_track_info.get('track_data') or failed_track_info.get('spotify_track', {})
sp_id = track_data.get('id', '') if isinstance(track_data, dict) else ''
if str(sp_id).startswith('wing_it_'):
wing_it_skipped += 1
logger.info(f"[Wishlist Processing] Skipping wing-it track: {track_name}")
continue
logger.error(f"[Wishlist Processing] Adding track {i+1}/{max_failed_tracks}: {track_name}")
success = wishlist_service.add_failed_track_from_modal(
track_info=failed_track_info,
source_type='playlist',
source_context=source_context,
profile_id=batch.get('profile_id', 1)
)
if success:
wishlist_added_count += 1
logger.info(f"[Wishlist Processing] Added {track_name} to wishlist")
try:
if automation_engine:
automation_engine.emit('wishlist_item_added', {
'artist': failed_track_info.get('artist_name', ''),
'title': track_name,
'reason': failed_track_info.get('failure_reason', ''),
})
except Exception:
pass
else:
logger.error(f"[Wishlist Processing] Failed to add {track_name} to wishlist")
except Exception as e:
error_count += 1
logger.error(f"[Wishlist Processing] Exception adding track to wishlist: {e}")
if wing_it_skipped:
logger.warning(f"[Wishlist Processing] Skipped {wing_it_skipped} wing-it fallback tracks")
logger.error(f"[Wishlist Processing] Added {wishlist_added_count}/{failed_count} failed tracks to wishlist (errors: {error_count})")
except Exception as e:
error_count = len(permanently_failed_tracks)
logger.error(f"[Wishlist Processing] Critical error adding failed tracks to wishlist: {e}")
import traceback
traceback.print_exc()
else:
logger.error(" [Wishlist Processing] No failed tracks to add to wishlist")
# Store completion summary in batch for API response (matching sync.py pattern)
completion_summary = {
'tracks_added': wishlist_added_count,
'errors': error_count,
'total_failed': failed_count
}
with tasks_lock:
if batch_id in download_batches:
download_batches[batch_id]['wishlist_summary'] = completion_summary
download_batches[batch_id]['wishlist_processing_complete'] = True
# Phase already set to 'complete' in _on_download_completed
logger.info(f"[Wishlist Processing] Completed wishlist processing for batch {batch_id}")
# Auto-cleanup: Clear completed downloads from slskd
try:
logger.info(f"[Auto-Cleanup] Clearing completed downloads from slskd after batch {batch_id}")
run_async(soulseek_client.clear_all_completed_downloads())
logger.info("[Auto-Cleanup] Completed downloads cleared from slskd")
except Exception as cleanup_error:
logger.warning(f"[Auto-Cleanup] Failed to clear completed downloads: {cleanup_error}")
# Sweep empty directories left behind by this batch's downloads
try:
_sweep_empty_download_directories()
except Exception as sweep_error:
logger.warning(f"[Auto-Cleanup] Failed to sweep empty directories: {sweep_error}")
return completion_summary
except Exception as e:
logger.error(f"[Wishlist Processing] CRITICAL ERROR in wishlist processing: {e}")
import traceback
traceback.print_exc()
# Mark batch as complete even with errors to prevent infinite loops
try:
with tasks_lock:
if batch_id in download_batches:
download_batches[batch_id]['phase'] = 'complete'
download_batches[batch_id]['completion_time'] = time.time() # Track for auto-cleanup
download_batches[batch_id]['wishlist_summary'] = {
'tracks_added': 0,
'errors': 1,
'total_failed': 0,
'error_message': str(e)
}
download_batches[batch_id]['wishlist_processing_complete'] = True
except Exception as lock_error:
logger.error(f"[Wishlist Processing] Failed to update batch after error: {lock_error}")
return {'tracks_added': 0, 'errors': 1, 'total_failed': 0}

View file

@ -16907,185 +16907,10 @@ def _start_next_batch_of_downloads(batch_id):
def _process_failed_tracks_to_wishlist_exact(batch_id):
"""
Process failed and cancelled tracks to wishlist - EXACT replication of sync.py's on_all_downloads_complete() logic.
This matches sync.py's behavior precisely.
"""
try:
from core.wishlist_service import get_wishlist_service
logger.info(f"[Wishlist Processing] Starting wishlist processing for batch {batch_id}")
with tasks_lock:
if batch_id not in download_batches:
logger.warning(f"[Wishlist Processing] Batch {batch_id} not found")
return {'tracks_added': 0, 'errors': 0}
batch = download_batches[batch_id]
# Wing It mode — skip wishlist entirely for failed tracks
if batch.get('wing_it'):
failed_count = len(batch.get('permanently_failed_tracks', []))
logger.error(f"[Wing It] Skipping wishlist for {failed_count} failed tracks (wing it mode)")
return {'tracks_added': 0, 'errors': 0}
permanently_failed_tracks = batch.get('permanently_failed_tracks', [])
cancelled_tracks = batch.get('cancelled_tracks', set())
# STEP 0: Remove completed tracks from wishlist (THIS WAS MISSING!)
logger.info("[Wishlist Processing] Checking completed tracks for wishlist removal")
_remove_completed_tracks_from_wishlist(
batch,
download_tasks,
_check_and_remove_from_wishlist,
)
# STEP 1: Add cancelled tracks that were missing to permanently_failed_tracks (replicating sync.py)
# This matches sync.py's logic for adding cancelled missing tracks to the failed list
if cancelled_tracks:
logger.warning(f"[Wishlist Processing] Processing {len(cancelled_tracks)} cancelled tracks")
processed_count = _add_cancelled_tracks_to_failed_tracks(
batch,
download_tasks,
permanently_failed_tracks,
)
logger.warning(f"[Wishlist Processing] Processed {processed_count} cancelled tracks")
# STEP 1.5: Recover any failed/not_found tasks not captured in permanently_failed_tracks.
# Stuck detection (in _on_download_completed, _check_batch_completion_v2, and the Safety Valve)
# can force-mark tasks as not_found/failed without adding them to permanently_failed_tracks,
# causing them to silently skip wishlist processing.
recovered_count = _recover_uncaptured_failed_tracks(
batch,
download_tasks,
permanently_failed_tracks,
)
if recovered_count:
logger.warning(f"[Wishlist Processing] Recovered {recovered_count} uncaptured failed tracks for wishlist")
# STEP 2: Add permanently failed tracks to wishlist (exact sync.py logic)
failed_count = len(permanently_failed_tracks)
wishlist_added_count = 0
error_count = 0
logger.error(f"[Wishlist Processing] Processing {failed_count} failed tracks for wishlist")
if permanently_failed_tracks:
try:
wishlist_service = get_wishlist_service()
# Create source_context identical to sync.py
source_context = _build_wishlist_source_context(batch)
# Process each failed track (matching sync.py's loop) with safety limit
max_failed_tracks = min(len(permanently_failed_tracks), 50) # Safety limit
wing_it_skipped = 0
for i, failed_track_info in enumerate(permanently_failed_tracks[:max_failed_tracks]):
try:
track_name = failed_track_info.get('track_name', f'Track {i+1}')
# Skip wing-it fallback tracks — they had no real metadata match,
# so adding them to wishlist would just retry with the same raw data.
# Check the track ID prefix since the wishlist payload helper overwrites source.
track_data = failed_track_info.get('track_data') or failed_track_info.get('spotify_track', {})
sp_id = track_data.get('id', '') if isinstance(track_data, dict) else ''
if str(sp_id).startswith('wing_it_'):
wing_it_skipped += 1
logger.info(f"[Wishlist Processing] Skipping wing-it track: {track_name}")
continue
logger.error(f"[Wishlist Processing] Adding track {i+1}/{max_failed_tracks}: {track_name}")
success = wishlist_service.add_failed_track_from_modal(
track_info=failed_track_info,
source_type='playlist',
source_context=source_context,
profile_id=batch.get('profile_id', 1)
)
if success:
wishlist_added_count += 1
logger.info(f"[Wishlist Processing] Added {track_name} to wishlist")
try:
if automation_engine:
automation_engine.emit('wishlist_item_added', {
'artist': failed_track_info.get('artist_name', ''),
'title': track_name,
'reason': failed_track_info.get('failure_reason', ''),
})
except Exception:
pass
else:
logger.error(f"[Wishlist Processing] Failed to add {track_name} to wishlist")
except Exception as e:
error_count += 1
logger.error(f"[Wishlist Processing] Exception adding track to wishlist: {e}")
if wing_it_skipped:
logger.warning(f"[Wishlist Processing] Skipped {wing_it_skipped} wing-it fallback tracks")
logger.error(f"[Wishlist Processing] Added {wishlist_added_count}/{failed_count} failed tracks to wishlist (errors: {error_count})")
except Exception as e:
error_count = len(permanently_failed_tracks)
logger.error(f"[Wishlist Processing] Critical error adding failed tracks to wishlist: {e}")
import traceback
traceback.print_exc()
else:
logger.error(" [Wishlist Processing] No failed tracks to add to wishlist")
# Store completion summary in batch for API response (matching sync.py pattern)
completion_summary = {
'tracks_added': wishlist_added_count,
'errors': error_count,
'total_failed': failed_count
}
with tasks_lock:
if batch_id in download_batches:
download_batches[batch_id]['wishlist_summary'] = completion_summary
download_batches[batch_id]['wishlist_processing_complete'] = True
# Phase already set to 'complete' in _on_download_completed
logger.info(f"[Wishlist Processing] Completed wishlist processing for batch {batch_id}")
# Auto-cleanup: Clear completed downloads from slskd
try:
logger.info(f"[Auto-Cleanup] Clearing completed downloads from slskd after batch {batch_id}")
run_async(soulseek_client.clear_all_completed_downloads())
logger.info("[Auto-Cleanup] Completed downloads cleared from slskd")
except Exception as cleanup_error:
logger.warning(f"[Auto-Cleanup] Failed to clear completed downloads: {cleanup_error}")
# Sweep empty directories left behind by this batch's downloads
try:
_sweep_empty_download_directories()
except Exception as sweep_error:
logger.warning(f"[Auto-Cleanup] Failed to sweep empty directories: {sweep_error}")
return completion_summary
except Exception as e:
logger.error(f"[Wishlist Processing] CRITICAL ERROR in wishlist processing: {e}")
import traceback
traceback.print_exc()
# Mark batch as complete even with errors to prevent infinite loops
try:
with tasks_lock:
if batch_id in download_batches:
download_batches[batch_id]['phase'] = 'complete'
download_batches[batch_id]['completion_time'] = time.time() # Track for auto-cleanup
download_batches[batch_id]['wishlist_summary'] = {
'tracks_added': 0,
'errors': 1,
'total_failed': 0,
'error_message': str(e)
}
download_batches[batch_id]['wishlist_processing_complete'] = True
except Exception as lock_error:
logger.error(f"[Wishlist Processing] Failed to update batch after error: {lock_error}")
return {'tracks_added': 0, 'errors': 1, 'total_failed': 0}
from core.downloads.wishlist_failed import (
_process_failed_tracks_to_wishlist_exact,
init as _init_wishlist_failed,
)
def _process_failed_tracks_to_wishlist_exact_with_auto_completion(batch_id):
"""
@ -33324,6 +33149,12 @@ _init_download_validation(
soulseek_client_obj=soulseek_client,
)
_init_wishlist_failed(
engine=automation_engine,
soulseek_client_obj=soulseek_client,
sweep_fn=_sweep_empty_download_directories,
)
_init_debug_info(
soulsync_version=SOULSYNC_VERSION,
direct_run=_DIRECT_RUN,