Extract wishlist logic into dedicated package

- add core/wishlist as the home for wishlist payload, resolution, state, processing, reporting, and selection helpers
- move wishlist-specific tests into tests/wishlist alongside the new package layout
- keep web_server.py and the import/search callers as thin adapters for now
This commit is contained in:
Antti Kettunen 2026-04-28 17:05:01 +03:00
parent ed26e0f726
commit f32fc9d56e
No known key found for this signature in database
GPG key ID: C6B2A3D250359BD7
29 changed files with 3319 additions and 1640 deletions

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_service")
def sanitize_track_data_for_processing(track_data):
"""
Sanitizes track data from wishlist service to ensure consistent format.
Preserves album dict to retain full metadata (images, id, etc.) and normalizes artist field.
"""
if not isinstance(track_data, dict):
logger.info(f"[Sanitize] Unexpected track data type: {type(track_data)}")
return track_data
# Create a copy to avoid modifying original data
sanitized = track_data.copy()
# Handle album field - preserve dict format to retain full metadata (images, id, etc.)
# Downstream code already handles both dict and string formats defensively
raw_album = sanitized.get('album', '')
if not isinstance(raw_album, (dict, str)):
sanitized['album'] = str(raw_album)
# Handle artists field - ensure it's a list of strings
raw_artists = sanitized.get('artists', [])
if isinstance(raw_artists, list):
processed_artists = []
for artist in raw_artists:
if isinstance(artist, str):
processed_artists.append(artist)
elif isinstance(artist, dict) and 'name' in artist:
processed_artists.append(artist['name'])
else:
processed_artists.append(str(artist))
sanitized['artists'] = processed_artists
else:
logger.info(f"[Sanitize] Unexpected artists format: {type(raw_artists)}")
sanitized['artists'] = [str(raw_artists)] if raw_artists else []
return sanitized
def get_track_artist_name(track_info):
"""Extract artist name from track info, handling different data formats."""
if not track_info:
return "Unknown Artist"
artists = track_info.get('artists', [])
if artists and len(artists) > 0:
first_artist = artists[0]
if isinstance(first_artist, dict) and 'name' in first_artist:
return first_artist['name']
if isinstance(first_artist, str):
return first_artist
artist = track_info.get('artist')
if artist:
return artist
return "Unknown Artist"
def ensure_spotify_track_format(track_info):
"""
Ensure track_info has proper Spotify track structure for wishlist service.
Converts webui track format to match sync.py's spotify_track format.
"""
if not track_info:
return {}
# If it already has the proper Spotify structure, return as-is
if isinstance(track_info.get('artists'), list) and len(track_info.get('artists', [])) > 0:
first_artist = track_info['artists'][0]
if isinstance(first_artist, dict) and 'name' in first_artist:
# Already has proper Spotify format
return track_info
# Convert to proper Spotify format
artists_list = []
# Handle different artist formats from webui
artists = track_info.get('artists', [])
if artists:
if isinstance(artists, list):
for artist in artists:
if isinstance(artist, dict) and 'name' in artist:
artists_list.append({'name': artist['name']})
elif isinstance(artist, str):
artists_list.append({'name': artist})
else:
artists_list.append({'name': str(artist)})
else:
# Single artist as string
artists_list.append({'name': str(artists)})
else:
# Fallback: try single artist field
artist = track_info.get('artist')
if artist:
artists_list.append({'name': str(artist)})
else:
artists_list.append({'name': 'Unknown Artist'})
# Build album object - preserve ALL fields (id, release_date, total_tracks,
# album_type, images, etc.) so wishlist tracks retain full album context
# for correct folder placement, multi-disc support, and classification
album_data = track_info.get('album', {})
if isinstance(album_data, dict):
album = dict(album_data) # Copy all fields
album.setdefault('name', 'Unknown Album')
else:
album = {
'name': str(album_data) if album_data else track_info.get('name', 'Unknown Album'),
'album_type': 'single',
'total_tracks': 1,
'release_date': '',
}
album.setdefault('images', [])
album.setdefault('album_type', 'album')
album.setdefault('total_tracks', 0)
# Build proper Spotify track structure
spotify_track = {
'id': track_info.get('id', f"webui_{hash(str(track_info))}"),
'name': track_info.get('name', 'Unknown Track'),
'artists': artists_list, # Proper Spotify format
'album': album,
'duration_ms': track_info.get('duration_ms', 0),
'track_number': track_info.get('track_number', 1),
'disc_number': track_info.get('disc_number', 1),
'preview_url': track_info.get('preview_url'),
'external_urls': track_info.get('external_urls', {}),
'popularity': track_info.get('popularity', 0),
'source': 'webui_modal' # Mark as coming from webui
}
return spotify_track
def build_cancelled_task_wishlist_payload(task, profile_id: int = 1):
"""Build the wishlist payload for a cancelled download task.
This preserves the current web_server.py behavior while moving the
data-shaping logic into the wishlist package.
"""
if not task:
return {}
track_info = task.get('track_info', {})
artists_data = track_info.get('artists', [])
formatted_artists = []
for artist in artists_data:
if isinstance(artist, str):
formatted_artists.append({'name': artist})
elif isinstance(artist, dict):
if 'name' in artist and isinstance(artist['name'], str):
formatted_artists.append(artist)
elif 'name' in artist and isinstance(artist['name'], dict) and 'name' in artist['name']:
formatted_artists.append({'name': artist['name']['name']})
else:
formatted_artists.append({'name': str(artist)})
else:
formatted_artists.append({'name': str(artist)})
# Build album data - preserve all fields (including artists) for correct folder placement
album_raw = track_info.get('album', {})
if isinstance(album_raw, dict):
album_data = dict(album_raw) # Copy all fields including artists
album_data.setdefault('name', 'Unknown Album')
album_data.setdefault('album_type', track_info.get('album_type', 'album'))
# Add images fallback if not present
if 'images' not in album_data and track_info.get('album_image_url'):
album_data['images'] = [{'url': track_info.get('album_image_url')}]
else:
# album is a string (album name)
album_data = {
'name': str(album_raw) if album_raw else 'Unknown Album',
'album_type': track_info.get('album_type', 'album')
}
# Add album image if available
if track_info.get('album_image_url'):
album_data['images'] = [{'url': track_info.get('album_image_url')}]
spotify_track_data = {
'id': track_info.get('id'),
'name': track_info.get('name'),
'artists': formatted_artists,
'album': album_data,
'duration_ms': track_info.get('duration_ms')
}
source_context = {
'playlist_name': task.get('playlist_name', 'Unknown Playlist'),
'playlist_id': task.get('playlist_id'),
'added_from': 'modal_cancellation_v2',
}
return {
'spotify_track_data': spotify_track_data,
'failure_reason': 'Download cancelled by user (v2)',
'source_type': 'playlist',
'source_context': source_context,
'profile_id': profile_id,
}
def build_failed_track_wishlist_context(
track_info,
*,
track_index: int = 0,
retry_count: int = 0,
failure_reason: str = 'Download failed',
candidates=None,
):
"""Build the track-info payload used when queue tasks get added back to wishlist."""
track_info = track_info or {}
return {
'download_index': track_index,
'table_index': track_index,
'track_name': track_info.get('name', 'Unknown Track'),
'artist_name': get_track_artist_name(track_info),
'retry_count': retry_count,
'spotify_track': ensure_spotify_track_format(track_info),
'failure_reason': failure_reason,
'candidates': list(candidates or []),
}
def extract_spotify_track_from_modal_info(track_info: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""
Extract Spotify track data from modal track_info structure.
Handles different formats from sync.py and artists.py modals.
"""
try:
# Try to find Spotify track data in various locations within track_info
# Check if we have direct Spotify track reference
if "spotify_track" in track_info and track_info["spotify_track"]:
spotify_track = track_info["spotify_track"]
# Convert to dictionary if it's an object
if hasattr(spotify_track, "__dict__"):
return spotify_track_object_to_dict(spotify_track)
if isinstance(spotify_track, dict):
return spotify_track
# Check if we have slskd_result with embedded metadata
if "slskd_result" in track_info and track_info["slskd_result"]:
slskd_result = track_info["slskd_result"]
# Look for Spotify metadata in the result
if hasattr(slskd_result, "artist") and hasattr(slskd_result, "title"):
album_name = getattr(slskd_result, "album", "") or getattr(slskd_result, "title", "Unknown Album")
return {
"id": f"reconstructed_{hash(f'{slskd_result.artist}_{slskd_result.title}')}",
"name": getattr(slskd_result, "title", "Unknown Track"),
"artists": [{"name": getattr(slskd_result, "artist", "Unknown Artist")}],
"album": {"name": album_name, "images": [], "album_type": "single", "total_tracks": 1},
"duration_ms": 0,
"reconstructed": True,
}
# If no Spotify data found, try to reconstruct from available info
logger.warning("Could not find Spotify track data in modal info, attempting reconstruction")
return None
except Exception as e:
logger.error(f"Error extracting Spotify track from modal info: {e}")
return None
def spotify_track_object_to_dict(spotify_track) -> Dict[str, Any]:
"""Convert a Spotify track object or TrackResult object to a dictionary."""
try:
logger.debug(
"Converting track object to dict: type=%s has_title=%s has_artist=%s has_id=%s",
type(spotify_track),
hasattr(spotify_track, "title"),
hasattr(spotify_track, "artist"),
hasattr(spotify_track, "id"),
)
# Check if this is a TrackResult object (has title/artist but no id)
if hasattr(spotify_track, "title") and hasattr(spotify_track, "artist") and not hasattr(spotify_track, "id"):
logger.debug("Detected TrackResult object, converting")
# Handle TrackResult objects - these don't have Spotify IDs
album_name = getattr(spotify_track, "album", "") or getattr(spotify_track, "title", "Unknown Album")
result = {
"id": f"trackresult_{hash(f'{spotify_track.artist}_{spotify_track.title}')}",
"name": getattr(spotify_track, "title", "Unknown Track"),
"artists": [{"name": getattr(spotify_track, "artist", "Unknown Artist")}],
"album": {"name": album_name, "images": [], "album_type": "single", "total_tracks": 1},
"duration_ms": 0,
"preview_url": None,
"external_urls": {},
"popularity": 0,
"source": "trackresult",
}
logger.debug(
"TrackResult converted successfully: name=%s artist=%s",
result["name"],
result["artists"][0]["name"],
)
return result
# Handle regular Spotify Track objects
logger.debug("Processing as Spotify Track object")
# Handle artists list carefully to avoid TrackResult serialization issues
artists_list = []
raw_artists = getattr(spotify_track, "artists", [])
logger.debug("Raw artists: %r (type=%s)", raw_artists, type(raw_artists))
for artist in raw_artists:
logger.debug("Processing artist: %r (type=%s)", artist, type(artist))
if hasattr(artist, "name"):
artists_list.append({"name": artist.name})
elif isinstance(artist, str):
artists_list.append({"name": artist})
else:
# Convert any complex objects to string to avoid serialization issues
artists_list.append({"name": str(artist)})
# Handle album safely
album_name = "Unknown Album"
if hasattr(spotify_track, "album") and spotify_track.album:
if hasattr(spotify_track.album, "name"):
album_name = spotify_track.album.name
else:
album_name = str(spotify_track.album)
result = {
"id": getattr(spotify_track, "id", None),
"name": getattr(spotify_track, "name", "Unknown Track"),
"artists": artists_list,
"album": {"name": album_name},
"duration_ms": getattr(spotify_track, "duration_ms", 0),
"preview_url": getattr(spotify_track, "preview_url", None),
"external_urls": getattr(spotify_track, "external_urls", {}),
"popularity": getattr(spotify_track, "popularity", 0),
"track_number": getattr(spotify_track, "track_number", 1),
"disc_number": getattr(spotify_track, "disc_number", 1),
}
logger.debug(
"Spotify Track converted: name=%s artists=%s",
result["name"],
[a["name"] for a in result["artists"]],
)
# Test JSON serialization before returning to catch any remaining issues
try:
json.dumps(result)
logger.debug("Conversion result is JSON serializable")
except Exception as json_error:
logger.error("Conversion result is NOT JSON serializable: %s", json_error)
logger.error("Conversion result content: %r", result)
# Return a safe fallback
return {
"id": f"fallback_{hash(str(spotify_track))}",
"name": str(getattr(spotify_track, "name", "Unknown Track")),
"artists": [{"name": "Unknown Artist"}],
"album": {"name": "Unknown Album"},
"duration_ms": 0,
"preview_url": None,
"external_urls": {},
"popularity": 0,
"source": "fallback",
}
return result
except Exception as e:
logger.error(f"Error converting track object to dict: {e}")
logger.error(f"Object type: {type(spotify_track)}")
logger.error(f"Object attributes: {dir(spotify_track)}")
return {}
__all__ = [
"sanitize_track_data_for_processing",
"get_track_artist_name",
"ensure_spotify_track_format",
"build_cancelled_task_wishlist_payload",
"build_failed_track_wishlist_context",
"extract_spotify_track_from_modal_info",
"spotify_track_object_to_dict",
]

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

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

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

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("imports.side_effects")
def _primary_track_artist_name(track_info: Dict[str, Any]) -> str:
artists = (track_info or {}).get("artists", [])
if isinstance(artists, list) and artists:
first = artists[0]
if isinstance(first, dict):
return str(first.get("name", "") or "")
return str(first or "")
if isinstance(artists, str):
return artists
return str((track_info or {}).get("artist", "") or "")
def _all_profile_wishlist_tracks(wishlist_service, database=None) -> List[Dict[str, Any]]:
database = database or get_database()
all_profiles = database.get_all_profiles()
wishlist_tracks: List[Dict[str, Any]] = []
for profile in all_profiles:
wishlist_tracks.extend(wishlist_service.get_wishlist_tracks_for_download(profile_id=profile["id"]))
return wishlist_tracks
def check_and_remove_from_wishlist(context: Dict[str, Any], wishlist_service=None, database=None) -> None:
"""Check whether a successful download should be removed from the wishlist."""
try:
wishlist_service = wishlist_service or get_wishlist_service()
source = get_import_source(context)
source_ids = get_import_source_ids(context)
source_label = {
"spotify": "Spotify",
"itunes": "iTunes",
"deezer": "Deezer",
"discogs": "Discogs",
"hydrabase": "Hydrabase",
}.get(source, "Source")
track_info = get_import_track_info(context) or get_import_search_result(context)
search_result = get_import_original_search(context) or get_import_search_result(context)
track_id = None
if source == "spotify":
track_id = source_ids.get("track_id") or None
if track_id:
logger.info("[Wishlist] Found %s track ID from source_ids: %s", source_label, track_id)
elif "wishlist_id" in track_info:
wishlist_id = track_info["wishlist_id"]
logger.info("[Wishlist] Found wishlist_id in context: %s", wishlist_id)
wishlist_tracks = _all_profile_wishlist_tracks(wishlist_service, database=database)
for wishlist_track in wishlist_tracks:
if wishlist_track.get("wishlist_id") == wishlist_id:
track_id = wishlist_track.get("spotify_track_id") or wishlist_track.get("id")
logger.info("[Wishlist] Found track ID from wishlist entry: %s", track_id)
break
if not track_id:
track_name = track_info.get("name") or search_result.get("title", "")
artist_name = _primary_track_artist_name(track_info) or _primary_track_artist_name(search_result)
if track_name and artist_name:
logger.warning(
"[Wishlist] No track ID found, checking for fuzzy match: '%s' by '%s'",
track_name,
artist_name,
)
wishlist_tracks = _all_profile_wishlist_tracks(wishlist_service, database=database)
for wishlist_track in wishlist_tracks:
wl_name = wishlist_track.get("name", "").lower()
wl_artists = wishlist_track.get("artists", [])
wl_artist_name = ""
if wl_artists:
if isinstance(wl_artists[0], dict):
wl_artist_name = wl_artists[0].get("name", "").lower()
else:
wl_artist_name = str(wl_artists[0]).lower()
if wl_name == track_name.lower() and wl_artist_name == artist_name.lower():
track_id = wishlist_track.get("spotify_track_id") or wishlist_track.get("id")
logger.info("[Wishlist] Found fuzzy match - track ID: %s", track_id)
break
if track_id:
logger.info("[Wishlist] Attempting to remove track from wishlist: %s", track_id)
removed = wishlist_service.mark_track_download_result(track_id, success=True)
if removed:
logger.info("[Wishlist] Successfully removed track from wishlist: %s", track_id)
else:
logger.warning(" [Wishlist] Track not found in wishlist or already removed: %s", track_id)
else:
logger.warning(" [Wishlist] No track ID found for wishlist removal check")
except Exception as exc:
logger.error("[Wishlist] Error in wishlist removal check: %s", exc)
def check_and_remove_track_from_wishlist_by_metadata(
track_data: Dict[str, Any],
wishlist_service=None,
database=None,
) -> bool:
"""Remove a wishlist track by metadata after a database/library match."""
try:
wishlist_service = wishlist_service or get_wishlist_service()
track_name = track_data.get("name", "")
track_id = track_data.get("id", "")
artists = track_data.get("artists", [])
logger.info("[Analysis] Checking if track should be removed from wishlist: '%s' (ID: %s)", track_name, track_id)
if track_id:
removed = wishlist_service.mark_track_download_result(track_id, success=True)
if removed:
logger.info("[Analysis] Removed track from wishlist via direct ID match: %s", track_id)
return True
if track_name and artists:
primary_artist = _primary_track_artist_name(track_data)
if primary_artist:
logger.warning(
"[Analysis] No direct ID match, trying fuzzy match: '%s' by '%s'",
track_name,
primary_artist,
)
wishlist_tracks = _all_profile_wishlist_tracks(wishlist_service, database=database)
for wishlist_track in wishlist_tracks:
wl_name = wishlist_track.get("name", "").lower()
wl_artists = wishlist_track.get("artists", [])
wl_artist_name = ""
if wl_artists:
if isinstance(wl_artists[0], dict):
wl_artist_name = wl_artists[0].get("name", "").lower()
else:
wl_artist_name = str(wl_artists[0]).lower()
if wl_name == track_name.lower() and wl_artist_name == primary_artist.lower():
spotify_track_id = wishlist_track.get("spotify_track_id") or wishlist_track.get("id")
if spotify_track_id:
removed = wishlist_service.mark_track_download_result(spotify_track_id, success=True)
if removed:
logger.info("[Analysis] Removed track from wishlist via fuzzy match: %s", spotify_track_id)
return True
logger.warning(" [Analysis] Track not found in wishlist or already removed: '%s'", track_name)
return False
except Exception as e:
logger.error("[Analysis] Error checking wishlist removal by metadata: %s", e)
import traceback
traceback.print_exc()
return False
__all__ = ["check_and_remove_from_wishlist", "check_and_remove_track_from_wishlist_by_metadata"]

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

@ -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,233 @@
from contextlib import contextmanager
from types import SimpleNamespace
from core.wishlist.processing import WishlistAutoProcessingRuntime, process_wishlist_automatically
class _FakeLogger:
def __init__(self):
self.info_messages = []
self.warning_messages = []
self.error_messages = []
self.debug_messages = []
def info(self, msg):
self.info_messages.append(msg)
def warning(self, msg):
self.warning_messages.append(msg)
def error(self, msg):
self.error_messages.append(msg)
def debug(self, msg):
self.debug_messages.append(msg)
class _FakeProfilesDatabase:
def __init__(self, profiles):
self._profiles = profiles
def get_all_profiles(self):
return list(self._profiles)
class _FakeWishlistService:
def __init__(self, tracks, count=None):
self._tracks = tracks
self._count = count if count is not None else len(tracks)
def get_wishlist_count(self, profile_id=1):
return self._count
def get_wishlist_tracks_for_download(self, profile_id=1):
return list(self._tracks)
def mark_track_download_result(self, spotify_track_id, success, error_message=None, profile_id=1):
return True
class _FakeCursor:
def __init__(self, db):
self.db = db
self.calls = []
self._last_sql = ""
def execute(self, sql, params=None):
self.calls.append((sql, params))
self._last_sql = sql
if "INSERT OR REPLACE INTO metadata" in sql and params:
self.db.cycle_value = params[0]
def fetchone(self):
if "SELECT value FROM metadata WHERE key = 'wishlist_cycle'" in self._last_sql:
return {"value": self.db.cycle_value}
return None
class _FakeMusicDatabase:
def __init__(self, cycle_value="albums"):
self.cycle_value = cycle_value
self.cursor_obj = _FakeCursor(self)
self.commits = 0
self.duplicate_removals = []
self.track_checks = []
def _get_connection(self):
class _Conn:
def __init__(self, outer):
self.outer = outer
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def cursor(self):
return self.outer.cursor_obj
def commit(self):
self.outer.commits += 1
return _Conn(self)
def remove_wishlist_duplicates(self, profile_id=1):
self.duplicate_removals.append(profile_id)
return 0
def check_track_exists(self, track_name, artist_name, confidence_threshold=0.7, server_source=None, album=None):
self.track_checks.append((track_name, artist_name, confidence_threshold, server_source, album))
return None, 0.0
class _FakeExecutor:
def __init__(self):
self.submissions = []
def submit(self, fn, *args, **kwargs):
self.submissions.append((fn, args, kwargs))
return SimpleNamespace()
class _FakeLock:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def _build_runtime(
*,
tracks,
cycle_value="albums",
count=None,
profiles=None,
active_server="navidrome",
progress_calls=None,
guard_events=None,
batch_map=None,
):
if progress_calls is None:
progress_calls = []
if guard_events is None:
guard_events = []
if batch_map is None:
batch_map = {}
wishlist_service = _FakeWishlistService(tracks, count=count)
profiles_db = _FakeProfilesDatabase(profiles or [{"id": 1}])
music_db = _FakeMusicDatabase(cycle_value=cycle_value)
executor = _FakeExecutor()
logger = _FakeLogger()
@contextmanager
def guard():
guard_events.append("enter")
try:
yield True
finally:
guard_events.append("exit")
@contextmanager
def app_context():
yield
def progress_callback(*args, **kwargs):
progress_calls.append((args, kwargs))
runtime = WishlistAutoProcessingRuntime(
processing_guard=guard,
is_actually_processing=lambda: False,
app_context_factory=app_context,
get_wishlist_service=lambda: wishlist_service,
get_profiles_database=lambda: profiles_db,
get_music_database=lambda: music_db,
download_batches=batch_map,
tasks_lock=_FakeLock(),
update_automation_progress=progress_callback,
automation_engine=None,
missing_download_executor=executor,
run_full_missing_tracks_process=lambda *args, **kwargs: None,
get_batch_max_concurrent=lambda: 4,
get_active_server=lambda: active_server,
logger=logger,
current_time_fn=lambda: 123.0,
profile_id=1,
)
return runtime, wishlist_service, profiles_db, music_db, executor, logger, progress_calls, guard_events
def test_process_wishlist_automatically_toggles_cycle_when_no_tracks_match_current_cycle():
runtime, _service, _profiles_db, music_db, executor, logger, progress_calls, guard_events = _build_runtime(
tracks=[
{
"name": "Single Track",
"artists": [{"name": "Artist A"}],
"spotify_data": {"album": {"album_type": "single"}},
}
],
cycle_value="albums",
count=1,
)
process_wishlist_automatically(runtime, automation_id="auto-1")
assert executor.submissions == []
assert music_db.cycle_value == "singles"
assert music_db.commits == 1
assert any("No albums tracks in wishlist" in msg for msg in logger.warning_messages)
assert guard_events == ["enter", "exit"]
assert [kwargs.get("progress") for _args, kwargs in progress_calls if "progress" in kwargs] == [10, 25, 40]
def test_process_wishlist_automatically_creates_batch_for_matching_tracks():
batch_map = {}
runtime, _service, _profiles_db, music_db, executor, logger, progress_calls, guard_events = _build_runtime(
tracks=[
{
"name": "Album Track",
"artists": [{"name": "Artist A"}],
"spotify_data": {"album": {"album_type": "album"}},
}
],
cycle_value="albums",
count=1,
batch_map=batch_map,
)
process_wishlist_automatically(runtime, automation_id="auto-2")
assert len(executor.submissions) == 1
submitted_fn, submitted_args, submitted_kwargs = executor.submissions[0]
assert submitted_args[1] == "wishlist"
assert submitted_args[2][0]["_original_index"] == 0
assert len(batch_map) == 1
batch = next(iter(batch_map.values()))
assert batch["phase"] == "analysis"
assert batch["playlist_name"] == "Wishlist (Auto - Albums)"
assert batch["analysis_total"] == 1
assert any(kwargs.get("progress") == 50 for _args, kwargs in progress_calls)
assert guard_events == ["enter", "exit"]
assert any("Starting automatic wishlist batch" in msg for msg in logger.info_messages)

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)
music_db = _FakeMusicDatabase(owned_matches=owned_matches)
executor = _FakeExecutor()
logger = _FakeLogger()
activity_calls = []
batch_map = batch_map or {}
runtime = WishlistManualDownloadRuntime(
get_wishlist_service=lambda: wishlist_service,
get_music_database=lambda: music_db,
download_batches=batch_map,
tasks_lock=_FakeLock(),
missing_download_executor=executor,
run_full_missing_tracks_process=lambda *args, **kwargs: None,
get_batch_max_concurrent=lambda: 4,
add_activity_item=lambda *args: activity_calls.append(args),
active_server="navidrome",
logger=logger,
profile_id=1,
)
return runtime, wishlist_service, music_db, executor, logger, activity_calls, batch_map
def test_start_manual_wishlist_download_batch_filters_track_ids_and_starts_batch():
runtime, _service, _db, executor, logger, activity_calls, batch_map = _build_runtime(
tracks=[
{
"id": "track-1",
"name": "Song 1",
"artists": [{"name": "Artist 1"}],
"album": {"name": "Album 1", "album_type": "album"},
},
{
"id": "track-2",
"name": "Song 2",
"artists": [{"name": "Artist 2"}],
"album": {"name": "Album 2", "album_type": "album"},
},
]
)
payload, status = processing.start_manual_wishlist_download_batch(
runtime,
track_ids=["track-2"],
category=None,
)
assert status == 200
assert payload["success"] is True
assert "batch_id" in payload
assert activity_calls == [("", "Wishlist Download Started", "1 tracks", "Now")]
assert len(executor.submissions) == 1
_submitted_fn, submitted_args, _submitted_kwargs = executor.submissions[0]
assert submitted_args[1] == "wishlist"
assert submitted_args[2][0]["id"] == "track-2"
assert submitted_args[2][0]["_original_index"] == 0
assert batch_map[payload["batch_id"]]["analysis_total"] == 1
assert batch_map[payload["batch_id"]]["force_download_all"] is True
assert any("Filtered to 1 specific tracks by ID" in msg for msg in logger.info_messages)
def test_start_manual_wishlist_download_batch_skips_enhance_tracks_during_cleanup():
runtime, service, _db, executor, logger, activity_calls, batch_map = _build_runtime(
tracks=[
{
"id": "enhance-1",
"name": "Enhance Song",
"artists": [{"name": "Artist A"}],
"album": {"name": "Enhance Album", "album_type": "album"},
"source_type": "enhance",
},
{
"id": "owned-1",
"name": "Owned Song",
"artists": [{"name": "Artist B"}],
"album": {"name": "Owned Album", "album_type": "album"},
},
],
owned_matches={("Owned Song", "Artist B")},
)
payload, status = processing.start_manual_wishlist_download_batch(runtime)
assert status == 200
assert payload["success"] is True
assert service.removed_ids == {"owned-1"}
assert len(executor.submissions) == 1
_submitted_fn, submitted_args, _submitted_kwargs = executor.submissions[0]
assert [track["id"] for track in submitted_args[2]] == ["enhance-1"]
assert batch_map[payload["batch_id"]]["analysis_total"] == 1
assert activity_calls == [("", "Wishlist Download Started", "1 tracks", "Now")]
assert any("Cleaned up 1 already-owned tracks" in msg for msg in logger.info_messages)

View file

@ -0,0 +1,58 @@
from types import SimpleNamespace
from core.wishlist import payloads
def test_sanitize_track_data_for_processing_normalizes_artists_and_album():
track = {
"name": "Song",
"album": 123,
"artists": [{"name": "Artist One"}, "Artist Two", SimpleNamespace(name="Artist Three")],
}
out = payloads.sanitize_track_data_for_processing(track)
assert out["album"] == "123"
assert out["artists"] == ["Artist One", "Artist Two", "namespace(name='Artist Three')"]
def test_get_track_artist_name_prefers_artists_list_then_artist_field():
assert payloads.get_track_artist_name({"artists": [{"name": "Artist One"}]}) == "Artist One"
assert payloads.get_track_artist_name({"artist": "Solo Artist"}) == "Solo Artist"
assert payloads.get_track_artist_name({}) == "Unknown Artist"
def test_ensure_spotify_track_format_preserves_existing_shape():
track = {
"id": "sp-1",
"name": "Song",
"artists": [{"name": "Artist One"}],
"album": {"name": "Album", "album_type": "ep", "total_tracks": 4},
}
out = payloads.ensure_spotify_track_format(track)
assert out is track
def test_ensure_spotify_track_format_builds_webui_shape():
track = {
"name": "Song",
"artist": "Artist One",
"album": {"name": "Album One", "release_date": "2024-01-01"},
"duration_ms": 1234,
"track_number": 7,
"disc_number": 2,
"preview_url": "https://example.test/preview",
"external_urls": {"spotify": "https://open.spotify.com/track/1"},
"popularity": 42,
}
out = payloads.ensure_spotify_track_format(track)
assert out["name"] == "Song"
assert out["artists"] == [{"name": "Artist One"}]
assert out["album"]["name"] == "Album One"
assert out["album"]["album_type"] == "album"
assert out["album"]["total_tracks"] == 0
assert out["source"] == "webui_modal"

View file

@ -0,0 +1,225 @@
from contextlib import contextmanager
from core.wishlist import processing
class _FakeLogger:
def __init__(self):
self.errors = []
self.infos = []
self.warnings = []
def error(self, msg):
self.errors.append(msg)
def info(self, msg):
self.infos.append(msg)
def warning(self, msg):
self.warnings.append(msg)
class _FakeAutomationEngine:
def __init__(self):
self.events = []
def emit(self, name, payload):
self.events.append((name, payload))
class _FakeCursor:
def __init__(self):
self.calls = []
def execute(self, sql, params=None):
self.calls.append((sql, params))
class _FakeConnection:
def __init__(self):
self.cursor_obj = _FakeCursor()
self.committed = False
def cursor(self):
return self.cursor_obj
def commit(self):
self.committed = True
class _FakeDB:
def __init__(self):
self.connection = _FakeConnection()
@contextmanager
def _get_connection(self):
yield self.connection
class _FakeLock:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def test_remove_completed_tracks_from_wishlist_calls_remover():
batch = {"queue": ["a", "b"]}
download_tasks = {
"a": {"status": "completed", "track_info": {"name": "Song A"}},
"b": {"status": "failed", "track_info": {"name": "Song B"}},
}
calls = []
removed = processing.remove_completed_tracks_from_wishlist(
batch,
download_tasks,
lambda context: calls.append(context),
logger=_FakeLogger(),
)
assert removed == 1
assert calls == [{"track_info": {"name": "Song A"}, "original_search_result": {"name": "Song A"}}]
def test_add_cancelled_tracks_to_failed_tracks_builds_entries():
batch = {"queue": ["a"], "cancelled_tracks": {1}}
download_tasks = {
"a": {
"status": "cancelled",
"track_index": 1,
"track_info": {"name": "Song A", "artist": "Artist A", "artists": [{"name": "Artist A"}]},
"cached_candidates": [{"title": "candidate"}],
}
}
failed = []
processed = processing.add_cancelled_tracks_to_failed_tracks(
batch,
download_tasks,
failed,
logger=_FakeLogger(),
)
assert processed == 1
assert failed[0]["track_name"] == "Song A"
assert failed[0]["artist_name"] == "Artist A"
assert failed[0]["failure_reason"] == "Download cancelled"
def test_recover_uncaptured_failed_tracks_builds_entries():
batch = {"queue": ["a"]}
download_tasks = {
"a": {
"status": "failed",
"track_index": 2,
"track_info": {"name": "Song B", "artist": "Artist B", "artists": [{"name": "Artist B"}]},
"retry_count": 3,
"error_message": "boom",
"cached_candidates": [],
}
}
failed = []
recovered = processing.recover_uncaptured_failed_tracks(
batch,
download_tasks,
failed,
logger=_FakeLogger(),
)
assert recovered == 1
assert failed[0]["track_name"] == "Song B"
assert failed[0]["retry_count"] == 3
assert failed[0]["failure_reason"] == "boom"
def test_finalize_auto_wishlist_completion_toggles_cycle_and_resets_state():
db = _FakeDB()
automation_engine = _FakeAutomationEngine()
resets = []
activities = []
summary = {"tracks_added": 2, "total_failed": 5, "errors": 0}
result = processing.finalize_auto_wishlist_completion(
"batch-1",
summary,
download_batches={"batch-1": {"current_cycle": "albums"}},
tasks_lock=_FakeLock(),
reset_processing_state=lambda: resets.append(True),
add_activity_item=lambda *args: activities.append(args),
automation_engine=automation_engine,
db_factory=lambda: db,
logger=_FakeLogger(),
)
assert result is summary
assert resets == [True]
assert activities == [("", "Wishlist Updated", "2 failed tracks added to wishlist", "Now")]
assert automation_engine.events == [
(
"wishlist_processing_completed",
{
"tracks_processed": "5",
"tracks_found": "2",
"tracks_failed": "3",
},
)
]
assert db.connection.committed is True
assert db.connection.cursor_obj.calls[0][1] == ("singles",)
def test_automatic_wishlist_cleanup_after_db_update_removes_library_matches():
class _CleanupWishlistService:
def __init__(self, tracks):
self.tracks = tracks
self.removed = []
def get_wishlist_tracks_for_download(self, profile_id=1):
return list(self.tracks)
def mark_track_download_result(self, spotify_track_id, success, error_message=None, profile_id=1):
self.removed.append((spotify_track_id, success, error_message, profile_id))
return True
class _CleanupProfilesDatabase:
def get_all_profiles(self):
return [{"id": 1}]
class _CleanupMusicDatabase:
def check_track_exists(self, track_name, artist_name, confidence_threshold=0.7, server_source=None, album=None):
if track_name == "Song A" and artist_name == "Artist A":
return {"id": "db-track"}, 0.9
return None, 0.0
wishlist_service = _CleanupWishlistService(
[
{
"name": "Song A",
"artists": [{"name": "Artist A"}],
"spotify_track_id": "sp-1",
"id": "sp-1",
"album": {"name": "Album A"},
},
{
"name": "Song B",
"artists": [{"name": "Artist B"}],
"spotify_track_id": "sp-2",
"id": "sp-2",
"album": {"name": "Album B"},
},
]
)
removed = processing.automatic_wishlist_cleanup_after_db_update(
wishlist_service=wishlist_service,
profiles_database=_CleanupProfilesDatabase(),
music_database=_CleanupMusicDatabase(),
active_server="navidrome",
logger=_FakeLogger(),
)
assert removed == 1
assert wishlist_service.removed == [("sp-1", True, None, 1)]

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,79 @@
from types import SimpleNamespace
from core.wishlist import resolution
class _FakeWishlistService:
def __init__(self, tracks):
self.tracks = tracks
self.removed = []
def get_wishlist_tracks_for_download(self, profile_id=1):
return list(self.tracks)
def mark_track_download_result(self, spotify_track_id, success, error_message=None, profile_id=1):
self.removed.append((spotify_track_id, success, error_message, profile_id))
return True
def test_check_and_remove_from_wishlist_uses_search_result_fallback():
fake_db = SimpleNamespace(get_all_profiles=lambda: [{"id": 1}])
wishlist_service = _FakeWishlistService(
[
{
"wishlist_id": 11,
"spotify_track_id": "sp-track-1",
"id": "sp-track-1",
"name": "Song One",
"artists": [{"name": "Artist One"}],
}
]
)
context = {
"search_result": {
"title": "Song One",
"artist": "Artist One",
"album": "Album One",
},
"track_info": {},
"original_search_result": {},
}
resolution.check_and_remove_from_wishlist(
context,
wishlist_service=wishlist_service,
database=fake_db,
)
assert wishlist_service.removed == [("sp-track-1", True, None, 1)]
def test_check_and_remove_track_from_wishlist_by_metadata_uses_fuzzy_match():
fake_db = SimpleNamespace(get_all_profiles=lambda: [{"id": 1}])
wishlist_service = _FakeWishlistService(
[
{
"wishlist_id": 22,
"spotify_track_id": "sp-track-2",
"id": "sp-track-2",
"name": "Song Two",
"artists": [{"name": "Artist Two"}],
}
]
)
track_data = {
"name": "Song Two",
"id": "",
"artists": [{"name": "Artist Two"}],
}
removed = resolution.check_and_remove_track_from_wishlist_by_metadata(
track_data,
wishlist_service=wishlist_service,
database=fake_db,
)
assert removed is True
assert wishlist_service.removed == [("sp-track-2", True, None, 1)]

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