From f32fc9d56edb0e2da97ae9400f984e60dcc9c97c Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Tue, 28 Apr 2026 17:05:01 +0300 Subject: [PATCH] Extract wishlist logic into dedicated package - add core/wishlist as the home for wishlist payload, resolution, state, processing, reporting, and selection helpers - move wishlist-specific tests into tests/wishlist alongside the new package layout - keep web_server.py and the import/search callers as thin adapters for now --- core/imports/pipeline.py | 2 +- core/imports/side_effects.py | 78 +- core/search/library_check.py | 41 +- core/wishlist/__init__.py | 24 + core/wishlist/classification.py | 38 + core/wishlist/payloads.py | 396 ++++++ core/wishlist/presence.py | 48 + core/wishlist/processing.py | 717 +++++++++++ core/wishlist/reporting.py | 51 + core/wishlist/resolution.py | 173 +++ core/wishlist/selection.py | 93 ++ core/wishlist/service.py | 309 +++++ core/wishlist/state.py | 134 ++ core/wishlist_service.py | 427 +------ tests/imports/test_import_side_effects.py | 43 - tests/wishlist/__init__.py | 1 + tests/wishlist/test_automation.py | 233 ++++ tests/wishlist/test_cancelled_payload.py | 37 + tests/wishlist/test_classification.py | 19 + tests/wishlist/test_cleanup.py | 99 ++ tests/wishlist/test_failed_track_payload.py | 27 + tests/wishlist/test_manual_download.py | 173 +++ tests/wishlist/test_payloads.py | 58 + tests/wishlist/test_processing.py | 225 ++++ tests/wishlist/test_reporting.py | 37 + tests/wishlist/test_resolution.py | 79 ++ tests/wishlist/test_selection.py | 41 + tests/wishlist/test_state.py | 113 ++ web_server.py | 1243 +++---------------- 29 files changed, 3319 insertions(+), 1640 deletions(-) create mode 100644 core/wishlist/__init__.py create mode 100644 core/wishlist/classification.py create mode 100644 core/wishlist/payloads.py create mode 100644 core/wishlist/presence.py create mode 100644 core/wishlist/processing.py create mode 100644 core/wishlist/reporting.py create mode 100644 core/wishlist/resolution.py create mode 100644 core/wishlist/selection.py create mode 100644 core/wishlist/service.py create mode 100644 core/wishlist/state.py create mode 100644 tests/wishlist/__init__.py create mode 100644 tests/wishlist/test_automation.py create mode 100644 tests/wishlist/test_cancelled_payload.py create mode 100644 tests/wishlist/test_classification.py create mode 100644 tests/wishlist/test_cleanup.py create mode 100644 tests/wishlist/test_failed_track_payload.py create mode 100644 tests/wishlist/test_manual_download.py create mode 100644 tests/wishlist/test_payloads.py create mode 100644 tests/wishlist/test_processing.py create mode 100644 tests/wishlist/test_reporting.py create mode 100644 tests/wishlist/test_resolution.py create mode 100644 tests/wishlist/test_selection.py create mode 100644 tests/wishlist/test_state.py diff --git a/core/imports/pipeline.py b/core/imports/pipeline.py index 7eaacc5d..b34b7483 100644 --- a/core/imports/pipeline.py +++ b/core/imports/pipeline.py @@ -34,13 +34,13 @@ from core.imports.context import ( from core.imports.filename import extract_track_number_from_filename from core.imports.guards import check_flac_bit_depth, move_to_quarantine from core.imports.side_effects import ( - check_and_remove_from_wishlist, emit_track_downloaded, record_download_provenance, record_library_history_download, record_retag_download, record_soulsync_library_entry, ) +from core.wishlist.resolution import check_and_remove_from_wishlist from core.runtime_state import ( add_activity_item, download_batches, diff --git a/core/imports/side_effects.py b/core/imports/side_effects.py index b5936a8a..2824dd2c 100644 --- a/core/imports/side_effects.py +++ b/core/imports/side_effects.py @@ -5,7 +5,7 @@ from __future__ import annotations import hashlib import json import os -from typing import Any, Dict, List, Optional +from typing import Any, Dict from config.settings import config_manager from core.imports.context import ( @@ -23,7 +23,6 @@ from core.imports.context import ( normalize_import_context, get_library_source_id_columns, ) -from core.wishlist_service import get_wishlist_service from database.music_database import get_database from utils.logging_config import get_logger @@ -47,15 +46,6 @@ def _primary_track_artist_name(track_info: Dict[str, Any]) -> str: return str((track_info or {}).get("artist", "") or "") -def _all_profile_wishlist_tracks(wishlist_service) -> List[Dict[str, Any]]: - database = get_database() - all_profiles = database.get_all_profiles() - wishlist_tracks: List[Dict[str, Any]] = [] - for profile in all_profiles: - wishlist_tracks.extend(wishlist_service.get_wishlist_tracks_for_download(profile_id=profile["id"])) - return wishlist_tracks - - def _stable_soulsync_id(text: str) -> str: return str(abs(int(hashlib.md5(text.encode("utf-8", errors="replace")).hexdigest(), 16)) % (10 ** 9)) @@ -506,69 +496,3 @@ def record_retag_download(context: Dict[str, Any], artist_context: Dict[str, Any db.trim_retag_groups(100) except Exception as exc: logger.error("[Retag] Could not record track for retag: %s", exc) - - -def check_and_remove_from_wishlist(context: Dict[str, Any]) -> None: - """Check whether a successful download should be removed from the wishlist.""" - try: - wishlist_service = get_wishlist_service() - source = get_import_source(context) - source_ids = get_import_source_ids(context) - source_label = { - "spotify": "Spotify", - "itunes": "iTunes", - "deezer": "Deezer", - "discogs": "Discogs", - "hydrabase": "Hydrabase", - }.get(source, "Source") - track_info = get_import_track_info(context) or get_import_search_result(context) - search_result = get_import_original_search(context) or get_import_search_result(context) - track_id = None - - if source == "spotify": - track_id = source_ids.get("track_id") or None - if track_id: - logger.info("[Wishlist] Found %s track ID from source_ids: %s", source_label, track_id) - elif "wishlist_id" in track_info: - wishlist_id = track_info["wishlist_id"] - logger.info("[Wishlist] Found wishlist_id in context: %s", wishlist_id) - wishlist_tracks = _all_profile_wishlist_tracks(wishlist_service) - for wishlist_track in wishlist_tracks: - if wishlist_track.get("wishlist_id") == wishlist_id: - track_id = wishlist_track.get("spotify_track_id") or wishlist_track.get("id") - logger.info("[Wishlist] Found track ID from wishlist entry: %s", track_id) - break - - if not track_id: - track_name = track_info.get("name") or search_result.get("title", "") - artist_name = _primary_track_artist_name(track_info) or _primary_track_artist_name(search_result) - - if track_name and artist_name: - logger.warning("[Wishlist] No track ID found, checking for fuzzy match: '%s' by '%s'", track_name, artist_name) - - wishlist_tracks = _all_profile_wishlist_tracks(wishlist_service) - for wishlist_track in wishlist_tracks: - wl_name = wishlist_track.get("name", "").lower() - wl_artists = wishlist_track.get("artists", []) - wl_artist_name = "" - if wl_artists: - if isinstance(wl_artists[0], dict): - wl_artist_name = wl_artists[0].get("name", "").lower() - else: - wl_artist_name = str(wl_artists[0]).lower() - if wl_name == track_name.lower() and wl_artist_name == artist_name.lower(): - track_id = wishlist_track.get("spotify_track_id") or wishlist_track.get("id") - logger.info("[Wishlist] Found fuzzy match - track ID: %s", track_id) - break - - if track_id: - logger.info("[Wishlist] Attempting to remove track from wishlist: %s", track_id) - removed = wishlist_service.mark_track_download_result(track_id, success=True) - if removed: - logger.info("[Wishlist] Successfully removed track from wishlist: %s", track_id) - else: - logger.warning("ℹ️ [Wishlist] Track not found in wishlist or already removed: %s", track_id) - else: - logger.warning("ℹ️ [Wishlist] No track ID found for wishlist removal check") - except Exception as exc: - logger.error("[Wishlist] Error in wishlist removal check: %s", exc) diff --git a/core/search/library_check.py b/core/search/library_check.py index 89e89119..fd843e94 100644 --- a/core/search/library_check.py +++ b/core/search/library_check.py @@ -12,10 +12,11 @@ completes. from __future__ import annotations -import json import logging from typing import Optional +from core.wishlist.presence import load_wishlist_keys as _load_wishlist_keys_shared + logger = logging.getLogger(__name__) @@ -47,43 +48,7 @@ def _resolve_plex_credentials(plex_client, config_manager) -> tuple[str, str]: def _load_wishlist_keys(cursor, profile_id: int) -> set[str]: - """Build a set of `name|||artist` keys from the wishlist for fast lookup. - - Try the profile-aware schema first; fall back to the legacy schema if - profile_id column is missing (older DBs). Errors at any level are - swallowed — wishlist annotation is best-effort. - """ - keys: set[str] = set() - - def _absorb(rows): - for wr in rows: - try: - wd = json.loads(wr[0]) if isinstance(wr[0], str) else {} - wname = (wd.get('name') or '').lower() - wartists = wd.get('artists', []) - if wartists: - first = wartists[0] - wa = first.get('name', '') if isinstance(first, dict) else str(first) - else: - wa = '' - if wname: - keys.add(wname + '|||' + wa.lower().strip()) - except Exception: - pass - - try: - cursor.execute("SELECT spotify_data FROM wishlist_tracks WHERE profile_id = ?", (profile_id,)) - _absorb(cursor.fetchall()) - return keys - except Exception: - pass - - try: - cursor.execute("SELECT spotify_data FROM wishlist_tracks") - _absorb(cursor.fetchall()) - except Exception: - pass - return keys + return _load_wishlist_keys_shared(cursor, profile_id) def check_library_presence( diff --git a/core/wishlist/__init__.py b/core/wishlist/__init__.py new file mode 100644 index 00000000..0f64f5c8 --- /dev/null +++ b/core/wishlist/__init__.py @@ -0,0 +1,24 @@ +"""Wishlist package. + +This package collects the wishlist service and the shared helpers that are +being peeled out of the old controller-heavy code paths. +""" + +from core.wishlist.processing import ( + WishlistAutoProcessingRuntime, + WishlistManualDownloadRuntime, + cleanup_wishlist_against_library, + process_wishlist_automatically, + start_manual_wishlist_download_batch, +) +from core.wishlist.service import WishlistService, get_wishlist_service + +__all__ = [ + "WishlistService", + "get_wishlist_service", + "WishlistAutoProcessingRuntime", + "WishlistManualDownloadRuntime", + "cleanup_wishlist_against_library", + "process_wishlist_automatically", + "start_manual_wishlist_download_batch", +] diff --git a/core/wishlist/classification.py b/core/wishlist/classification.py new file mode 100644 index 00000000..d6930515 --- /dev/null +++ b/core/wishlist/classification.py @@ -0,0 +1,38 @@ +"""Wishlist track classification helpers.""" + +from __future__ import annotations + +import json +from typing import Any, Dict + + +def classify_wishlist_track(track: Dict[str, Any]) -> str: + """Classify a wishlist track as `singles` or `albums`.""" + spotify_data = track.get('spotify_data', {}) + if isinstance(spotify_data, str): + try: + spotify_data = json.loads(spotify_data) + except Exception: + spotify_data = {} + + album_data = spotify_data.get('album') or {} + if not isinstance(album_data, dict): + album_data = {} + total_tracks = album_data.get('total_tracks') + album_type = album_data.get('album_type', '').lower() + + # Prioritize Spotify's album_type classification (most accurate) + if album_type in ('single', 'ep'): + return 'singles' + if album_type in ('album', 'compilation'): + return 'albums' + + # Fallback: track count heuristic + if total_tracks is not None and total_tracks > 0: + return 'singles' if total_tracks < 6 else 'albums' + + # No classification data — default to albums + return 'albums' + + +__all__ = ["classify_wishlist_track"] diff --git a/core/wishlist/payloads.py b/core/wishlist/payloads.py new file mode 100644 index 00000000..76f74a9a --- /dev/null +++ b/core/wishlist/payloads.py @@ -0,0 +1,396 @@ +"""Wishlist payload normalization helpers.""" + +from __future__ import annotations + +import json +from typing import Any, Dict, Optional + +from utils.logging_config import get_logger + + +logger = get_logger("wishlist_service") + + +def sanitize_track_data_for_processing(track_data): + """ + Sanitizes track data from wishlist service to ensure consistent format. + Preserves album dict to retain full metadata (images, id, etc.) and normalizes artist field. + """ + if not isinstance(track_data, dict): + logger.info(f"[Sanitize] Unexpected track data type: {type(track_data)}") + return track_data + + # Create a copy to avoid modifying original data + sanitized = track_data.copy() + + # Handle album field - preserve dict format to retain full metadata (images, id, etc.) + # Downstream code already handles both dict and string formats defensively + raw_album = sanitized.get('album', '') + if not isinstance(raw_album, (dict, str)): + sanitized['album'] = str(raw_album) + + # Handle artists field - ensure it's a list of strings + raw_artists = sanitized.get('artists', []) + if isinstance(raw_artists, list): + processed_artists = [] + for artist in raw_artists: + if isinstance(artist, str): + processed_artists.append(artist) + elif isinstance(artist, dict) and 'name' in artist: + processed_artists.append(artist['name']) + else: + processed_artists.append(str(artist)) + sanitized['artists'] = processed_artists + else: + logger.info(f"[Sanitize] Unexpected artists format: {type(raw_artists)}") + sanitized['artists'] = [str(raw_artists)] if raw_artists else [] + + return sanitized + + +def get_track_artist_name(track_info): + """Extract artist name from track info, handling different data formats.""" + if not track_info: + return "Unknown Artist" + + artists = track_info.get('artists', []) + if artists and len(artists) > 0: + first_artist = artists[0] + if isinstance(first_artist, dict) and 'name' in first_artist: + return first_artist['name'] + if isinstance(first_artist, str): + return first_artist + + artist = track_info.get('artist') + if artist: + return artist + + return "Unknown Artist" + + +def ensure_spotify_track_format(track_info): + """ + Ensure track_info has proper Spotify track structure for wishlist service. + Converts webui track format to match sync.py's spotify_track format. + """ + if not track_info: + return {} + + # If it already has the proper Spotify structure, return as-is + if isinstance(track_info.get('artists'), list) and len(track_info.get('artists', [])) > 0: + first_artist = track_info['artists'][0] + if isinstance(first_artist, dict) and 'name' in first_artist: + # Already has proper Spotify format + return track_info + + # Convert to proper Spotify format + artists_list = [] + + # Handle different artist formats from webui + artists = track_info.get('artists', []) + if artists: + if isinstance(artists, list): + for artist in artists: + if isinstance(artist, dict) and 'name' in artist: + artists_list.append({'name': artist['name']}) + elif isinstance(artist, str): + artists_list.append({'name': artist}) + else: + artists_list.append({'name': str(artist)}) + else: + # Single artist as string + artists_list.append({'name': str(artists)}) + else: + # Fallback: try single artist field + artist = track_info.get('artist') + if artist: + artists_list.append({'name': str(artist)}) + else: + artists_list.append({'name': 'Unknown Artist'}) + + # Build album object - preserve ALL fields (id, release_date, total_tracks, + # album_type, images, etc.) so wishlist tracks retain full album context + # for correct folder placement, multi-disc support, and classification + album_data = track_info.get('album', {}) + if isinstance(album_data, dict): + album = dict(album_data) # Copy all fields + album.setdefault('name', 'Unknown Album') + else: + album = { + 'name': str(album_data) if album_data else track_info.get('name', 'Unknown Album'), + 'album_type': 'single', + 'total_tracks': 1, + 'release_date': '', + } + album.setdefault('images', []) + album.setdefault('album_type', 'album') + album.setdefault('total_tracks', 0) + + # Build proper Spotify track structure + spotify_track = { + 'id': track_info.get('id', f"webui_{hash(str(track_info))}"), + 'name': track_info.get('name', 'Unknown Track'), + 'artists': artists_list, # Proper Spotify format + 'album': album, + 'duration_ms': track_info.get('duration_ms', 0), + 'track_number': track_info.get('track_number', 1), + 'disc_number': track_info.get('disc_number', 1), + 'preview_url': track_info.get('preview_url'), + 'external_urls': track_info.get('external_urls', {}), + 'popularity': track_info.get('popularity', 0), + 'source': 'webui_modal' # Mark as coming from webui + } + + return spotify_track + + +def build_cancelled_task_wishlist_payload(task, profile_id: int = 1): + """Build the wishlist payload for a cancelled download task. + + This preserves the current web_server.py behavior while moving the + data-shaping logic into the wishlist package. + """ + if not task: + return {} + + track_info = task.get('track_info', {}) + artists_data = track_info.get('artists', []) + formatted_artists = [] + + for artist in artists_data: + if isinstance(artist, str): + formatted_artists.append({'name': artist}) + elif isinstance(artist, dict): + if 'name' in artist and isinstance(artist['name'], str): + formatted_artists.append(artist) + elif 'name' in artist and isinstance(artist['name'], dict) and 'name' in artist['name']: + formatted_artists.append({'name': artist['name']['name']}) + else: + formatted_artists.append({'name': str(artist)}) + else: + formatted_artists.append({'name': str(artist)}) + + # Build album data - preserve all fields (including artists) for correct folder placement + album_raw = track_info.get('album', {}) + if isinstance(album_raw, dict): + album_data = dict(album_raw) # Copy all fields including artists + album_data.setdefault('name', 'Unknown Album') + album_data.setdefault('album_type', track_info.get('album_type', 'album')) + # Add images fallback if not present + if 'images' not in album_data and track_info.get('album_image_url'): + album_data['images'] = [{'url': track_info.get('album_image_url')}] + else: + # album is a string (album name) + album_data = { + 'name': str(album_raw) if album_raw else 'Unknown Album', + 'album_type': track_info.get('album_type', 'album') + } + # Add album image if available + if track_info.get('album_image_url'): + album_data['images'] = [{'url': track_info.get('album_image_url')}] + + spotify_track_data = { + 'id': track_info.get('id'), + 'name': track_info.get('name'), + 'artists': formatted_artists, + 'album': album_data, + 'duration_ms': track_info.get('duration_ms') + } + + source_context = { + 'playlist_name': task.get('playlist_name', 'Unknown Playlist'), + 'playlist_id': task.get('playlist_id'), + 'added_from': 'modal_cancellation_v2', + } + + return { + 'spotify_track_data': spotify_track_data, + 'failure_reason': 'Download cancelled by user (v2)', + 'source_type': 'playlist', + 'source_context': source_context, + 'profile_id': profile_id, + } + + +def build_failed_track_wishlist_context( + track_info, + *, + track_index: int = 0, + retry_count: int = 0, + failure_reason: str = 'Download failed', + candidates=None, +): + """Build the track-info payload used when queue tasks get added back to wishlist.""" + track_info = track_info or {} + return { + 'download_index': track_index, + 'table_index': track_index, + 'track_name': track_info.get('name', 'Unknown Track'), + 'artist_name': get_track_artist_name(track_info), + 'retry_count': retry_count, + 'spotify_track': ensure_spotify_track_format(track_info), + 'failure_reason': failure_reason, + 'candidates': list(candidates or []), + } + + +def extract_spotify_track_from_modal_info(track_info: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """ + Extract Spotify track data from modal track_info structure. + + Handles different formats from sync.py and artists.py modals. + """ + try: + # Try to find Spotify track data in various locations within track_info + + # Check if we have direct Spotify track reference + if "spotify_track" in track_info and track_info["spotify_track"]: + spotify_track = track_info["spotify_track"] + + # Convert to dictionary if it's an object + if hasattr(spotify_track, "__dict__"): + return spotify_track_object_to_dict(spotify_track) + if isinstance(spotify_track, dict): + return spotify_track + + # Check if we have slskd_result with embedded metadata + if "slskd_result" in track_info and track_info["slskd_result"]: + slskd_result = track_info["slskd_result"] + + # Look for Spotify metadata in the result + if hasattr(slskd_result, "artist") and hasattr(slskd_result, "title"): + album_name = getattr(slskd_result, "album", "") or getattr(slskd_result, "title", "Unknown Album") + return { + "id": f"reconstructed_{hash(f'{slskd_result.artist}_{slskd_result.title}')}", + "name": getattr(slskd_result, "title", "Unknown Track"), + "artists": [{"name": getattr(slskd_result, "artist", "Unknown Artist")}], + "album": {"name": album_name, "images": [], "album_type": "single", "total_tracks": 1}, + "duration_ms": 0, + "reconstructed": True, + } + + # If no Spotify data found, try to reconstruct from available info + logger.warning("Could not find Spotify track data in modal info, attempting reconstruction") + return None + + except Exception as e: + logger.error(f"Error extracting Spotify track from modal info: {e}") + return None + + +def spotify_track_object_to_dict(spotify_track) -> Dict[str, Any]: + """Convert a Spotify track object or TrackResult object to a dictionary.""" + try: + logger.debug( + "Converting track object to dict: type=%s has_title=%s has_artist=%s has_id=%s", + type(spotify_track), + hasattr(spotify_track, "title"), + hasattr(spotify_track, "artist"), + hasattr(spotify_track, "id"), + ) + + # Check if this is a TrackResult object (has title/artist but no id) + if hasattr(spotify_track, "title") and hasattr(spotify_track, "artist") and not hasattr(spotify_track, "id"): + logger.debug("Detected TrackResult object, converting") + # Handle TrackResult objects - these don't have Spotify IDs + album_name = getattr(spotify_track, "album", "") or getattr(spotify_track, "title", "Unknown Album") + result = { + "id": f"trackresult_{hash(f'{spotify_track.artist}_{spotify_track.title}')}", + "name": getattr(spotify_track, "title", "Unknown Track"), + "artists": [{"name": getattr(spotify_track, "artist", "Unknown Artist")}], + "album": {"name": album_name, "images": [], "album_type": "single", "total_tracks": 1}, + "duration_ms": 0, + "preview_url": None, + "external_urls": {}, + "popularity": 0, + "source": "trackresult", + } + logger.debug( + "TrackResult converted successfully: name=%s artist=%s", + result["name"], + result["artists"][0]["name"], + ) + return result + + # Handle regular Spotify Track objects + logger.debug("Processing as Spotify Track object") + + # Handle artists list carefully to avoid TrackResult serialization issues + artists_list = [] + raw_artists = getattr(spotify_track, "artists", []) + logger.debug("Raw artists: %r (type=%s)", raw_artists, type(raw_artists)) + + for artist in raw_artists: + logger.debug("Processing artist: %r (type=%s)", artist, type(artist)) + if hasattr(artist, "name"): + artists_list.append({"name": artist.name}) + elif isinstance(artist, str): + artists_list.append({"name": artist}) + else: + # Convert any complex objects to string to avoid serialization issues + artists_list.append({"name": str(artist)}) + + # Handle album safely + album_name = "Unknown Album" + if hasattr(spotify_track, "album") and spotify_track.album: + if hasattr(spotify_track.album, "name"): + album_name = spotify_track.album.name + else: + album_name = str(spotify_track.album) + + result = { + "id": getattr(spotify_track, "id", None), + "name": getattr(spotify_track, "name", "Unknown Track"), + "artists": artists_list, + "album": {"name": album_name}, + "duration_ms": getattr(spotify_track, "duration_ms", 0), + "preview_url": getattr(spotify_track, "preview_url", None), + "external_urls": getattr(spotify_track, "external_urls", {}), + "popularity": getattr(spotify_track, "popularity", 0), + "track_number": getattr(spotify_track, "track_number", 1), + "disc_number": getattr(spotify_track, "disc_number", 1), + } + + logger.debug( + "Spotify Track converted: name=%s artists=%s", + result["name"], + [a["name"] for a in result["artists"]], + ) + + # Test JSON serialization before returning to catch any remaining issues + try: + json.dumps(result) + logger.debug("Conversion result is JSON serializable") + except Exception as json_error: + logger.error("Conversion result is NOT JSON serializable: %s", json_error) + logger.error("Conversion result content: %r", result) + # Return a safe fallback + return { + "id": f"fallback_{hash(str(spotify_track))}", + "name": str(getattr(spotify_track, "name", "Unknown Track")), + "artists": [{"name": "Unknown Artist"}], + "album": {"name": "Unknown Album"}, + "duration_ms": 0, + "preview_url": None, + "external_urls": {}, + "popularity": 0, + "source": "fallback", + } + + return result + except Exception as e: + logger.error(f"Error converting track object to dict: {e}") + logger.error(f"Object type: {type(spotify_track)}") + logger.error(f"Object attributes: {dir(spotify_track)}") + return {} + + +__all__ = [ + "sanitize_track_data_for_processing", + "get_track_artist_name", + "ensure_spotify_track_format", + "build_cancelled_task_wishlist_payload", + "build_failed_track_wishlist_context", + "extract_spotify_track_from_modal_info", + "spotify_track_object_to_dict", +] diff --git a/core/wishlist/presence.py b/core/wishlist/presence.py new file mode 100644 index 00000000..16eddc2b --- /dev/null +++ b/core/wishlist/presence.py @@ -0,0 +1,48 @@ +"""Wishlist lookup helpers for search and library checks.""" + +from __future__ import annotations + +import json + + +def load_wishlist_keys(cursor, profile_id: int) -> set[str]: + """Build a set of `name|||artist` keys from the wishlist for fast lookup. + + Try the profile-aware schema first; fall back to the legacy schema if + profile_id column is missing (older DBs). Errors at any level are + swallowed — wishlist annotation is best-effort. + """ + keys: set[str] = set() + + def _absorb(rows): + for wr in rows: + try: + wd = json.loads(wr[0]) if isinstance(wr[0], str) else {} + wname = (wd.get("name") or "").lower() + wartists = wd.get("artists", []) + if wartists: + first = wartists[0] + wa = first.get("name", "") if isinstance(first, dict) else str(first) + else: + wa = "" + if wname: + keys.add(wname + "|||" + wa.lower().strip()) + except Exception: + pass + + try: + cursor.execute("SELECT spotify_data FROM wishlist_tracks WHERE profile_id = ?", (profile_id,)) + _absorb(cursor.fetchall()) + return keys + except Exception: + pass + + try: + cursor.execute("SELECT spotify_data FROM wishlist_tracks") + _absorb(cursor.fetchall()) + except Exception: + pass + return keys + + +__all__ = ["load_wishlist_keys"] diff --git a/core/wishlist/processing.py b/core/wishlist/processing.py new file mode 100644 index 00000000..db5744df --- /dev/null +++ b/core/wishlist/processing.py @@ -0,0 +1,717 @@ +"""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", +] diff --git a/core/wishlist/reporting.py b/core/wishlist/reporting.py new file mode 100644 index 00000000..71c306fa --- /dev/null +++ b/core/wishlist/reporting.py @@ -0,0 +1,51 @@ +"""Wishlist reporting helpers.""" + +from __future__ import annotations + +from typing import Any, Iterable + +from core.wishlist.classification import classify_wishlist_track +from core.wishlist.selection import sanitize_and_dedupe_wishlist_tracks + + +def count_wishlist_tracks_by_category(raw_tracks: Iterable[dict[str, Any]]) -> dict[str, int]: + """Count deduped wishlist tracks by category.""" + sanitized_tracks, _ = sanitize_and_dedupe_wishlist_tracks(raw_tracks) + + singles_count = 0 + albums_count = 0 + + for track in sanitized_tracks: + if classify_wishlist_track(track) == 'singles': + singles_count += 1 + else: + albums_count += 1 + + total = singles_count + albums_count + return { + 'singles': singles_count, + 'albums': albums_count, + 'total': total, + } + + +def build_wishlist_stats_payload( + raw_tracks: Iterable[dict[str, Any]], + *, + next_run_in_seconds: int, + is_auto_processing: bool, + current_cycle: str, +) -> dict[str, Any]: + """Build the API payload used by the wishlist stats endpoint.""" + counts = count_wishlist_tracks_by_category(raw_tracks) + return { + "singles": counts["singles"], + "albums": counts["albums"], + "total": counts["total"], + "next_run_in_seconds": next_run_in_seconds, + "is_auto_processing": is_auto_processing, + "current_cycle": current_cycle, + } + + +__all__ = ["count_wishlist_tracks_by_category", "build_wishlist_stats_payload"] diff --git a/core/wishlist/resolution.py b/core/wishlist/resolution.py new file mode 100644 index 00000000..398c00f8 --- /dev/null +++ b/core/wishlist/resolution.py @@ -0,0 +1,173 @@ +"""Wishlist resolution and removal helpers.""" + +from __future__ import annotations + +from typing import Any, Dict, List + +from core.imports.context import ( + get_import_original_search, + get_import_search_result, + get_import_source, + get_import_source_ids, + get_import_track_info, +) +from core.wishlist.service import get_wishlist_service +from database.music_database import get_database +from utils.logging_config import get_logger + + +logger = get_logger("imports.side_effects") + + +def _primary_track_artist_name(track_info: Dict[str, Any]) -> str: + artists = (track_info or {}).get("artists", []) + if isinstance(artists, list) and artists: + first = artists[0] + if isinstance(first, dict): + return str(first.get("name", "") or "") + return str(first or "") + if isinstance(artists, str): + return artists + return str((track_info or {}).get("artist", "") or "") + + +def _all_profile_wishlist_tracks(wishlist_service, database=None) -> List[Dict[str, Any]]: + database = database or get_database() + all_profiles = database.get_all_profiles() + wishlist_tracks: List[Dict[str, Any]] = [] + for profile in all_profiles: + wishlist_tracks.extend(wishlist_service.get_wishlist_tracks_for_download(profile_id=profile["id"])) + return wishlist_tracks + + +def check_and_remove_from_wishlist(context: Dict[str, Any], wishlist_service=None, database=None) -> None: + """Check whether a successful download should be removed from the wishlist.""" + try: + wishlist_service = wishlist_service or get_wishlist_service() + source = get_import_source(context) + source_ids = get_import_source_ids(context) + source_label = { + "spotify": "Spotify", + "itunes": "iTunes", + "deezer": "Deezer", + "discogs": "Discogs", + "hydrabase": "Hydrabase", + }.get(source, "Source") + track_info = get_import_track_info(context) or get_import_search_result(context) + search_result = get_import_original_search(context) or get_import_search_result(context) + track_id = None + + if source == "spotify": + track_id = source_ids.get("track_id") or None + if track_id: + logger.info("[Wishlist] Found %s track ID from source_ids: %s", source_label, track_id) + elif "wishlist_id" in track_info: + wishlist_id = track_info["wishlist_id"] + logger.info("[Wishlist] Found wishlist_id in context: %s", wishlist_id) + wishlist_tracks = _all_profile_wishlist_tracks(wishlist_service, database=database) + for wishlist_track in wishlist_tracks: + if wishlist_track.get("wishlist_id") == wishlist_id: + track_id = wishlist_track.get("spotify_track_id") or wishlist_track.get("id") + logger.info("[Wishlist] Found track ID from wishlist entry: %s", track_id) + break + + if not track_id: + track_name = track_info.get("name") or search_result.get("title", "") + artist_name = _primary_track_artist_name(track_info) or _primary_track_artist_name(search_result) + + if track_name and artist_name: + logger.warning( + "[Wishlist] No track ID found, checking for fuzzy match: '%s' by '%s'", + track_name, + artist_name, + ) + + wishlist_tracks = _all_profile_wishlist_tracks(wishlist_service, database=database) + for wishlist_track in wishlist_tracks: + wl_name = wishlist_track.get("name", "").lower() + wl_artists = wishlist_track.get("artists", []) + wl_artist_name = "" + if wl_artists: + if isinstance(wl_artists[0], dict): + wl_artist_name = wl_artists[0].get("name", "").lower() + else: + wl_artist_name = str(wl_artists[0]).lower() + if wl_name == track_name.lower() and wl_artist_name == artist_name.lower(): + track_id = wishlist_track.get("spotify_track_id") or wishlist_track.get("id") + logger.info("[Wishlist] Found fuzzy match - track ID: %s", track_id) + break + + if track_id: + logger.info("[Wishlist] Attempting to remove track from wishlist: %s", track_id) + removed = wishlist_service.mark_track_download_result(track_id, success=True) + if removed: + logger.info("[Wishlist] Successfully removed track from wishlist: %s", track_id) + else: + logger.warning("ℹ️ [Wishlist] Track not found in wishlist or already removed: %s", track_id) + else: + logger.warning("ℹ️ [Wishlist] No track ID found for wishlist removal check") + except Exception as exc: + logger.error("[Wishlist] Error in wishlist removal check: %s", exc) + + +def check_and_remove_track_from_wishlist_by_metadata( + track_data: Dict[str, Any], + wishlist_service=None, + database=None, +) -> bool: + """Remove a wishlist track by metadata after a database/library match.""" + try: + wishlist_service = wishlist_service or get_wishlist_service() + track_name = track_data.get("name", "") + track_id = track_data.get("id", "") + artists = track_data.get("artists", []) + + logger.info("[Analysis] Checking if track should be removed from wishlist: '%s' (ID: %s)", track_name, track_id) + + if track_id: + removed = wishlist_service.mark_track_download_result(track_id, success=True) + if removed: + logger.info("[Analysis] Removed track from wishlist via direct ID match: %s", track_id) + return True + + if track_name and artists: + primary_artist = _primary_track_artist_name(track_data) + if primary_artist: + logger.warning( + "[Analysis] No direct ID match, trying fuzzy match: '%s' by '%s'", + track_name, + primary_artist, + ) + + wishlist_tracks = _all_profile_wishlist_tracks(wishlist_service, database=database) + for wishlist_track in wishlist_tracks: + wl_name = wishlist_track.get("name", "").lower() + wl_artists = wishlist_track.get("artists", []) + wl_artist_name = "" + + if wl_artists: + if isinstance(wl_artists[0], dict): + wl_artist_name = wl_artists[0].get("name", "").lower() + else: + wl_artist_name = str(wl_artists[0]).lower() + + if wl_name == track_name.lower() and wl_artist_name == primary_artist.lower(): + spotify_track_id = wishlist_track.get("spotify_track_id") or wishlist_track.get("id") + if spotify_track_id: + removed = wishlist_service.mark_track_download_result(spotify_track_id, success=True) + if removed: + logger.info("[Analysis] Removed track from wishlist via fuzzy match: %s", spotify_track_id) + return True + + logger.warning("ℹ️ [Analysis] Track not found in wishlist or already removed: '%s'", track_name) + return False + + except Exception as e: + logger.error("[Analysis] Error checking wishlist removal by metadata: %s", e) + import traceback + + traceback.print_exc() + return False + + +__all__ = ["check_and_remove_from_wishlist", "check_and_remove_track_from_wishlist_by_metadata"] diff --git a/core/wishlist/selection.py b/core/wishlist/selection.py new file mode 100644 index 00000000..16b8dd37 --- /dev/null +++ b/core/wishlist/selection.py @@ -0,0 +1,93 @@ +"""Wishlist track selection helpers.""" + +from __future__ import annotations + +from typing import Any, Callable, Iterable + +from core.wishlist.classification import classify_wishlist_track +from core.wishlist.payloads import sanitize_track_data_for_processing + + +def sanitize_and_dedupe_wishlist_tracks( + raw_tracks: Iterable[dict[str, Any]], + *, + sanitizer: Callable[[dict[str, Any]], dict[str, Any]] = sanitize_track_data_for_processing, +) -> tuple[list[dict[str, Any]], int]: + """Sanitize wishlist tracks and drop duplicate track IDs.""" + sanitized_tracks: list[dict[str, Any]] = [] + seen_track_ids: set[str] = set() + duplicates_found = 0 + + for track in raw_tracks: + sanitized_track = sanitizer(track) + spotify_track_id = sanitized_track.get('spotify_track_id') or sanitized_track.get('id') + + if spotify_track_id and spotify_track_id in seen_track_ids: + duplicates_found += 1 + continue + + sanitized_tracks.append(sanitized_track) + if spotify_track_id: + seen_track_ids.add(spotify_track_id) + + return sanitized_tracks, duplicates_found + + +def filter_wishlist_tracks_by_category( + tracks: Iterable[dict[str, Any]], + category: str, + *, + classifier: Callable[[dict[str, Any]], str] = classify_wishlist_track, +) -> tuple[list[dict[str, Any]], int]: + """Filter wishlist tracks by category and return the matches plus total count.""" + filtered_tracks: list[dict[str, Any]] = [] + seen_track_ids: set[str] = set() + + for track in tracks: + track_category = classifier(track) + spotify_track_id = track.get('spotify_track_id') or track.get('id') + if category != track_category: + continue + + if spotify_track_id: + if spotify_track_id in seen_track_ids: + continue + seen_track_ids.add(spotify_track_id) + + filtered_tracks.append(track) + + total_in_category = sum(1 for track in tracks if classifier(track) == category) + return filtered_tracks, total_in_category + + +def prepare_wishlist_tracks_for_display( + raw_tracks: Iterable[dict[str, Any]], + *, + category: str | None = None, + limit: int | None = None, +) -> dict[str, Any]: + """Sanitize, dedupe, and optionally filter wishlist tracks for API output.""" + sanitized_tracks, duplicates_found = sanitize_and_dedupe_wishlist_tracks(raw_tracks) + + result_tracks = sanitized_tracks + total = len(sanitized_tracks) + + if category: + result_tracks, total = filter_wishlist_tracks_by_category(sanitized_tracks, category) + + if limit is not None: + result_tracks = result_tracks[:limit] + + return { + 'tracks': result_tracks, + 'total': total, + 'duplicates_found': duplicates_found, + 'category': category, + } + + +__all__ = [ + "sanitize_and_dedupe_wishlist_tracks", + "filter_wishlist_tracks_by_category", + "prepare_wishlist_tracks_for_display", +] diff --git a/core/wishlist/service.py b/core/wishlist/service.py new file mode 100644 index 00000000..73bb6a36 --- /dev/null +++ b/core/wishlist/service.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 + +""" +Wishlist Service - High-level service for managing failed download track wishlist +""" + +from typing import Any, Dict, List, Optional + +from core.wishlist.payloads import extract_spotify_track_from_modal_info +from database.music_database import get_database +from utils.logging_config import get_logger + + +logger = get_logger("wishlist_service") + + +class WishlistService: + """Service for managing the wishlist of failed download tracks""" + + def __init__(self, database_path: str = "database/music_library.db"): + self.database_path = database_path + self._database = None + + @property + def database(self): + """Get database instance (lazy loading)""" + if self._database is None: + self._database = get_database(self.database_path) + return self._database + + def add_failed_track_from_modal( + self, + track_info: Dict[str, Any], + source_type: str = "unknown", + source_context: Dict[str, Any] = None, + profile_id: int = 1, + ) -> bool: + """ + Add a failed track from a download modal to the wishlist. + + Args: + track_info: Track info dictionary from modal's permanently_failed_tracks + source_type: Type of source ('playlist', 'album', 'manual') + source_context: Additional context (playlist name, album info, etc.) + """ + try: + # Extract Spotify track data from the track_info structure + spotify_track = extract_spotify_track_from_modal_info(track_info) + if not spotify_track: + logger.error("Could not extract Spotify track data from modal info") + return False + + # Get failure reason from track_info if available + failure_reason = track_info.get("failure_reason", "Download failed") + + # Create source info + source_info = source_context or {} + + # Clean up candidates to avoid TrackResult serialization issues + candidates = track_info.get("candidates", []) + cleaned_candidates = [] + for candidate in candidates: + if hasattr(candidate, "__dict__"): + # Convert TrackResult objects to simple dictionaries + cleaned_candidates.append( + { + "title": getattr(candidate, "title", "Unknown"), + "artist": getattr(candidate, "artist", "Unknown"), + "filename": getattr(candidate, "filename", "Unknown"), + } + ) + else: + # Keep simple data as-is + cleaned_candidates.append(candidate) + + source_info["original_modal_data"] = { + "download_index": track_info.get("download_index"), + "table_index": track_info.get("table_index"), + "candidates": cleaned_candidates, + } + + # Add to wishlist via database + return self.database.add_to_wishlist( + spotify_track_data=spotify_track, + failure_reason=failure_reason, + source_type=source_type, + source_info=source_info, + profile_id=profile_id, + ) + + except Exception as e: + logger.error(f"Error adding failed track to wishlist: {e}") + return False + + def add_spotify_track_to_wishlist( + self, + spotify_track_data: Dict[str, Any], + failure_reason: str, + source_type: str = "manual", + source_context: Dict[str, Any] = None, + profile_id: int = 1, + ) -> bool: + """ + Directly add a Spotify track to the wishlist. + + Args: + spotify_track_data: Full Spotify track data dictionary + failure_reason: Reason for the failure + source_type: Source type ('playlist', 'album', 'manual') + source_context: Additional context information + profile_id: Profile to add to + """ + return self.database.add_to_wishlist( + spotify_track_data=spotify_track_data, + failure_reason=failure_reason, + source_type=source_type, + source_info=source_context or {}, + profile_id=profile_id, + ) + + def get_wishlist_tracks_for_download( + self, + limit: Optional[int] = None, + profile_id: int = 1, + ) -> List[Dict[str, Any]]: + """ + Get wishlist tracks formatted for the download modal. + Returns tracks in a format similar to playlist tracks for compatibility. + """ + try: + wishlist_tracks = self.database.get_wishlist_tracks(limit=limit, profile_id=profile_id) + formatted_tracks = [] + + for wishlist_track in wishlist_tracks: + spotify_data = wishlist_track["spotify_data"] + + # Create a track object similar to what download modals expect + formatted_track = { + "wishlist_id": wishlist_track["id"], + "spotify_track_id": wishlist_track["spotify_track_id"], + "spotify_data": spotify_data, + "failure_reason": wishlist_track["failure_reason"], + "retry_count": wishlist_track["retry_count"], + "date_added": wishlist_track["date_added"], + "last_attempted": wishlist_track["last_attempted"], + "source_type": wishlist_track["source_type"], + "source_info": wishlist_track["source_info"], + # Format for modal compatibility (similar to Spotify Track objects) + "id": spotify_data.get("id"), + "name": spotify_data.get("name", "Unknown Track"), + "artists": spotify_data.get("artists", []), + "album": spotify_data.get("album") or {}, + "duration_ms": spotify_data.get("duration_ms", 0), + "preview_url": spotify_data.get("preview_url"), + "external_urls": spotify_data.get("external_urls", {}), + "popularity": spotify_data.get("popularity", 0), + "track_number": spotify_data.get("track_number", 1), + "disc_number": spotify_data.get("disc_number", 1), + } + + formatted_tracks.append(formatted_track) + + return formatted_tracks + + except Exception as e: + logger.error(f"Error getting wishlist tracks for download: {e}") + return [] + + def mark_track_download_result( + self, + spotify_track_id: str, + success: bool, + error_message: str = None, + profile_id: int = 1, + ) -> bool: + """ + Mark the result of a download attempt for a wishlist track. + + Args: + spotify_track_id: Spotify track ID + success: Whether the download was successful + error_message: Error message if failed + profile_id: Profile to scope the operation to + """ + return self.database.update_wishlist_retry(spotify_track_id, success, error_message, profile_id=profile_id) + + def remove_track_from_wishlist(self, spotify_track_id: str, profile_id: int = 1) -> bool: + """Remove a track from the wishlist (typically after successful download)""" + return self.database.remove_from_wishlist(spotify_track_id, profile_id=profile_id) + + def get_wishlist_count(self, profile_id: int = 1) -> int: + """Get the total number of tracks in the wishlist""" + return self.database.get_wishlist_count(profile_id=profile_id) + + def clear_wishlist(self, profile_id: int = 1) -> bool: + """Clear all tracks from the wishlist""" + return self.database.clear_wishlist(profile_id=profile_id) + + def check_track_in_wishlist(self, spotify_track_id: str) -> bool: + """Check if a track exists in the wishlist by Spotify track ID""" + try: + wishlist_tracks = self.get_wishlist_tracks_for_download() + for track in wishlist_tracks: + if track.get("spotify_track_id") == spotify_track_id or track.get("id") == spotify_track_id: + return True + return False + except Exception as e: + logger.error(f"Error checking track in wishlist: {e}") + return False + + def find_matching_wishlist_track(self, track_name: str, artist_name: str) -> Optional[Dict[str, Any]]: + """ + Find a matching track in the wishlist using fuzzy matching on name and artist. + Returns the first matching wishlist track or None if no match found. + """ + try: + wishlist_tracks = self.get_wishlist_tracks_for_download() + + # Normalize input for comparison + normalized_track_name = track_name.lower().strip() + normalized_artist_name = artist_name.lower().strip() + + for wl_track in wishlist_tracks: + wl_name = wl_track.get("name", "").lower().strip() + wl_artists = wl_track.get("artists", []) + + # Extract artist name from wishlist track + wl_artist_name = "" + if wl_artists: + if isinstance(wl_artists[0], dict): + wl_artist_name = wl_artists[0].get("name", "").lower().strip() + else: + wl_artist_name = str(wl_artists[0]).lower().strip() + + # Simple exact matching (could be enhanced with fuzzy matching algorithms) + if wl_name == normalized_track_name and wl_artist_name == normalized_artist_name: + return wl_track + + return None + + except Exception as e: + logger.error(f"Error finding matching wishlist track: {e}") + return None + + def get_wishlist_summary(self, profile_id: int = 1) -> Dict[str, Any]: + """Get a summary of the wishlist for dashboard display""" + try: + total_tracks = self.get_wishlist_count(profile_id=profile_id) + + if total_tracks == 0: + return { + "total_tracks": 0, + "by_source_type": {}, + "recent_failures": [], + } + + # Get detailed breakdown + wishlist_tracks = self.database.get_wishlist_tracks(profile_id=profile_id) + + # Group by source type + by_source_type = {} + recent_failures = [] + + for track in wishlist_tracks: + source_type = track["source_type"] + by_source_type[source_type] = by_source_type.get(source_type, 0) + 1 + + # Keep track of recent failures (last 5) + if len(recent_failures) < 5: + spotify_data = track["spotify_data"] + recent_failures.append( + { + "name": spotify_data.get("name", "Unknown Track"), + "artist": ( + spotify_data.get("artists", [{}])[0].get("name", "Unknown Artist") + if isinstance(spotify_data.get("artists", [{}])[0], dict) + else spotify_data.get("artists", ["Unknown Artist"])[0] + ) + if spotify_data.get("artists") + else "Unknown Artist", + "failure_reason": track["failure_reason"], + "retry_count": track["retry_count"], + "date_added": track["date_added"], + } + ) + + return { + "total_tracks": total_tracks, + "by_source_type": by_source_type, + "recent_failures": recent_failures, + } + + except Exception as e: + logger.error(f"Error getting wishlist summary: {e}") + return {"total_tracks": 0, "by_source_type": {}, "recent_failures": []} + + +_wishlist_service = None + + +def get_wishlist_service() -> WishlistService: + """Get the global wishlist service instance""" + global _wishlist_service + if _wishlist_service is None: + _wishlist_service = WishlistService() + return _wishlist_service + + +__all__ = ["WishlistService", "get_wishlist_service"] diff --git a/core/wishlist/state.py b/core/wishlist/state.py new file mode 100644 index 00000000..3270dcfe --- /dev/null +++ b/core/wishlist/state.py @@ -0,0 +1,134 @@ +"""Wishlist processing state helpers.""" + +from __future__ import annotations + +from typing import Any, Callable, Optional + + +WISHLIST_STUCK_TIMEOUT_SECONDS = 900 + + +def flag_age_seconds(started_at: Optional[float], now: Optional[float] = None) -> float: + """Return the age of a flag in seconds.""" + if not started_at: + return 0.0 + if now is None: + import time + + now = time.time() + return max(0.0, now - started_at) + + +def is_flag_recent( + active: bool, + started_at: Optional[float], + timeout_seconds: int = WISHLIST_STUCK_TIMEOUT_SECONDS, + now: Optional[float] = None, +) -> bool: + """Return True when an active flag is still within the allowed window.""" + if not active or not started_at: + return False + return flag_age_seconds(started_at, now=now) <= timeout_seconds + + +def is_flag_stuck( + active: bool, + started_at: Optional[float], + timeout_seconds: int = WISHLIST_STUCK_TIMEOUT_SECONDS, + now: Optional[float] = None, +) -> bool: + """Return True when an active flag has exceeded the timeout.""" + if not active or not started_at: + return False + return flag_age_seconds(started_at, now=now) > timeout_seconds + + +def is_wishlist_actually_processing( + active: bool, + started_at: Optional[float], + timeout_seconds: int = WISHLIST_STUCK_TIMEOUT_SECONDS, + now: Optional[float] = None, + on_stuck: Callable[[], None] | None = None, + logger: Any | None = None, +) -> bool: + """Return True only when wishlist processing is active and still recent.""" + if not is_flag_recent(active, started_at, timeout_seconds=timeout_seconds, now=now): + if active: + stuck_minutes = flag_age_seconds(started_at, now=now) / 60 + if logger is not None: + logger.warning(f"[Stuck Detection] Wishlist flag stuck for {stuck_minutes:.1f} minutes - auto-recovering") + if on_stuck is not None: + on_stuck() + return False + + return True + + +def reset_flag_if_stuck( + active: bool, + started_at: Optional[float], + *, + timeout_seconds: int = WISHLIST_STUCK_TIMEOUT_SECONDS, + now: Optional[float] = None, + label: str = "Wishlist auto-processing", + logger: Any | None = None, + reset_callback: Callable[[], None], +) -> bool: + """Reset a processing flag if it has exceeded the timeout.""" + if not is_flag_stuck(active, started_at, timeout_seconds=timeout_seconds, now=now): + return False + + stuck_minutes = flag_age_seconds(started_at, now=now) / 60 + if logger is not None: + logger.info(f"[Stuck Detection] {label} flag has been stuck for {stuck_minutes:.1f} minutes - RESETTING") + reset_callback() + return True + + +def get_wishlist_cycle(db_factory: Callable[[], Any], default_cycle: str = "albums") -> str: + """Return the stored wishlist cycle, creating the default entry if needed.""" + db = db_factory() + with db._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT value FROM metadata WHERE key = 'wishlist_cycle'") + row = cursor.fetchone() + + if row: + return row["value"] + + cursor.execute( + """ + INSERT OR REPLACE INTO metadata (key, value, updated_at) + VALUES ('wishlist_cycle', ?, CURRENT_TIMESTAMP) + """, + (default_cycle,), + ) + conn.commit() + return default_cycle + + +def set_wishlist_cycle(db_factory: Callable[[], Any], cycle: str) -> None: + """Persist the wishlist cycle value.""" + 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) + """, + (cycle,), + ) + conn.commit() + + +__all__ = [ + "WISHLIST_STUCK_TIMEOUT_SECONDS", + "flag_age_seconds", + "is_flag_recent", + "is_flag_stuck", + "is_wishlist_actually_processing", + "reset_flag_if_stuck", + "get_wishlist_cycle", + "set_wishlist_cycle", +] diff --git a/core/wishlist_service.py b/core/wishlist_service.py index 3c185ea7..c22b50a8 100644 --- a/core/wishlist_service.py +++ b/core/wishlist_service.py @@ -1,426 +1,5 @@ -#!/usr/bin/env python3 +"""Compatibility shim for legacy wishlist service imports.""" -""" -Wishlist Service - High-level service for managing failed download track wishlist -""" +from core.wishlist.service import WishlistService, get_wishlist_service -from typing import List, Dict, Any, Optional -from datetime import datetime -from database.music_database import get_database -from utils.logging_config import get_logger - -logger = get_logger("wishlist_service") - -class WishlistService: - """Service for managing the wishlist of failed download tracks""" - - def __init__(self, database_path: str = "database/music_library.db"): - self.database_path = database_path - self._database = None - - @property - def database(self): - """Get database instance (lazy loading)""" - if self._database is None: - self._database = get_database(self.database_path) - return self._database - - def add_failed_track_from_modal(self, track_info: Dict[str, Any], source_type: str = "unknown", - source_context: Dict[str, Any] = None, profile_id: int = 1) -> bool: - """ - Add a failed track from a download modal to the wishlist. - - Args: - track_info: Track info dictionary from modal's permanently_failed_tracks - source_type: Type of source ('playlist', 'album', 'manual') - source_context: Additional context (playlist name, album info, etc.) - """ - try: - # Extract Spotify track data from the track_info structure - spotify_track = self._extract_spotify_track_from_modal_info(track_info) - if not spotify_track: - logger.error("Could not extract Spotify track data from modal info") - return False - - # Get failure reason from track_info if available - failure_reason = track_info.get('failure_reason', 'Download failed') - - # Create source info - source_info = source_context or {} - - # Clean up candidates to avoid TrackResult serialization issues - candidates = track_info.get('candidates', []) - cleaned_candidates = [] - for candidate in candidates: - if hasattr(candidate, '__dict__'): - # Convert TrackResult objects to simple dictionaries - cleaned_candidates.append({ - 'title': getattr(candidate, 'title', 'Unknown'), - 'artist': getattr(candidate, 'artist', 'Unknown'), - 'filename': getattr(candidate, 'filename', 'Unknown') - }) - else: - # Keep simple data as-is - cleaned_candidates.append(candidate) - - source_info['original_modal_data'] = { - 'download_index': track_info.get('download_index'), - 'table_index': track_info.get('table_index'), - 'candidates': cleaned_candidates - } - - # Add to wishlist via database - return self.database.add_to_wishlist( - spotify_track_data=spotify_track, - failure_reason=failure_reason, - source_type=source_type, - source_info=source_info, - profile_id=profile_id - ) - - except Exception as e: - logger.error(f"Error adding failed track to wishlist: {e}") - return False - - def add_spotify_track_to_wishlist(self, spotify_track_data: Dict[str, Any], failure_reason: str, - source_type: str = "manual", source_context: Dict[str, Any] = None, - profile_id: int = 1) -> bool: - """ - Directly add a Spotify track to the wishlist. - - Args: - spotify_track_data: Full Spotify track data dictionary - failure_reason: Reason for the failure - source_type: Source type ('playlist', 'album', 'manual') - source_context: Additional context information - profile_id: Profile to add to - """ - return self.database.add_to_wishlist( - spotify_track_data=spotify_track_data, - failure_reason=failure_reason, - source_type=source_type, - source_info=source_context or {}, - profile_id=profile_id - ) - - def get_wishlist_tracks_for_download(self, limit: Optional[int] = None, profile_id: int = 1) -> List[Dict[str, Any]]: - """ - Get wishlist tracks formatted for the download modal. - Returns tracks in a format similar to playlist tracks for compatibility. - """ - try: - wishlist_tracks = self.database.get_wishlist_tracks(limit=limit, profile_id=profile_id) - formatted_tracks = [] - - for wishlist_track in wishlist_tracks: - spotify_data = wishlist_track['spotify_data'] - - # Create a track object similar to what download modals expect - formatted_track = { - 'wishlist_id': wishlist_track['id'], - 'spotify_track_id': wishlist_track['spotify_track_id'], - 'spotify_data': spotify_data, - 'failure_reason': wishlist_track['failure_reason'], - 'retry_count': wishlist_track['retry_count'], - 'date_added': wishlist_track['date_added'], - 'last_attempted': wishlist_track['last_attempted'], - 'source_type': wishlist_track['source_type'], - 'source_info': wishlist_track['source_info'], - - # Format for modal compatibility (similar to Spotify Track objects) - 'id': spotify_data.get('id'), - 'name': spotify_data.get('name', 'Unknown Track'), - 'artists': spotify_data.get('artists', []), - 'album': spotify_data.get('album') or {}, - 'duration_ms': spotify_data.get('duration_ms', 0), - 'preview_url': spotify_data.get('preview_url'), - 'external_urls': spotify_data.get('external_urls', {}), - 'popularity': spotify_data.get('popularity', 0), - 'track_number': spotify_data.get('track_number', 1), - 'disc_number': spotify_data.get('disc_number', 1) - } - - formatted_tracks.append(formatted_track) - - return formatted_tracks - - except Exception as e: - logger.error(f"Error getting wishlist tracks for download: {e}") - return [] - - def mark_track_download_result(self, spotify_track_id: str, success: bool, error_message: str = None, profile_id: int = 1) -> bool: - """ - Mark the result of a download attempt for a wishlist track. - - Args: - spotify_track_id: Spotify track ID - success: Whether the download was successful - error_message: Error message if failed - profile_id: Profile to scope the operation to - """ - return self.database.update_wishlist_retry(spotify_track_id, success, error_message, profile_id=profile_id) - - def remove_track_from_wishlist(self, spotify_track_id: str, profile_id: int = 1) -> bool: - """Remove a track from the wishlist (typically after successful download)""" - return self.database.remove_from_wishlist(spotify_track_id, profile_id=profile_id) - - def get_wishlist_count(self, profile_id: int = 1) -> int: - """Get the total number of tracks in the wishlist""" - return self.database.get_wishlist_count(profile_id=profile_id) - - def clear_wishlist(self, profile_id: int = 1) -> bool: - """Clear all tracks from the wishlist""" - return self.database.clear_wishlist(profile_id=profile_id) - - def check_track_in_wishlist(self, spotify_track_id: str) -> bool: - """Check if a track exists in the wishlist by Spotify track ID""" - try: - wishlist_tracks = self.get_wishlist_tracks_for_download() - for track in wishlist_tracks: - if track.get('spotify_track_id') == spotify_track_id or track.get('id') == spotify_track_id: - return True - return False - except Exception as e: - logger.error(f"Error checking track in wishlist: {e}") - return False - - def find_matching_wishlist_track(self, track_name: str, artist_name: str) -> Optional[Dict[str, Any]]: - """ - Find a matching track in the wishlist using fuzzy matching on name and artist. - Returns the first matching wishlist track or None if no match found. - """ - try: - wishlist_tracks = self.get_wishlist_tracks_for_download() - - # Normalize input for comparison - normalized_track_name = track_name.lower().strip() - normalized_artist_name = artist_name.lower().strip() - - for wl_track in wishlist_tracks: - wl_name = wl_track.get('name', '').lower().strip() - wl_artists = wl_track.get('artists', []) - - # Extract artist name from wishlist track - wl_artist_name = '' - if wl_artists: - if isinstance(wl_artists[0], dict): - wl_artist_name = wl_artists[0].get('name', '').lower().strip() - else: - wl_artist_name = str(wl_artists[0]).lower().strip() - - # Simple exact matching (could be enhanced with fuzzy matching algorithms) - if wl_name == normalized_track_name and wl_artist_name == normalized_artist_name: - return wl_track - - return None - - except Exception as e: - logger.error(f"Error finding matching wishlist track: {e}") - return None - - def get_wishlist_summary(self, profile_id: int = 1) -> Dict[str, Any]: - """Get a summary of the wishlist for dashboard display""" - try: - total_tracks = self.get_wishlist_count(profile_id=profile_id) - - if total_tracks == 0: - return { - 'total_tracks': 0, - 'by_source_type': {}, - 'recent_failures': [] - } - - # Get detailed breakdown - wishlist_tracks = self.database.get_wishlist_tracks(profile_id=profile_id) - - # Group by source type - by_source_type = {} - recent_failures = [] - - for track in wishlist_tracks: - source_type = track['source_type'] - by_source_type[source_type] = by_source_type.get(source_type, 0) + 1 - - # Keep track of recent failures (last 5) - if len(recent_failures) < 5: - spotify_data = track['spotify_data'] - recent_failures.append({ - 'name': spotify_data.get('name', 'Unknown Track'), - 'artist': (spotify_data.get('artists', [{}])[0].get('name', 'Unknown Artist') if isinstance(spotify_data.get('artists', [{}])[0], dict) else spotify_data.get('artists', ['Unknown Artist'])[0]) if spotify_data.get('artists') else 'Unknown Artist', - 'failure_reason': track['failure_reason'], - 'retry_count': track['retry_count'], - 'date_added': track['date_added'] - }) - - return { - 'total_tracks': total_tracks, - 'by_source_type': by_source_type, - 'recent_failures': recent_failures - } - - except Exception as e: - logger.error(f"Error getting wishlist summary: {e}") - return { - 'total_tracks': 0, - 'by_source_type': {}, - 'recent_failures': [] - } - - def _extract_spotify_track_from_modal_info(self, track_info: Dict[str, Any]) -> Optional[Dict[str, Any]]: - """ - Extract Spotify track data from modal track_info structure. - Handles different formats from sync.py and artists.py modals. - """ - try: - # Try to find Spotify track data in various locations within track_info - - # Check if we have direct Spotify track reference - if 'spotify_track' in track_info and track_info['spotify_track']: - spotify_track = track_info['spotify_track'] - - # Convert to dictionary if it's an object - if hasattr(spotify_track, '__dict__'): - return self._spotify_track_object_to_dict(spotify_track) - elif isinstance(spotify_track, dict): - return spotify_track - - # Check if we have slskd_result with embedded metadata - if 'slskd_result' in track_info and track_info['slskd_result']: - slskd_result = track_info['slskd_result'] - - # Look for Spotify metadata in the result - if hasattr(slskd_result, 'artist') and hasattr(slskd_result, 'title'): - album_name = getattr(slskd_result, 'album', '') or getattr(slskd_result, 'title', 'Unknown Album') - return { - 'id': f"reconstructed_{hash(f'{slskd_result.artist}_{slskd_result.title}')}", - 'name': getattr(slskd_result, 'title', 'Unknown Track'), - 'artists': [{'name': getattr(slskd_result, 'artist', 'Unknown Artist')}], - 'album': {'name': album_name, 'images': [], 'album_type': 'single', 'total_tracks': 1}, - 'duration_ms': 0, - 'reconstructed': True - } - - # If no Spotify data found, try to reconstruct from available info - logger.warning("Could not find Spotify track data in modal info, attempting reconstruction") - return None - - except Exception as e: - logger.error(f"Error extracting Spotify track from modal info: {e}") - return None - - def _spotify_track_object_to_dict(self, spotify_track) -> Dict[str, Any]: - """Convert a Spotify track object or TrackResult object to a dictionary""" - try: - logger.debug( - "Converting track object to dict: type=%s has_title=%s has_artist=%s has_id=%s", - type(spotify_track), - hasattr(spotify_track, 'title'), - hasattr(spotify_track, 'artist'), - hasattr(spotify_track, 'id'), - ) - - # Check if this is a TrackResult object (has title/artist but no id) - if hasattr(spotify_track, 'title') and hasattr(spotify_track, 'artist') and not hasattr(spotify_track, 'id'): - logger.debug("Detected TrackResult object, converting") - # Handle TrackResult objects - these don't have Spotify IDs - album_name = getattr(spotify_track, 'album', '') or getattr(spotify_track, 'title', 'Unknown Album') - result = { - 'id': f"trackresult_{hash(f'{spotify_track.artist}_{spotify_track.title}')}", - 'name': getattr(spotify_track, 'title', 'Unknown Track'), - 'artists': [{'name': getattr(spotify_track, 'artist', 'Unknown Artist')}], - 'album': {'name': album_name, 'images': [], 'album_type': 'single', 'total_tracks': 1}, - 'duration_ms': 0, - 'preview_url': None, - 'external_urls': {}, - 'popularity': 0, - 'source': 'trackresult' - } - logger.debug( - "TrackResult converted successfully: name=%s artist=%s", - result['name'], - result['artists'][0]['name'], - ) - return result - - # Handle regular Spotify Track objects - logger.debug("Processing as Spotify Track object") - - # Handle artists list carefully to avoid TrackResult serialization issues - artists_list = [] - raw_artists = getattr(spotify_track, 'artists', []) - logger.debug("Raw artists: %r (type=%s)", raw_artists, type(raw_artists)) - - for artist in raw_artists: - logger.debug("Processing artist: %r (type=%s)", artist, type(artist)) - if hasattr(artist, 'name'): - artists_list.append({'name': artist.name}) - elif isinstance(artist, str): - artists_list.append({'name': artist}) - else: - # Convert any complex objects to string to avoid serialization issues - artists_list.append({'name': str(artist)}) - - # Handle album safely - album_name = 'Unknown Album' - if hasattr(spotify_track, 'album') and spotify_track.album: - if hasattr(spotify_track.album, 'name'): - album_name = spotify_track.album.name - else: - album_name = str(spotify_track.album) - - result = { - 'id': getattr(spotify_track, 'id', None), - 'name': getattr(spotify_track, 'name', 'Unknown Track'), - 'artists': artists_list, - 'album': {'name': album_name}, - 'duration_ms': getattr(spotify_track, 'duration_ms', 0), - 'preview_url': getattr(spotify_track, 'preview_url', None), - 'external_urls': getattr(spotify_track, 'external_urls', {}), - 'popularity': getattr(spotify_track, 'popularity', 0), - 'track_number': getattr(spotify_track, 'track_number', 1), - 'disc_number': getattr(spotify_track, 'disc_number', 1) - } - - logger.debug( - "Spotify Track converted: name=%s artists=%s", - result['name'], - [a['name'] for a in result['artists']], - ) - - # Test JSON serialization before returning to catch any remaining issues - try: - import json - json.dumps(result) - logger.debug("Conversion result is JSON serializable") - except Exception as json_error: - logger.error("Conversion result is NOT JSON serializable: %s", json_error) - logger.error("Conversion result content: %r", result) - # Return a safe fallback - return { - 'id': f"fallback_{hash(str(spotify_track))}", - 'name': str(getattr(spotify_track, 'name', 'Unknown Track')), - 'artists': [{'name': 'Unknown Artist'}], - 'album': {'name': 'Unknown Album'}, - 'duration_ms': 0, - 'preview_url': None, - 'external_urls': {}, - 'popularity': 0, - 'source': 'fallback' - } - - return result - except Exception as e: - logger.error(f"Error converting track object to dict: {e}") - logger.error(f"Object type: {type(spotify_track)}") - logger.error(f"Object attributes: {dir(spotify_track)}") - return {} - -# Global singleton instance -_wishlist_service = None - -def get_wishlist_service() -> WishlistService: - """Get the global wishlist service instance""" - global _wishlist_service - if _wishlist_service is None: - _wishlist_service = WishlistService() - return _wishlist_service +__all__ = ["WishlistService", "get_wishlist_service"] diff --git a/tests/imports/test_import_side_effects.py b/tests/imports/test_import_side_effects.py index d1dc744a..697d845b 100644 --- a/tests/imports/test_import_side_effects.py +++ b/tests/imports/test_import_side_effects.py @@ -12,19 +12,6 @@ class _FakeDB: return self._conn -class _FakeWishlistService: - def __init__(self, tracks): - self.tracks = tracks - self.removed = [] - - def get_wishlist_tracks_for_download(self, profile_id=1): - return list(self.tracks) - - def mark_track_download_result(self, spotify_track_id, success, error_message=None, profile_id=1): - self.removed.append((spotify_track_id, success, error_message, profile_id)) - return True - - def _make_soulsync_db(): conn = sqlite3.connect(":memory:") conn.row_factory = sqlite3.Row @@ -155,33 +142,3 @@ def test_record_soulsync_library_entry_writes_artist_album_and_track(tmp_path, m assert track_row["track_artist"] == "Guest Artist" assert track_row["album_id"] == album_row["id"] assert track_row["file_path"] == str(final_path) - - -def test_check_and_remove_from_wishlist_uses_search_result_fallback(monkeypatch): - fake_db = SimpleNamespace(get_all_profiles=lambda: [{"id": 1}]) - wishlist_service = _FakeWishlistService([ - { - "wishlist_id": 11, - "spotify_track_id": "sp-track-1", - "id": "sp-track-1", - "name": "Song One", - "artists": [{"name": "Artist One"}], - } - ]) - - monkeypatch.setattr(side_effects, "get_database", lambda: fake_db) - monkeypatch.setattr(side_effects, "get_wishlist_service", lambda: wishlist_service) - - context = { - "search_result": { - "title": "Song One", - "artist": "Artist One", - "album": "Album One", - }, - "track_info": {}, - "original_search_result": {}, - } - - side_effects.check_and_remove_from_wishlist(context) - - assert wishlist_service.removed == [("sp-track-1", True, None, 1)] diff --git a/tests/wishlist/__init__.py b/tests/wishlist/__init__.py new file mode 100644 index 00000000..ec0c8869 --- /dev/null +++ b/tests/wishlist/__init__.py @@ -0,0 +1 @@ +"""Wishlist resolver tests.""" diff --git a/tests/wishlist/test_automation.py b/tests/wishlist/test_automation.py new file mode 100644 index 00000000..beec3dc6 --- /dev/null +++ b/tests/wishlist/test_automation.py @@ -0,0 +1,233 @@ +from contextlib import contextmanager +from types import SimpleNamespace + +from core.wishlist.processing import WishlistAutoProcessingRuntime, process_wishlist_automatically + + +class _FakeLogger: + def __init__(self): + self.info_messages = [] + self.warning_messages = [] + self.error_messages = [] + self.debug_messages = [] + + def info(self, msg): + self.info_messages.append(msg) + + def warning(self, msg): + self.warning_messages.append(msg) + + def error(self, msg): + self.error_messages.append(msg) + + def debug(self, msg): + self.debug_messages.append(msg) + + +class _FakeProfilesDatabase: + def __init__(self, profiles): + self._profiles = profiles + + def get_all_profiles(self): + return list(self._profiles) + + +class _FakeWishlistService: + def __init__(self, tracks, count=None): + self._tracks = tracks + self._count = count if count is not None else len(tracks) + + def get_wishlist_count(self, profile_id=1): + return self._count + + def get_wishlist_tracks_for_download(self, profile_id=1): + return list(self._tracks) + + def mark_track_download_result(self, spotify_track_id, success, error_message=None, profile_id=1): + return True + + +class _FakeCursor: + def __init__(self, db): + self.db = db + self.calls = [] + self._last_sql = "" + + def execute(self, sql, params=None): + self.calls.append((sql, params)) + self._last_sql = sql + if "INSERT OR REPLACE INTO metadata" in sql and params: + self.db.cycle_value = params[0] + + def fetchone(self): + if "SELECT value FROM metadata WHERE key = 'wishlist_cycle'" in self._last_sql: + return {"value": self.db.cycle_value} + return None + + +class _FakeMusicDatabase: + def __init__(self, cycle_value="albums"): + self.cycle_value = cycle_value + self.cursor_obj = _FakeCursor(self) + self.commits = 0 + self.duplicate_removals = [] + self.track_checks = [] + + def _get_connection(self): + class _Conn: + def __init__(self, outer): + self.outer = outer + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def cursor(self): + return self.outer.cursor_obj + + def commit(self): + self.outer.commits += 1 + + return _Conn(self) + + def remove_wishlist_duplicates(self, profile_id=1): + self.duplicate_removals.append(profile_id) + return 0 + + def check_track_exists(self, track_name, artist_name, confidence_threshold=0.7, server_source=None, album=None): + self.track_checks.append((track_name, artist_name, confidence_threshold, server_source, album)) + return None, 0.0 + + +class _FakeExecutor: + def __init__(self): + self.submissions = [] + + def submit(self, fn, *args, **kwargs): + self.submissions.append((fn, args, kwargs)) + return SimpleNamespace() + + +class _FakeLock: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + +def _build_runtime( + *, + tracks, + cycle_value="albums", + count=None, + profiles=None, + active_server="navidrome", + progress_calls=None, + guard_events=None, + batch_map=None, +): + if progress_calls is None: + progress_calls = [] + if guard_events is None: + guard_events = [] + if batch_map is None: + batch_map = {} + + wishlist_service = _FakeWishlistService(tracks, count=count) + profiles_db = _FakeProfilesDatabase(profiles or [{"id": 1}]) + music_db = _FakeMusicDatabase(cycle_value=cycle_value) + executor = _FakeExecutor() + logger = _FakeLogger() + + @contextmanager + def guard(): + guard_events.append("enter") + try: + yield True + finally: + guard_events.append("exit") + + @contextmanager + def app_context(): + yield + + def progress_callback(*args, **kwargs): + progress_calls.append((args, kwargs)) + + runtime = WishlistAutoProcessingRuntime( + processing_guard=guard, + is_actually_processing=lambda: False, + app_context_factory=app_context, + get_wishlist_service=lambda: wishlist_service, + get_profiles_database=lambda: profiles_db, + get_music_database=lambda: music_db, + download_batches=batch_map, + tasks_lock=_FakeLock(), + update_automation_progress=progress_callback, + automation_engine=None, + missing_download_executor=executor, + run_full_missing_tracks_process=lambda *args, **kwargs: None, + get_batch_max_concurrent=lambda: 4, + get_active_server=lambda: active_server, + logger=logger, + current_time_fn=lambda: 123.0, + profile_id=1, + ) + return runtime, wishlist_service, profiles_db, music_db, executor, logger, progress_calls, guard_events + + +def test_process_wishlist_automatically_toggles_cycle_when_no_tracks_match_current_cycle(): + runtime, _service, _profiles_db, music_db, executor, logger, progress_calls, guard_events = _build_runtime( + tracks=[ + { + "name": "Single Track", + "artists": [{"name": "Artist A"}], + "spotify_data": {"album": {"album_type": "single"}}, + } + ], + cycle_value="albums", + count=1, + ) + + process_wishlist_automatically(runtime, automation_id="auto-1") + + assert executor.submissions == [] + assert music_db.cycle_value == "singles" + assert music_db.commits == 1 + assert any("No albums tracks in wishlist" in msg for msg in logger.warning_messages) + assert guard_events == ["enter", "exit"] + assert [kwargs.get("progress") for _args, kwargs in progress_calls if "progress" in kwargs] == [10, 25, 40] + + +def test_process_wishlist_automatically_creates_batch_for_matching_tracks(): + batch_map = {} + runtime, _service, _profiles_db, music_db, executor, logger, progress_calls, guard_events = _build_runtime( + tracks=[ + { + "name": "Album Track", + "artists": [{"name": "Artist A"}], + "spotify_data": {"album": {"album_type": "album"}}, + } + ], + cycle_value="albums", + count=1, + batch_map=batch_map, + ) + + process_wishlist_automatically(runtime, automation_id="auto-2") + + assert len(executor.submissions) == 1 + submitted_fn, submitted_args, submitted_kwargs = executor.submissions[0] + assert submitted_args[1] == "wishlist" + assert submitted_args[2][0]["_original_index"] == 0 + assert len(batch_map) == 1 + batch = next(iter(batch_map.values())) + assert batch["phase"] == "analysis" + assert batch["playlist_name"] == "Wishlist (Auto - Albums)" + assert batch["analysis_total"] == 1 + assert any(kwargs.get("progress") == 50 for _args, kwargs in progress_calls) + assert guard_events == ["enter", "exit"] + assert any("Starting automatic wishlist batch" in msg for msg in logger.info_messages) diff --git a/tests/wishlist/test_cancelled_payload.py b/tests/wishlist/test_cancelled_payload.py new file mode 100644 index 00000000..6e09709c --- /dev/null +++ b/tests/wishlist/test_cancelled_payload.py @@ -0,0 +1,37 @@ +from core.wishlist import payloads + + +def test_build_cancelled_task_wishlist_payload_normalizes_track_and_context(): + task = { + "playlist_name": "My Playlist", + "playlist_id": "pl-1", + "track_info": { + "id": "trk-1", + "name": "Song One", + "duration_ms": 123456, + "artists": [ + "Artist One", + {"name": "Artist Two"}, + {"name": {"name": "Nested Artist"}}, + ], + "album": {"name": "Album One"}, + "album_image_url": "https://img.example/cover.jpg", + }, + } + + out = payloads.build_cancelled_task_wishlist_payload(task, profile_id=7) + + assert out["profile_id"] == 7 + assert out["failure_reason"] == "Download cancelled by user (v2)" + assert out["source_type"] == "playlist" + assert out["source_context"] == { + "playlist_name": "My Playlist", + "playlist_id": "pl-1", + "added_from": "modal_cancellation_v2", + } + assert out["spotify_track_data"]["artists"] == [ + {"name": "Artist One"}, + {"name": "Artist Two"}, + {"name": "Nested Artist"}, + ] + assert out["spotify_track_data"]["album"]["images"] == [{"url": "https://img.example/cover.jpg"}] diff --git a/tests/wishlist/test_classification.py b/tests/wishlist/test_classification.py new file mode 100644 index 00000000..e75de5c9 --- /dev/null +++ b/tests/wishlist/test_classification.py @@ -0,0 +1,19 @@ +import pytest + +from core.wishlist.classification import classify_wishlist_track + + +@pytest.mark.parametrize( + "spotify_data,expected", + [ + ({"album": {"album_type": "single"}}, "singles"), + ({"album": {"album_type": "ep"}}, "singles"), + ({"album": {"album_type": "album"}}, "albums"), + ({"album": {"album_type": "compilation"}}, "albums"), + ({"album": {"total_tracks": 4}}, "singles"), + ({"album": {"total_tracks": 8}}, "albums"), + ({}, "albums"), + ], +) +def test_classify_wishlist_track(spotify_data, expected): + assert classify_wishlist_track({"spotify_data": spotify_data}) == expected diff --git a/tests/wishlist/test_cleanup.py b/tests/wishlist/test_cleanup.py new file mode 100644 index 00000000..37eb380e --- /dev/null +++ b/tests/wishlist/test_cleanup.py @@ -0,0 +1,99 @@ +from core.wishlist import processing + + +class _FakeLogger: + def __init__(self): + self.info_messages = [] + self.warning_messages = [] + self.error_messages = [] + + def info(self, msg): + self.info_messages.append(msg) + + def warning(self, msg): + self.warning_messages.append(msg) + + def error(self, msg): + self.error_messages.append(msg) + + +class _FakeWishlistService: + def __init__(self, tracks): + self._tracks = list(tracks) + self.removed_ids = set() + + def get_wishlist_tracks_for_download(self, profile_id=1): + return [ + track + for track in self._tracks + if (track.get("spotify_track_id") or track.get("id")) not in self.removed_ids + ] + + def mark_track_download_result(self, spotify_track_id, success, error_message=None, profile_id=1): + self.removed_ids.add(spotify_track_id) + return True + + +class _FakeMusicDatabase: + def __init__(self, owned_matches=None): + self.owned_matches = set(owned_matches or []) + self.track_checks = [] + + def check_track_exists(self, track_name, artist_name, confidence_threshold=0.7, server_source=None, album=None): + self.track_checks.append((track_name, artist_name, server_source, album)) + if (track_name, artist_name) in self.owned_matches: + return {"id": "db-track"}, 0.9 + return None, 0.0 + + +def test_cleanup_wishlist_against_library_removes_owned_tracks(): + service = _FakeWishlistService( + [ + { + "id": "track-1", + "name": "Song A", + "artists": [{"name": "Artist A"}], + "album": {"name": "Album A", "album_type": "album"}, + }, + { + "id": "track-2", + "name": "Song B", + "artists": [{"name": "Artist B"}], + "album": {"name": "Album B", "album_type": "album"}, + }, + ] + ) + db = _FakeMusicDatabase(owned_matches={("Song A", "Artist A")}) + logger = _FakeLogger() + + payload, status = processing.cleanup_wishlist_against_library( + service, + db, + 1, + "navidrome", + logger=logger, + ) + + assert status == 200 + assert payload["success"] is True + assert payload["removed_count"] == 1 + assert payload["processed_count"] == 2 + assert service.removed_ids == {"track-1"} + assert any("Completed cleanup: 1 tracks removed" in msg for msg in logger.info_messages) + + +def test_cleanup_wishlist_against_library_handles_empty_wishlist(): + service = _FakeWishlistService([]) + db = _FakeMusicDatabase() + logger = _FakeLogger() + + payload, status = processing.cleanup_wishlist_against_library( + service, + db, + 1, + "navidrome", + logger=logger, + ) + + assert status == 200 + assert payload == {"success": True, "message": "No tracks in wishlist to clean up", "removed_count": 0} diff --git a/tests/wishlist/test_failed_track_payload.py b/tests/wishlist/test_failed_track_payload.py new file mode 100644 index 00000000..e1f6b97e --- /dev/null +++ b/tests/wishlist/test_failed_track_payload.py @@ -0,0 +1,27 @@ +from core.wishlist import payloads + + +def test_build_failed_track_wishlist_context_uses_source_track_info(): + track_info = { + "name": "Song One", + "artist": "Artist One", + "artists": [{"name": "Artist One"}], + "album": {"name": "Album One", "album_type": "ep"}, + } + + out = payloads.build_failed_track_wishlist_context( + track_info, + track_index=3, + retry_count=2, + failure_reason="Download cancelled", + candidates=[{"title": "candidate"}], + ) + + assert out["download_index"] == 3 + assert out["table_index"] == 3 + assert out["track_name"] == "Song One" + assert out["artist_name"] == "Artist One" + assert out["retry_count"] == 2 + assert out["failure_reason"] == "Download cancelled" + assert out["candidates"] == [{"title": "candidate"}] + assert out["spotify_track"]["artists"] == [{"name": "Artist One"}] diff --git a/tests/wishlist/test_manual_download.py b/tests/wishlist/test_manual_download.py new file mode 100644 index 00000000..c9b7b927 --- /dev/null +++ b/tests/wishlist/test_manual_download.py @@ -0,0 +1,173 @@ +from core.wishlist import processing +from core.wishlist.processing import WishlistManualDownloadRuntime + + +class _FakeLogger: + def __init__(self): + self.info_messages = [] + self.warning_messages = [] + self.error_messages = [] + self.debug_messages = [] + + def info(self, msg): + self.info_messages.append(msg) + + def warning(self, msg): + self.warning_messages.append(msg) + + def error(self, msg): + self.error_messages.append(msg) + + def debug(self, msg): + self.debug_messages.append(msg) + + +class _FakeWishlistService: + def __init__(self, tracks): + self._tracks = list(tracks) + self.removed_ids = set() + self.duplicate_removals = [] + + def remove_wishlist_duplicates(self, profile_id=1): + self.duplicate_removals.append(profile_id) + return 0 + + def get_wishlist_tracks_for_download(self, profile_id=1): + return [ + track + for track in self._tracks + if (track.get("spotify_track_id") or track.get("id")) not in self.removed_ids + ] + + def mark_track_download_result(self, spotify_track_id, success, error_message=None, profile_id=1): + self.removed_ids.add(spotify_track_id) + return True + + +class _FakeMusicDatabase: + def __init__(self, owned_matches=None): + self.owned_matches = set(owned_matches or []) + self.track_checks = [] + self.duplicate_removals = [] + + def remove_wishlist_duplicates(self, profile_id=1): + self.duplicate_removals.append(profile_id) + return 0 + + def check_track_exists(self, track_name, artist_name, confidence_threshold=0.7, server_source=None, album=None): + self.track_checks.append((track_name, artist_name, server_source, album)) + if (track_name, artist_name) in self.owned_matches: + return {"id": "db-track"}, 0.9 + return None, 0.0 + + +class _FakeExecutor: + def __init__(self): + self.submissions = [] + + def submit(self, fn, *args, **kwargs): + self.submissions.append((fn, args, kwargs)) + return object() + + +class _FakeLock: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + +def _build_runtime(tracks, owned_matches=None, batch_map=None): + wishlist_service = _FakeWishlistService(tracks) + music_db = _FakeMusicDatabase(owned_matches=owned_matches) + executor = _FakeExecutor() + logger = _FakeLogger() + activity_calls = [] + batch_map = batch_map or {} + + runtime = WishlistManualDownloadRuntime( + get_wishlist_service=lambda: wishlist_service, + get_music_database=lambda: music_db, + download_batches=batch_map, + tasks_lock=_FakeLock(), + missing_download_executor=executor, + run_full_missing_tracks_process=lambda *args, **kwargs: None, + get_batch_max_concurrent=lambda: 4, + add_activity_item=lambda *args: activity_calls.append(args), + active_server="navidrome", + logger=logger, + profile_id=1, + ) + return runtime, wishlist_service, music_db, executor, logger, activity_calls, batch_map + + +def test_start_manual_wishlist_download_batch_filters_track_ids_and_starts_batch(): + runtime, _service, _db, executor, logger, activity_calls, batch_map = _build_runtime( + tracks=[ + { + "id": "track-1", + "name": "Song 1", + "artists": [{"name": "Artist 1"}], + "album": {"name": "Album 1", "album_type": "album"}, + }, + { + "id": "track-2", + "name": "Song 2", + "artists": [{"name": "Artist 2"}], + "album": {"name": "Album 2", "album_type": "album"}, + }, + ] + ) + + payload, status = processing.start_manual_wishlist_download_batch( + runtime, + track_ids=["track-2"], + category=None, + ) + + assert status == 200 + assert payload["success"] is True + assert "batch_id" in payload + assert activity_calls == [("", "Wishlist Download Started", "1 tracks", "Now")] + assert len(executor.submissions) == 1 + _submitted_fn, submitted_args, _submitted_kwargs = executor.submissions[0] + assert submitted_args[1] == "wishlist" + assert submitted_args[2][0]["id"] == "track-2" + assert submitted_args[2][0]["_original_index"] == 0 + assert batch_map[payload["batch_id"]]["analysis_total"] == 1 + assert batch_map[payload["batch_id"]]["force_download_all"] is True + assert any("Filtered to 1 specific tracks by ID" in msg for msg in logger.info_messages) + + +def test_start_manual_wishlist_download_batch_skips_enhance_tracks_during_cleanup(): + runtime, service, _db, executor, logger, activity_calls, batch_map = _build_runtime( + tracks=[ + { + "id": "enhance-1", + "name": "Enhance Song", + "artists": [{"name": "Artist A"}], + "album": {"name": "Enhance Album", "album_type": "album"}, + "source_type": "enhance", + }, + { + "id": "owned-1", + "name": "Owned Song", + "artists": [{"name": "Artist B"}], + "album": {"name": "Owned Album", "album_type": "album"}, + }, + ], + owned_matches={("Owned Song", "Artist B")}, + ) + + payload, status = processing.start_manual_wishlist_download_batch(runtime) + + assert status == 200 + assert payload["success"] is True + assert service.removed_ids == {"owned-1"} + assert len(executor.submissions) == 1 + _submitted_fn, submitted_args, _submitted_kwargs = executor.submissions[0] + assert [track["id"] for track in submitted_args[2]] == ["enhance-1"] + assert batch_map[payload["batch_id"]]["analysis_total"] == 1 + assert activity_calls == [("", "Wishlist Download Started", "1 tracks", "Now")] + assert any("Cleaned up 1 already-owned tracks" in msg for msg in logger.info_messages) diff --git a/tests/wishlist/test_payloads.py b/tests/wishlist/test_payloads.py new file mode 100644 index 00000000..72f3f7db --- /dev/null +++ b/tests/wishlist/test_payloads.py @@ -0,0 +1,58 @@ +from types import SimpleNamespace + +from core.wishlist import payloads + + +def test_sanitize_track_data_for_processing_normalizes_artists_and_album(): + track = { + "name": "Song", + "album": 123, + "artists": [{"name": "Artist One"}, "Artist Two", SimpleNamespace(name="Artist Three")], + } + + out = payloads.sanitize_track_data_for_processing(track) + + assert out["album"] == "123" + assert out["artists"] == ["Artist One", "Artist Two", "namespace(name='Artist Three')"] + + +def test_get_track_artist_name_prefers_artists_list_then_artist_field(): + assert payloads.get_track_artist_name({"artists": [{"name": "Artist One"}]}) == "Artist One" + assert payloads.get_track_artist_name({"artist": "Solo Artist"}) == "Solo Artist" + assert payloads.get_track_artist_name({}) == "Unknown Artist" + + +def test_ensure_spotify_track_format_preserves_existing_shape(): + track = { + "id": "sp-1", + "name": "Song", + "artists": [{"name": "Artist One"}], + "album": {"name": "Album", "album_type": "ep", "total_tracks": 4}, + } + + out = payloads.ensure_spotify_track_format(track) + + assert out is track + + +def test_ensure_spotify_track_format_builds_webui_shape(): + track = { + "name": "Song", + "artist": "Artist One", + "album": {"name": "Album One", "release_date": "2024-01-01"}, + "duration_ms": 1234, + "track_number": 7, + "disc_number": 2, + "preview_url": "https://example.test/preview", + "external_urls": {"spotify": "https://open.spotify.com/track/1"}, + "popularity": 42, + } + + out = payloads.ensure_spotify_track_format(track) + + assert out["name"] == "Song" + assert out["artists"] == [{"name": "Artist One"}] + assert out["album"]["name"] == "Album One" + assert out["album"]["album_type"] == "album" + assert out["album"]["total_tracks"] == 0 + assert out["source"] == "webui_modal" diff --git a/tests/wishlist/test_processing.py b/tests/wishlist/test_processing.py new file mode 100644 index 00000000..3c55d3f9 --- /dev/null +++ b/tests/wishlist/test_processing.py @@ -0,0 +1,225 @@ +from contextlib import contextmanager + +from core.wishlist import processing + + +class _FakeLogger: + def __init__(self): + self.errors = [] + self.infos = [] + self.warnings = [] + + def error(self, msg): + self.errors.append(msg) + + def info(self, msg): + self.infos.append(msg) + + def warning(self, msg): + self.warnings.append(msg) + + +class _FakeAutomationEngine: + def __init__(self): + self.events = [] + + def emit(self, name, payload): + self.events.append((name, payload)) + + +class _FakeCursor: + def __init__(self): + self.calls = [] + + def execute(self, sql, params=None): + self.calls.append((sql, params)) + + +class _FakeConnection: + def __init__(self): + self.cursor_obj = _FakeCursor() + self.committed = False + + def cursor(self): + return self.cursor_obj + + def commit(self): + self.committed = True + + +class _FakeDB: + def __init__(self): + self.connection = _FakeConnection() + + @contextmanager + def _get_connection(self): + yield self.connection + + +class _FakeLock: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + +def test_remove_completed_tracks_from_wishlist_calls_remover(): + batch = {"queue": ["a", "b"]} + download_tasks = { + "a": {"status": "completed", "track_info": {"name": "Song A"}}, + "b": {"status": "failed", "track_info": {"name": "Song B"}}, + } + calls = [] + + removed = processing.remove_completed_tracks_from_wishlist( + batch, + download_tasks, + lambda context: calls.append(context), + logger=_FakeLogger(), + ) + + assert removed == 1 + assert calls == [{"track_info": {"name": "Song A"}, "original_search_result": {"name": "Song A"}}] + + +def test_add_cancelled_tracks_to_failed_tracks_builds_entries(): + batch = {"queue": ["a"], "cancelled_tracks": {1}} + download_tasks = { + "a": { + "status": "cancelled", + "track_index": 1, + "track_info": {"name": "Song A", "artist": "Artist A", "artists": [{"name": "Artist A"}]}, + "cached_candidates": [{"title": "candidate"}], + } + } + failed = [] + + processed = processing.add_cancelled_tracks_to_failed_tracks( + batch, + download_tasks, + failed, + logger=_FakeLogger(), + ) + + assert processed == 1 + assert failed[0]["track_name"] == "Song A" + assert failed[0]["artist_name"] == "Artist A" + assert failed[0]["failure_reason"] == "Download cancelled" + + +def test_recover_uncaptured_failed_tracks_builds_entries(): + batch = {"queue": ["a"]} + download_tasks = { + "a": { + "status": "failed", + "track_index": 2, + "track_info": {"name": "Song B", "artist": "Artist B", "artists": [{"name": "Artist B"}]}, + "retry_count": 3, + "error_message": "boom", + "cached_candidates": [], + } + } + failed = [] + + recovered = processing.recover_uncaptured_failed_tracks( + batch, + download_tasks, + failed, + logger=_FakeLogger(), + ) + + assert recovered == 1 + assert failed[0]["track_name"] == "Song B" + assert failed[0]["retry_count"] == 3 + assert failed[0]["failure_reason"] == "boom" + + +def test_finalize_auto_wishlist_completion_toggles_cycle_and_resets_state(): + db = _FakeDB() + automation_engine = _FakeAutomationEngine() + resets = [] + activities = [] + summary = {"tracks_added": 2, "total_failed": 5, "errors": 0} + + result = processing.finalize_auto_wishlist_completion( + "batch-1", + summary, + download_batches={"batch-1": {"current_cycle": "albums"}}, + tasks_lock=_FakeLock(), + reset_processing_state=lambda: resets.append(True), + add_activity_item=lambda *args: activities.append(args), + automation_engine=automation_engine, + db_factory=lambda: db, + logger=_FakeLogger(), + ) + + assert result is summary + assert resets == [True] + assert activities == [("", "Wishlist Updated", "2 failed tracks added to wishlist", "Now")] + assert automation_engine.events == [ + ( + "wishlist_processing_completed", + { + "tracks_processed": "5", + "tracks_found": "2", + "tracks_failed": "3", + }, + ) + ] + assert db.connection.committed is True + assert db.connection.cursor_obj.calls[0][1] == ("singles",) + + +def test_automatic_wishlist_cleanup_after_db_update_removes_library_matches(): + class _CleanupWishlistService: + def __init__(self, tracks): + self.tracks = tracks + self.removed = [] + + def get_wishlist_tracks_for_download(self, profile_id=1): + return list(self.tracks) + + def mark_track_download_result(self, spotify_track_id, success, error_message=None, profile_id=1): + self.removed.append((spotify_track_id, success, error_message, profile_id)) + return True + + class _CleanupProfilesDatabase: + def get_all_profiles(self): + return [{"id": 1}] + + class _CleanupMusicDatabase: + def check_track_exists(self, track_name, artist_name, confidence_threshold=0.7, server_source=None, album=None): + if track_name == "Song A" and artist_name == "Artist A": + return {"id": "db-track"}, 0.9 + return None, 0.0 + + wishlist_service = _CleanupWishlistService( + [ + { + "name": "Song A", + "artists": [{"name": "Artist A"}], + "spotify_track_id": "sp-1", + "id": "sp-1", + "album": {"name": "Album A"}, + }, + { + "name": "Song B", + "artists": [{"name": "Artist B"}], + "spotify_track_id": "sp-2", + "id": "sp-2", + "album": {"name": "Album B"}, + }, + ] + ) + + removed = processing.automatic_wishlist_cleanup_after_db_update( + wishlist_service=wishlist_service, + profiles_database=_CleanupProfilesDatabase(), + music_database=_CleanupMusicDatabase(), + active_server="navidrome", + logger=_FakeLogger(), + ) + + assert removed == 1 + assert wishlist_service.removed == [("sp-1", True, None, 1)] diff --git a/tests/wishlist/test_reporting.py b/tests/wishlist/test_reporting.py new file mode 100644 index 00000000..13587140 --- /dev/null +++ b/tests/wishlist/test_reporting.py @@ -0,0 +1,37 @@ +from core.wishlist import reporting + + +def test_count_wishlist_tracks_by_category_dedupes_before_counting(): + raw_tracks = [ + {"id": "1", "spotify_data": {"album": {"album_type": "single"}}}, + {"id": "1", "spotify_data": {"album": {"album_type": "single"}}}, + {"id": "2", "spotify_data": {"album": {"total_tracks": 8}}}, + {"id": "3", "spotify_data": {"album": {"album_type": "ep"}}}, + ] + + out = reporting.count_wishlist_tracks_by_category(raw_tracks) + + assert out == {"singles": 2, "albums": 1, "total": 3} + + +def test_build_wishlist_stats_payload_combines_counts_and_metadata(): + raw_tracks = [ + {"id": "1", "spotify_data": {"album": {"album_type": "single"}}}, + {"id": "2", "spotify_data": {"album": {"total_tracks": 8}}}, + ] + + out = reporting.build_wishlist_stats_payload( + raw_tracks, + next_run_in_seconds=42, + is_auto_processing=True, + current_cycle="singles", + ) + + assert out == { + "singles": 1, + "albums": 1, + "total": 2, + "next_run_in_seconds": 42, + "is_auto_processing": True, + "current_cycle": "singles", + } diff --git a/tests/wishlist/test_resolution.py b/tests/wishlist/test_resolution.py new file mode 100644 index 00000000..6edb29f5 --- /dev/null +++ b/tests/wishlist/test_resolution.py @@ -0,0 +1,79 @@ +from types import SimpleNamespace + +from core.wishlist import resolution + + +class _FakeWishlistService: + def __init__(self, tracks): + self.tracks = tracks + self.removed = [] + + def get_wishlist_tracks_for_download(self, profile_id=1): + return list(self.tracks) + + def mark_track_download_result(self, spotify_track_id, success, error_message=None, profile_id=1): + self.removed.append((spotify_track_id, success, error_message, profile_id)) + return True + + +def test_check_and_remove_from_wishlist_uses_search_result_fallback(): + fake_db = SimpleNamespace(get_all_profiles=lambda: [{"id": 1}]) + wishlist_service = _FakeWishlistService( + [ + { + "wishlist_id": 11, + "spotify_track_id": "sp-track-1", + "id": "sp-track-1", + "name": "Song One", + "artists": [{"name": "Artist One"}], + } + ] + ) + + context = { + "search_result": { + "title": "Song One", + "artist": "Artist One", + "album": "Album One", + }, + "track_info": {}, + "original_search_result": {}, + } + + resolution.check_and_remove_from_wishlist( + context, + wishlist_service=wishlist_service, + database=fake_db, + ) + + assert wishlist_service.removed == [("sp-track-1", True, None, 1)] + + +def test_check_and_remove_track_from_wishlist_by_metadata_uses_fuzzy_match(): + fake_db = SimpleNamespace(get_all_profiles=lambda: [{"id": 1}]) + wishlist_service = _FakeWishlistService( + [ + { + "wishlist_id": 22, + "spotify_track_id": "sp-track-2", + "id": "sp-track-2", + "name": "Song Two", + "artists": [{"name": "Artist Two"}], + } + ] + ) + + track_data = { + "name": "Song Two", + "id": "", + "artists": [{"name": "Artist Two"}], + } + + removed = resolution.check_and_remove_track_from_wishlist_by_metadata( + track_data, + wishlist_service=wishlist_service, + database=fake_db, + ) + + assert removed is True + assert wishlist_service.removed == [("sp-track-2", True, None, 1)] diff --git a/tests/wishlist/test_selection.py b/tests/wishlist/test_selection.py new file mode 100644 index 00000000..c06606d7 --- /dev/null +++ b/tests/wishlist/test_selection.py @@ -0,0 +1,41 @@ +from core.wishlist import selection + + +def test_sanitize_and_dedupe_wishlist_tracks_removes_duplicate_ids(): + raw_tracks = [ + {"id": "1", "name": "Song One", "artists": [{"name": "Artist One"}]}, + {"id": "1", "name": "Song One", "artists": [{"name": "Artist One"}]}, + {"id": "2", "name": "Song Two", "artists": [{"name": "Artist Two"}]}, + ] + + tracks, duplicates_found = selection.sanitize_and_dedupe_wishlist_tracks(raw_tracks) + + assert duplicates_found == 1 + assert [track["id"] for track in tracks] == ["1", "2"] + + +def test_filter_wishlist_tracks_by_category_uses_classifier(): + tracks = [ + {"id": "1", "spotify_data": {"album": {"album_type": "single"}}}, + {"id": "2", "spotify_data": {"album": {"total_tracks": 8}}}, + {"id": "3", "spotify_data": {"album": {"album_type": "ep"}}}, + ] + + filtered, total = selection.filter_wishlist_tracks_by_category(tracks, "singles") + + assert [track["id"] for track in filtered] == ["1", "3"] + assert total == 2 + + +def test_prepare_wishlist_tracks_for_display_applies_limit_after_category_filter(): + raw_tracks = [ + {"id": "1", "spotify_data": {"album": {"album_type": "single"}}}, + {"id": "2", "spotify_data": {"album": {"album_type": "single"}}}, + {"id": "3", "spotify_data": {"album": {"total_tracks": 8}}}, + ] + + out = selection.prepare_wishlist_tracks_for_display(raw_tracks, category="singles", limit=1) + + assert out["tracks"][0]["id"] == "1" + assert out["total"] == 2 + assert out["duplicates_found"] == 0 diff --git a/tests/wishlist/test_state.py b/tests/wishlist/test_state.py new file mode 100644 index 00000000..2f865980 --- /dev/null +++ b/tests/wishlist/test_state.py @@ -0,0 +1,113 @@ +from contextlib import contextmanager + +from core.wishlist import state + + +class _FakeLogger: + def __init__(self): + self.warnings = [] + + def warning(self, msg): + self.warnings.append(msg) + + +class _FakeCursor: + def __init__(self, row=None): + self.row = row + self.calls = [] + + def execute(self, sql, params=None): + self.calls.append((sql, params)) + + def fetchone(self): + return self.row + + +class _FakeConnection: + def __init__(self, row=None): + self.cursor_obj = _FakeCursor(row=row) + self.committed = False + + def cursor(self): + return self.cursor_obj + + def commit(self): + self.committed = True + + +class _FakeDB: + def __init__(self, row=None): + self.connection = _FakeConnection(row=row) + + @contextmanager + def _get_connection(self): + yield self.connection + + +def test_flag_age_seconds_returns_zero_for_missing_timestamp(): + assert state.flag_age_seconds(None, now=100.0) == 0.0 + + +def test_flag_age_seconds_uses_now_minus_started_at(): + assert state.flag_age_seconds(25.0, now=100.0) == 75.0 + + +def test_is_flag_recent_requires_active_and_recent_timestamp(): + assert state.is_flag_recent(True, 100.0, timeout_seconds=10, now=105.0) is True + assert state.is_flag_recent(True, 100.0, timeout_seconds=10, now=111.0) is False + assert state.is_flag_recent(False, 100.0, timeout_seconds=10, now=105.0) is False + + +def test_is_flag_stuck_requires_active_and_expired_timestamp(): + assert state.is_flag_stuck(True, 100.0, timeout_seconds=10, now=111.0) is True + assert state.is_flag_stuck(True, 100.0, timeout_seconds=10, now=105.0) is False + assert state.is_flag_stuck(False, 100.0, timeout_seconds=10, now=111.0) is False + + +def test_is_wishlist_actually_processing_warns_and_recovers_when_stuck(): + logger = _FakeLogger() + calls = [] + + result = state.is_wishlist_actually_processing( + True, + 100.0, + timeout_seconds=10, + now=700.0, + on_stuck=lambda: calls.append(True), + logger=logger, + ) + + assert result is False + assert calls == [True] + assert logger.warnings == [ + "[Stuck Detection] Wishlist flag stuck for 10.0 minutes - auto-recovering" + ] + + +def test_get_wishlist_cycle_creates_default_entry_when_missing(): + db = _FakeDB(row=None) + + cycle = state.get_wishlist_cycle(lambda: db) + + assert cycle == "albums" + assert db.connection.committed is True + assert db.connection.cursor_obj.calls[0][0] == "SELECT value FROM metadata WHERE key = 'wishlist_cycle'" + assert db.connection.cursor_obj.calls[1][1] == ("albums",) + + +def test_get_wishlist_cycle_returns_stored_value_when_present(): + db = _FakeDB(row={"value": "singles"}) + + cycle = state.get_wishlist_cycle(lambda: db) + + assert cycle == "singles" + assert db.connection.committed is False + + +def test_set_wishlist_cycle_persists_value(): + db = _FakeDB() + + state.set_wishlist_cycle(lambda: db, "singles") + + assert db.connection.committed is True + assert db.connection.cursor_obj.calls[-1][1] == ("singles",) diff --git a/web_server.py b/web_server.py index 586a35fc..3748bf52 100644 --- a/web_server.py +++ b/web_server.py @@ -18,6 +18,7 @@ import sqlite3 import types import collections import functools +from contextlib import contextmanager from pathlib import Path from urllib.parse import quote, urljoin, urlparse @@ -105,6 +106,37 @@ from core.imports.context import ( get_import_track_info, normalize_import_context, ) +from core.wishlist.payloads import ( + build_cancelled_task_wishlist_payload as _build_cancelled_task_wishlist_payload, + build_failed_track_wishlist_context as _build_failed_track_wishlist_context, + ensure_spotify_track_format as _ensure_spotify_track_format, + get_track_artist_name as _get_track_artist_name, +) +from core.wishlist.reporting import build_wishlist_stats_payload as _build_wishlist_stats_payload +from core.wishlist.selection import prepare_wishlist_tracks_for_display as _prepare_wishlist_tracks_for_display +from core.wishlist.processing import ( + add_cancelled_tracks_to_failed_tracks as _add_cancelled_tracks_to_failed_tracks, + automatic_wishlist_cleanup_after_db_update as _cleanup_wishlist_after_db_update, + cleanup_wishlist_against_library as _cleanup_wishlist_against_library, + build_wishlist_source_context as _build_wishlist_source_context, + finalize_auto_wishlist_completion as _finalize_auto_wishlist_completion, + start_manual_wishlist_download_batch as _start_manual_wishlist_download_batch, + process_wishlist_automatically as _process_wishlist_automatically_impl, + recover_uncaptured_failed_tracks as _recover_uncaptured_failed_tracks, + remove_completed_tracks_from_wishlist as _remove_completed_tracks_from_wishlist, + WishlistAutoProcessingRuntime as _WishlistAutoProcessingRuntime, + WishlistManualDownloadRuntime as _WishlistManualDownloadRuntime, +) +from core.wishlist.resolution import ( + check_and_remove_from_wishlist as _check_and_remove_from_wishlist, + check_and_remove_track_from_wishlist_by_metadata as _check_and_remove_track_from_wishlist_by_metadata, +) +from core.wishlist.state import ( + get_wishlist_cycle as _get_wishlist_cycle, + is_wishlist_actually_processing as _is_wishlist_actually_processing, + reset_flag_if_stuck as _reset_wishlist_flag_if_stuck, + set_wishlist_cycle as _set_wishlist_cycle, +) from core.imports.album import ( build_album_import_context, build_album_import_match_payload, @@ -17094,8 +17126,6 @@ def _execute_retag(group_id, album_id): "phase": "Error", "error_message": str(e) }) - - def _check_and_remove_from_wishlist(context): """ Check if a successfully downloaded track should be removed from wishlist. @@ -17272,13 +17302,9 @@ def _check_and_remove_track_from_wishlist_by_metadata(track_data): traceback.print_exc() return False -# Auto-wishlist cleanup logic lives in core/downloads/cleanup.py. -from core.downloads import cleanup as _downloads_cleanup - - def _automatic_wishlist_cleanup_after_db_update(): """Automatic wishlist cleanup that runs after database updates.""" - _downloads_cleanup.cleanup_wishlist_after_db_update(config_manager) + return _cleanup_wishlist_after_db_update(logger=logger) # ── Update detection ───────────────────────────────────────────── _GITHUB_REPO = "Nezreka/SoulSync" @@ -17380,46 +17406,6 @@ def start_simple_background_monitor(): monitor_thread.daemon = True monitor_thread.start() -# =============================== -# == AUTOMATIC WISHLIST PROCESSING == -# =============================== - -def _sanitize_track_data_for_processing(track_data): - """ - Sanitizes track data from wishlist service to ensure consistent format. - Preserves album dict to retain full metadata (images, id, etc.) and normalizes artist field. - """ - if not isinstance(track_data, dict): - logger.info(f"[Sanitize] Unexpected track data type: {type(track_data)}") - return track_data - - # Create a copy to avoid modifying original data - sanitized = track_data.copy() - - # Handle album field - preserve dict format to retain full metadata (images, id, etc.) - # Downstream code already handles both dict and string formats defensively - raw_album = sanitized.get('album', '') - if not isinstance(raw_album, (dict, str)): - sanitized['album'] = str(raw_album) - - # Handle artists field - ensure it's a list of strings - raw_artists = sanitized.get('artists', []) - if isinstance(raw_artists, list): - processed_artists = [] - for artist in raw_artists: - if isinstance(artist, str): - processed_artists.append(artist) - elif isinstance(artist, dict) and 'name' in artist: - processed_artists.append(artist['name']) - else: - processed_artists.append(str(artist)) - sanitized['artists'] = processed_artists - else: - logger.info(f"[Sanitize] Unexpected artists format: {type(raw_artists)}") - sanitized['artists'] = [str(raw_artists)] if raw_artists else [] - - return sanitized - def check_and_recover_stuck_flags(): """ Check if wishlist_auto_processing or watchlist_auto_scanning flags are stuck. @@ -17433,17 +17419,23 @@ def check_and_recover_stuck_flags(): current_time = time.time() stuck_timeout = 900 # 15 minutes in seconds (reduced from 2 hours for faster recovery) - # Check wishlist flag - if wishlist_auto_processing: - time_stuck = current_time - wishlist_auto_processing_timestamp - if time_stuck > stuck_timeout: - stuck_minutes = time_stuck / 60 - logger.info(f"[Stuck Detection] Wishlist auto-processing flag has been stuck for {stuck_minutes:.1f} minutes - RESETTING") - with wishlist_timer_lock: - wishlist_auto_processing = False - wishlist_auto_processing_timestamp = 0 + def _reset_wishlist_processing_state(): + global wishlist_auto_processing, wishlist_auto_processing_timestamp + with wishlist_timer_lock: + wishlist_auto_processing = False + wishlist_auto_processing_timestamp = 0 - return True + # Check wishlist flag + if _reset_wishlist_flag_if_stuck( + wishlist_auto_processing, + wishlist_auto_processing_timestamp, + timeout_seconds=stuck_timeout, + now=current_time, + label="Wishlist auto-processing", + logger=logger, + reset_callback=_reset_wishlist_processing_state, + ): + return True # Check watchlist flag if watchlist_auto_scanning: @@ -17465,21 +17457,17 @@ def is_wishlist_actually_processing(): """ global wishlist_auto_processing, wishlist_auto_processing_timestamp - if not wishlist_auto_processing: - return False - import time current_time = time.time() - time_since_start = current_time - wishlist_auto_processing_timestamp - # If more than 15 minutes, flag is stuck - auto-recover and return False - if time_since_start > 900: # 15 minutes - stuck_minutes = time_since_start / 60 - logger.warning(f"[Stuck Detection] Wishlist flag stuck for {stuck_minutes:.1f} minutes - auto-recovering") - check_and_recover_stuck_flags() - return False - - return True + return _is_wishlist_actually_processing( + wishlist_auto_processing, + wishlist_auto_processing_timestamp, + timeout_seconds=900, + now=current_time, + on_stuck=check_and_recover_stuck_flags, + logger=logger, + ) def is_watchlist_actually_scanning(): """ @@ -17504,328 +17492,52 @@ def is_watchlist_actually_scanning(): return True -def _classify_wishlist_track(track): - """Classify a wishlist track as 'singles' or 'albums'. - - Uses Spotify's album_type as the primary signal (most authoritative), - falls back to total_tracks heuristic, defaults to 'albums'. - - Returns: - 'singles' or 'albums' - """ - spotify_data = track.get('spotify_data', {}) - if isinstance(spotify_data, str): - try: - import json - spotify_data = json.loads(spotify_data) - except Exception: - spotify_data = {} - - album_data = spotify_data.get('album') or {} - if not isinstance(album_data, dict): - album_data = {} - total_tracks = album_data.get('total_tracks') - album_type = album_data.get('album_type', '').lower() - - # Prioritize Spotify's album_type classification (most accurate) - if album_type in ('single', 'ep'): - return 'singles' - if album_type in ('album', 'compilation'): - return 'albums' - - # Fallback: track count heuristic - if total_tracks is not None and total_tracks > 0: - return 'singles' if total_tracks < 6 else 'albums' - - # No classification data — default to albums - return 'albums' - - def _process_wishlist_automatically(automation_id=None): """Main automatic processing logic that runs in background thread.""" global wishlist_auto_processing, wishlist_auto_processing_timestamp + from core.wishlist_service import get_wishlist_service - 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 is_wishlist_actually_processing(): - logger.info("[Auto-Wishlist] Already processing (verified with stuck detection), skipping.") - return - - # Check conditions and set flag - should_skip_already_running = False + @contextmanager + def _processing_guard(): + global wishlist_auto_processing, wishlist_auto_processing_timestamp with wishlist_timer_lock: - # Re-check inside lock to handle race conditions if wishlist_auto_processing: - logger.info("[Auto-Wishlist] Already processing (race condition check), skipping.") - should_skip_already_running = True - - else: - # Set flag and timestamp - import time - wishlist_auto_processing = True - wishlist_auto_processing_timestamp = time.time() - logger.info(f"[Auto-Wishlist] Flag set at timestamp {wishlist_auto_processing_timestamp}") - - if should_skip_already_running: - return - - # Use app context for database operations - with app.app_context(): - from core.wishlist_service import get_wishlist_service - wishlist_service = get_wishlist_service() - - # Check if wishlist has tracks across all profiles - database = get_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") - _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.") - with wishlist_timer_lock: - wishlist_auto_processing = False - wishlist_auto_processing_timestamp = 0 + yield False 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 tasks_lock: - for _batch_id, batch_data in 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") - with wishlist_timer_lock: - wishlist_auto_processing = False - return + wishlist_auto_processing = True + wishlist_auto_processing_timestamp = time.time() + logger.info(f"[Auto-Wishlist] Flag set at timestamp {wishlist_auto_processing_timestamp}") - # CRITICAL: Clean duplicates BEFORE fetching tracks to prevent count mismatches - # This prevents the "11 tracks shown but 12 counted" bug - from database.music_database import MusicDatabase - db = MusicDatabase() + try: + yield True + finally: + with wishlist_timer_lock: + wishlist_auto_processing = False + wishlist_auto_processing_timestamp = 0 - logger.warning("[Auto-Wishlist] Cleaning duplicate tracks before processing...") - for p in all_profiles: - duplicates_removed = db.remove_wishlist_duplicates(profile_id=p['id']) - if duplicates_removed > 0: - logger.warning(f"[Auto-Wishlist] Removed {duplicates_removed} duplicate tracks from profile {p['id']}") + runtime = _WishlistAutoProcessingRuntime( + processing_guard=_processing_guard, + is_actually_processing=is_wishlist_actually_processing, + app_context_factory=lambda: app.app_context(), + get_wishlist_service=get_wishlist_service, + get_profiles_database=get_database, + get_music_database=MusicDatabase, + download_batches=download_batches, + tasks_lock=tasks_lock, + update_automation_progress=_update_automation_progress, + automation_engine=automation_engine, + missing_download_executor=missing_download_executor, + run_full_missing_tracks_process=_run_full_missing_tracks_process, + get_batch_max_concurrent=_get_batch_max_concurrent, + get_active_server=config_manager.get_active_media_server, + logger=logger, + current_time_fn=time.time, + profile_id=1, + ) - # 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 = config_manager.get_active_media_server() - cleanup_tracks = [] - for p in all_profiles: - cleanup_tracks.extend(wishlist_service.get_wishlist_tracks_for_download(profile_id=p['id'])) - cleanup_removed = 0 - - for track in cleanup_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 - - # Check if track exists in library - 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 = db.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 - break - except Exception as db_error: - continue - - # Remove from wishlist if found in library - 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"[Auto-Wishlist] Removed already-owned track: '{track_name}' by {artist_name}") - except Exception as remove_error: - logger.error(f"[Auto-Wishlist] Error removing track from wishlist: {remove_error}") - - if cleanup_removed > 0: - logger.info(f"[Auto-Wishlist] Cleaned up {cleanup_removed} already-owned tracks from wishlist") - _update_automation_progress(automation_id, progress=25, phase='Cleaned up duplicates', - log_line=f'Removed {cleanup_removed} already-owned tracks', log_type='success') - else: - _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 p in all_profiles: - raw_wishlist_tracks.extend(wishlist_service.get_wishlist_tracks_for_download(profile_id=p['id'])) - if not raw_wishlist_tracks: - logger.warning("No tracks returned from wishlist service.") - with wishlist_timer_lock: - wishlist_auto_processing = False - wishlist_auto_processing_timestamp = 0 - return - - # SANITIZE: Ensure consistent data format from wishlist service - wishlist_tracks = [] - seen_track_ids_sanitation = set() # Deduplicate during sanitization - duplicates_found = 0 - for track in raw_wishlist_tracks: - sanitized_track = _sanitize_track_data_for_processing(track) - spotify_track_id = sanitized_track.get('spotify_track_id') or sanitized_track.get('id') - - # Skip duplicates during sanitization - if spotify_track_id and spotify_track_id in seen_track_ids_sanitation: - duplicates_found += 1 - continue - - wishlist_tracks.append(sanitized_track) - if spotify_track_id: - seen_track_ids_sanitation.add(spotify_track_id) - - 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 - with db._get_connection() as conn: - cursor = conn.cursor() - cursor.execute("SELECT value FROM metadata WHERE key = 'wishlist_cycle'") - row = cursor.fetchone() - - if row: - current_cycle = row['value'] - else: - # Default to albums on first run - current_cycle = 'albums' - cursor.execute(""" - INSERT OR REPLACE INTO metadata (key, value, updated_at) - VALUES ('wishlist_cycle', 'albums', CURRENT_TIMESTAMP) - """) - conn.commit() - - # Filter tracks by current cycle category - filtered_tracks = [] - seen_track_ids_filtering = set() # Deduplicate during filtering - for track in wishlist_tracks: - track_category = _classify_wishlist_track(track) - spotify_track_id = track.get('spotify_track_id') or track.get('id') - matches_category = (current_cycle == track_category) - - # Only add if matches category AND not a duplicate - if matches_category: - # Only deduplicate if track has a valid ID - if spotify_track_id: - if spotify_track_id not in seen_track_ids_filtering: - filtered_tracks.append(track) - seen_track_ids_filtering.add(spotify_track_id) - else: - # No ID - can't deduplicate safely, always add - filtered_tracks.append(track) - - 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") - _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' - 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: {current_cycle} → {next_cycle}") - - with wishlist_timer_lock: - wishlist_auto_processing = False - wishlist_auto_processing_timestamp = 0 - 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 tasks_lock: - download_batches[batch_id] = { - 'phase': 'analysis', - 'playlist_id': playlist_id, - 'playlist_name': playlist_name, - 'queue': [], - 'active_count': 0, - 'max_concurrent': _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': time.time(), - # 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': 1 - } - - logger.info(f"Starting automatic wishlist batch {batch_id} with {len(wishlist_tracks)} tracks") - _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 - missing_download_executor.submit(_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() - _update_automation_progress(automation_id, log_line=f'Error: {str(e)}', log_type='error') - - with wishlist_timer_lock: - wishlist_auto_processing = False - wishlist_auto_processing_timestamp = 0 - raise # re-raise so automation wrapper returns error status + _process_wishlist_automatically_impl(runtime, automation_id=automation_id) # =============================== # == DATABASE UPDATER API == @@ -18432,26 +18144,6 @@ def get_wishlist_stats(): wishlist_service = get_wishlist_service() raw_tracks = wishlist_service.get_wishlist_tracks_for_download(profile_id=get_current_profile_id()) - singles_count = 0 - albums_count = 0 - seen_ids = set() - - for track in raw_tracks: - # Deduplicate by ID (same as tracks endpoint) so counts match - track_id = track.get('spotify_track_id') or track.get('id') - if track_id: - if track_id in seen_ids: - continue - seen_ids.add(track_id) - - category = _classify_wishlist_track(track) - if category == 'singles': - singles_count += 1 - else: - albums_count += 1 - - total_count = singles_count + albums_count - # Calculate time until next auto-processing and get processing state next_run_in_seconds = automation_engine.get_system_automation_next_run_seconds('process_wishlist') if automation_engine else 0 @@ -18460,24 +18152,14 @@ def get_wishlist_stats(): # Get current cycle (albums or singles) from database.music_database import MusicDatabase - db = MusicDatabase() - try: - with db._get_connection() as conn: - cursor = conn.cursor() - cursor.execute("SELECT value FROM metadata WHERE key = 'wishlist_cycle'") - row = cursor.fetchone() - current_cycle = row['value'] if row else 'albums' - except Exception: - current_cycle = 'albums' # Safe fallback + current_cycle = _get_wishlist_cycle(MusicDatabase) - return jsonify({ - "singles": singles_count, - "albums": albums_count, - "total": total_count, - "next_run_in_seconds": next_run_in_seconds, - "is_auto_processing": is_processing, - "current_cycle": current_cycle - }) + return jsonify(_build_wishlist_stats_payload( + raw_tracks, + next_run_in_seconds=next_run_in_seconds, + is_auto_processing=is_processing, + current_cycle=current_cycle, + )) except Exception as e: logger.error(f"Error getting wishlist stats: {e}") @@ -18495,24 +18177,7 @@ def get_wishlist_cycle(): """ try: from database.music_database import MusicDatabase - db = MusicDatabase() - - # Get cycle from metadata table - with db._get_connection() as conn: - cursor = conn.cursor() - cursor.execute("SELECT value FROM metadata WHERE key = 'wishlist_cycle'") - row = cursor.fetchone() - - if row: - cycle = row['value'] - else: - # Default to albums on first run - cycle = 'albums' - cursor.execute(""" - INSERT OR REPLACE INTO metadata (key, value, updated_at) - VALUES ('wishlist_cycle', 'albums', CURRENT_TIMESTAMP) - """) - conn.commit() + cycle = _get_wishlist_cycle(MusicDatabase) return jsonify({"cycle": cycle}) @@ -18536,16 +18201,7 @@ def set_wishlist_cycle(): return jsonify({"error": "Invalid cycle. Must be 'albums' or 'singles'"}), 400 from database.music_database import MusicDatabase - db = MusicDatabase() - - # Store cycle in metadata table - 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) - """, (cycle,)) - conn.commit() + _set_wishlist_cycle(MusicDatabase, cycle) logger.info(f"Wishlist cycle set to: {cycle}") return jsonify({"success": True, "cycle": cycle}) @@ -18721,60 +18377,25 @@ def get_wishlist_tracks(): wishlist_service = get_wishlist_service() raw_tracks = wishlist_service.get_wishlist_tracks_for_download(profile_id=get_current_profile_id()) - # SANITIZE: Ensure consistent data format for frontend - sanitized_tracks = [] - seen_track_ids_sanitation = set() # Deduplicate during sanitization - duplicates_found = 0 - for track in raw_tracks: - sanitized_track = _sanitize_track_data_for_processing(track) - spotify_track_id = sanitized_track.get('spotify_track_id') or sanitized_track.get('id') + prepared = _prepare_wishlist_tracks_for_display(raw_tracks, category=category, limit=limit) - # Skip duplicates during sanitization - if spotify_track_id and spotify_track_id in seen_track_ids_sanitation: - duplicates_found += 1 - continue + if prepared["duplicates_found"] > 0: + logger.warning( + "[API-Wishlist-Tracks] Found and removed %s duplicate tracks during sanitization", + prepared["duplicates_found"], + ) - sanitized_tracks.append(sanitized_track) - if spotify_track_id: - seen_track_ids_sanitation.add(spotify_track_id) - - if duplicates_found > 0: - logger.warning(f"[API-Wishlist-Tracks] Found and removed {duplicates_found} duplicate tracks during sanitization") - - # FILTER by category if specified if category: - filtered_tracks = [] - seen_track_ids_filtering = set() # Deduplicate during filtering - for track in sanitized_tracks: - track_category = _classify_wishlist_track(track) - spotify_track_id = track.get('spotify_track_id') or track.get('id') - matches_category = (category == track_category) + logger.info( + "Wishlist filter: %s/%s tracks in '%s' category (limit: %s)", + len(prepared["tracks"]), + prepared["total"], + category, + limit or "none", + ) + return jsonify({"tracks": prepared["tracks"], "category": category, "total": prepared["total"]}) - # Only add if matches category AND not a duplicate - if matches_category: - # Only deduplicate if track has a valid ID - if spotify_track_id: - if spotify_track_id not in seen_track_ids_filtering: - filtered_tracks.append(track) - seen_track_ids_filtering.add(spotify_track_id) - else: - # No ID - can't deduplicate safely, always add - filtered_tracks.append(track) - - # Apply limit early for performance - if limit and len(filtered_tracks) >= limit: - break - - # Count total in category (quick scan — no heavy processing, just classification) - total_in_category = sum(1 for t in sanitized_tracks if _classify_wishlist_track(t) == category) - - logger.info(f"Wishlist filter: {len(filtered_tracks)}/{total_in_category} tracks in '{category}' category (limit: {limit or 'none'})") - return jsonify({"tracks": filtered_tracks, "category": category, "total": total_in_category}) - - # Apply limit to non-filtered results - total_count = len(sanitized_tracks) - result_tracks = sanitized_tracks[:limit] if limit else sanitized_tracks - return jsonify({"tracks": result_tracks, "total": total_count}) + return jsonify({"tracks": prepared["tracks"], "total": prepared["total"]}) except Exception as e: logger.error(f"Error getting wishlist tracks: {e}") return jsonify({"error": str(e)}), 500 @@ -18797,225 +18418,33 @@ def start_wishlist_missing_downloads(): }), 409 data = request.get_json() or {} - force_download_all = data.get('force_download_all', False) - category = data.get('category') # Get category filter (albums or singles) - track_ids = data.get('track_ids') # NEW: Get specific track IDs from frontend - from core.wishlist_service import get_wishlist_service from database.music_database import MusicDatabase wishlist_service = get_wishlist_service() - - # CRITICAL: Clean duplicates BEFORE fetching tracks to prevent count mismatches - # This prevents the "11 tracks shown but 12 counted" bug - logger.warning("[Manual-Wishlist] Cleaning duplicate tracks before download...") db = MusicDatabase() manual_profile_id = get_current_profile_id() - 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") + manual_runtime = _WishlistManualDownloadRuntime( + get_wishlist_service=lambda: wishlist_service, + get_music_database=lambda: db, + download_batches=download_batches, + tasks_lock=tasks_lock, + missing_download_executor=missing_download_executor, + run_full_missing_tracks_process=_run_full_missing_tracks_process, + get_batch_max_concurrent=_get_batch_max_concurrent, + add_activity_item=add_activity_item, + active_server=config_manager.get_active_media_server(), + logger=logger, + profile_id=manual_profile_id, + ) - # CLEANUP: Remove tracks from wishlist that already exist in library - # This prevents wasting bandwidth on tracks we already have - logger.info("[Manual-Wishlist] Checking wishlist against library for already-owned tracks...") - active_server = config_manager.get_active_media_server() - cleanup_tracks = wishlist_service.get_wishlist_tracks_for_download(profile_id=manual_profile_id) - cleanup_removed = 0 - - for track in cleanup_tracks: - # BYPASS: Don't remove enhance tracks — they intentionally re-download existing library tracks - if track.get('source_type') == 'enhance': - 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 - - # Check if track exists in library - 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 = db.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 - break - except Exception as db_error: - continue - - # Remove from wishlist if found in library - 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"[Manual-Wishlist] Removed already-owned track: '{track_name}' by {artist_name}") - except Exception as remove_error: - logger.error(f"[Manual-Wishlist] Error removing track from wishlist: {remove_error}") - - if cleanup_removed > 0: - logger.info(f"[Manual-Wishlist] Cleaned up {cleanup_removed} already-owned tracks from wishlist") - - # Get wishlist tracks formatted for download modal (after cleanup) - raw_wishlist_tracks = wishlist_service.get_wishlist_tracks_for_download(profile_id=manual_profile_id) - if not raw_wishlist_tracks: - return jsonify({"success": False, "error": "No tracks in wishlist"}), 400 - - # SANITIZE: Ensure consistent data format from wishlist service - wishlist_tracks = [] - seen_track_ids_sanitation = set() # Deduplicate during sanitization - duplicates_found = 0 - for track in raw_wishlist_tracks: - sanitized_track = _sanitize_track_data_for_processing(track) - spotify_track_id = sanitized_track.get('spotify_track_id') or sanitized_track.get('id') - - # Skip duplicates during sanitization - if spotify_track_id and spotify_track_id in seen_track_ids_sanitation: - duplicates_found += 1 - continue - - wishlist_tracks.append(sanitized_track) - if spotify_track_id: - seen_track_ids_sanitation.add(spotify_track_id) - - 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") - - # FILTER BY TRACK IDs if specified (prioritized - prevents race conditions) - if track_ids: - # Build a lookup by track ID for O(1) access - 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 - - # Iterate in track_ids order (matches frontend display order) - # Stamp each track with its original position in the track_ids array - # so track_index matches the modal row even if cleanup removed some tracks - 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 # Preserve frontend table position - 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)") - - # FILTER BY CATEGORY if specified and no track_ids (backward compatibility) - elif category: - import json - filtered_tracks = [] - seen_track_ids = set() # Track IDs we've already added to prevent duplicates - for track in wishlist_tracks: - # Extract track count from spotify_data - spotify_data = track.get('spotify_data', {}) - if isinstance(spotify_data, str): - try: - spotify_data = json.loads(spotify_data) - except: - spotify_data = {} - - album_data = spotify_data.get('album', {}) - if not isinstance(album_data, dict): - album_data = {} - total_tracks = album_data.get('total_tracks') - album_type = album_data.get('album_type', 'album').lower() - - # Categorize by track count if available, otherwise use album_type - # Single: 1 track, EP: 2-5 tracks, Album: 6+ tracks - is_single_or_ep = False - is_album = False - - if total_tracks is not None and total_tracks > 0: - # Use track count (most accurate) - is_single_or_ep = total_tracks < 6 - is_album = total_tracks >= 6 - else: - # Fall back to Spotify's album_type - is_single_or_ep = album_type in ['single', 'ep'] - is_album = album_type == 'album' - - spotify_track_id = track.get('spotify_track_id') or track.get('id') - matches_category = (category == 'singles' and is_single_or_ep) or (category == 'albums' and is_album) - - # Only add if matches category AND not a duplicate - if matches_category: - # Only deduplicate if track has a valid ID - if spotify_track_id: - if spotify_track_id not in seen_track_ids: - filtered_tracks.append(track) - seen_track_ids.add(spotify_track_id) - else: - # No ID - can't deduplicate safely, always add - filtered_tracks.append(track) - - wishlist_tracks = filtered_tracks - logger.info(f"[Manual-Wishlist] Filtered to {len(wishlist_tracks)} tracks for category: {category}") - - # Stamp original index on each track so task indices match frontend row order - for i, track in enumerate(wishlist_tracks): - track['_original_index'] = i - - # Add activity for wishlist download start - add_activity_item("", "Wishlist Download Started", f"{len(wishlist_tracks)} tracks", "Now") - - batch_id = str(uuid.uuid4()) - - # Use "wishlist" as the playlist_id for consistency in the modal system - playlist_id = "wishlist" - playlist_name = "Wishlist" - - # Create task queue for this batch - convert wishlist tracks to the expected format - task_queue = [] - with tasks_lock: - download_batches[batch_id] = { - 'phase': 'analysis', - 'playlist_id': playlist_id, - 'playlist_name': playlist_name, - 'queue': task_queue, - 'active_count': 0, - 'max_concurrent': _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 — always skip the library check - 'force_download_all': True, - # Profile context for failed track wishlist re-adds - 'profile_id': manual_profile_id - } - - # Submit the wishlist processing job using the same processing function - missing_download_executor.submit(_run_full_missing_tracks_process, batch_id, playlist_id, wishlist_tracks) - - return jsonify({ - "success": True, - "batch_id": batch_id - }) + payload, status_code = _start_manual_wishlist_download_batch( + manual_runtime, + track_ids=data.get('track_ids'), + category=data.get('category'), + force_download_all=data.get('force_download_all', False), + ) + return jsonify(payload), status_code except Exception as e: logger.error(f"Error starting wishlist download process: {e}") @@ -19070,81 +18499,14 @@ def cleanup_wishlist(): wishlist_service = get_wishlist_service() db = MusicDatabase() active_server = config_manager.get_active_media_server() - - logger.info("[Wishlist Cleanup] Starting wishlist cleanup process...") - - # Get wishlist tracks for current profile - cleanup_profile_id = get_current_profile_id() - wishlist_tracks = wishlist_service.get_wishlist_tracks_for_download(profile_id=cleanup_profile_id) - if not wishlist_tracks: - return jsonify({"success": True, "message": "No tracks in wishlist to clean up", "removed_count": 0}) - - logger.info(f"[Wishlist Cleanup] Found {len(wishlist_tracks)} tracks in wishlist") - - removed_count = 0 - processed_count = 0 - - for track in wishlist_tracks: - processed_count += 1 - 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') - - # Skip if no essential data - if not track_name or not artists or not spotify_track_id: - continue - - logger.info(f"[Wishlist Cleanup] Checking track {processed_count}/{len(wishlist_tracks)}: '{track_name}'") - - # Check each artist - found_in_db = False - for artist in artists: - # Handle both string format and dict format - 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 = db.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"[Wishlist Cleanup] Track found in database: '{track_name}' by {artist_name} (confidence: {confidence:.2f})") - break - - except Exception as db_error: - logger.error(f"[Wishlist Cleanup] Error checking database for track '{track_name}': {db_error}") - continue - - # If found in database, remove from wishlist - 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"[Wishlist Cleanup] Removed track from wishlist: '{track_name}' ({spotify_track_id})") - else: - logger.error(f"[Wishlist Cleanup] Failed to remove track from wishlist: '{track_name}' ({spotify_track_id})") - except Exception as remove_error: - logger.error(f"[Wishlist Cleanup] Error removing track from wishlist: {remove_error}") - - logger.info(f"[Wishlist Cleanup] Completed cleanup: {removed_count} tracks removed from wishlist") - - return jsonify({ - "success": True, - "message": f"Wishlist cleanup completed: {removed_count} tracks removed", - "removed_count": removed_count, - "processed_count": processed_count - }) + payload, status_code = _cleanup_wishlist_against_library( + wishlist_service, + db, + get_current_profile_id(), + active_server, + logger=logger, + ) + return jsonify(payload), status_code except Exception as e: logger.error(f"Error in wishlist cleanup: {e}") @@ -21171,101 +20533,6 @@ def _start_next_batch_of_downloads(batch_id): -def _get_track_artist_name(track_info): - """Extract artist name from track info, handling different data formats (replicating sync.py)""" - if not track_info: - return "Unknown Artist" - - # Handle Spotify API format with artists array - artists = track_info.get('artists', []) - if artists and len(artists) > 0: - if isinstance(artists[0], dict) and 'name' in artists[0]: - return artists[0]['name'] - elif isinstance(artists[0], str): - return artists[0] - - # Fallback to single artist field - artist = track_info.get('artist') - if artist: - return artist - - return "Unknown Artist" - -def _ensure_spotify_track_format(track_info): - """ - Ensure track_info has proper Spotify track structure for wishlist service. - Converts webui track format to match sync.py's spotify_track format. - """ - if not track_info: - return {} - - # If it already has the proper Spotify structure, return as-is - if isinstance(track_info.get('artists'), list) and len(track_info.get('artists', [])) > 0: - first_artist = track_info['artists'][0] - if isinstance(first_artist, dict) and 'name' in first_artist: - # Already has proper Spotify format - return track_info - - # Convert to proper Spotify format - artists_list = [] - - # Handle different artist formats from webui - artists = track_info.get('artists', []) - if artists: - if isinstance(artists, list): - for artist in artists: - if isinstance(artist, dict) and 'name' in artist: - artists_list.append({'name': artist['name']}) - elif isinstance(artist, str): - artists_list.append({'name': artist}) - else: - artists_list.append({'name': str(artist)}) - else: - # Single artist as string - artists_list.append({'name': str(artists)}) - else: - # Fallback: try single artist field - artist = track_info.get('artist') - if artist: - artists_list.append({'name': str(artist)}) - else: - artists_list.append({'name': 'Unknown Artist'}) - - # Build album object — preserve ALL fields (id, release_date, total_tracks, - # album_type, images, etc.) so wishlist tracks retain full album context - # for correct folder placement, multi-disc support, and classification - album_data = track_info.get('album', {}) - if isinstance(album_data, dict): - album = dict(album_data) # Copy all fields - album.setdefault('name', 'Unknown Album') - else: - album = { - 'name': str(album_data) if album_data else track_info.get('name', 'Unknown Album'), - 'album_type': 'single', - 'total_tracks': 1, - 'release_date': '', - } - album.setdefault('images', []) - album.setdefault('album_type', 'album') - album.setdefault('total_tracks', 0) - - # Build proper Spotify track structure - spotify_track = { - 'id': track_info.get('id', f"webui_{hash(str(track_info))}"), - 'name': track_info.get('name', 'Unknown Track'), - 'artists': artists_list, # Proper Spotify format - 'album': album, - 'duration_ms': track_info.get('duration_ms', 0), - 'track_number': track_info.get('track_number', 1), - 'disc_number': track_info.get('disc_number', 1), - 'preview_url': track_info.get('preview_url'), - 'external_urls': track_info.get('external_urls', {}), - 'popularity': track_info.get('popularity', 0), - 'source': 'webui_modal' # Mark as coming from webui - } - - return spotify_track - 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. @@ -21273,7 +20540,6 @@ def _process_failed_tracks_to_wishlist_exact(batch_id): """ try: from core.wishlist_service import get_wishlist_service - from datetime import datetime logger.info(f"[Wishlist Processing] Starting wishlist processing for batch {batch_id}") @@ -21294,83 +20560,37 @@ def _process_failed_tracks_to_wishlist_exact(batch_id): # STEP 0: Remove completed tracks from wishlist (THIS WAS MISSING!) logger.info("[Wishlist Processing] Checking completed tracks for wishlist removal") - 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} - _check_and_remove_from_wishlist(context) - except Exception as e: - logger.error(f"[Wishlist Processing] Error removing completed track from wishlist: {e}") + _remove_completed_tracks_from_wishlist( + batch, + download_tasks, + _check_and_remove_from_wishlist, + logger=logger, + ) # 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") - - # Process cancelled tracks with safeguard to prevent infinite loops - processed_count = 0 - max_process = 100 # Safety limit - - with tasks_lock: - for task_id in batch.get('queue', [])[:max_process]: # Limit processing - if task_id in download_tasks: - task = download_tasks[task_id] - track_index = task.get('track_index', 0) - if track_index in cancelled_tracks: - # Check if track was actually missing (not successfully downloaded) - task_status = task.get('status', 'unknown') - if task_status != 'completed': - # Build cancelled track info matching sync.py format - original_track_info = task.get('track_info', {}) - spotify_track_data = _ensure_spotify_track_format(original_track_info) - - cancelled_track_info = { - 'download_index': track_index, - 'table_index': track_index, - 'track_name': original_track_info.get('name', 'Unknown Track'), - 'artist_name': _get_track_artist_name(original_track_info), - 'retry_count': 0, - 'spotify_track': spotify_track_data, # Properly formatted spotify track - 'failure_reason': 'Download cancelled', - 'candidates': task.get('cached_candidates', []) - } - - # Check if not already in permanently_failed_tracks (sync.py does this check) - if not any(t.get('table_index') == track_index for t in permanently_failed_tracks): - 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") - + processed_count = _add_cancelled_tracks_to_failed_tracks( + batch, + download_tasks, + permanently_failed_tracks, + logger=logger, + ) 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. - with tasks_lock: - for task_id in batch.get('queue', []): - if task_id in download_tasks: - task = download_tasks[task_id] - if task['status'] in ('failed', 'not_found'): - track_index = task.get('track_index', 0) - if not any(t.get('table_index') == track_index for t in permanently_failed_tracks): - original_track_info = task.get('track_info', {}) - spotify_track_data = _ensure_spotify_track_format(original_track_info) - recovered_track_info = { - 'download_index': track_index, - 'table_index': track_index, - 'track_name': original_track_info.get('name', 'Unknown Track'), - 'artist_name': _get_track_artist_name(original_track_info), - 'retry_count': task.get('retry_count', 0), - 'spotify_track': spotify_track_data, - 'failure_reason': task.get('error_message', 'Download failed'), - 'candidates': task.get('cached_candidates', []) - } - permanently_failed_tracks.append(recovered_track_info) - logger.error(f"[Wishlist Processing] Recovered uncaptured failed track for wishlist: {recovered_track_info['track_name']}") + recovered_count = _recover_uncaptured_failed_tracks( + batch, + download_tasks, + permanently_failed_tracks, + logger=logger, + ) + 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) @@ -21384,12 +20604,7 @@ def _process_failed_tracks_to_wishlist_exact(batch_id): wishlist_service = get_wishlist_service() # Create source_context identical to sync.py - source_context = { - 'playlist_name': batch.get('playlist_name', 'Unknown Playlist'), - 'playlist_id': batch.get('playlist_id', None), - 'added_from': 'webui_modal', # Distinguish from sync_page_modal - 'timestamp': datetime.now().isoformat() - } + 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 @@ -21507,62 +20722,32 @@ def _process_failed_tracks_to_wishlist_exact_with_auto_completion(batch_id): This extends the standard processing with automatic scheduling of the next cycle. """ global wishlist_auto_processing + global wishlist_auto_processing_timestamp try: logger.info(f"[Auto-Wishlist] Processing completion for auto-initiated batch {batch_id}") - # Run standard wishlist processing completion_summary = _process_failed_tracks_to_wishlist_exact(batch_id) - - # Log auto-processing completion - 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") - - # Add activity for wishlist processing - if tracks_added > 0: - add_activity_item("", "Wishlist Updated", f"{tracks_added} failed tracks added to wishlist", "Now") - # TOGGLE CYCLE: Switch to next category for next run - try: - with tasks_lock: - if batch_id in download_batches: - current_cycle = download_batches[batch_id].get('current_cycle', 'albums') - else: - current_cycle = 'albums' # Default fallback + def _reset_auto_processing_state(): + global wishlist_auto_processing, wishlist_auto_processing_timestamp + with wishlist_timer_lock: + wishlist_auto_processing = False + wishlist_auto_processing_timestamp = 0 - next_cycle = 'singles' if current_cycle == 'albums' else 'albums' + from database.music_database import MusicDatabase - from database.music_database import MusicDatabase - db = MusicDatabase() - 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}") - - # Mark auto-processing as complete and reset timestamp - with wishlist_timer_lock: - wishlist_auto_processing = False - wishlist_auto_processing_timestamp = 0 - - 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 + return _finalize_auto_wishlist_completion( + batch_id, + completion_summary, + download_batches=download_batches, + tasks_lock=tasks_lock, + reset_processing_state=_reset_auto_processing_state, + add_activity_item=add_activity_item, + automation_engine=automation_engine, + db_factory=MusicDatabase, + logger=logger, + ) except Exception as e: logger.error(f"[Auto-Wishlist] Error in auto-completion processing: {e}") @@ -23041,69 +22226,13 @@ def _add_cancelled_task_to_wishlist(task): try: from core.wishlist_service import get_wishlist_service wishlist_service = get_wishlist_service() - - track_info = task.get('track_info', {}) - artists_data = track_info.get('artists', []) - formatted_artists = [] - - for artist in artists_data: - if isinstance(artist, str): - formatted_artists.append({'name': artist}) - elif isinstance(artist, dict): - if 'name' in artist and isinstance(artist['name'], str): - formatted_artists.append(artist) - elif 'name' in artist and isinstance(artist['name'], dict) and 'name' in artist['name']: - formatted_artists.append({'name': artist['name']['name']}) - else: - formatted_artists.append({'name': str(artist)}) - else: - formatted_artists.append({'name': str(artist)}) - - # Build album data - preserve all fields (including artists) for correct folder placement - album_raw = track_info.get('album', {}) - if isinstance(album_raw, dict): - album_data = dict(album_raw) # Copy all fields including artists - album_data.setdefault('name', 'Unknown Album') - album_data.setdefault('album_type', track_info.get('album_type', 'album')) - # Add images fallback if not present - if 'images' not in album_data and track_info.get('album_image_url'): - album_data['images'] = [{'url': track_info.get('album_image_url')}] - else: - # album is a string (album name) - album_data = { - 'name': str(album_raw) if album_raw else 'Unknown Album', - 'album_type': track_info.get('album_type', 'album') - } - # Add album image if available - if track_info.get('album_image_url'): - album_data['images'] = [{'url': track_info.get('album_image_url')}] - - spotify_track_data = { - 'id': track_info.get('id'), - 'name': track_info.get('name'), - 'artists': formatted_artists, - 'album': album_data, - 'duration_ms': track_info.get('duration_ms') - } - - source_context = { - 'playlist_name': task.get('playlist_name', 'Unknown Playlist'), - 'playlist_id': task.get('playlist_id'), - 'added_from': 'modal_cancellation_v2' - } - - success = wishlist_service.add_spotify_track_to_wishlist( - spotify_track_data=spotify_track_data, - failure_reason="Download cancelled by user (v2)", - source_type="playlist", - source_context=source_context, - profile_id=get_current_profile_id() - ) + payload = _build_cancelled_task_wishlist_payload(task, profile_id=get_current_profile_id()) + success = wishlist_service.add_spotify_track_to_wishlist(**payload) if success: - logger.info(f"[Atomic Cancel] Added '{track_info.get('name')}' to wishlist") + logger.info(f"[Atomic Cancel] Added '{task.get('track_info', {}).get('name')}' to wishlist") else: - logger.error(f"[Atomic Cancel] Failed to add '{track_info.get('name')}' to wishlist") + logger.error(f"[Atomic Cancel] Failed to add '{task.get('track_info', {}).get('name')}' to wishlist") except Exception as e: logger.error(f"[Atomic Cancel] Critical error adding to wishlist: {e}")