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.
224 lines
11 KiB
Python
224 lines
11 KiB
Python
"""Failed-tracks wishlist processing — lifted from web_server.py.
|
||
|
||
Body is byte-identical to the original. Wishlist helpers are
|
||
direct imports from core.wishlist.*; runtime state comes from
|
||
core.runtime_state; automation_engine, download_orchestrator, and the
|
||
sweep helper are injected via init() because they are constructed
|
||
in web_server.py.
|
||
"""
|
||
import logging
|
||
import time
|
||
|
||
from core.runtime_state import (
|
||
download_batches,
|
||
download_tasks,
|
||
tasks_lock,
|
||
)
|
||
from core.wishlist.processing import (
|
||
add_cancelled_tracks_to_failed_tracks as _add_cancelled_tracks_to_failed_tracks,
|
||
build_wishlist_source_context as _build_wishlist_source_context,
|
||
recover_uncaptured_failed_tracks as _recover_uncaptured_failed_tracks,
|
||
remove_completed_tracks_from_wishlist as _remove_completed_tracks_from_wishlist,
|
||
resolve_wishlist_source_type_for_batch as _resolve_wishlist_source_type_for_batch,
|
||
)
|
||
from core.wishlist.resolution import (
|
||
check_and_remove_from_wishlist as _check_and_remove_from_wishlist,
|
||
)
|
||
from utils.async_helpers import run_async
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Injected at runtime via init().
|
||
automation_engine = None
|
||
download_orchestrator = None
|
||
_sweep_empty_download_directories = None
|
||
|
||
|
||
def init(engine, download_orchestrator_obj, sweep_fn):
|
||
"""Bind shared singletons + the sweep helper from web_server."""
|
||
global automation_engine, download_orchestrator, _sweep_empty_download_directories
|
||
automation_engine = engine
|
||
download_orchestrator = download_orchestrator_obj
|
||
_sweep_empty_download_directories = sweep_fn
|
||
|
||
|
||
def _process_failed_tracks_to_wishlist_exact(batch_id):
|
||
"""
|
||
Process failed and cancelled tracks to wishlist - EXACT replication of sync.py's on_all_downloads_complete() logic.
|
||
This matches sync.py's behavior precisely.
|
||
"""
|
||
try:
|
||
from core.wishlist_service import get_wishlist_service
|
||
|
||
logger.info(f"[Wishlist Processing] Starting wishlist processing for batch {batch_id}")
|
||
|
||
with tasks_lock:
|
||
if batch_id not in download_batches:
|
||
logger.warning(f"[Wishlist Processing] Batch {batch_id} not found")
|
||
return {'tracks_added': 0, 'errors': 0}
|
||
|
||
batch = download_batches[batch_id]
|
||
|
||
# Wing It mode — skip wishlist entirely for failed tracks
|
||
if batch.get('wing_it'):
|
||
failed_count = len(batch.get('permanently_failed_tracks', []))
|
||
logger.error(f"[Wing It] Skipping wishlist for {failed_count} failed tracks (wing it mode)")
|
||
return {'tracks_added': 0, 'errors': 0}
|
||
permanently_failed_tracks = batch.get('permanently_failed_tracks', [])
|
||
cancelled_tracks = batch.get('cancelled_tracks', set())
|
||
|
||
# STEP 0: Remove completed tracks from wishlist (THIS WAS MISSING!)
|
||
logger.info("[Wishlist Processing] Checking completed tracks for wishlist removal")
|
||
_remove_completed_tracks_from_wishlist(
|
||
batch,
|
||
download_tasks,
|
||
_check_and_remove_from_wishlist,
|
||
)
|
||
|
||
# STEP 1: Add cancelled tracks that were missing to permanently_failed_tracks (replicating sync.py)
|
||
# This matches sync.py's logic for adding cancelled missing tracks to the failed list
|
||
if cancelled_tracks:
|
||
logger.warning(f"[Wishlist Processing] Processing {len(cancelled_tracks)} cancelled tracks")
|
||
processed_count = _add_cancelled_tracks_to_failed_tracks(
|
||
batch,
|
||
download_tasks,
|
||
permanently_failed_tracks,
|
||
)
|
||
logger.warning(f"[Wishlist Processing] Processed {processed_count} cancelled tracks")
|
||
|
||
# STEP 1.5: Recover any failed/not_found tasks not captured in permanently_failed_tracks.
|
||
# Stuck detection (in _on_download_completed, _check_batch_completion_v2, and the Safety Valve)
|
||
# can force-mark tasks as not_found/failed without adding them to permanently_failed_tracks,
|
||
# causing them to silently skip wishlist processing.
|
||
recovered_count = _recover_uncaptured_failed_tracks(
|
||
batch,
|
||
download_tasks,
|
||
permanently_failed_tracks,
|
||
)
|
||
if recovered_count:
|
||
logger.warning(f"[Wishlist Processing] Recovered {recovered_count} uncaptured failed tracks for wishlist")
|
||
|
||
# STEP 2: Add permanently failed tracks to wishlist (exact sync.py logic)
|
||
failed_count = len(permanently_failed_tracks)
|
||
wishlist_added_count = 0
|
||
error_count = 0
|
||
|
||
logger.error(f"[Wishlist Processing] Processing {failed_count} failed tracks for wishlist")
|
||
|
||
if permanently_failed_tracks:
|
||
try:
|
||
wishlist_service = get_wishlist_service()
|
||
|
||
# Create source_context identical to sync.py
|
||
source_context = _build_wishlist_source_context(batch)
|
||
batch_source_type = _resolve_wishlist_source_type_for_batch(batch)
|
||
|
||
# Process each failed track (matching sync.py's loop) with safety limit
|
||
max_failed_tracks = min(len(permanently_failed_tracks), 50) # Safety limit
|
||
wing_it_skipped = 0
|
||
for i, failed_track_info in enumerate(permanently_failed_tracks[:max_failed_tracks]):
|
||
try:
|
||
track_name = failed_track_info.get('track_name', f'Track {i+1}')
|
||
|
||
# Skip wing-it fallback tracks — they had no real metadata match,
|
||
# so adding them to wishlist would just retry with the same raw data.
|
||
# Check the track ID prefix since the wishlist payload helper overwrites source.
|
||
track_data = failed_track_info.get('track_data') or failed_track_info.get('spotify_track', {})
|
||
sp_id = track_data.get('id', '') if isinstance(track_data, dict) else ''
|
||
if str(sp_id).startswith('wing_it_'):
|
||
wing_it_skipped += 1
|
||
logger.info(f"[Wishlist Processing] Skipping wing-it track: {track_name}")
|
||
continue
|
||
|
||
logger.error(f"[Wishlist Processing] Adding track {i+1}/{max_failed_tracks}: {track_name}")
|
||
|
||
success = wishlist_service.add_failed_track_from_modal(
|
||
track_info=failed_track_info,
|
||
source_type=batch_source_type,
|
||
source_context=source_context,
|
||
profile_id=batch.get('profile_id', 1)
|
||
)
|
||
if success:
|
||
wishlist_added_count += 1
|
||
logger.info(f"[Wishlist Processing] Added {track_name} to wishlist")
|
||
try:
|
||
if automation_engine:
|
||
automation_engine.emit('wishlist_item_added', {
|
||
'artist': failed_track_info.get('artist_name', ''),
|
||
'title': track_name,
|
||
'reason': failed_track_info.get('failure_reason', ''),
|
||
})
|
||
except Exception as e:
|
||
logger.debug("emit wishlist_item_added failed: %s", e)
|
||
else:
|
||
logger.error(f"[Wishlist Processing] Failed to add {track_name} to wishlist")
|
||
|
||
except Exception as e:
|
||
error_count += 1
|
||
logger.error(f"[Wishlist Processing] Exception adding track to wishlist: {e}")
|
||
|
||
if wing_it_skipped:
|
||
logger.warning(f"[Wishlist Processing] Skipped {wing_it_skipped} wing-it fallback tracks")
|
||
logger.error(f"[Wishlist Processing] Added {wishlist_added_count}/{failed_count} failed tracks to wishlist (errors: {error_count})")
|
||
|
||
except Exception as e:
|
||
error_count = len(permanently_failed_tracks)
|
||
logger.error(f"[Wishlist Processing] Critical error adding failed tracks to wishlist: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
else:
|
||
logger.error("ℹ️ [Wishlist Processing] No failed tracks to add to wishlist")
|
||
|
||
# Store completion summary in batch for API response (matching sync.py pattern)
|
||
completion_summary = {
|
||
'tracks_added': wishlist_added_count,
|
||
'errors': error_count,
|
||
'total_failed': failed_count
|
||
}
|
||
|
||
with tasks_lock:
|
||
if batch_id in download_batches:
|
||
download_batches[batch_id]['wishlist_summary'] = completion_summary
|
||
download_batches[batch_id]['wishlist_processing_complete'] = True
|
||
# Phase already set to 'complete' in _on_download_completed
|
||
|
||
logger.info(f"[Wishlist Processing] Completed wishlist processing for batch {batch_id}")
|
||
|
||
# Auto-cleanup: Clear completed downloads from slskd
|
||
try:
|
||
logger.info(f"[Auto-Cleanup] Clearing completed downloads from slskd after batch {batch_id}")
|
||
run_async(download_orchestrator.clear_all_completed_downloads())
|
||
logger.info("[Auto-Cleanup] Completed downloads cleared from slskd")
|
||
except Exception as cleanup_error:
|
||
logger.warning(f"[Auto-Cleanup] Failed to clear completed downloads: {cleanup_error}")
|
||
|
||
# Sweep empty directories left behind by this batch's downloads
|
||
try:
|
||
_sweep_empty_download_directories()
|
||
except Exception as sweep_error:
|
||
logger.warning(f"[Auto-Cleanup] Failed to sweep empty directories: {sweep_error}")
|
||
|
||
return completion_summary
|
||
|
||
except Exception as e:
|
||
logger.error(f"[Wishlist Processing] CRITICAL ERROR in wishlist processing: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
# Mark batch as complete even with errors to prevent infinite loops
|
||
try:
|
||
with tasks_lock:
|
||
if batch_id in download_batches:
|
||
download_batches[batch_id]['phase'] = 'complete'
|
||
download_batches[batch_id]['completion_time'] = time.time() # Track for auto-cleanup
|
||
download_batches[batch_id]['wishlist_summary'] = {
|
||
'tracks_added': 0,
|
||
'errors': 1,
|
||
'total_failed': 0,
|
||
'error_message': str(e)
|
||
}
|
||
download_batches[batch_id]['wishlist_processing_complete'] = True
|
||
except Exception as lock_error:
|
||
logger.error(f"[Wishlist Processing] Failed to update batch after error: {lock_error}")
|
||
|
||
return {'tracks_added': 0, 'errors': 1, 'total_failed': 0}
|