Root cause (#700): the Soulseek album-bundle path downloads whole releases into a private staging dir, then per-track workers claim those files via the staging-match shortcut. When slskd files arrived without ID3 tags (common for FLAC rips), the staging cache fell back to the filename stem as the title — and stems shaped like "Artist - Album - 03 - Title" could not clear the 0.80 title- similarity threshold against the clean Spotify track name. Every track in the album went not_found, the batch ended "failed" in the Downloads UI with an empty queue, and the bundle-downloaded files just sat unused in staging. Fix: in _staging_title_variants, add a trailing-title variant by extracting the segments after a bare track-number block (e.g. "03") between " - " delimiters. Conservative — only fires when a clear digit segment is present, so real song titles with dashes like "Hold Me - Live" are left intact. Generated as an additional variant alongside the existing raw/compacted/feat-stripped/bonus-stripped forms, so behavior on already-matching files is unchanged. Downstream (#698): the album-bundle staging miss pushed every failed track to the wishlist labelled as a playlist track, and a couple of fallback paths in ensure_wishlist_track_format and the slskd-result reconstruction hardcoded album_type='single' / total_tracks=1 on the stored album dict. On wishlist requeue the path builder saw album_type='single' and routed the download through single_path, dumping the file in the Singles tree even though it belonged to an album. (Running Reorganize would fix it because the DB album linkage was still correct, but the file landed in the wrong place first.) Fixes: - new resolve_wishlist_source_type_for_batch() returns 'album' for is_album_download batches; wishlist_failed.py now calls it instead of hardcoding 'playlist' - build_wishlist_source_context() threads album_context / artist_context / is_album_download from the batch into the wishlist row so future requeue logic has authoritative routing data - the non-dict-album fallback in ensure_wishlist_track_format and the slskd-result reconstruction default album_type='album' (and total_tracks=0 = unknown) instead of lying with 'single'/1; the existing setdefault chain handles dict-shaped album data unchanged Tests: - 2 staging-match tests pin the new tail-extraction behavior against a realistic untagged slskd stem, plus a negative test that confirms a dash-in-title without a digit segment still does NOT extract a variant - 2 payload tests pin the album_type='album' default for both fallback paths - 4 processing tests pin resolve_wishlist_source_type_for_batch() and the album-context threading in build_wishlist_source_context() 3974 pass; no behavioural change on already-working flows.
755 lines
32 KiB
Python
755 lines
32 KiB
Python
"""Wishlist processing helpers."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import uuid
|
||
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
from contextlib import AbstractContextManager
|
||
from types import SimpleNamespace
|
||
from typing import Any, Callable, Dict
|
||
|
||
from core.wishlist.payloads import build_failed_track_wishlist_context
|
||
from core.wishlist.selection import filter_wishlist_tracks_by_category, sanitize_and_dedupe_wishlist_tracks
|
||
from core.wishlist.service import get_wishlist_service
|
||
from core.wishlist.state import get_wishlist_cycle, set_wishlist_cycle
|
||
from utils.logging_config import get_logger
|
||
|
||
|
||
module_logger = get_logger("wishlist.processing")
|
||
logger = module_logger
|
||
|
||
|
||
@dataclass
|
||
class WishlistAutoProcessingRuntime:
|
||
"""Dependencies needed to run automatic wishlist processing outside the controller."""
|
||
|
||
processing_guard: Callable[[], AbstractContextManager[bool]]
|
||
is_actually_processing: Callable[[], bool]
|
||
app_context_factory: Callable[[], AbstractContextManager[Any]]
|
||
get_profiles_database: Callable[[], Any]
|
||
get_music_database: Callable[[], Any]
|
||
download_batches: Dict[str, Dict[str, Any]]
|
||
tasks_lock: Any
|
||
update_automation_progress: Callable[..., Any]
|
||
automation_engine: Any
|
||
missing_download_executor: Any
|
||
run_full_missing_tracks_process: Callable[[str, str, list[dict[str, Any]]], Any]
|
||
get_batch_max_concurrent: Callable[[], int]
|
||
get_active_server: Callable[[], str]
|
||
current_time_fn: Callable[[], float]
|
||
profile_id: int = 1
|
||
logger: Any = module_logger
|
||
|
||
|
||
def remove_completed_tracks_from_wishlist(
|
||
batch: Dict[str, Any],
|
||
download_tasks: Dict[str, Dict[str, Any]],
|
||
remove_from_wishlist: Callable[[Dict[str, Any]], Any],
|
||
*,
|
||
logger=logger,
|
||
) -> int:
|
||
"""Remove completed batch tasks from the wishlist."""
|
||
removed_count = 0
|
||
for task_id in batch.get('queue', []):
|
||
if task_id in download_tasks:
|
||
task = download_tasks[task_id]
|
||
if task.get('status') == 'completed':
|
||
try:
|
||
track_info = task.get('track_info', {})
|
||
context = {'track_info': track_info, 'original_search_result': track_info}
|
||
remove_from_wishlist(context)
|
||
removed_count += 1
|
||
except Exception as exc:
|
||
logger.error(f"[Wishlist Processing] Error removing completed track from wishlist: {exc}")
|
||
return removed_count
|
||
|
||
|
||
def add_cancelled_tracks_to_failed_tracks(
|
||
batch: Dict[str, Any],
|
||
download_tasks: Dict[str, Dict[str, Any]],
|
||
permanently_failed_tracks: list[Dict[str, Any]],
|
||
*,
|
||
logger=logger,
|
||
max_process: int = 100,
|
||
) -> int:
|
||
"""Promote cancelled-but-missing tasks into the failed-track list."""
|
||
cancelled_tracks = batch.get('cancelled_tracks', set())
|
||
if not cancelled_tracks:
|
||
return 0
|
||
|
||
processed_count = 0
|
||
for task_id in batch.get('queue', [])[:max_process]:
|
||
if task_id not in download_tasks:
|
||
continue
|
||
task = download_tasks[task_id]
|
||
track_index = task.get('track_index', 0)
|
||
if track_index not in cancelled_tracks:
|
||
continue
|
||
|
||
if task.get('status', 'unknown') == 'completed':
|
||
continue
|
||
|
||
original_track_info = task.get('track_info', {})
|
||
cancelled_track_info = build_failed_track_wishlist_context(
|
||
original_track_info,
|
||
track_index=track_index,
|
||
retry_count=0,
|
||
failure_reason='Download cancelled',
|
||
candidates=task.get('cached_candidates', []),
|
||
)
|
||
|
||
if any(t.get('table_index') == track_index for t in permanently_failed_tracks):
|
||
continue
|
||
|
||
permanently_failed_tracks.append(cancelled_track_info)
|
||
processed_count += 1
|
||
logger.error(
|
||
f"[Wishlist Processing] Added cancelled missing track {cancelled_track_info['track_name']} to failed list for wishlist"
|
||
)
|
||
|
||
return processed_count
|
||
|
||
|
||
def recover_uncaptured_failed_tracks(
|
||
batch: Dict[str, Any],
|
||
download_tasks: Dict[str, Dict[str, Any]],
|
||
permanently_failed_tracks: list[Dict[str, Any]],
|
||
*,
|
||
logger=logger,
|
||
) -> int:
|
||
"""Recover tasks force-marked failed/not_found so wishlist processing does not skip them."""
|
||
recovered_count = 0
|
||
for task_id in batch.get('queue', []):
|
||
if task_id not in download_tasks:
|
||
continue
|
||
task = download_tasks[task_id]
|
||
if task.get('status') not in ('failed', 'not_found'):
|
||
continue
|
||
|
||
track_index = task.get('track_index', 0)
|
||
if any(t.get('table_index') == track_index for t in permanently_failed_tracks):
|
||
continue
|
||
|
||
original_track_info = task.get('track_info', {})
|
||
recovered_track_info = build_failed_track_wishlist_context(
|
||
original_track_info,
|
||
track_index=track_index,
|
||
retry_count=task.get('retry_count', 0),
|
||
failure_reason=task.get('error_message', 'Download failed'),
|
||
candidates=task.get('cached_candidates', []),
|
||
)
|
||
permanently_failed_tracks.append(recovered_track_info)
|
||
recovered_count += 1
|
||
logger.error(
|
||
f"[Wishlist Processing] Recovered uncaptured failed track for wishlist: {recovered_track_info['track_name']}"
|
||
)
|
||
|
||
return recovered_count
|
||
|
||
|
||
def resolve_wishlist_source_type_for_batch(batch: Dict[str, Any]) -> str:
|
||
"""Pick the wishlist ``source_type`` for failed tracks coming out of a
|
||
download batch.
|
||
|
||
Album-context batches must produce ``'album'`` provenance — the legacy
|
||
hardcoded ``'playlist'`` mislabels every album-batch failure, breaks
|
||
the wishlist UI's source filter, and makes ``by_source_type`` stats
|
||
look like all wishlist work came from playlists.
|
||
"""
|
||
return 'album' if batch.get('is_album_download') else 'playlist'
|
||
|
||
|
||
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()
|
||
context = {
|
||
'playlist_name': batch.get('playlist_name', 'Unknown Playlist'),
|
||
'playlist_id': batch.get('playlist_id', None),
|
||
'added_from': 'webui_modal',
|
||
'timestamp': current_time.isoformat(),
|
||
}
|
||
# Preserve album-batch provenance so wishlist requeue has a real signal
|
||
# for album-vs-single routing instead of relying on per-track album dicts
|
||
# that may have been mangled by reconstruction fallbacks.
|
||
if batch.get('is_album_download'):
|
||
context['is_album_download'] = True
|
||
album_ctx = batch.get('album_context')
|
||
if isinstance(album_ctx, dict):
|
||
context['album_context'] = album_ctx
|
||
artist_ctx = batch.get('artist_context')
|
||
if isinstance(artist_ctx, dict):
|
||
context['artist_context'] = artist_ctx
|
||
return context
|
||
|
||
|
||
def finalize_auto_wishlist_completion(
|
||
batch_id: str,
|
||
completion_summary: Dict[str, Any],
|
||
*,
|
||
download_batches: Dict[str, Dict[str, Any]],
|
||
tasks_lock,
|
||
reset_processing_state: Callable[[], None],
|
||
add_activity_item: Callable[[Any, Any, Any, Any], Any],
|
||
automation_engine,
|
||
db_factory: Callable[[], Any],
|
||
logger=logger,
|
||
) -> Dict[str, Any]:
|
||
"""Finalize auto wishlist processing after a batch finishes."""
|
||
tracks_added = completion_summary.get('tracks_added', 0)
|
||
total_failed = completion_summary.get('total_failed', 0)
|
||
logger.error(
|
||
f"[Auto-Wishlist] Background processing complete: {tracks_added} added to wishlist, {total_failed} failed"
|
||
)
|
||
|
||
if tracks_added > 0:
|
||
add_activity_item("", "Wishlist Updated", f"{tracks_added} failed tracks added to wishlist", "Now")
|
||
|
||
try:
|
||
with tasks_lock:
|
||
if batch_id in download_batches:
|
||
current_cycle = download_batches[batch_id].get('current_cycle', 'albums')
|
||
else:
|
||
current_cycle = 'albums'
|
||
|
||
next_cycle = 'singles' if current_cycle == 'albums' else 'albums'
|
||
|
||
db = db_factory()
|
||
with db._get_connection() as conn:
|
||
cursor = conn.cursor()
|
||
cursor.execute(
|
||
"""
|
||
INSERT OR REPLACE INTO metadata (key, value, updated_at)
|
||
VALUES ('wishlist_cycle', ?, CURRENT_TIMESTAMP)
|
||
""",
|
||
(next_cycle,),
|
||
)
|
||
conn.commit()
|
||
|
||
logger.info(f"[Auto-Wishlist] Cycle toggled after completion: {current_cycle} → {next_cycle}")
|
||
except Exception as cycle_error:
|
||
logger.error(f"[Auto-Wishlist] Error toggling cycle: {cycle_error}")
|
||
|
||
reset_processing_state()
|
||
|
||
try:
|
||
if automation_engine:
|
||
automation_engine.emit('wishlist_processing_completed', {
|
||
'tracks_processed': str(total_failed),
|
||
'tracks_found': str(tracks_added),
|
||
'tracks_failed': str(total_failed - tracks_added),
|
||
})
|
||
except Exception as e:
|
||
logger.debug("emit wishlist_processing_completed failed: %s", e)
|
||
|
||
return completion_summary
|
||
|
||
|
||
def remove_tracks_already_in_library(
|
||
wishlist_service,
|
||
profiles_database,
|
||
music_database,
|
||
active_server: str,
|
||
*,
|
||
logger=logger,
|
||
skip_track_fn: Callable[[dict[str, Any]], bool] | None = None,
|
||
log_prefix: str = "[Auto-Wishlist]",
|
||
) -> int:
|
||
"""Remove wishlist entries that are already present in the library."""
|
||
from core.library import manual_library_match as _mlm
|
||
|
||
all_profiles = profiles_database.get_all_profiles()
|
||
# Carry (profile_id, track) so the match check can be profile-scoped.
|
||
cleanup_tracks: list[tuple[int, dict]] = []
|
||
for profile in all_profiles:
|
||
pid = profile["id"]
|
||
for t in wishlist_service.get_wishlist_tracks_for_download(profile_id=pid):
|
||
cleanup_tracks.append((pid, t))
|
||
|
||
cleanup_removed = 0
|
||
for profile_id, 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
|
||
|
||
# Manual match check — skip fuzzy search if user already linked this track.
|
||
if _mlm.get_match_for_track(music_database, profile_id, track, default_source='wishlist'):
|
||
try:
|
||
removed = wishlist_service.mark_track_download_result(spotify_track_id, success=True)
|
||
if removed:
|
||
cleanup_removed += 1
|
||
logger.info(f"{log_prefix} [Manual Match] Skipped already-matched track: '{track_name}'")
|
||
except Exception as _mlm_err:
|
||
logger.error(f"{log_prefix} [Manual Match] Error removing track: {_mlm_err}")
|
||
continue
|
||
|
||
found_in_db = False
|
||
matched_artist_name = ''
|
||
for artist in artists:
|
||
if isinstance(artist, str):
|
||
artist_name = artist
|
||
elif isinstance(artist, dict) and 'name' in artist:
|
||
artist_name = artist['name']
|
||
else:
|
||
artist_name = str(artist)
|
||
|
||
try:
|
||
db_track, confidence = music_database.check_track_exists(
|
||
track_name,
|
||
artist_name,
|
||
confidence_threshold=0.7,
|
||
server_source=active_server,
|
||
album=track_album,
|
||
)
|
||
|
||
if db_track and confidence >= 0.7:
|
||
found_in_db = True
|
||
matched_artist_name = artist_name
|
||
break
|
||
except Exception:
|
||
continue
|
||
|
||
if found_in_db:
|
||
try:
|
||
removed = wishlist_service.mark_track_download_result(spotify_track_id, success=True)
|
||
if removed:
|
||
cleanup_removed += 1
|
||
logger.info(f"{log_prefix} Removed already-owned track: '{track_name}' by {matched_artist_name or artist_name}")
|
||
except Exception as remove_error:
|
||
logger.error(f"{log_prefix} Error removing track from wishlist: {remove_error}")
|
||
|
||
return cleanup_removed
|
||
|
||
|
||
@dataclass
|
||
class WishlistManualDownloadRuntime:
|
||
"""Dependencies needed to start a manual wishlist download batch outside the controller."""
|
||
|
||
get_music_database: Callable[[], Any]
|
||
download_batches: Dict[str, Dict[str, Any]]
|
||
tasks_lock: Any
|
||
missing_download_executor: Any
|
||
run_full_missing_tracks_process: Callable[[str, str, list[dict[str, Any]]], Any]
|
||
get_batch_max_concurrent: Callable[[], int]
|
||
add_activity_item: Callable[[Any, Any, Any, Any], Any]
|
||
active_server: str
|
||
profile_id: int
|
||
logger: Any = module_logger
|
||
|
||
|
||
def start_manual_wishlist_download_batch(
|
||
runtime: WishlistManualDownloadRuntime,
|
||
*,
|
||
track_ids=None,
|
||
category: str | None = None,
|
||
force_download_all: bool = False,
|
||
) -> tuple[Dict[str, Any], int]:
|
||
"""Submit a manual wishlist batch.
|
||
|
||
The batch entry is created synchronously so the frontend can start polling
|
||
status immediately. The slow library-cleanup pass and master-worker hand-off
|
||
run in the background, freeing the request handler from a 30s+ block on
|
||
per-track DB checks for large wishlists.
|
||
"""
|
||
logger = runtime.logger
|
||
|
||
try:
|
||
batch_id = str(uuid.uuid4())
|
||
playlist_id = "wishlist"
|
||
playlist_name = "Wishlist"
|
||
|
||
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(),
|
||
'queue_index': 0,
|
||
# analysis_total starts at 0; the bg job updates it after cleanup
|
||
# finishes and the real track count is known.
|
||
'analysis_total': 0,
|
||
'analysis_processed': 0,
|
||
'analysis_results': [],
|
||
'permanently_failed_tracks': [],
|
||
'cancelled_tracks': set(),
|
||
'force_download_all': True,
|
||
'profile_id': runtime.profile_id,
|
||
}
|
||
|
||
runtime.missing_download_executor.submit(
|
||
_prepare_and_run_manual_wishlist_batch,
|
||
runtime,
|
||
batch_id,
|
||
track_ids,
|
||
category,
|
||
)
|
||
|
||
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 _prepare_and_run_manual_wishlist_batch(
|
||
runtime: WishlistManualDownloadRuntime,
|
||
batch_id: str,
|
||
track_ids,
|
||
category: str | None,
|
||
) -> None:
|
||
"""Background worker for the manual wishlist batch — does the slow cleanup
|
||
+ sanitize + filter + master-worker hand-off off the request thread."""
|
||
logger = runtime.logger
|
||
|
||
try:
|
||
wishlist_service = get_wishlist_service()
|
||
db = runtime.get_music_database()
|
||
manual_profile_id = runtime.profile_id
|
||
|
||
logger.warning("[Manual-Wishlist] Cleaning duplicate tracks before download...")
|
||
duplicates_removed = db.remove_wishlist_duplicates(profile_id=manual_profile_id)
|
||
if duplicates_removed > 0:
|
||
logger.warning(f"[Manual-Wishlist] Removed {duplicates_removed} duplicate tracks")
|
||
|
||
# NOTE: We deliberately do NOT call remove_tracks_already_in_library here.
|
||
# Wishlist tracks are already known-missing (force_download_all=True is set on
|
||
# the batch). The library check duplicates the work the master worker would
|
||
# skip, and on large wishlists costs ~1s per track in serial DB lookups.
|
||
# The standalone /api/wishlist/cleanup endpoint still runs that pass when
|
||
# users explicitly ask for maintenance.
|
||
|
||
raw_wishlist_tracks = wishlist_service.get_wishlist_tracks_for_download(profile_id=manual_profile_id)
|
||
if not raw_wishlist_tracks:
|
||
logger.warning("[Manual-Wishlist] No tracks in wishlist after cleanup — marking batch complete")
|
||
with runtime.tasks_lock:
|
||
if batch_id in runtime.download_batches:
|
||
runtime.download_batches[batch_id]['phase'] = 'complete'
|
||
runtime.download_batches[batch_id]['error'] = 'No tracks in wishlist'
|
||
return
|
||
|
||
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
|
||
|
||
# Update batch with the real track count now that filtering is done
|
||
with runtime.tasks_lock:
|
||
if batch_id in runtime.download_batches:
|
||
runtime.download_batches[batch_id]['analysis_total'] = len(wishlist_tracks)
|
||
|
||
runtime.add_activity_item("", "Wishlist Download Started", f"{len(wishlist_tracks)} tracks", "Now")
|
||
|
||
logger.info(f"Starting wishlist batch {batch_id} with {len(wishlist_tracks)} tracks")
|
||
runtime.run_full_missing_tracks_process(batch_id, "wishlist", wishlist_tracks)
|
||
|
||
except Exception as exc:
|
||
logger.error(f"Error preparing manual wishlist batch {batch_id}: {exc}")
|
||
import traceback
|
||
|
||
traceback.print_exc()
|
||
with runtime.tasks_lock:
|
||
if batch_id in runtime.download_batches:
|
||
runtime.download_batches[batch_id]['phase'] = 'error'
|
||
runtime.download_batches[batch_id]['error'] = str(exc)
|
||
|
||
|
||
def cleanup_wishlist_against_library(
|
||
wishlist_service,
|
||
music_database,
|
||
profile_id: int,
|
||
active_server: str,
|
||
*,
|
||
logger=logger,
|
||
) -> tuple[Dict[str, Any], int]:
|
||
"""Remove wishlist tracks that already exist in the library for one profile."""
|
||
try:
|
||
logger.info("[Wishlist Cleanup] Starting wishlist cleanup process...")
|
||
|
||
wishlist_tracks = wishlist_service.get_wishlist_tracks_for_download(profile_id=profile_id)
|
||
if not wishlist_tracks:
|
||
return {"success": True, "message": "No tracks in wishlist to clean up", "removed_count": 0}, 200
|
||
|
||
logger.info(f"[Wishlist Cleanup] Found {len(wishlist_tracks)} tracks in wishlist")
|
||
|
||
removed_count = remove_tracks_already_in_library(
|
||
wishlist_service,
|
||
SimpleNamespace(get_all_profiles=lambda: [{"id": profile_id}]),
|
||
music_database,
|
||
active_server,
|
||
logger=logger,
|
||
log_prefix="[Wishlist Cleanup]",
|
||
)
|
||
|
||
logger.info(f"[Wishlist Cleanup] Completed cleanup: {removed_count} tracks removed from wishlist")
|
||
return {
|
||
"success": True,
|
||
"message": f"Wishlist cleanup completed: {removed_count} tracks removed",
|
||
"removed_count": removed_count,
|
||
"processed_count": len(wishlist_tracks),
|
||
}, 200
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error in wishlist cleanup: {e}")
|
||
import traceback
|
||
|
||
traceback.print_exc()
|
||
return {"success": False, "error": str(e)}, 500
|
||
|
||
|
||
def process_wishlist_automatically(runtime: WishlistAutoProcessingRuntime, automation_id=None):
|
||
"""Run automatic wishlist processing outside the controller."""
|
||
logger = runtime.logger
|
||
logger.info("[Auto-Wishlist] Timer triggered - starting automatic wishlist processing...")
|
||
|
||
try:
|
||
# CRITICAL FIX: Use smart stuck detection BEFORE acquiring lock
|
||
# This prevents deadlock and handles stuck flags (2-hour timeout)
|
||
if runtime.is_actually_processing():
|
||
logger.info("[Auto-Wishlist] Already processing (verified with stuck detection), skipping.")
|
||
return
|
||
|
||
with runtime.processing_guard() as acquired:
|
||
if not acquired:
|
||
logger.info("[Auto-Wishlist] Already processing (race condition check), skipping.")
|
||
return
|
||
|
||
with runtime.app_context_factory():
|
||
wishlist_service = get_wishlist_service()
|
||
|
||
# Check if wishlist has tracks across all profiles
|
||
database = runtime.get_profiles_database()
|
||
all_profiles = database.get_all_profiles()
|
||
count = sum(wishlist_service.get_wishlist_count(profile_id=p['id']) for p in all_profiles)
|
||
logger.info(f"[Auto-Wishlist] Wishlist count check: {count} tracks found across {len(all_profiles)} profiles")
|
||
runtime.update_automation_progress(automation_id, progress=10, phase='Checking wishlist',
|
||
log_line=f'{count} tracks across {len(all_profiles)} profiles', log_type='info')
|
||
if count == 0:
|
||
logger.warning("ℹ️ [Auto-Wishlist] No tracks in wishlist for auto-processing.")
|
||
return
|
||
|
||
logger.info(f"[Auto-Wishlist] Found {count} tracks in wishlist, starting automatic processing...")
|
||
|
||
# Check if wishlist processing is already active (auto or manual)
|
||
playlist_id = "wishlist"
|
||
with runtime.tasks_lock:
|
||
for _batch_id, batch_data in runtime.download_batches.items():
|
||
batch_playlist_id = batch_data.get('playlist_id')
|
||
# Check for both auto ('wishlist') and manual ('wishlist_manual') batches
|
||
if (batch_playlist_id in ['wishlist', 'wishlist_manual'] and
|
||
batch_data.get('phase') not in ['complete', 'error', 'cancelled']):
|
||
logger.info(f"Wishlist processing already active in another batch ({batch_playlist_id}), skipping automatic start")
|
||
return
|
||
|
||
# CRITICAL: Clean duplicates BEFORE fetching tracks to prevent count mismatches
|
||
# This prevents the "11 tracks shown but 12 counted" bug
|
||
music_database = runtime.get_music_database()
|
||
|
||
logger.warning("[Auto-Wishlist] Cleaning duplicate tracks before processing...")
|
||
for profile in all_profiles:
|
||
duplicates_removed = music_database.remove_wishlist_duplicates(profile_id=profile['id'])
|
||
if duplicates_removed > 0:
|
||
logger.warning(f"[Auto-Wishlist] Removed {duplicates_removed} duplicate tracks from profile {profile['id']}")
|
||
|
||
# NOTE: We deliberately do NOT call remove_tracks_already_in_library here.
|
||
# The batch sets force_download_all=True (see comment a few lines below),
|
||
# so wishlist tracks are treated as known-missing and the master worker
|
||
# skips per-track library lookups. Doing the same expensive scan here
|
||
# before submitting the batch defeats that optimization and adds
|
||
# ~1s per track in serial DB queries. The standalone
|
||
# /api/wishlist/cleanup endpoint still exposes that pass for users
|
||
# who want explicit maintenance.
|
||
runtime.update_automation_progress(automation_id, progress=25, phase='Preparing wishlist',
|
||
log_line='Skipped library scan — wishlist tracks treated as known-missing',
|
||
log_type='info')
|
||
|
||
# Get wishlist tracks for processing - combine all profiles
|
||
raw_wishlist_tracks = []
|
||
for profile in all_profiles:
|
||
raw_wishlist_tracks.extend(wishlist_service.get_wishlist_tracks_for_download(profile_id=profile['id']))
|
||
if not raw_wishlist_tracks:
|
||
logger.warning("No tracks returned from wishlist service.")
|
||
return
|
||
|
||
# SANITIZE: Ensure consistent data format from wishlist service
|
||
wishlist_tracks, duplicates_found = sanitize_and_dedupe_wishlist_tracks(raw_wishlist_tracks)
|
||
if duplicates_found > 0:
|
||
logger.warning(f"[Auto-Wishlist] Found and removed {duplicates_found} duplicate tracks during sanitization")
|
||
logger.info(f"[Auto-Wishlist] Sanitized {len(wishlist_tracks)} tracks from wishlist service")
|
||
|
||
# CYCLE FILTERING: Get current cycle and filter tracks by category
|
||
current_cycle = get_wishlist_cycle(lambda: music_database)
|
||
|
||
# Filter tracks by current cycle category
|
||
filtered_tracks, _ = filter_wishlist_tracks_by_category(wishlist_tracks, current_cycle)
|
||
|
||
logger.info(f"[Auto-Wishlist] Current cycle: {current_cycle}")
|
||
logger.info(f"[Auto-Wishlist] Filtered {len(filtered_tracks)}/{len(wishlist_tracks)} tracks for '{current_cycle}' category")
|
||
runtime.update_automation_progress(automation_id, progress=40, phase=f'Processing {current_cycle}',
|
||
log_line=f'Cycle: {current_cycle} — {len(filtered_tracks)} tracks to process', log_type='info')
|
||
|
||
# If no tracks in this category, skip to next cycle immediately
|
||
if len(filtered_tracks) == 0:
|
||
logger.warning(f"ℹ️ [Auto-Wishlist] No {current_cycle} tracks in wishlist, toggling cycle and scheduling next run")
|
||
|
||
# Toggle cycle
|
||
next_cycle = 'singles' if current_cycle == 'albums' else 'albums'
|
||
set_wishlist_cycle(lambda: music_database, next_cycle)
|
||
logger.info(f"[Auto-Wishlist] Cycle toggled: {current_cycle} → {next_cycle}")
|
||
return
|
||
|
||
# Use filtered tracks for processing — stamp original index
|
||
wishlist_tracks = filtered_tracks
|
||
for i, track in enumerate(wishlist_tracks):
|
||
track['_original_index'] = i
|
||
|
||
# Create batch for automatic processing
|
||
batch_id = str(uuid.uuid4())
|
||
playlist_name = f"Wishlist (Auto - {current_cycle.capitalize()})"
|
||
|
||
# Create task queue - convert wishlist tracks to expected format
|
||
with runtime.tasks_lock:
|
||
runtime.download_batches[batch_id] = {
|
||
'phase': 'analysis',
|
||
'playlist_id': playlist_id,
|
||
'playlist_name': playlist_name,
|
||
'queue': [],
|
||
'active_count': 0,
|
||
'max_concurrent': runtime.get_batch_max_concurrent(), # Wishlist always does single-track downloads, not folder grabs
|
||
'queue_index': 0,
|
||
'analysis_total': len(wishlist_tracks),
|
||
'analysis_processed': 0,
|
||
'analysis_results': [],
|
||
# Track state management (replicating sync.py)
|
||
'permanently_failed_tracks': [],
|
||
'cancelled_tracks': set(),
|
||
# Wishlist tracks are already known-missing — skip the expensive library check
|
||
'force_download_all': True,
|
||
# Mark as auto-initiated
|
||
'auto_initiated': True,
|
||
'auto_processing_timestamp': runtime.current_time_fn(),
|
||
# Store current cycle for toggling after completion
|
||
'current_cycle': current_cycle,
|
||
# Profile context for failed track wishlist re-adds (auto = profile 1 default)
|
||
'profile_id': runtime.profile_id,
|
||
}
|
||
|
||
logger.info(f"Starting automatic wishlist batch {batch_id} with {len(wishlist_tracks)} tracks")
|
||
runtime.update_automation_progress(automation_id, progress=50, phase=f'Downloading {len(wishlist_tracks)} tracks',
|
||
log_line=f'Started batch: {len(wishlist_tracks)} {current_cycle}', log_type='success')
|
||
|
||
# Submit the wishlist processing job using existing infrastructure
|
||
runtime.missing_download_executor.submit(runtime.run_full_missing_tracks_process, batch_id, playlist_id, wishlist_tracks)
|
||
|
||
# Don't mark auto_processing as False here - let completion handler do it
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error in automatic wishlist processing: {e}")
|
||
import traceback
|
||
|
||
traceback.print_exc()
|
||
runtime.update_automation_progress(automation_id, log_line=f'Error: {str(e)}', log_type='error')
|
||
raise
|
||
|
||
|
||
def automatic_wishlist_cleanup_after_db_update(
|
||
*,
|
||
wishlist_service=None,
|
||
profiles_database=None,
|
||
music_database=None,
|
||
active_server: str | None = None,
|
||
logger=logger,
|
||
) -> int:
|
||
"""Remove wishlist entries that already exist in the library after a DB update."""
|
||
try:
|
||
from config.settings import config_manager
|
||
from database.music_database import MusicDatabase, get_database
|
||
|
||
wishlist_service = wishlist_service or get_wishlist_service()
|
||
profiles_database = profiles_database or get_database()
|
||
music_database = music_database or MusicDatabase()
|
||
active_server = active_server or config_manager.get_active_media_server()
|
||
|
||
logger.info("[Auto Cleanup] Starting automatic wishlist cleanup after database update...")
|
||
|
||
all_profiles = profiles_database.get_all_profiles()
|
||
wishlist_tracks = []
|
||
for profile in all_profiles:
|
||
wishlist_tracks.extend(wishlist_service.get_wishlist_tracks_for_download(profile_id=profile["id"]))
|
||
|
||
if not wishlist_tracks:
|
||
logger.warning("[Auto Cleanup] No tracks in wishlist to clean up")
|
||
return 0
|
||
|
||
logger.info(f"[Auto Cleanup] Found {len(wishlist_tracks)} tracks in wishlist")
|
||
|
||
removed_count = remove_tracks_already_in_library(
|
||
wishlist_service,
|
||
profiles_database,
|
||
music_database,
|
||
active_server,
|
||
logger=logger,
|
||
log_prefix="[Auto Cleanup]",
|
||
)
|
||
|
||
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",
|
||
]
|