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:
parent
ed26e0f726
commit
f32fc9d56e
29 changed files with 3319 additions and 1640 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
24
core/wishlist/__init__.py
Normal 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",
|
||||
]
|
||||
38
core/wishlist/classification.py
Normal file
38
core/wishlist/classification.py
Normal 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
396
core/wishlist/payloads.py
Normal 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
48
core/wishlist/presence.py
Normal 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
717
core/wishlist/processing.py
Normal 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",
|
||||
]
|
||||
51
core/wishlist/reporting.py
Normal file
51
core/wishlist/reporting.py
Normal 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
173
core/wishlist/resolution.py
Normal 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"]
|
||||
93
core/wishlist/selection.py
Normal file
93
core/wishlist/selection.py
Normal 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
309
core/wishlist/service.py
Normal 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
134
core/wishlist/state.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
1
tests/wishlist/__init__.py
Normal file
1
tests/wishlist/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Wishlist resolver tests."""
|
||||
233
tests/wishlist/test_automation.py
Normal file
233
tests/wishlist/test_automation.py
Normal 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)
|
||||
37
tests/wishlist/test_cancelled_payload.py
Normal file
37
tests/wishlist/test_cancelled_payload.py
Normal 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"}]
|
||||
19
tests/wishlist/test_classification.py
Normal file
19
tests/wishlist/test_classification.py
Normal 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
|
||||
99
tests/wishlist/test_cleanup.py
Normal file
99
tests/wishlist/test_cleanup.py
Normal 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}
|
||||
27
tests/wishlist/test_failed_track_payload.py
Normal file
27
tests/wishlist/test_failed_track_payload.py
Normal 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"}]
|
||||
173
tests/wishlist/test_manual_download.py
Normal file
173
tests/wishlist/test_manual_download.py
Normal 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)
|
||||
58
tests/wishlist/test_payloads.py
Normal file
58
tests/wishlist/test_payloads.py
Normal 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"
|
||||
225
tests/wishlist/test_processing.py
Normal file
225
tests/wishlist/test_processing.py
Normal 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)]
|
||||
37
tests/wishlist/test_reporting.py
Normal file
37
tests/wishlist/test_reporting.py
Normal 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",
|
||||
}
|
||||
79
tests/wishlist/test_resolution.py
Normal file
79
tests/wishlist/test_resolution.py
Normal 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)]
|
||||
41
tests/wishlist/test_selection.py
Normal file
41
tests/wishlist/test_selection.py
Normal 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
|
||||
113
tests/wishlist/test_state.py
Normal file
113
tests/wishlist/test_state.py
Normal 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",)
|
||||
1243
web_server.py
1243
web_server.py
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue