The manual album "Add to Wishlist" modal had NO ownership check at any layer — the album view opened the modal without ownership info, the modal added every track, and the backend (add_album_track_to_wishlist) added each unconditionally. So adding an album you (partially) own dumped the owned tracks straight into the wishlist (carlosjfcasero #825) — and the auto-cleanup doesn't reliably remove them. The bulk discography path already dedups (full missing-track analysis); this path didn't. Backend (the reliable seam): add_album_track_to_wishlist now skips a track that already exists in the library, gated on the same wishlist.allow_duplicate_tracks toggle the watchlist scan + cleanup use — OFF → skip owned (returns {success, skipped:true}), ON → add anyway. Default is ON, so default users are unaffected; the quality re-download flow uses a different endpoint, so it's untouched. Frontend: handleAddToWishlist + addModalTracksToWishlist count skipped tracks separately so the toast is honest ("Added 3 (5 already owned)" / "All N already in your library") instead of falsely claiming owned tracks were added. Tests: skips owned when duplicates off, adds missing when off, adds owned when on (and doesn't even run the check then). 205 wishlist tests pass.
497 lines
20 KiB
Python
497 lines
20 KiB
Python
"""Wishlist controller helpers for Flask-style endpoints."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import threading
|
|
from dataclasses import dataclass
|
|
from typing import Any, Callable, Dict
|
|
|
|
from core.wishlist.reporting import build_wishlist_stats_payload
|
|
from core.wishlist.selection import prepare_wishlist_tracks_for_display
|
|
from core.wishlist.service import get_wishlist_service
|
|
from core.wishlist.state import get_wishlist_cycle as _get_wishlist_cycle
|
|
from core.wishlist.state import set_wishlist_cycle as _set_wishlist_cycle
|
|
from utils.logging_config import get_logger
|
|
|
|
|
|
module_logger = get_logger("wishlist.routes")
|
|
logger = module_logger
|
|
|
|
|
|
@dataclass
|
|
class WishlistRouteRuntime:
|
|
"""Dependencies needed to service wishlist HTTP endpoints outside the controller."""
|
|
|
|
get_music_database: Callable[[], Any]
|
|
profile_id: int
|
|
download_batches: Dict[str, Dict[str, Any]]
|
|
download_tasks: Dict[str, Dict[str, Any]]
|
|
tasks_lock: Any
|
|
is_wishlist_actually_processing: Callable[[], bool]
|
|
reset_wishlist_processing_state: Callable[[], None]
|
|
add_activity_item: Callable[[Any, Any, Any, Any], Any]
|
|
active_server: str
|
|
logger: Any = module_logger
|
|
get_next_run_seconds: Callable[[str], int] | None = None
|
|
thread_factory: Callable[..., Any] = threading.Thread
|
|
|
|
|
|
def _build_album_images(album: Dict[str, Any]) -> list[dict[str, Any]]:
|
|
if isinstance(album.get("images"), list) and album.get("images"):
|
|
return list(album["images"])
|
|
if album.get("image_url"):
|
|
return [{"url": album["image_url"], "height": 640, "width": 640}]
|
|
return []
|
|
|
|
|
|
def _build_track_data(track: Dict[str, Any], album: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Project a wishlist-modal add payload into the canonical
|
|
wishlist track shape.
|
|
|
|
Pre-fix this used default values (``track_number=1``,
|
|
``disc_number=1``, ``total_tracks=1``, ``release_date=''``) when
|
|
the upstream UI omitted a field. That silently poisoned every
|
|
wishlist row added from the library "add to wishlist" modal:
|
|
track_number locked to 1 regardless of source position, year
|
|
dropped from folder paths because release_date was empty. The
|
|
library modal flow is what's used for "add this album's missing
|
|
tracks to wishlist" and "add this playlist to wishlist" bulk
|
|
actions — the most common user path, so the regression was
|
|
everywhere.
|
|
|
|
Now preserve missing values explicitly (None for numeric
|
|
positions, omit-or-empty for release_date) so the downstream
|
|
import pipeline can detect-and-recover via
|
|
``core/imports/track_number.py:resolve_track_number`` instead
|
|
of locking to 1.
|
|
"""
|
|
album_images = _build_album_images(album)
|
|
return {
|
|
"id": track.get("id"),
|
|
"name": track.get("name"),
|
|
"artists": track.get("artists", []),
|
|
"album": {
|
|
"id": album.get("id"),
|
|
"name": album.get("name"),
|
|
"artists": album.get("artists", []),
|
|
"images": album_images,
|
|
"album_type": album.get("album_type", "album"),
|
|
# release_date stays as whatever the upstream sent
|
|
# (including '' when truly unknown). Path template
|
|
# gracefully omits the year when empty; we don't fake
|
|
# a date.
|
|
"release_date": album.get("release_date", ""),
|
|
# total_tracks=None preserves "we don't know"; UI uses
|
|
# this for category classification + path math. Pre-fix
|
|
# default of 1 mislabelled multi-track albums as singles.
|
|
"total_tracks": album.get("total_tracks"),
|
|
},
|
|
"duration_ms": track.get("duration_ms", 0),
|
|
# Numeric positions: None when missing, not 1.
|
|
"track_number": track.get("track_number"),
|
|
"disc_number": track.get("disc_number"),
|
|
"explicit": track.get("explicit", False),
|
|
"popularity": track.get("popularity", 0),
|
|
"preview_url": track.get("preview_url"),
|
|
"external_urls": track.get("external_urls", {}),
|
|
}
|
|
|
|
|
|
def _build_spotify_track_data(track: Dict[str, Any], album: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Backward-compatible wrapper for `_build_track_data`."""
|
|
return _build_track_data(track, album)
|
|
|
|
|
|
def _load_track_spotify_data(track: Dict[str, Any]) -> Dict[str, Any]:
|
|
spotify_data = track.get("spotify_data", {})
|
|
if isinstance(spotify_data, str):
|
|
try:
|
|
spotify_data = json.loads(spotify_data)
|
|
except Exception:
|
|
spotify_data = {}
|
|
if not isinstance(spotify_data, dict):
|
|
spotify_data = {}
|
|
return spotify_data
|
|
|
|
|
|
def _album_lookup_id(spotify_data: Dict[str, Any]) -> tuple[str | None, Dict[str, Any]]:
|
|
album_data = spotify_data.get("album") or {}
|
|
if not isinstance(album_data, dict):
|
|
album_data = {}
|
|
|
|
track_album_id = album_data.get("id")
|
|
if not track_album_id:
|
|
album_name = album_data.get("name", "Unknown Album")
|
|
artists = spotify_data.get("artists", [])
|
|
if isinstance(artists, list) and artists and isinstance(artists[0], dict):
|
|
artist_name = artists[0].get("name", "Unknown Artist")
|
|
elif isinstance(artists, list) and artists and isinstance(artists[0], str):
|
|
artist_name = artists[0]
|
|
else:
|
|
artist_name = "Unknown Artist"
|
|
custom_id = f"{album_name}_{artist_name}"
|
|
track_album_id = re.sub(r"[^a-zA-Z0-9\s_-]", "", custom_id)
|
|
track_album_id = re.sub(r"\s+", "_", track_album_id).lower()
|
|
|
|
return track_album_id, album_data
|
|
|
|
|
|
def process_wishlist_api(
|
|
runtime: WishlistRouteRuntime,
|
|
*,
|
|
start_processing: Callable[[], None],
|
|
) -> tuple[Dict[str, Any], int]:
|
|
"""Trigger wishlist processing in the background."""
|
|
try:
|
|
if runtime.is_wishlist_actually_processing():
|
|
return {"success": False, "error": "Wishlist processing already in progress"}, 409
|
|
|
|
thread = runtime.thread_factory(target=start_processing, daemon=True)
|
|
thread.start()
|
|
return {"success": True, "message": "Wishlist processing started"}, 200
|
|
except Exception as exc:
|
|
runtime.logger.error("Error starting wishlist processing: %s", exc)
|
|
return {"success": False, "error": str(exc)}, 500
|
|
|
|
|
|
def get_wishlist_count(runtime: WishlistRouteRuntime) -> tuple[Dict[str, Any], int]:
|
|
"""Return the current wishlist count for the active profile."""
|
|
try:
|
|
count = get_wishlist_service().get_wishlist_count(profile_id=runtime.profile_id)
|
|
return {"count": count}, 200
|
|
except Exception as exc:
|
|
runtime.logger.error("Error getting wishlist count: %s", exc)
|
|
return {"error": str(exc)}, 500
|
|
|
|
|
|
def get_wishlist_stats(runtime: WishlistRouteRuntime) -> tuple[Dict[str, Any], int]:
|
|
"""Return wishlist statistics for the UI."""
|
|
try:
|
|
raw_tracks = get_wishlist_service().get_wishlist_tracks_for_download(profile_id=runtime.profile_id)
|
|
next_run_in_seconds = runtime.get_next_run_seconds("process_wishlist") if runtime.get_next_run_seconds else 0
|
|
is_processing = runtime.is_wishlist_actually_processing()
|
|
current_cycle = _get_wishlist_cycle(runtime.get_music_database)
|
|
|
|
payload = build_wishlist_stats_payload(
|
|
raw_tracks,
|
|
next_run_in_seconds=next_run_in_seconds,
|
|
is_auto_processing=is_processing,
|
|
current_cycle=current_cycle,
|
|
)
|
|
return payload, 200
|
|
except Exception as exc:
|
|
runtime.logger.error("Error getting wishlist stats: %s", exc)
|
|
return {"error": str(exc)}, 500
|
|
|
|
|
|
def get_wishlist_cycle(runtime: WishlistRouteRuntime) -> tuple[Dict[str, Any], int]:
|
|
"""Return the current wishlist cycle."""
|
|
try:
|
|
cycle = _get_wishlist_cycle(runtime.get_music_database)
|
|
return {"cycle": cycle}, 200
|
|
except Exception as exc:
|
|
runtime.logger.error("Error getting wishlist cycle: %s", exc)
|
|
return {"error": str(exc)}, 500
|
|
|
|
|
|
def set_wishlist_cycle(runtime: WishlistRouteRuntime, cycle: str) -> tuple[Dict[str, Any], int]:
|
|
"""Persist the wishlist cycle."""
|
|
try:
|
|
if cycle not in ["albums", "singles"]:
|
|
return {"error": "Invalid cycle. Must be 'albums' or 'singles'"}, 400
|
|
|
|
_set_wishlist_cycle(runtime.get_music_database, cycle)
|
|
runtime.logger.info("Wishlist cycle set to: %s", cycle)
|
|
return {"success": True, "cycle": cycle}, 200
|
|
except Exception as exc:
|
|
runtime.logger.error("Error setting wishlist cycle: %s", exc)
|
|
return {"error": str(exc)}, 500
|
|
|
|
|
|
def get_wishlist_tracks(
|
|
runtime: WishlistRouteRuntime,
|
|
*,
|
|
category: str | None = None,
|
|
limit: int | None = None,
|
|
) -> tuple[Dict[str, Any], int]:
|
|
"""Return wishlist tracks for the modal UI."""
|
|
try:
|
|
db = runtime.get_music_database()
|
|
|
|
with runtime.tasks_lock:
|
|
wishlist_batch_active = any(
|
|
batch.get("playlist_id") == "wishlist" and batch.get("phase") in ["analysis", "downloading"]
|
|
for batch in runtime.download_batches.values()
|
|
)
|
|
|
|
if not wishlist_batch_active:
|
|
duplicates_removed = db.remove_wishlist_duplicates(profile_id=runtime.profile_id)
|
|
if duplicates_removed > 0:
|
|
runtime.logger.warning("Cleaned %s duplicate tracks from wishlist", duplicates_removed)
|
|
else:
|
|
runtime.logger.warning("Skipping wishlist duplicate cleanup - download in progress")
|
|
|
|
raw_tracks = get_wishlist_service().get_wishlist_tracks_for_download(profile_id=runtime.profile_id)
|
|
prepared = prepare_wishlist_tracks_for_display(raw_tracks, category=category, limit=limit)
|
|
|
|
if prepared["duplicates_found"] > 0:
|
|
runtime.logger.warning(
|
|
"[API-Wishlist-Tracks] Found and removed %s duplicate tracks during sanitization",
|
|
prepared["duplicates_found"],
|
|
)
|
|
|
|
if category:
|
|
runtime.logger.info(
|
|
"Wishlist filter: %s/%s tracks in '%s' category (limit: %s)",
|
|
len(prepared["tracks"]),
|
|
prepared["total"],
|
|
category,
|
|
limit or "none",
|
|
)
|
|
return {"tracks": prepared["tracks"], "category": category, "total": prepared["total"]}, 200
|
|
|
|
return {"tracks": prepared["tracks"], "total": prepared["total"]}, 200
|
|
except Exception as exc:
|
|
runtime.logger.error("Error getting wishlist tracks: %s", exc)
|
|
return {"error": str(exc)}, 500
|
|
|
|
|
|
def clear_wishlist(runtime: WishlistRouteRuntime) -> tuple[Dict[str, Any], int]:
|
|
"""Clear the wishlist and cancel active wishlist batches."""
|
|
try:
|
|
success = get_wishlist_service().clear_wishlist(profile_id=runtime.profile_id)
|
|
|
|
if success:
|
|
cancelled_count = 0
|
|
with runtime.tasks_lock:
|
|
for _batch_id, batch_data in runtime.download_batches.items():
|
|
if batch_data.get("playlist_id") == "wishlist" and batch_data.get("phase") not in (
|
|
"complete",
|
|
"error",
|
|
"cancelled",
|
|
):
|
|
batch_data["phase"] = "cancelled"
|
|
for task_id in batch_data.get("queue", []):
|
|
if task_id in runtime.download_tasks and runtime.download_tasks[task_id]["status"] not in (
|
|
"completed",
|
|
"failed",
|
|
"not_found",
|
|
"cancelled",
|
|
):
|
|
runtime.download_tasks[task_id]["status"] = "cancelled"
|
|
cancelled_count += 1
|
|
|
|
runtime.reset_wishlist_processing_state()
|
|
|
|
if cancelled_count > 0:
|
|
runtime.logger.warning("[Wishlist Clear] Cancelled %s active wishlist downloads", cancelled_count)
|
|
runtime.add_activity_item("", "Wishlist Cleared", f"Wishlist cleared and {cancelled_count} downloads cancelled", "Now")
|
|
|
|
return {
|
|
"success": True,
|
|
"message": "Wishlist cleared successfully",
|
|
"cancelled_downloads": cancelled_count,
|
|
}, 200
|
|
|
|
return {"success": False, "error": "Failed to clear wishlist"}, 500
|
|
except Exception as exc:
|
|
runtime.logger.error("Error clearing wishlist: %s", exc)
|
|
return {"success": False, "error": str(exc)}, 500
|
|
|
|
|
|
def remove_track_from_wishlist(
|
|
runtime: WishlistRouteRuntime,
|
|
spotify_track_id: str | None,
|
|
) -> tuple[Dict[str, Any], int]:
|
|
"""Remove a single track from the wishlist."""
|
|
try:
|
|
if not spotify_track_id:
|
|
return {"success": False, "error": "No spotify_track_id provided"}, 400
|
|
|
|
success = get_wishlist_service().remove_track_from_wishlist(
|
|
spotify_track_id,
|
|
profile_id=runtime.profile_id,
|
|
)
|
|
|
|
if success:
|
|
runtime.logger.info("Successfully removed track from wishlist: %s", spotify_track_id)
|
|
return {"success": True, "message": "Track removed from wishlist"}, 200
|
|
|
|
runtime.logger.warning("Failed to remove track from wishlist: %s", spotify_track_id)
|
|
return {"success": False, "error": "Track not found in wishlist"}, 404
|
|
except Exception as exc:
|
|
runtime.logger.error("Error removing track from wishlist: %s", exc)
|
|
return {"success": False, "error": str(exc)}, 500
|
|
|
|
|
|
def remove_album_from_wishlist(
|
|
runtime: WishlistRouteRuntime,
|
|
*,
|
|
album_id: str | None = None,
|
|
album_name_filter: str | None = None,
|
|
) -> tuple[Dict[str, Any], int]:
|
|
"""Remove every wishlist track that belongs to the selected album."""
|
|
try:
|
|
if not album_id and not album_name_filter:
|
|
return {"success": False, "error": "No album_id or album_name provided"}, 400
|
|
|
|
wishlist_service = get_wishlist_service()
|
|
all_tracks = wishlist_service.get_wishlist_tracks_for_download(profile_id=runtime.profile_id)
|
|
|
|
tracks_to_remove = []
|
|
for track in all_tracks:
|
|
spotify_data = _load_track_spotify_data(track)
|
|
track_album_id, album_data = _album_lookup_id(spotify_data)
|
|
|
|
matched = False
|
|
if album_id and track_album_id == album_id:
|
|
matched = True
|
|
elif album_name_filter:
|
|
track_album_name = album_data.get("name", "")
|
|
if isinstance(spotify_data.get("album"), str):
|
|
track_album_name = spotify_data["album"]
|
|
if track_album_name and track_album_name.lower().strip() == album_name_filter.lower().strip():
|
|
matched = True
|
|
|
|
if matched:
|
|
spotify_track_id = track.get("track_id") or track.get("spotify_track_id") or track.get("id")
|
|
if spotify_track_id:
|
|
tracks_to_remove.append(spotify_track_id)
|
|
|
|
removed_count = 0
|
|
album_remove_pid = runtime.profile_id
|
|
for spotify_track_id in tracks_to_remove:
|
|
if wishlist_service.remove_track_from_wishlist(spotify_track_id, profile_id=album_remove_pid):
|
|
removed_count += 1
|
|
|
|
if removed_count > 0:
|
|
runtime.logger.info("Successfully removed %s tracks from album %s", removed_count, album_id)
|
|
return {
|
|
"success": True,
|
|
"message": f"Removed {removed_count} track(s) from wishlist",
|
|
"removed_count": removed_count,
|
|
}, 200
|
|
|
|
runtime.logger.warning("No tracks found for album %s", album_id)
|
|
return {"success": False, "error": "No tracks found for this album"}, 404
|
|
except Exception as exc:
|
|
runtime.logger.error("Error removing album from wishlist: %s", exc)
|
|
return {"success": False, "error": str(exc)}, 500
|
|
|
|
|
|
def remove_batch_from_wishlist(
|
|
runtime: WishlistRouteRuntime,
|
|
spotify_track_ids,
|
|
) -> tuple[Dict[str, Any], int]:
|
|
"""Remove a batch of tracks from the wishlist."""
|
|
try:
|
|
if not spotify_track_ids or not isinstance(spotify_track_ids, list):
|
|
return {"success": False, "error": "Missing or invalid spotify_track_ids"}, 400
|
|
|
|
removed = 0
|
|
pid = runtime.profile_id
|
|
for track_id in spotify_track_ids:
|
|
if get_wishlist_service().remove_track_from_wishlist(track_id, profile_id=pid):
|
|
removed += 1
|
|
|
|
runtime.logger.info("Batch removed %s track(s) from wishlist", removed)
|
|
return {
|
|
"success": True,
|
|
"removed": removed,
|
|
"message": f"Removed {removed} track{'s' if removed != 1 else ''} from wishlist",
|
|
}, 200
|
|
except Exception as exc:
|
|
runtime.logger.error("Error batch removing from wishlist: %s", exc)
|
|
return {"success": False, "error": str(exc)}, 500
|
|
|
|
|
|
def add_album_track_to_wishlist(
|
|
runtime: WishlistRouteRuntime,
|
|
*,
|
|
track: Dict[str, Any] | None,
|
|
artist: Dict[str, Any] | None,
|
|
album: Dict[str, Any] | None,
|
|
source_type: str = "album",
|
|
source_context: Dict[str, Any] | None = None,
|
|
) -> tuple[Dict[str, Any], int]:
|
|
"""Add a single album track to the wishlist."""
|
|
try:
|
|
if not track or not artist or not album:
|
|
return {"success": False, "error": "Missing required fields: track, artist, album"}, 400
|
|
|
|
track_data = _build_track_data(track, album)
|
|
|
|
# #825: don't add a track that's already in the library, unless the user
|
|
# has opted into duplicates. The manual album "add to wishlist" modal
|
|
# otherwise dumped owned tracks straight into the wishlist with no check
|
|
# (carlosjfcasero) — and the auto-cleanup may not reliably remove them.
|
|
# Respects the same wishlist.allow_duplicate_tracks toggle the watchlist
|
|
# scan + cleanup use: OFF → skip owned, ON → add anyway. (The quality
|
|
# re-download flow uses a different endpoint, so it's unaffected.)
|
|
try:
|
|
from config.settings import config_manager as _cfg
|
|
if not _cfg.get('wishlist.allow_duplicate_tracks', True):
|
|
_db = runtime.get_music_database()
|
|
_existing, _conf = _db.check_track_exists(
|
|
track.get('name', ''), artist.get('name', ''),
|
|
confidence_threshold=0.7,
|
|
server_source=runtime.active_server,
|
|
album=album.get('name', ''),
|
|
)
|
|
if _existing and _conf >= 0.7:
|
|
runtime.logger.info(
|
|
"[Wishlist Add] skipping '%s' by '%s' — already in library "
|
|
"(allow_duplicate_tracks is off)",
|
|
track.get('name'), artist.get('name'))
|
|
return {"success": True, "skipped": True,
|
|
"message": f"'{track.get('name')}' is already in your library"}, 200
|
|
except Exception as _own_err:
|
|
runtime.logger.debug("Wishlist add ownership check failed (adding anyway): %s", _own_err)
|
|
|
|
enhanced_source_context = {
|
|
**(source_context or {}),
|
|
"artist_id": artist.get("id"),
|
|
"artist_name": artist.get("name"),
|
|
"album_id": album.get("id"),
|
|
"album_name": album.get("name"),
|
|
"added_via": "library_wishlist_modal",
|
|
}
|
|
|
|
success = get_wishlist_service().add_track_to_wishlist(
|
|
track_data=track_data,
|
|
failure_reason="Added from library (incomplete album)",
|
|
source_type=source_type,
|
|
source_context=enhanced_source_context,
|
|
profile_id=runtime.profile_id,
|
|
)
|
|
|
|
if success:
|
|
runtime.logger.info("Added track '%s' by '%s' to wishlist", track.get("name"), artist.get("name"))
|
|
return {"success": True, "message": f"Added '{track.get('name')}' to wishlist"}, 200
|
|
|
|
runtime.logger.error("Failed to add track '%s' to wishlist", track.get("name"))
|
|
return {"success": False, "error": "Failed to add track to wishlist"}, 200
|
|
except Exception as exc:
|
|
runtime.logger.error("Error adding track to wishlist: %s", exc)
|
|
import traceback
|
|
|
|
traceback.print_exc()
|
|
return {"success": False, "error": str(exc)}, 500
|
|
|
|
|
|
__all__ = [
|
|
"WishlistRouteRuntime",
|
|
"process_wishlist_api",
|
|
"get_wishlist_count",
|
|
"get_wishlist_stats",
|
|
"get_wishlist_cycle",
|
|
"set_wishlist_cycle",
|
|
"get_wishlist_tracks",
|
|
"clear_wishlist",
|
|
"remove_track_from_wishlist",
|
|
"remove_album_from_wishlist",
|
|
"remove_batch_from_wishlist",
|
|
"add_album_track_to_wishlist",
|
|
]
|