Merge pull request #400 from kettui/refactor/extract-wishlist-code

Extract wishlist core logic from web_server.py
This commit is contained in:
BoulderBadgeDad 2026-04-28 20:02:39 -07:00 committed by GitHub
commit 8019e13a2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 5134 additions and 1930 deletions

View file

@ -457,12 +457,25 @@ class DownloadOrchestrator:
True if successful
"""
results = []
for client in [self.soulseek, self.youtube, self.tidal, self.qobuz, self.hifi, self.deezer_dl, self.lidarr]:
if client:
try:
results.append(await client.clear_all_completed_downloads())
except Exception:
pass
for name, client in [
("soulseek", self.soulseek),
("youtube", self.youtube),
("tidal", self.tidal),
("qobuz", self.qobuz),
("hifi", self.hifi),
("deezer_dl", self.deezer_dl),
("lidarr", self.lidarr),
]:
if not client:
continue
if hasattr(client, "is_configured") and not client.is_configured():
logger.debug("Skipping %s clear_all_completed_downloads (not configured)", name)
continue
try:
results.append(await client.clear_all_completed_downloads())
except Exception as exc:
logger.warning("%s clear_all_completed_downloads failed: %s", name, exc)
results.append(False)
return all(results) if results else True

View file

@ -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,

View file

@ -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)

View file

@ -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(

24
core/wishlist/__init__.py Normal file
View file

@ -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",
]

View file

@ -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"]

396
core/wishlist/payloads.py Normal file
View file

@ -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.payloads")
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",
]

48
core/wishlist/presence.py Normal file
View file

@ -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"]

720
core/wishlist/processing.py Normal file
View file

@ -0,0 +1,720 @@
"""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.service import get_wishlist_service
from core.wishlist.state import get_wishlist_cycle, set_wishlist_cycle
from utils.logging_config import get_logger
module_logger = get_logger("wishlist.processing")
logger = module_logger
@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_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]
current_time_fn: Callable[[], float]
profile_id: int = 1
logger: Any = module_logger
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=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=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=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=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=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_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
profile_id: int
logger: Any = module_logger
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 = 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=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 = 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=logger,
) -> int:
"""Remove wishlist entries that already exist in the library after a DB update."""
try:
from config.settings import config_manager
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",
]

View file

@ -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"]

173
core/wishlist/resolution.py Normal file
View file

@ -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("wishlist.resolution")
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"]

437
core/wishlist/routes.py Normal file
View file

@ -0,0 +1,437 @@
"""Wishlist controller helpers for Flask-style endpoints."""
from __future__ import annotations
import json
import re
import threading
from dataclasses import dataclass
from typing import Any, Callable, Dict
from core.wishlist.reporting import build_wishlist_stats_payload
from core.wishlist.selection import prepare_wishlist_tracks_for_display
from core.wishlist.service import get_wishlist_service
from core.wishlist.state import get_wishlist_cycle as _get_wishlist_cycle
from core.wishlist.state import set_wishlist_cycle as _set_wishlist_cycle
from utils.logging_config import get_logger
module_logger = get_logger("wishlist.routes")
logger = module_logger
@dataclass
class WishlistRouteRuntime:
"""Dependencies needed to service wishlist HTTP endpoints outside the controller."""
get_music_database: Callable[[], Any]
profile_id: int
download_batches: Dict[str, Dict[str, Any]]
download_tasks: Dict[str, Dict[str, Any]]
tasks_lock: Any
is_wishlist_actually_processing: Callable[[], bool]
reset_wishlist_processing_state: Callable[[], None]
add_activity_item: Callable[[Any, Any, Any, Any], Any]
active_server: str
logger: Any = module_logger
get_next_run_seconds: Callable[[str], int] | None = None
thread_factory: Callable[..., Any] = threading.Thread
def _build_album_images(album: Dict[str, Any]) -> list[dict[str, Any]]:
if isinstance(album.get("images"), list) and album.get("images"):
return list(album["images"])
if album.get("image_url"):
return [{"url": album["image_url"], "height": 640, "width": 640}]
return []
def _build_spotify_track_data(track: Dict[str, Any], album: Dict[str, Any]) -> Dict[str, Any]:
album_images = _build_album_images(album)
return {
"id": track.get("id"),
"name": track.get("name"),
"artists": track.get("artists", []),
"album": {
"id": album.get("id"),
"name": album.get("name"),
"artists": album.get("artists", []),
"images": album_images,
"album_type": album.get("album_type", "album"),
"release_date": album.get("release_date", ""),
"total_tracks": album.get("total_tracks", 1),
},
"duration_ms": track.get("duration_ms", 0),
"track_number": track.get("track_number", 1),
"disc_number": track.get("disc_number", 1),
"explicit": track.get("explicit", False),
"popularity": track.get("popularity", 0),
"preview_url": track.get("preview_url"),
"external_urls": track.get("external_urls", {}),
}
def _load_track_spotify_data(track: Dict[str, Any]) -> Dict[str, Any]:
spotify_data = track.get("spotify_data", {})
if isinstance(spotify_data, str):
try:
spotify_data = json.loads(spotify_data)
except Exception:
spotify_data = {}
if not isinstance(spotify_data, dict):
spotify_data = {}
return spotify_data
def _album_lookup_id(spotify_data: Dict[str, Any]) -> tuple[str | None, Dict[str, Any]]:
album_data = spotify_data.get("album") or {}
if not isinstance(album_data, dict):
album_data = {}
track_album_id = album_data.get("id")
if not track_album_id:
album_name = album_data.get("name", "Unknown Album")
artists = spotify_data.get("artists", [])
if isinstance(artists, list) and artists and isinstance(artists[0], dict):
artist_name = artists[0].get("name", "Unknown Artist")
elif isinstance(artists, list) and artists and isinstance(artists[0], str):
artist_name = artists[0]
else:
artist_name = "Unknown Artist"
custom_id = f"{album_name}_{artist_name}"
track_album_id = re.sub(r"[^a-zA-Z0-9\s_-]", "", custom_id)
track_album_id = re.sub(r"\s+", "_", track_album_id).lower()
return track_album_id, album_data
def process_wishlist_api(
runtime: WishlistRouteRuntime,
*,
start_processing: Callable[[], None],
) -> tuple[Dict[str, Any], int]:
"""Trigger wishlist processing in the background."""
try:
if runtime.is_wishlist_actually_processing():
return {"success": False, "error": "Wishlist processing already in progress"}, 409
thread = runtime.thread_factory(target=start_processing, daemon=True)
thread.start()
return {"success": True, "message": "Wishlist processing started"}, 200
except Exception as exc:
runtime.logger.error("Error starting wishlist processing: %s", exc)
return {"success": False, "error": str(exc)}, 500
def get_wishlist_count(runtime: WishlistRouteRuntime) -> tuple[Dict[str, Any], int]:
"""Return the current wishlist count for the active profile."""
try:
count = get_wishlist_service().get_wishlist_count(profile_id=runtime.profile_id)
return {"count": count}, 200
except Exception as exc:
runtime.logger.error("Error getting wishlist count: %s", exc)
return {"error": str(exc)}, 500
def get_wishlist_stats(runtime: WishlistRouteRuntime) -> tuple[Dict[str, Any], int]:
"""Return wishlist statistics for the UI."""
try:
raw_tracks = get_wishlist_service().get_wishlist_tracks_for_download(profile_id=runtime.profile_id)
next_run_in_seconds = runtime.get_next_run_seconds("process_wishlist") if runtime.get_next_run_seconds else 0
is_processing = runtime.is_wishlist_actually_processing()
current_cycle = _get_wishlist_cycle(runtime.get_music_database)
payload = build_wishlist_stats_payload(
raw_tracks,
next_run_in_seconds=next_run_in_seconds,
is_auto_processing=is_processing,
current_cycle=current_cycle,
)
return payload, 200
except Exception as exc:
runtime.logger.error("Error getting wishlist stats: %s", exc)
return {"error": str(exc)}, 500
def get_wishlist_cycle(runtime: WishlistRouteRuntime) -> tuple[Dict[str, Any], int]:
"""Return the current wishlist cycle."""
try:
cycle = _get_wishlist_cycle(runtime.get_music_database)
return {"cycle": cycle}, 200
except Exception as exc:
runtime.logger.error("Error getting wishlist cycle: %s", exc)
return {"error": str(exc)}, 500
def set_wishlist_cycle(runtime: WishlistRouteRuntime, cycle: str) -> tuple[Dict[str, Any], int]:
"""Persist the wishlist cycle."""
try:
if cycle not in ["albums", "singles"]:
return {"error": "Invalid cycle. Must be 'albums' or 'singles'"}, 400
_set_wishlist_cycle(runtime.get_music_database, cycle)
runtime.logger.info("Wishlist cycle set to: %s", cycle)
return {"success": True, "cycle": cycle}, 200
except Exception as exc:
runtime.logger.error("Error setting wishlist cycle: %s", exc)
return {"error": str(exc)}, 500
def get_wishlist_tracks(
runtime: WishlistRouteRuntime,
*,
category: str | None = None,
limit: int | None = None,
) -> tuple[Dict[str, Any], int]:
"""Return wishlist tracks for the modal UI."""
try:
db = runtime.get_music_database()
with runtime.tasks_lock:
wishlist_batch_active = any(
batch.get("playlist_id") == "wishlist" and batch.get("phase") in ["analysis", "downloading"]
for batch in runtime.download_batches.values()
)
if not wishlist_batch_active:
duplicates_removed = db.remove_wishlist_duplicates(profile_id=runtime.profile_id)
if duplicates_removed > 0:
runtime.logger.warning("Cleaned %s duplicate tracks from wishlist", duplicates_removed)
else:
runtime.logger.warning("Skipping wishlist duplicate cleanup - download in progress")
raw_tracks = get_wishlist_service().get_wishlist_tracks_for_download(profile_id=runtime.profile_id)
prepared = prepare_wishlist_tracks_for_display(raw_tracks, category=category, limit=limit)
if prepared["duplicates_found"] > 0:
runtime.logger.warning(
"[API-Wishlist-Tracks] Found and removed %s duplicate tracks during sanitization",
prepared["duplicates_found"],
)
if category:
runtime.logger.info(
"Wishlist filter: %s/%s tracks in '%s' category (limit: %s)",
len(prepared["tracks"]),
prepared["total"],
category,
limit or "none",
)
return {"tracks": prepared["tracks"], "category": category, "total": prepared["total"]}, 200
return {"tracks": prepared["tracks"], "total": prepared["total"]}, 200
except Exception as exc:
runtime.logger.error("Error getting wishlist tracks: %s", exc)
return {"error": str(exc)}, 500
def clear_wishlist(runtime: WishlistRouteRuntime) -> tuple[Dict[str, Any], int]:
"""Clear the wishlist and cancel active wishlist batches."""
try:
success = get_wishlist_service().clear_wishlist(profile_id=runtime.profile_id)
if success:
cancelled_count = 0
with runtime.tasks_lock:
for _batch_id, batch_data in runtime.download_batches.items():
if batch_data.get("playlist_id") == "wishlist" and batch_data.get("phase") not in (
"complete",
"error",
"cancelled",
):
batch_data["phase"] = "cancelled"
for task_id in batch_data.get("queue", []):
if task_id in runtime.download_tasks and runtime.download_tasks[task_id]["status"] not in (
"completed",
"failed",
"not_found",
"cancelled",
):
runtime.download_tasks[task_id]["status"] = "cancelled"
cancelled_count += 1
runtime.reset_wishlist_processing_state()
if cancelled_count > 0:
runtime.logger.warning("[Wishlist Clear] Cancelled %s active wishlist downloads", cancelled_count)
runtime.add_activity_item("", "Wishlist Cleared", f"Wishlist cleared and {cancelled_count} downloads cancelled", "Now")
return {
"success": True,
"message": "Wishlist cleared successfully",
"cancelled_downloads": cancelled_count,
}, 200
return {"success": False, "error": "Failed to clear wishlist"}, 500
except Exception as exc:
runtime.logger.error("Error clearing wishlist: %s", exc)
return {"success": False, "error": str(exc)}, 500
def remove_track_from_wishlist(
runtime: WishlistRouteRuntime,
spotify_track_id: str | None,
) -> tuple[Dict[str, Any], int]:
"""Remove a single track from the wishlist."""
try:
if not spotify_track_id:
return {"success": False, "error": "No spotify_track_id provided"}, 400
success = get_wishlist_service().remove_track_from_wishlist(
spotify_track_id,
profile_id=runtime.profile_id,
)
if success:
runtime.logger.info("Successfully removed track from wishlist: %s", spotify_track_id)
return {"success": True, "message": "Track removed from wishlist"}, 200
runtime.logger.warning("Failed to remove track from wishlist: %s", spotify_track_id)
return {"success": False, "error": "Track not found in wishlist"}, 404
except Exception as exc:
runtime.logger.error("Error removing track from wishlist: %s", exc)
return {"success": False, "error": str(exc)}, 500
def remove_album_from_wishlist(
runtime: WishlistRouteRuntime,
*,
album_id: str | None = None,
album_name_filter: str | None = None,
) -> tuple[Dict[str, Any], int]:
"""Remove every wishlist track that belongs to the selected album."""
try:
if not album_id and not album_name_filter:
return {"success": False, "error": "No album_id or album_name provided"}, 400
wishlist_service = get_wishlist_service()
all_tracks = wishlist_service.get_wishlist_tracks_for_download(profile_id=runtime.profile_id)
tracks_to_remove = []
for track in all_tracks:
spotify_data = _load_track_spotify_data(track)
track_album_id, album_data = _album_lookup_id(spotify_data)
matched = False
if album_id and track_album_id == album_id:
matched = True
elif album_name_filter:
track_album_name = album_data.get("name", "")
if isinstance(spotify_data.get("album"), str):
track_album_name = spotify_data["album"]
if track_album_name and track_album_name.lower().strip() == album_name_filter.lower().strip():
matched = True
if matched:
spotify_track_id = track.get("spotify_track_id") or track.get("id")
if spotify_track_id:
tracks_to_remove.append(spotify_track_id)
removed_count = 0
album_remove_pid = runtime.profile_id
for spotify_track_id in tracks_to_remove:
if wishlist_service.remove_track_from_wishlist(spotify_track_id, profile_id=album_remove_pid):
removed_count += 1
if removed_count > 0:
runtime.logger.info("Successfully removed %s tracks from album %s", removed_count, album_id)
return {
"success": True,
"message": f"Removed {removed_count} track(s) from wishlist",
"removed_count": removed_count,
}, 200
runtime.logger.warning("No tracks found for album %s", album_id)
return {"success": False, "error": "No tracks found for this album"}, 404
except Exception as exc:
runtime.logger.error("Error removing album from wishlist: %s", exc)
return {"success": False, "error": str(exc)}, 500
def remove_batch_from_wishlist(
runtime: WishlistRouteRuntime,
spotify_track_ids,
) -> tuple[Dict[str, Any], int]:
"""Remove a batch of tracks from the wishlist."""
try:
if not spotify_track_ids or not isinstance(spotify_track_ids, list):
return {"success": False, "error": "Missing or invalid spotify_track_ids"}, 400
removed = 0
pid = runtime.profile_id
for track_id in spotify_track_ids:
if get_wishlist_service().remove_track_from_wishlist(track_id, profile_id=pid):
removed += 1
runtime.logger.info("Batch removed %s track(s) from wishlist", removed)
return {
"success": True,
"removed": removed,
"message": f"Removed {removed} track{'s' if removed != 1 else ''} from wishlist",
}, 200
except Exception as exc:
runtime.logger.error("Error batch removing from wishlist: %s", exc)
return {"success": False, "error": str(exc)}, 500
def add_album_track_to_wishlist(
runtime: WishlistRouteRuntime,
*,
track: Dict[str, Any] | None,
artist: Dict[str, Any] | None,
album: Dict[str, Any] | None,
source_type: str = "album",
source_context: Dict[str, Any] | None = None,
) -> tuple[Dict[str, Any], int]:
"""Add a single album track to the wishlist."""
try:
if not track or not artist or not album:
return {"success": False, "error": "Missing required fields: track, artist, album"}, 400
spotify_track_data = _build_spotify_track_data(track, album)
enhanced_source_context = {
**(source_context or {}),
"artist_id": artist.get("id"),
"artist_name": artist.get("name"),
"album_id": album.get("id"),
"album_name": album.get("name"),
"added_via": "library_wishlist_modal",
}
success = get_wishlist_service().add_spotify_track_to_wishlist(
spotify_track_data=spotify_track_data,
failure_reason="Added from library (incomplete album)",
source_type=source_type,
source_context=enhanced_source_context,
profile_id=runtime.profile_id,
)
if success:
runtime.logger.info("Added track '%s' by '%s' to wishlist", track.get("name"), artist.get("name"))
return {"success": True, "message": f"Added '{track.get('name')}' to wishlist"}, 200
runtime.logger.error("Failed to add track '%s' to wishlist", track.get("name"))
return {"success": False, "error": "Failed to add track to wishlist"}, 200
except Exception as exc:
runtime.logger.error("Error adding track to wishlist: %s", exc)
import traceback
traceback.print_exc()
return {"success": False, "error": str(exc)}, 500
__all__ = [
"WishlistRouteRuntime",
"process_wishlist_api",
"get_wishlist_count",
"get_wishlist_stats",
"get_wishlist_cycle",
"set_wishlist_cycle",
"get_wishlist_tracks",
"clear_wishlist",
"remove_track_from_wishlist",
"remove_album_from_wishlist",
"remove_batch_from_wishlist",
"add_album_track_to_wishlist",
]

View file

@ -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",
]

309
core/wishlist/service.py Normal file
View file

@ -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"]

134
core/wishlist/state.py Normal file
View file

@ -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",
]

View file

@ -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"]

View file

@ -0,0 +1,61 @@
from core.download_orchestrator import DownloadOrchestrator
class _FakeClient:
def __init__(self, configured=True, clear_result=True):
self.configured = configured
self.clear_result = clear_result
self.clear_calls = 0
def is_configured(self):
return self.configured
async def clear_all_completed_downloads(self):
self.clear_calls += 1
return self.clear_result
def _build_orchestrator(**clients):
orch = DownloadOrchestrator.__new__(DownloadOrchestrator)
orch.soulseek = clients.get("soulseek")
orch.youtube = clients.get("youtube")
orch.tidal = clients.get("tidal")
orch.qobuz = clients.get("qobuz")
orch.hifi = clients.get("hifi")
orch.deezer_dl = clients.get("deezer_dl")
orch.lidarr = clients.get("lidarr")
return orch
def _run_async(coro):
import asyncio
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()
def test_clear_all_completed_downloads_ignores_unconfigured_clients():
orch = _build_orchestrator(
soulseek=_FakeClient(configured=True, clear_result=True),
youtube=_FakeClient(configured=False, clear_result=False),
)
result = _run_async(orch.clear_all_completed_downloads())
assert result is True
assert orch.soulseek.clear_calls == 1
assert orch.youtube.clear_calls == 0
def test_clear_all_completed_downloads_propagates_configured_failures():
orch = _build_orchestrator(
soulseek=_FakeClient(configured=True, clear_result=False),
)
result = _run_async(orch.clear_all_completed_downloads())
assert result is False
assert orch.soulseek.clear_calls == 1

View file

@ -323,7 +323,7 @@ def test_youtube_task_uses_get_download_status_to_resolve_path(monkeypatch):
assert any(c[0] == 'mark_completed' for c in rec.calls)
def test_fuzzy_context_matching_when_exact_key_missing():
def test_fuzzy_context_matching_when_exact_key_missing(monkeypatch):
"""When exact key isn't in matched_downloads_context, worker tries fuzzy match
constrained to same Soulseek username."""
download_tasks['t1'] = {
@ -339,6 +339,7 @@ def test_fuzzy_context_matching_when_exact_key_missing():
deps, rec = _build_deps(
find_completed_file=lambda *a, **kw: (None, None), # file not found
)
monkeypatch.setattr(pp.time, 'sleep', lambda s: None)
# Won't find file → marks failed. But the fuzzy match log path executes.
pp.run_post_processing_worker('t1', 'b1', deps)
assert download_tasks['t1']['status'] == 'failed'

View file

@ -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)]

View file

@ -0,0 +1 @@
"""Wishlist resolver tests."""

View file

@ -0,0 +1,327 @@
from contextlib import contextmanager
from types import SimpleNamespace
from core.wishlist import processing
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,
guard_acquired=True,
is_actually_processing=False,
):
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)
processing.get_wishlist_service = lambda: wishlist_service
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 guard_acquired
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,
app_context_factory=app_context,
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,
is_actually_processing=lambda: is_actually_processing,
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)
def test_process_wishlist_automatically_returns_early_when_already_processing():
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,
is_actually_processing=True,
)
process_wishlist_automatically(runtime, automation_id="auto-3")
assert executor.submissions == []
assert music_db.duplicate_removals == []
assert progress_calls == []
assert guard_events == []
assert any("Already processing" in msg for msg in logger.info_messages)
def test_process_wishlist_automatically_returns_early_when_guard_not_acquired():
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,
guard_acquired=False,
)
process_wishlist_automatically(runtime, automation_id="auto-4")
assert executor.submissions == []
assert music_db.duplicate_removals == []
assert progress_calls == []
assert guard_events == ["enter", "exit"]
assert any("race condition check" in msg for msg in logger.info_messages)
def test_process_wishlist_automatically_returns_early_when_no_tracks_are_available():
runtime, _service, _profiles_db, music_db, executor, logger, progress_calls, guard_events = _build_runtime(
tracks=[],
cycle_value="albums",
count=0,
)
process_wishlist_automatically(runtime, automation_id="auto-5")
assert executor.submissions == []
assert music_db.duplicate_removals == []
assert guard_events == ["enter", "exit"]
assert [kwargs.get("progress") for _args, kwargs in progress_calls if "progress" in kwargs] == [10]
assert any("No tracks in wishlist for auto-processing" in msg for msg in logger.warning_messages)
def test_process_wishlist_automatically_skips_when_wishlist_batch_is_already_active():
batch_map = {
"batch-1": {
"playlist_id": "wishlist",
"phase": "analysis",
}
}
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-6")
assert executor.submissions == []
assert music_db.duplicate_removals == []
assert guard_events == ["enter", "exit"]
assert [kwargs.get("progress") for _args, kwargs in progress_calls if "progress" in kwargs] == [10]
assert any("already active in another batch" in msg for msg in logger.info_messages)

View file

@ -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"}]

View file

@ -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

View file

@ -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}

View file

@ -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"}]

View file

@ -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)
processing.get_wishlist_service = lambda: wishlist_service
music_db = _FakeMusicDatabase(owned_matches=owned_matches)
executor = _FakeExecutor()
logger = _FakeLogger()
activity_calls = []
batch_map = batch_map or {}
runtime = WishlistManualDownloadRuntime(
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)

View file

@ -0,0 +1,92 @@
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"
def test_extract_spotify_track_from_modal_info_converts_trackresult_like_object():
track_info = {
"spotify_track": SimpleNamespace(
title="Song Two",
artist="Artist Two",
album="Album Two",
)
}
out = payloads.extract_spotify_track_from_modal_info(track_info)
assert out["source"] == "trackresult"
assert out["name"] == "Song Two"
assert out["artists"] == [{"name": "Artist Two"}]
assert out["album"]["name"] == "Album Two"
def test_extract_spotify_track_from_modal_info_reconstructs_from_slskd_result():
track_info = {
"slskd_result": SimpleNamespace(
title="Song Three",
artist="Artist Three",
album="Album Three",
)
}
out = payloads.extract_spotify_track_from_modal_info(track_info)
assert out["reconstructed"] is True
assert out["name"] == "Song Three"
assert out["artists"] == [{"name": "Artist Three"}]
assert out["album"]["name"] == "Album Three"

View file

@ -0,0 +1,56 @@
import json
from core.wishlist import presence
class _FakeCursor:
def __init__(self, profile_rows=None, legacy_rows=None, fail_profile_query=False):
self.profile_rows = list(profile_rows or [])
self.legacy_rows = list(legacy_rows or [])
self.fail_profile_query = fail_profile_query
self.calls = []
self._last_sql = ""
def execute(self, sql, params=None):
self.calls.append((sql, params))
self._last_sql = sql
if self.fail_profile_query and "WHERE profile_id = ?" in sql:
raise RuntimeError("profile_id column missing")
def fetchall(self):
if "WHERE profile_id = ?" in self._last_sql and not self.fail_profile_query:
return list(self.profile_rows)
return list(self.legacy_rows)
def test_load_wishlist_keys_uses_profile_specific_query():
cursor = _FakeCursor(
profile_rows=[
(json.dumps({"name": "Song One", "artists": [{"name": "Artist One"}]}),),
("not-json",),
]
)
keys = presence.load_wishlist_keys(cursor, profile_id=7)
assert keys == {"song one|||artist one"}
assert cursor.calls == [
("SELECT spotify_data FROM wishlist_tracks WHERE profile_id = ?", (7,)),
]
def test_load_wishlist_keys_falls_back_to_legacy_schema():
cursor = _FakeCursor(
fail_profile_query=True,
legacy_rows=[
(json.dumps({"name": "Song Two", "artists": [{"name": "Artist Two"}]}),),
],
)
keys = presence.load_wishlist_keys(cursor, profile_id=3)
assert keys == {"song two|||artist two"}
assert cursor.calls == [
("SELECT spotify_data FROM wishlist_tracks WHERE profile_id = ?", (3,)),
("SELECT spotify_data FROM wishlist_tracks", None),
]

View file

@ -0,0 +1,334 @@
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)]
class _CleanupProfilesDatabase:
def get_all_profiles(self):
return [{"id": 1}]
class _CleanupWishlistService:
def __init__(self, tracks):
self._tracks = list(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 _CleanupMusicDatabase:
def __init__(self):
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 == "Owned Song" and artist_name == "Artist A":
return {"id": "db-track"}, 0.9
if track_name == "Broken Song":
raise RuntimeError("boom")
return None, 0.0
def test_remove_tracks_already_in_library_skips_enhance_tracks_and_handles_errors():
wishlist_service = _CleanupWishlistService(
[
{
"name": "Owned Song",
"artists": ["Artist A"],
"spotify_track_id": "sp-1",
"album": {"name": "Album A"},
},
{
"name": "Enhance Song",
"artists": [{"name": "Artist B"}],
"spotify_track_id": "sp-2",
"source_type": "enhance",
"album": {"name": "Album B"},
},
{
"name": "Broken Song",
"artists": [{"name": "Artist C"}],
"spotify_track_id": "sp-3",
"album": {"name": "Album C"},
},
]
)
music_db = _CleanupMusicDatabase()
removed = processing.remove_tracks_already_in_library(
wishlist_service,
_CleanupProfilesDatabase(),
music_db,
"navidrome",
logger=_FakeLogger(),
skip_track_fn=lambda track: track.get("source_type") == "enhance",
)
assert removed == 1
assert wishlist_service.removed == [("sp-1", True, None, 1)]
assert music_db.track_checks == [
("Owned Song", "Artist A", "navidrome", "Album A"),
("Broken Song", "Artist C", "navidrome", "Album C"),
]
def test_finalize_auto_wishlist_completion_with_no_tracks_added_still_resets_state():
db = _FakeDB()
automation_engine = _FakeAutomationEngine()
resets = []
activities = []
summary = {"tracks_added": 0, "total_failed": 5, "errors": 0}
result = processing.finalize_auto_wishlist_completion(
"batch-2",
summary,
download_batches={"batch-2": {"current_cycle": "singles"}},
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 == []
assert automation_engine.events == [
(
"wishlist_processing_completed",
{
"tracks_processed": "5",
"tracks_found": "0",
"tracks_failed": "5",
},
)
]
assert db.connection.committed is True

View file

@ -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",
}

View file

@ -0,0 +1,181 @@
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_from_wishlist_uses_spotify_source_id():
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 = {
"source": "spotify",
"track_info": {
"id": "sp-track-1",
"name": "Song One",
"artists": [{"name": "Artist One"}],
},
"search_result": {},
"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_from_wishlist_uses_wishlist_id_lookup():
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"}],
}
]
)
context = {
"source": "manual",
"track_info": {"wishlist_id": 22},
"search_result": {},
"original_search_result": {},
}
resolution.check_and_remove_from_wishlist(
context,
wishlist_service=wishlist_service,
database=fake_db,
)
assert wishlist_service.removed == [("sp-track-2", 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)]
def test_check_and_remove_track_from_wishlist_by_metadata_uses_direct_id_match():
fake_db = SimpleNamespace(get_all_profiles=lambda: [{"id": 1}])
wishlist_service = _FakeWishlistService([])
track_data = {
"name": "Song Three",
"id": "sp-track-3",
"artists": [{"name": "Artist Three"}],
}
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-3", True, None, 1)]
def test_check_and_remove_track_from_wishlist_by_metadata_returns_false_when_no_match():
fake_db = SimpleNamespace(get_all_profiles=lambda: [{"id": 1}])
wishlist_service = _FakeWishlistService([])
removed = resolution.check_and_remove_track_from_wishlist_by_metadata(
{
"name": "Missing Song",
"id": "",
"artists": [{"name": "Missing Artist"}],
},
wishlist_service=wishlist_service,
database=fake_db,
)
assert removed is False
assert wishlist_service.removed == []

View file

@ -0,0 +1,559 @@
import json
import core.wishlist.routes as routes_module
from core.wishlist.routes import (
WishlistRouteRuntime,
add_album_track_to_wishlist,
clear_wishlist,
get_wishlist_count,
get_wishlist_cycle,
get_wishlist_stats,
get_wishlist_tracks,
process_wishlist_api,
remove_album_from_wishlist,
remove_batch_from_wishlist,
remove_track_from_wishlist,
set_wishlist_cycle,
)
class _FakeLogger:
def __init__(self):
self.info_messages = []
self.warning_messages = []
self.error_messages = []
self.debug_messages = []
def info(self, msg, *args):
self.info_messages.append(msg % args if args else msg)
def warning(self, msg, *args):
self.warning_messages.append(msg % args if args else msg)
def error(self, msg, *args):
self.error_messages.append(msg % args if args else msg)
def debug(self, msg, *args):
self.debug_messages.append(msg % args if args else msg)
class _FakeThread:
def __init__(self, target=None, daemon=False):
self.target = target
self.daemon = daemon
self.started = False
def start(self):
self.started = True
if self.target:
self.target()
class _FakeThreadFactory:
def __init__(self):
self.created = []
def __call__(self, *args, **kwargs):
thread = _FakeThread(*args, **kwargs)
self.created.append(thread)
return thread
class _FakeLock:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
class _FakeCursor:
def __init__(self, db):
self.db = db
self.last_sql = ""
def execute(self, sql, params=None):
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 _FakeConnection:
def __init__(self, db):
self.db = db
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def cursor(self):
return self.db.cursor_obj
def commit(self):
self.db.commits += 1
class _FakeMusicDatabase:
def __init__(self, cycle_value="albums", duplicate_removals=0):
self.cycle_value = cycle_value
self.duplicate_removals = duplicate_removals
self.commits = 0
self.cursor_obj = _FakeCursor(self)
self.duplicate_cleanup_profiles = []
def _get_connection(self):
return _FakeConnection(self)
def remove_wishlist_duplicates(self, profile_id=1):
self.duplicate_cleanup_profiles.append(profile_id)
return self.duplicate_removals
class _FakeWishlistService:
def __init__(self, tracks=None, count=None, clear_result=True):
self.tracks = list(tracks or [])
self.count = len(self.tracks) if count is None else count
self.clear_result = clear_result
self.removed = []
self.add_calls = []
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 clear_wishlist(self, profile_id=1):
return self.clear_result
def remove_track_from_wishlist(self, spotify_track_id, profile_id=1):
self.removed.append((spotify_track_id, profile_id))
return True
def add_spotify_track_to_wishlist(self, **kwargs):
self.add_calls.append(kwargs)
return True
def _build_runtime(
*,
tracks=None,
count=None,
cycle_value="albums",
duplicate_removals=0,
clear_result=True,
actually_processing=False,
next_run_seconds=0,
download_batches=None,
download_tasks=None,
thread_factory=None,
reset_callback=None,
):
service = _FakeWishlistService(tracks=tracks, count=count, clear_result=clear_result)
routes_module.get_wishlist_service = lambda: service
db = _FakeMusicDatabase(cycle_value=cycle_value, duplicate_removals=duplicate_removals)
logger = _FakeLogger()
activity_calls = []
runtime = WishlistRouteRuntime(
get_music_database=lambda: db,
profile_id=1,
download_batches=download_batches if download_batches is not None else {},
download_tasks=download_tasks if download_tasks is not None else {},
tasks_lock=_FakeLock(),
is_wishlist_actually_processing=lambda: actually_processing,
reset_wishlist_processing_state=reset_callback or (lambda: None),
add_activity_item=lambda *args: activity_calls.append(args),
logger=logger,
active_server="navidrome",
get_next_run_seconds=(lambda _name: next_run_seconds),
thread_factory=thread_factory or _FakeThreadFactory(),
)
return runtime, service, db, logger, activity_calls
def test_process_wishlist_api_starts_background_thread_when_idle():
thread_factory = _FakeThreadFactory()
runtime, _service, _db, logger, _activity_calls = _build_runtime(
thread_factory=thread_factory,
)
start_calls = []
payload, status = process_wishlist_api(runtime, start_processing=lambda: start_calls.append("ran"))
assert status == 200
assert payload == {"success": True, "message": "Wishlist processing started"}
assert start_calls == ["ran"]
assert len(thread_factory.created) == 1
assert thread_factory.created[0].daemon is True
assert thread_factory.created[0].started is True
assert logger.error_messages == []
def test_process_wishlist_api_rejects_when_flag_is_set():
thread_factory = _FakeThreadFactory()
runtime, _service, _db, logger, _activity_calls = _build_runtime(
actually_processing=True,
thread_factory=thread_factory,
)
payload, status = process_wishlist_api(runtime, start_processing=lambda: None)
assert status == 409
assert payload == {"success": False, "error": "Wishlist processing already in progress"}
assert thread_factory.created == []
assert logger.error_messages == []
def test_get_wishlist_count_returns_profile_count():
runtime, _service, _db, _logger, _activity_calls = _build_runtime(count=7)
payload, status = get_wishlist_count(runtime)
assert status == 200
assert payload == {"count": 7}
def test_get_wishlist_stats_uses_cycle_and_next_run():
tracks = [
{
"id": "track-1",
"name": "Single Song",
"artists": [{"name": "Artist One"}],
"spotify_data": {"album": {"album_type": "single"}},
},
{
"id": "track-2",
"name": "Album Song",
"artists": [{"name": "Artist Two"}],
"spotify_data": {"album": {"total_tracks": 8}},
},
]
runtime, _service, _db, _logger, _activity_calls = _build_runtime(
tracks=tracks,
count=2,
cycle_value="albums",
actually_processing=True,
next_run_seconds=123,
)
payload, status = get_wishlist_stats(runtime)
assert status == 200
assert payload == {
"singles": 1,
"albums": 1,
"total": 2,
"next_run_in_seconds": 123,
"is_auto_processing": True,
"current_cycle": "albums",
}
def test_get_wishlist_tracks_filters_category_and_cleans_duplicates():
tracks = [
{
"id": "track-1",
"name": "Single One",
"artists": [{"name": "Artist One"}],
"spotify_data": {"album": {"album_type": "single"}},
},
{
"id": "track-2",
"name": "Single Two",
"artists": [{"name": "Artist Two"}],
"spotify_data": {"album": {"album_type": "single"}},
},
{
"id": "track-3",
"name": "Album Song",
"artists": [{"name": "Artist Three"}],
"spotify_data": {"album": {"total_tracks": 8}},
},
]
runtime, service, db, logger, _activity_calls = _build_runtime(
tracks=tracks,
duplicate_removals=2,
)
payload, status = get_wishlist_tracks(runtime, category="singles", limit=1)
assert status == 200
assert payload["category"] == "singles"
assert payload["total"] == 2
assert len(payload["tracks"]) == 1
assert payload["tracks"][0]["id"] == "track-1"
assert db.duplicate_cleanup_profiles == [1]
assert any("duplicate tracks from wishlist" in msg for msg in logger.warning_messages)
assert service.get_wishlist_tracks_for_download(profile_id=1)[0]["id"] == "track-1"
def test_clear_wishlist_cancels_active_batches_and_resets_state():
download_batches = {
"batch-1": {
"playlist_id": "wishlist",
"phase": "analysis",
"queue": ["task-1", "task-2", "task-3"],
},
"batch-2": {
"playlist_id": "other",
"phase": "analysis",
"queue": ["task-4"],
},
}
download_tasks = {
"task-1": {"status": "queued"},
"task-2": {"status": "in_progress"},
"task-3": {"status": "completed"},
"task-4": {"status": "queued"},
}
reset_calls = []
runtime, service, _db, logger, activity_calls = _build_runtime(
download_batches=download_batches,
download_tasks=download_tasks,
reset_callback=lambda: reset_calls.append("reset"),
)
payload, status = clear_wishlist(runtime)
assert status == 200
assert payload == {
"success": True,
"message": "Wishlist cleared successfully",
"cancelled_downloads": 2,
}
assert service.clear_result is True
assert download_batches["batch-1"]["phase"] == "cancelled"
assert download_batches["batch-2"]["phase"] == "analysis"
assert download_tasks["task-1"]["status"] == "cancelled"
assert download_tasks["task-2"]["status"] == "cancelled"
assert download_tasks["task-3"]["status"] == "completed"
assert download_tasks["task-4"]["status"] == "queued"
assert reset_calls == ["reset"]
assert activity_calls == [
("", "Wishlist Cleared", "Wishlist cleared and 2 downloads cancelled", "Now")
]
assert any("Cancelled 2 active wishlist downloads" in msg for msg in logger.warning_messages)
def test_remove_track_from_wishlist_requires_track_id():
runtime, _service, _db, _logger, _activity_calls = _build_runtime()
payload, status = remove_track_from_wishlist(runtime, None)
assert status == 400
assert payload == {"success": False, "error": "No spotify_track_id provided"}
def test_remove_track_from_wishlist_removes_single_track():
runtime, service, _db, _logger, _activity_calls = _build_runtime()
payload, status = remove_track_from_wishlist(runtime, "track-1")
assert status == 200
assert payload == {"success": True, "message": "Track removed from wishlist"}
assert service.removed == [("track-1", 1)]
def test_remove_album_from_wishlist_matches_album_name():
tracks = [
{
"wishlist_id": 1,
"spotify_track_id": "track-1",
"id": "track-1",
"spotify_data": json.dumps(
{
"album": {"name": "Complete Album"},
"artists": [{"name": "Artist One"}],
}
),
},
{
"wishlist_id": 2,
"spotify_track_id": "track-2",
"id": "track-2",
"spotify_data": {
"album": {"name": "Other Album"},
"artists": [{"name": "Artist Two"}],
},
},
]
runtime, service, _db, _logger, _activity_calls = _build_runtime(tracks=tracks)
payload, status = remove_album_from_wishlist(runtime, album_name_filter="complete album")
assert status == 200
assert payload == {
"success": True,
"message": "Removed 1 track(s) from wishlist",
"removed_count": 1,
}
assert service.removed == [("track-1", 1)]
def test_remove_batch_from_wishlist_returns_removed_count():
runtime, service, _db, _logger, _activity_calls = _build_runtime()
payload, status = remove_batch_from_wishlist(runtime, ["track-1", "track-2"])
assert status == 200
assert payload == {
"success": True,
"removed": 2,
"message": "Removed 2 tracks from wishlist",
}
assert service.removed == [("track-1", 1), ("track-2", 1)]
def test_set_wishlist_cycle_updates_metadata():
runtime, _service, db, _logger, _activity_calls = _build_runtime(cycle_value="albums")
payload, status = set_wishlist_cycle(runtime, "singles")
assert status == 200
assert payload == {"success": True, "cycle": "singles"}
assert db.cycle_value == "singles"
assert db.commits == 1
def test_get_wishlist_cycle_returns_stored_value():
runtime, _service, _db, _logger, _activity_calls = _build_runtime(cycle_value="singles")
payload, status = get_wishlist_cycle(runtime)
assert status == 200
assert payload == {"cycle": "singles"}
def test_add_album_track_to_wishlist_builds_spotify_payload_and_merges_context():
runtime, service, _db, _logger, _activity_calls = _build_runtime()
track = {
"id": "track-1",
"name": "Song One",
"artists": [{"name": "Artist One"}],
"duration_ms": 1234,
"track_number": 2,
"disc_number": 1,
"explicit": True,
"popularity": 77,
"preview_url": "https://example.test/preview",
"external_urls": {"spotify": "https://open.spotify.com/track/track-1"},
}
artist = {"id": "artist-1", "name": "Artist One"}
album = {
"id": "album-1",
"name": "Album One",
"artists": [{"name": "Artist One"}],
"image_url": "https://example.test/cover.jpg",
"release_date": "2024-01-01",
"total_tracks": 10,
}
payload, status = add_album_track_to_wishlist(
runtime,
track=track,
artist=artist,
album=album,
source_type="album",
source_context={"playlist_id": "pl-1"},
)
assert status == 200
assert payload == {"success": True, "message": "Added 'Song One' to wishlist"}
assert len(service.add_calls) == 1
add_call = service.add_calls[0]
assert add_call["failure_reason"] == "Added from library (incomplete album)"
assert add_call["source_type"] == "album"
assert add_call["profile_id"] == 1
assert add_call["source_context"] == {
"playlist_id": "pl-1",
"artist_id": "artist-1",
"artist_name": "Artist One",
"album_id": "album-1",
"album_name": "Album One",
"added_via": "library_wishlist_modal",
}
assert add_call["spotify_track_data"]["album"]["images"] == [
{"url": "https://example.test/cover.jpg", "height": 640, "width": 640}
]
assert add_call["spotify_track_data"]["duration_ms"] == 1234
assert add_call["spotify_track_data"]["explicit"] is True
def test_set_wishlist_cycle_rejects_invalid_cycle():
runtime, _service, _db, _logger, _activity_calls = _build_runtime()
payload, status = set_wishlist_cycle(runtime, "mixes")
assert status == 400
assert payload == {"error": "Invalid cycle. Must be 'albums' or 'singles'"}
def test_remove_album_from_wishlist_matches_album_id():
tracks = [
{
"wishlist_id": 1,
"spotify_track_id": "track-1",
"id": "track-1",
"spotify_data": {
"album": {"id": "album-1", "name": "Complete Album"},
"artists": [{"name": "Artist One"}],
},
},
{
"wishlist_id": 2,
"spotify_track_id": "track-2",
"id": "track-2",
"spotify_data": {
"album": {"id": "album-2", "name": "Other Album"},
"artists": [{"name": "Artist Two"}],
},
},
]
runtime, service, _db, _logger, _activity_calls = _build_runtime(tracks=tracks)
payload, status = remove_album_from_wishlist(runtime, album_id="album-1")
assert status == 200
assert payload == {
"success": True,
"message": "Removed 1 track(s) from wishlist",
"removed_count": 1,
}
assert service.removed == [("track-1", 1)]
def test_remove_album_from_wishlist_requires_lookup_fields():
runtime, _service, _db, _logger, _activity_calls = _build_runtime()
payload, status = remove_album_from_wishlist(runtime)
assert status == 400
assert payload == {"success": False, "error": "No album_id or album_name provided"}
def test_remove_batch_from_wishlist_rejects_invalid_payload():
runtime, _service, _db, _logger, _activity_calls = _build_runtime()
payload, status = remove_batch_from_wishlist(runtime, "track-1")
assert status == 400
assert payload == {"success": False, "error": "Missing or invalid spotify_track_ids"}
def test_add_album_track_to_wishlist_requires_required_fields():
runtime, _service, _db, _logger, _activity_calls = _build_runtime()
payload, status = add_album_track_to_wishlist(runtime, track=None, artist=None, album=None)
assert status == 400
assert payload == {
"success": False,
"error": "Missing required fields: track, artist, album",
}

View file

@ -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

View file

@ -0,0 +1,284 @@
from types import SimpleNamespace
from core.wishlist.service import WishlistService
class _FakeWishlistDatabase:
def __init__(self, tracks=None, count=None):
self.tracks = list(tracks or [])
self.count = len(self.tracks) if count is None else count
self.add_calls = []
self.track_queries = []
def add_to_wishlist(self, **kwargs):
self.add_calls.append(kwargs)
return True
def get_wishlist_tracks(self, limit=None, profile_id=1):
self.track_queries.append(("get_wishlist_tracks", limit, profile_id))
tracks = list(self.tracks)
return tracks if limit is None else tracks[:limit]
def get_wishlist_count(self, profile_id=1):
self.track_queries.append(("get_wishlist_count", profile_id))
return self.count
def _build_service(fake_db):
service = WishlistService(database_path="test.db")
service._database = fake_db
return service
def test_add_failed_track_from_modal_normalizes_candidates_and_forwards_payload():
fake_db = _FakeWishlistDatabase()
service = _build_service(fake_db)
track_info = {
"failure_reason": "Download cancelled",
"download_index": 4,
"table_index": 4,
"candidates": [
SimpleNamespace(title="Candidate Song", artist="Candidate Artist", filename="track.flac"),
{"title": "kept"},
],
"spotify_track": {
"id": "sp-1",
"name": "Song One",
"artists": [{"name": "Artist One"}],
"album": {"name": "Album One"},
},
}
source_context = {"playlist_name": "Playlist One"}
result = service.add_failed_track_from_modal(
track_info,
source_type="playlist",
source_context=source_context,
profile_id=3,
)
assert result is True
assert fake_db.add_calls[0]["spotify_track_data"]["id"] == "sp-1"
assert fake_db.add_calls[0]["failure_reason"] == "Download cancelled"
assert fake_db.add_calls[0]["source_type"] == "playlist"
assert fake_db.add_calls[0]["profile_id"] == 3
assert fake_db.add_calls[0]["source_info"]["playlist_name"] == "Playlist One"
assert fake_db.add_calls[0]["source_info"]["original_modal_data"] == {
"download_index": 4,
"table_index": 4,
"candidates": [
{
"title": "Candidate Song",
"artist": "Candidate Artist",
"filename": "track.flac",
},
{"title": "kept"},
],
}
def test_add_failed_track_from_modal_returns_false_when_no_spotify_track_found():
fake_db = _FakeWishlistDatabase()
service = _build_service(fake_db)
result = service.add_failed_track_from_modal({"track_info": {}}, profile_id=1)
assert result is False
assert fake_db.add_calls == []
def test_get_wishlist_tracks_for_download_formats_modal_shape():
fake_db = _FakeWishlistDatabase(
tracks=[
{
"id": "wl-1",
"spotify_track_id": "sp-1",
"spotify_data": {
"id": "sp-1",
"name": "Song One",
"artists": [{"name": "Artist One"}],
"album": {"name": "Album One"},
"duration_ms": 321,
"preview_url": "https://example.test/preview",
"external_urls": {"spotify": "https://open.spotify.com/track/sp-1"},
"popularity": 88,
"track_number": 7,
"disc_number": 2,
},
"failure_reason": "Download failed",
"retry_count": 2,
"date_added": "2024-01-01",
"last_attempted": "2024-01-02",
"source_type": "playlist",
"source_info": {"playlist_name": "Playlist One"},
}
]
)
service = _build_service(fake_db)
formatted_tracks = service.get_wishlist_tracks_for_download(limit=1, profile_id=7)
assert fake_db.track_queries == [("get_wishlist_tracks", 1, 7)]
assert formatted_tracks == [
{
"wishlist_id": "wl-1",
"spotify_track_id": "sp-1",
"spotify_data": {
"id": "sp-1",
"name": "Song One",
"artists": [{"name": "Artist One"}],
"album": {"name": "Album One"},
"duration_ms": 321,
"preview_url": "https://example.test/preview",
"external_urls": {"spotify": "https://open.spotify.com/track/sp-1"},
"popularity": 88,
"track_number": 7,
"disc_number": 2,
},
"failure_reason": "Download failed",
"retry_count": 2,
"date_added": "2024-01-01",
"last_attempted": "2024-01-02",
"source_type": "playlist",
"source_info": {"playlist_name": "Playlist One"},
"id": "sp-1",
"name": "Song One",
"artists": [{"name": "Artist One"}],
"album": {"name": "Album One"},
"duration_ms": 321,
"preview_url": "https://example.test/preview",
"external_urls": {"spotify": "https://open.spotify.com/track/sp-1"},
"popularity": 88,
"track_number": 7,
"disc_number": 2,
}
]
def test_check_track_in_wishlist_and_find_matching_wishlist_track_handle_id_variants():
fake_db = _FakeWishlistDatabase(
tracks=[
{
"id": "wl-1",
"spotify_track_id": "sp-1",
"name": "Song One",
"artists": [{"name": "Artist One"}],
"spotify_data": {
"id": "sp-1",
"name": "Song One",
"artists": [{"name": "Artist One"}],
"album": {"name": "Album One"},
},
"failure_reason": "Download failed",
"retry_count": 1,
"date_added": "2024-01-01",
"last_attempted": None,
"source_type": "playlist",
"source_info": {},
},
{
"id": "sp-2",
"spotify_track_id": "sp-2",
"name": "Song Two",
"artists": ["Artist Two"],
"spotify_data": {
"id": "sp-2",
"name": "Song Two",
"artists": [{"name": "Artist Two"}],
"album": {"name": "Album Two"},
},
"failure_reason": "Download failed",
"retry_count": 1,
"date_added": "2024-01-02",
"last_attempted": None,
"source_type": "manual",
"source_info": {},
},
]
)
service = _build_service(fake_db)
formatted_tracks = service.get_wishlist_tracks_for_download()
assert service.check_track_in_wishlist("sp-1") is True
assert service.check_track_in_wishlist("sp-2") is True
assert service.check_track_in_wishlist("missing") is False
assert service.find_matching_wishlist_track("Song One", "Artist One") == formatted_tracks[0]
assert service.find_matching_wishlist_track("Song Two", "Artist Two") == formatted_tracks[1]
assert service.find_matching_wishlist_track("Missing", "Nobody") is None
def test_get_wishlist_summary_returns_empty_summary_when_count_is_zero():
fake_db = _FakeWishlistDatabase(tracks=[], count=0)
service = _build_service(fake_db)
assert service.get_wishlist_summary(profile_id=2) == {
"total_tracks": 0,
"by_source_type": {},
"recent_failures": [],
}
def test_get_wishlist_summary_groups_by_source_type_and_limits_recent_failures():
fake_db = _FakeWishlistDatabase(
tracks=[
{
"source_type": "playlist",
"failure_reason": "f1",
"retry_count": 1,
"date_added": "2024-01-01",
"spotify_data": {"name": "Song A", "artists": [{"name": "Artist A"}]},
},
{
"source_type": "playlist",
"failure_reason": "f2",
"retry_count": 2,
"date_added": "2024-01-02",
"spotify_data": {"name": "Song B", "artists": ["Artist B"]},
},
{
"source_type": "album",
"failure_reason": "f3",
"retry_count": 3,
"date_added": "2024-01-03",
"spotify_data": {"name": "Song C", "artists": [{"name": "Artist C"}]},
},
{
"source_type": "manual",
"failure_reason": "f4",
"retry_count": 4,
"date_added": "2024-01-04",
"spotify_data": {"name": "Song D", "artists": [{"name": "Artist D"}]},
},
{
"source_type": "manual",
"failure_reason": "f5",
"retry_count": 5,
"date_added": "2024-01-05",
"spotify_data": {"name": "Song E", "artists": [{"name": "Artist E"}]},
},
{
"source_type": "manual",
"failure_reason": "f6",
"retry_count": 6,
"date_added": "2024-01-06",
"spotify_data": {"name": "Song F", "artists": [{"name": "Artist F"}]},
},
]
)
service = _build_service(fake_db)
summary = service.get_wishlist_summary(profile_id=4)
assert summary["total_tracks"] == 6
assert summary["by_source_type"] == {"playlist": 2, "album": 1, "manual": 3}
assert len(summary["recent_failures"]) == 5
assert summary["recent_failures"][0] == {
"name": "Song A",
"artist": "Artist A",
"failure_reason": "f1",
"retry_count": 1,
"date_added": "2024-01-01",
}
assert summary["recent_failures"][1]["artist"] == "Artist B"
assert summary["recent_failures"][-1]["name"] == "Song E"

View file

@ -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",)

File diff suppressed because it is too large Load diff