"""Wishlist processing helpers.""" from __future__ import annotations import uuid from dataclasses import dataclass from datetime import datetime from contextlib import AbstractContextManager from types import SimpleNamespace from typing import Any, Callable, Dict from core.wishlist.payloads import build_failed_track_wishlist_context from core.wishlist.selection import filter_wishlist_tracks_by_category, sanitize_and_dedupe_wishlist_tracks from core.wishlist.state import get_wishlist_cycle, set_wishlist_cycle @dataclass class WishlistAutoProcessingRuntime: """Dependencies needed to run automatic wishlist processing outside the controller.""" processing_guard: Callable[[], AbstractContextManager[bool]] is_actually_processing: Callable[[], bool] app_context_factory: Callable[[], AbstractContextManager[Any]] get_wishlist_service: Callable[[], Any] get_profiles_database: Callable[[], Any] get_music_database: Callable[[], Any] download_batches: Dict[str, Dict[str, Any]] tasks_lock: Any update_automation_progress: Callable[..., Any] automation_engine: Any missing_download_executor: Any run_full_missing_tracks_process: Callable[[str, str, list[dict[str, Any]]], Any] get_batch_max_concurrent: Callable[[], int] get_active_server: Callable[[], str] logger: Any current_time_fn: Callable[[], float] profile_id: int = 1 def remove_completed_tracks_from_wishlist( batch: Dict[str, Any], download_tasks: Dict[str, Dict[str, Any]], remove_from_wishlist: Callable[[Dict[str, Any]], Any], *, logger, ) -> int: """Remove completed batch tasks from the wishlist.""" removed_count = 0 for task_id in batch.get('queue', []): if task_id in download_tasks: task = download_tasks[task_id] if task.get('status') == 'completed': try: track_info = task.get('track_info', {}) context = {'track_info': track_info, 'original_search_result': track_info} remove_from_wishlist(context) removed_count += 1 except Exception as exc: logger.error(f"[Wishlist Processing] Error removing completed track from wishlist: {exc}") return removed_count def add_cancelled_tracks_to_failed_tracks( batch: Dict[str, Any], download_tasks: Dict[str, Dict[str, Any]], permanently_failed_tracks: list[Dict[str, Any]], *, logger, max_process: int = 100, ) -> int: """Promote cancelled-but-missing tasks into the failed-track list.""" cancelled_tracks = batch.get('cancelled_tracks', set()) if not cancelled_tracks: return 0 processed_count = 0 for task_id in batch.get('queue', [])[:max_process]: if task_id not in download_tasks: continue task = download_tasks[task_id] track_index = task.get('track_index', 0) if track_index not in cancelled_tracks: continue if task.get('status', 'unknown') == 'completed': continue original_track_info = task.get('track_info', {}) cancelled_track_info = build_failed_track_wishlist_context( original_track_info, track_index=track_index, retry_count=0, failure_reason='Download cancelled', candidates=task.get('cached_candidates', []), ) if any(t.get('table_index') == track_index for t in permanently_failed_tracks): continue permanently_failed_tracks.append(cancelled_track_info) processed_count += 1 logger.error( f"[Wishlist Processing] Added cancelled missing track {cancelled_track_info['track_name']} to failed list for wishlist" ) return processed_count def recover_uncaptured_failed_tracks( batch: Dict[str, Any], download_tasks: Dict[str, Dict[str, Any]], permanently_failed_tracks: list[Dict[str, Any]], *, logger, ) -> int: """Recover tasks force-marked failed/not_found so wishlist processing does not skip them.""" recovered_count = 0 for task_id in batch.get('queue', []): if task_id not in download_tasks: continue task = download_tasks[task_id] if task.get('status') not in ('failed', 'not_found'): continue track_index = task.get('track_index', 0) if any(t.get('table_index') == track_index for t in permanently_failed_tracks): continue original_track_info = task.get('track_info', {}) recovered_track_info = build_failed_track_wishlist_context( original_track_info, track_index=track_index, retry_count=task.get('retry_count', 0), failure_reason=task.get('error_message', 'Download failed'), candidates=task.get('cached_candidates', []), ) permanently_failed_tracks.append(recovered_track_info) recovered_count += 1 logger.error( f"[Wishlist Processing] Recovered uncaptured failed track for wishlist: {recovered_track_info['track_name']}" ) return recovered_count def build_wishlist_source_context(batch: Dict[str, Any], current_time: datetime | None = None) -> Dict[str, Any]: """Build the source_context payload used when adding failed tracks back to the wishlist.""" current_time = current_time or datetime.now() return { 'playlist_name': batch.get('playlist_name', 'Unknown Playlist'), 'playlist_id': batch.get('playlist_id', None), 'added_from': 'webui_modal', 'timestamp': current_time.isoformat(), } def finalize_auto_wishlist_completion( batch_id: str, completion_summary: Dict[str, Any], *, download_batches: Dict[str, Dict[str, Any]], tasks_lock, reset_processing_state: Callable[[], None], add_activity_item: Callable[[Any, Any, Any, Any], Any], automation_engine, db_factory: Callable[[], Any], logger, ) -> Dict[str, Any]: """Finalize auto wishlist processing after a batch finishes.""" tracks_added = completion_summary.get('tracks_added', 0) total_failed = completion_summary.get('total_failed', 0) logger.error( f"[Auto-Wishlist] Background processing complete: {tracks_added} added to wishlist, {total_failed} failed" ) if tracks_added > 0: add_activity_item("", "Wishlist Updated", f"{tracks_added} failed tracks added to wishlist", "Now") try: with tasks_lock: if batch_id in download_batches: current_cycle = download_batches[batch_id].get('current_cycle', 'albums') else: current_cycle = 'albums' next_cycle = 'singles' if current_cycle == 'albums' else 'albums' db = db_factory() with db._get_connection() as conn: cursor = conn.cursor() cursor.execute( """ INSERT OR REPLACE INTO metadata (key, value, updated_at) VALUES ('wishlist_cycle', ?, CURRENT_TIMESTAMP) """, (next_cycle,), ) conn.commit() logger.info(f"[Auto-Wishlist] Cycle toggled after completion: {current_cycle} → {next_cycle}") except Exception as cycle_error: logger.error(f"[Auto-Wishlist] Error toggling cycle: {cycle_error}") reset_processing_state() try: if automation_engine: automation_engine.emit('wishlist_processing_completed', { 'tracks_processed': str(total_failed), 'tracks_found': str(tracks_added), 'tracks_failed': str(total_failed - tracks_added), }) except Exception: pass return completion_summary def remove_tracks_already_in_library( wishlist_service, profiles_database, music_database, active_server: str, *, logger, skip_track_fn: Callable[[dict[str, Any]], bool] | None = None, log_prefix: str = "[Auto-Wishlist]", ) -> int: """Remove wishlist entries that are already present in the library.""" all_profiles = profiles_database.get_all_profiles() cleanup_tracks = [] for profile in all_profiles: cleanup_tracks.extend(wishlist_service.get_wishlist_tracks_for_download(profile_id=profile["id"])) cleanup_removed = 0 for track in cleanup_tracks: if skip_track_fn and skip_track_fn(track): continue track_name = track.get('name', '') artists = track.get('artists', []) spotify_track_id = track.get('spotify_track_id') or track.get('id') track_album = track.get('album', {}).get('name') if isinstance(track.get('album'), dict) else track.get('album') if not track_name or not artists or not spotify_track_id: continue found_in_db = False matched_artist_name = '' for artist in artists: if isinstance(artist, str): artist_name = artist elif isinstance(artist, dict) and 'name' in artist: artist_name = artist['name'] else: artist_name = str(artist) try: db_track, confidence = music_database.check_track_exists( track_name, artist_name, confidence_threshold=0.7, server_source=active_server, album=track_album, ) if db_track and confidence >= 0.7: found_in_db = True matched_artist_name = artist_name break except Exception: continue if found_in_db: try: removed = wishlist_service.mark_track_download_result(spotify_track_id, success=True) if removed: cleanup_removed += 1 logger.info(f"{log_prefix} Removed already-owned track: '{track_name}' by {matched_artist_name or artist_name}") except Exception as remove_error: logger.error(f"{log_prefix} Error removing track from wishlist: {remove_error}") return cleanup_removed @dataclass class WishlistManualDownloadRuntime: """Dependencies needed to start a manual wishlist download batch outside the controller.""" get_wishlist_service: Callable[[], Any] get_music_database: Callable[[], Any] download_batches: Dict[str, Dict[str, Any]] tasks_lock: Any missing_download_executor: Any run_full_missing_tracks_process: Callable[[str, str, list[dict[str, Any]]], Any] get_batch_max_concurrent: Callable[[], int] add_activity_item: Callable[[Any, Any, Any, Any], Any] active_server: str logger: Any profile_id: int def start_manual_wishlist_download_batch( runtime: WishlistManualDownloadRuntime, *, track_ids=None, category: str | None = None, force_download_all: bool = False, ) -> tuple[Dict[str, Any], int]: """Prepare and submit a manual wishlist batch.""" logger = runtime.logger try: wishlist_service = runtime.get_wishlist_service() db = runtime.get_music_database() manual_profile_id = runtime.profile_id logger.warning("[Manual-Wishlist] Cleaning duplicate tracks before download...") duplicates_removed = db.remove_wishlist_duplicates(profile_id=manual_profile_id) if duplicates_removed > 0: logger.warning(f"[Manual-Wishlist] Removed {duplicates_removed} duplicate tracks") logger.info("[Manual-Wishlist] Checking wishlist against library for already-owned tracks...") cleanup_removed = remove_tracks_already_in_library( wishlist_service, SimpleNamespace(get_all_profiles=lambda: [{"id": manual_profile_id}]), db, runtime.active_server, logger=logger, skip_track_fn=lambda track: track.get('source_type') == 'enhance', log_prefix="[Manual-Wishlist]", ) if cleanup_removed > 0: logger.info(f"[Manual-Wishlist] Cleaned up {cleanup_removed} already-owned tracks from wishlist") raw_wishlist_tracks = wishlist_service.get_wishlist_tracks_for_download(profile_id=manual_profile_id) if not raw_wishlist_tracks: return {"success": False, "error": "No tracks in wishlist"}, 400 wishlist_tracks, duplicates_found = sanitize_and_dedupe_wishlist_tracks(raw_wishlist_tracks) if duplicates_found > 0: logger.warning(f"[Manual-Wishlist] Found and removed {duplicates_found} duplicate tracks during sanitization") logger.info(f"[Manual-Wishlist] Sanitized {len(wishlist_tracks)} tracks from wishlist service") if track_ids: track_lookup = {} for track in wishlist_tracks: spotify_track_id = track.get('spotify_track_id') or track.get('id') if spotify_track_id and spotify_track_id not in track_lookup: track_lookup[spotify_track_id] = track filtered_tracks = [] seen_track_ids = set() for frontend_index, tid in enumerate(track_ids): if tid in track_lookup and tid not in seen_track_ids: track = track_lookup[tid] track['_original_index'] = frontend_index filtered_tracks.append(track) seen_track_ids.add(tid) wishlist_tracks = filtered_tracks logger.info(f"[Manual-Wishlist] Filtered to {len(wishlist_tracks)} specific tracks by ID (preserving frontend display order)") elif category: wishlist_tracks, _ = filter_wishlist_tracks_by_category(wishlist_tracks, category) logger.info(f"[Manual-Wishlist] Filtered to {len(wishlist_tracks)} tracks for category: {category}") for i, track in enumerate(wishlist_tracks): track['_original_index'] = i runtime.add_activity_item("", "Wishlist Download Started", f"{len(wishlist_tracks)} tracks", "Now") batch_id = str(uuid.uuid4()) playlist_id = "wishlist" playlist_name = "Wishlist" task_queue = [] with runtime.tasks_lock: runtime.download_batches[batch_id] = { 'phase': 'analysis', 'playlist_id': playlist_id, 'playlist_name': playlist_name, 'queue': task_queue, 'active_count': 0, 'max_concurrent': runtime.get_batch_max_concurrent(), 'queue_index': 0, 'analysis_total': len(wishlist_tracks), 'analysis_processed': 0, 'analysis_results': [], 'permanently_failed_tracks': [], 'cancelled_tracks': set(), 'force_download_all': True, 'profile_id': manual_profile_id, } logger.info(f"Starting wishlist batch {batch_id} with {len(wishlist_tracks)} tracks") runtime.missing_download_executor.submit(runtime.run_full_missing_tracks_process, batch_id, playlist_id, wishlist_tracks) return {"success": True, "batch_id": batch_id}, 200 except Exception as e: logger.error(f"Error starting wishlist download process: {e}") import traceback traceback.print_exc() return {"success": False, "error": str(e)}, 500 def cleanup_wishlist_against_library( wishlist_service, music_database, profile_id: int, active_server: str, *, logger, ) -> tuple[Dict[str, Any], int]: """Remove wishlist tracks that already exist in the library for one profile.""" try: logger.info("[Wishlist Cleanup] Starting wishlist cleanup process...") wishlist_tracks = wishlist_service.get_wishlist_tracks_for_download(profile_id=profile_id) if not wishlist_tracks: return {"success": True, "message": "No tracks in wishlist to clean up", "removed_count": 0}, 200 logger.info(f"[Wishlist Cleanup] Found {len(wishlist_tracks)} tracks in wishlist") removed_count = remove_tracks_already_in_library( wishlist_service, SimpleNamespace(get_all_profiles=lambda: [{"id": profile_id}]), music_database, active_server, logger=logger, log_prefix="[Wishlist Cleanup]", ) logger.info(f"[Wishlist Cleanup] Completed cleanup: {removed_count} tracks removed from wishlist") return { "success": True, "message": f"Wishlist cleanup completed: {removed_count} tracks removed", "removed_count": removed_count, "processed_count": len(wishlist_tracks), }, 200 except Exception as e: logger.error(f"Error in wishlist cleanup: {e}") import traceback traceback.print_exc() return {"success": False, "error": str(e)}, 500 def process_wishlist_automatically(runtime: WishlistAutoProcessingRuntime, automation_id=None): """Run automatic wishlist processing outside the controller.""" logger = runtime.logger logger.info("[Auto-Wishlist] Timer triggered - starting automatic wishlist processing...") try: # CRITICAL FIX: Use smart stuck detection BEFORE acquiring lock # This prevents deadlock and handles stuck flags (2-hour timeout) if runtime.is_actually_processing(): logger.info("[Auto-Wishlist] Already processing (verified with stuck detection), skipping.") return with runtime.processing_guard() as acquired: if not acquired: logger.info("[Auto-Wishlist] Already processing (race condition check), skipping.") return with runtime.app_context_factory(): wishlist_service = runtime.get_wishlist_service() # Check if wishlist has tracks across all profiles database = runtime.get_profiles_database() all_profiles = database.get_all_profiles() count = sum(wishlist_service.get_wishlist_count(profile_id=p['id']) for p in all_profiles) logger.info(f"[Auto-Wishlist] Wishlist count check: {count} tracks found across {len(all_profiles)} profiles") runtime.update_automation_progress(automation_id, progress=10, phase='Checking wishlist', log_line=f'{count} tracks across {len(all_profiles)} profiles', log_type='info') if count == 0: logger.warning("ℹ️ [Auto-Wishlist] No tracks in wishlist for auto-processing.") return logger.info(f"[Auto-Wishlist] Found {count} tracks in wishlist, starting automatic processing...") # Check if wishlist processing is already active (auto or manual) playlist_id = "wishlist" with runtime.tasks_lock: for _batch_id, batch_data in runtime.download_batches.items(): batch_playlist_id = batch_data.get('playlist_id') # Check for both auto ('wishlist') and manual ('wishlist_manual') batches if (batch_playlist_id in ['wishlist', 'wishlist_manual'] and batch_data.get('phase') not in ['complete', 'error', 'cancelled']): logger.info(f"Wishlist processing already active in another batch ({batch_playlist_id}), skipping automatic start") return # CRITICAL: Clean duplicates BEFORE fetching tracks to prevent count mismatches # This prevents the "11 tracks shown but 12 counted" bug music_database = runtime.get_music_database() logger.warning("[Auto-Wishlist] Cleaning duplicate tracks before processing...") for profile in all_profiles: duplicates_removed = music_database.remove_wishlist_duplicates(profile_id=profile['id']) if duplicates_removed > 0: logger.warning(f"[Auto-Wishlist] Removed {duplicates_removed} duplicate tracks from profile {profile['id']}") # CLEANUP: Remove tracks from wishlist that already exist in library # This prevents wasting bandwidth on tracks we already have logger.debug("[Auto-Wishlist] Checking wishlist against library for already-owned tracks...") active_server = runtime.get_active_server() cleanup_removed = remove_tracks_already_in_library( wishlist_service, database, music_database, active_server, logger=logger, ) if cleanup_removed > 0: logger.info(f"[Auto-Wishlist] Cleaned up {cleanup_removed} already-owned tracks from wishlist") runtime.update_automation_progress(automation_id, progress=25, phase='Cleaned up duplicates', log_line=f'Removed {cleanup_removed} already-owned tracks', log_type='success') else: runtime.update_automation_progress(automation_id, progress=25, phase='Cleanup done', log_line='No duplicates or already-owned tracks found', log_type='skip') # Get wishlist tracks for processing (after cleanup) - combine all profiles raw_wishlist_tracks = [] for profile in all_profiles: raw_wishlist_tracks.extend(wishlist_service.get_wishlist_tracks_for_download(profile_id=profile['id'])) if not raw_wishlist_tracks: logger.warning("No tracks returned from wishlist service.") return # SANITIZE: Ensure consistent data format from wishlist service wishlist_tracks, duplicates_found = sanitize_and_dedupe_wishlist_tracks(raw_wishlist_tracks) if duplicates_found > 0: logger.warning(f"[Auto-Wishlist] Found and removed {duplicates_found} duplicate tracks during sanitization") logger.info(f"[Auto-Wishlist] Sanitized {len(wishlist_tracks)} tracks from wishlist service") # CYCLE FILTERING: Get current cycle and filter tracks by category current_cycle = get_wishlist_cycle(lambda: music_database) # Filter tracks by current cycle category filtered_tracks, _ = filter_wishlist_tracks_by_category(wishlist_tracks, current_cycle) logger.info(f"[Auto-Wishlist] Current cycle: {current_cycle}") logger.info(f"[Auto-Wishlist] Filtered {len(filtered_tracks)}/{len(wishlist_tracks)} tracks for '{current_cycle}' category") runtime.update_automation_progress(automation_id, progress=40, phase=f'Processing {current_cycle}', log_line=f'Cycle: {current_cycle} — {len(filtered_tracks)} tracks to process', log_type='info') # If no tracks in this category, skip to next cycle immediately if len(filtered_tracks) == 0: logger.warning(f"ℹ️ [Auto-Wishlist] No {current_cycle} tracks in wishlist, toggling cycle and scheduling next run") # Toggle cycle next_cycle = 'singles' if current_cycle == 'albums' else 'albums' set_wishlist_cycle(lambda: music_database, next_cycle) logger.info(f"[Auto-Wishlist] Cycle toggled: {current_cycle} → {next_cycle}") return # Use filtered tracks for processing — stamp original index wishlist_tracks = filtered_tracks for i, track in enumerate(wishlist_tracks): track['_original_index'] = i # Create batch for automatic processing batch_id = str(uuid.uuid4()) playlist_name = f"Wishlist (Auto - {current_cycle.capitalize()})" # Create task queue - convert wishlist tracks to expected format with runtime.tasks_lock: runtime.download_batches[batch_id] = { 'phase': 'analysis', 'playlist_id': playlist_id, 'playlist_name': playlist_name, 'queue': [], 'active_count': 0, 'max_concurrent': runtime.get_batch_max_concurrent(), # Wishlist always does single-track downloads, not folder grabs 'queue_index': 0, 'analysis_total': len(wishlist_tracks), 'analysis_processed': 0, 'analysis_results': [], # Track state management (replicating sync.py) 'permanently_failed_tracks': [], 'cancelled_tracks': set(), # Wishlist tracks are already known-missing — skip the expensive library check 'force_download_all': True, # Mark as auto-initiated 'auto_initiated': True, 'auto_processing_timestamp': runtime.current_time_fn(), # Store current cycle for toggling after completion 'current_cycle': current_cycle, # Profile context for failed track wishlist re-adds (auto = profile 1 default) 'profile_id': runtime.profile_id, } logger.info(f"Starting automatic wishlist batch {batch_id} with {len(wishlist_tracks)} tracks") runtime.update_automation_progress(automation_id, progress=50, phase=f'Downloading {len(wishlist_tracks)} tracks', log_line=f'Started batch: {len(wishlist_tracks)} {current_cycle}', log_type='success') # Submit the wishlist processing job using existing infrastructure runtime.missing_download_executor.submit(runtime.run_full_missing_tracks_process, batch_id, playlist_id, wishlist_tracks) # Don't mark auto_processing as False here - let completion handler do it except Exception as e: logger.error(f"Error in automatic wishlist processing: {e}") import traceback traceback.print_exc() runtime.update_automation_progress(automation_id, log_line=f'Error: {str(e)}', log_type='error') raise def automatic_wishlist_cleanup_after_db_update( *, wishlist_service=None, profiles_database=None, music_database=None, active_server: str | None = None, logger, ) -> int: """Remove wishlist entries that already exist in the library after a DB update.""" try: from config.settings import config_manager from core.wishlist.service import get_wishlist_service from database.music_database import MusicDatabase, get_database wishlist_service = wishlist_service or get_wishlist_service() profiles_database = profiles_database or get_database() music_database = music_database or MusicDatabase() active_server = active_server or config_manager.get_active_media_server() logger.info("[Auto Cleanup] Starting automatic wishlist cleanup after database update...") all_profiles = profiles_database.get_all_profiles() wishlist_tracks = [] for profile in all_profiles: wishlist_tracks.extend(wishlist_service.get_wishlist_tracks_for_download(profile_id=profile["id"])) if not wishlist_tracks: logger.warning("[Auto Cleanup] No tracks in wishlist to clean up") return 0 logger.info(f"[Auto Cleanup] Found {len(wishlist_tracks)} tracks in wishlist") removed_count = 0 for track in wishlist_tracks: track_name = track.get('name', '') artists = track.get('artists', []) spotify_track_id = track.get('spotify_track_id') or track.get('id') track_album = track.get('album', {}).get('name') if isinstance(track.get('album'), dict) else track.get('album') if not track_name or not artists or not spotify_track_id: continue found_in_db = False for artist in artists: if isinstance(artist, str): artist_name = artist elif isinstance(artist, dict) and 'name' in artist: artist_name = artist['name'] else: artist_name = str(artist) try: db_track, confidence = music_database.check_track_exists( track_name, artist_name, confidence_threshold=0.7, server_source=active_server, album=track_album, ) if db_track and confidence >= 0.7: found_in_db = True logger.info( f"[Auto Cleanup] Track found in database: '{track_name}' by {artist_name} (confidence: {confidence:.2f})" ) break except Exception as db_error: logger.error(f"[Auto Cleanup] Error checking database for track '{track_name}': {db_error}") continue if found_in_db: try: removed = wishlist_service.mark_track_download_result(spotify_track_id, success=True) if removed: removed_count += 1 logger.info(f"[Auto Cleanup] Removed track from wishlist: '{track_name}' ({spotify_track_id})") except Exception as remove_error: logger.error(f"[Auto Cleanup] Error removing track from wishlist: {remove_error}") logger.info(f"[Auto Cleanup] Completed automatic cleanup: {removed_count} tracks removed from wishlist") return removed_count except Exception as e: logger.error(f"[Auto Cleanup] Error in automatic wishlist cleanup: {e}") import traceback traceback.print_exc() return 0 __all__ = [ "remove_completed_tracks_from_wishlist", "add_cancelled_tracks_to_failed_tracks", "recover_uncaptured_failed_tracks", "build_wishlist_source_context", "finalize_auto_wishlist_completion", "automatic_wishlist_cleanup_after_db_update", "WishlistAutoProcessingRuntime", "WishlistManualDownloadRuntime", "process_wishlist_automatically", "start_manual_wishlist_download_batch", "cleanup_wishlist_against_library", "remove_tracks_already_in_library", ]