soulsync/web_server.py
Broque Thomas 93ac55a709 Improve wishlist track sorting and cleanup logic
Wishlist tracks are now sorted by artist and track name for consistent display. The database query for wishlist tracks now orders by date_added instead of randomly. Added logic to remove completed tracks from the wishlist during failed track processing. Updated docker-compose.yml to mount the H: drive for transfer folders.
2025-09-12 15:19:36 -07:00

11525 lines
534 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import os
import json
import asyncio
import requests
import socket
import ipaddress
import subprocess
import platform
import threading
import time
import shutil
import glob
import uuid
import re
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
from flask import Flask, render_template, request, jsonify, redirect, send_file, Response
# --- Core Application Imports ---
# Import the same core clients and config manager used by the GUI app
from config.settings import config_manager
from core.spotify_client import SpotifyClient, Playlist as SpotifyPlaylist, Track as SpotifyTrack
from core.plex_client import PlexClient
from core.jellyfin_client import JellyfinClient
from core.soulseek_client import SoulseekClient
from core.tidal_client import TidalClient # Added import for Tidal
from core.matching_engine import MusicMatchingEngine
from core.database_update_worker import DatabaseUpdateWorker, DatabaseStatsWorker
from database.music_database import get_database
from services.sync_service import PlaylistSyncService
from datetime import datetime
import yt_dlp
from core.matching_engine import MusicMatchingEngine
# --- Flask App Setup ---
base_dir = os.path.abspath(os.path.dirname(__file__))
project_root = os.path.dirname(base_dir) # Go up one level to the project root
config_path = os.path.join(project_root, 'config', 'config.json')
if os.path.exists(config_path):
print(f"Found config file at: {config_path}")
# Assuming your config_manager has a method to load from a specific path
if hasattr(config_manager, 'load_config'):
config_manager.load_config(config_path)
print("✅ Web server configuration loaded successfully.")
else:
# Fallback if no load_config method, try re-initializing with path
print("🔴 WARNING: config_manager does not have a 'load_config' method. Attempting re-init.")
try:
from config.settings import ConfigManager
config_manager = ConfigManager(config_path)
print("✅ Web server configuration re-initialized successfully.")
except Exception as e:
print(f"🔴 FAILED to re-initialize config_manager: {e}")
else:
print(f"🔴 WARNING: config.json not found at {config_path}. Using default settings.")
# Correctly point to the 'webui' directory for templates and static files
app = Flask(
__name__,
template_folder=os.path.join(base_dir, 'webui'),
static_folder=os.path.join(base_dir, 'webui', 'static')
)
# --- Docker Helper Functions ---
def docker_resolve_path(path_str):
"""
Resolve absolute paths for Docker container access
In Docker, Windows drive paths (E:/) need to be mapped to WSL mount points (/mnt/e/)
"""
if os.path.exists('/.dockerenv') and len(path_str) >= 3 and path_str[1] == ':' and path_str[0].isalpha():
# Convert Windows path (E:/path) to WSL mount path (/mnt/e/path)
drive_letter = path_str[0].lower()
rest_of_path = path_str[2:].replace('\\', '/') # Remove E: and convert backslashes
return f"/host/mnt/{drive_letter}{rest_of_path}"
return path_str
def extract_filename(full_path):
"""
Extract filename by working backwards from the end until we hit a separator.
This is cross-platform compatible and handles both Windows and Unix path separators.
"""
if not full_path:
return ""
last_slash = max(full_path.rfind('/'), full_path.rfind('\\'))
if last_slash != -1:
return full_path[last_slash + 1:]
else:
return full_path
# --- Initialize Core Application Components ---
print("🚀 Initializing SoulSync services for Web UI...")
try:
spotify_client = SpotifyClient()
plex_client = PlexClient()
jellyfin_client = JellyfinClient()
soulseek_client = SoulseekClient()
tidal_client = TidalClient()
matching_engine = MusicMatchingEngine()
sync_service = PlaylistSyncService(spotify_client, plex_client, soulseek_client, jellyfin_client)
print("✅ Core service clients initialized.")
except Exception as e:
print(f"🔴 FATAL: Error initializing service clients: {e}")
spotify_client = plex_client = jellyfin_client = soulseek_client = tidal_client = matching_engine = sync_service = None
# --- Global Streaming State Management ---
# Thread-safe state tracking for streaming functionality
stream_state = {
"status": "stopped", # States: stopped, loading, queued, ready, error
"progress": 0,
"track_info": None,
"file_path": None, # Path to the audio file in the 'Stream' folder
"error_message": None
}
stream_lock = threading.Lock() # Prevent race conditions
stream_background_task = None
stream_executor = ThreadPoolExecutor(max_workers=1) # Only one stream at a time
# --- Global OAuth State Management ---
# Store PKCE values for Tidal OAuth flow
tidal_oauth_state = {
"code_verifier": None,
"code_challenge": None
}
tidal_oauth_lock = threading.Lock()
db_update_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="DBUpdate")
db_update_worker = None
db_update_state = {
"status": "idle", # idle, running, finished, error
"phase": "Idle",
"progress": 0,
"current_item": "",
"processed": 0,
"total": 0,
"error_message": ""
}
# --- Sync Page Globals ---
sync_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="SyncWorker")
active_sync_workers = {} # Key: playlist_id, Value: Future object
sync_states = {} # Key: playlist_id, Value: dict with progress info
sync_lock = threading.Lock()
db_update_lock = threading.Lock()
# --- Global Matched Downloads Context Management ---
# Thread-safe storage for matched download contexts
# Key: slskd download ID, Value: dict containing Spotify artist/album data
matched_downloads_context = {}
matched_context_lock = threading.Lock()
# --- Download Missing Tracks Modal State Management ---
# Thread-safe state tracking for modal download functionality with batch management
missing_download_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="MissingTrackWorker")
download_tasks = {} # task_id -> task state dict
download_batches = {} # batch_id -> {queue, active_count, max_concurrent}
tasks_lock = threading.Lock()
batch_locks = {} # batch_id -> Lock() for atomic batch operations
# --- Automatic Wishlist Processing Infrastructure ---
# Server-side timer system for automatic wishlist processing (replaces client-side JavaScript timers)
wishlist_auto_processor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="WishlistAutoProcessor")
wishlist_auto_timer = None # threading.Timer for scheduling next auto-processing
wishlist_auto_processing = False # Flag to prevent concurrent auto-processing
wishlist_timer_lock = threading.Lock() # Thread safety for timer operations
# --- Shared Transfer Data Cache ---
# Cache transfer data to avoid hammering the Soulseek API with multiple concurrent modals
transfer_data_cache = {
'data': {},
'last_update': 0,
'update_lock': threading.Lock(),
'cache_duration': 0.75 # Cache for 0.75 seconds for faster updates
}
def get_cached_transfer_data():
"""
Get transfer data with caching to reduce API calls when multiple modals are active.
Returns a lookup dictionary for efficient transfer matching.
"""
current_time = time.time()
with transfer_data_cache['update_lock']:
# Check if cache is still valid
if (current_time - transfer_data_cache['last_update']) < transfer_data_cache['cache_duration']:
return transfer_data_cache['data']
# Cache expired or empty, fetch new data
live_transfers_lookup = {}
try:
transfers_data = asyncio.run(soulseek_client._make_request('GET', 'transfers/downloads'))
if transfers_data:
all_transfers = []
for user_data in transfers_data:
username = user_data.get('username', 'Unknown')
if 'directories' in user_data:
for directory in user_data['directories']:
if 'files' in directory:
for file_info in directory['files']:
file_info['username'] = username
all_transfers.append(file_info)
for transfer in all_transfers:
key = f"{transfer.get('username')}::{extract_filename(transfer.get('filename', ''))}"
live_transfers_lookup[key] = transfer
# Update cache
transfer_data_cache['data'] = live_transfers_lookup
transfer_data_cache['last_update'] = current_time
except Exception as e:
print(f"⚠️ Could not fetch live transfers (cached): {e}")
# Return empty dict on error, but don't update cache timestamp
# This way we'll retry on the next request
return {}
return live_transfers_lookup
# --- Background Download Monitoring (GUI Parity) ---
class WebUIDownloadMonitor:
"""
Background monitor for download progress and retry logic, matching GUI's SyncStatusProcessingWorker.
Implements identical timeout detection and automatic retry functionality.
"""
def __init__(self):
self.monitoring = False
self.monitor_thread = None
self.monitored_batches = set()
def start_monitoring(self, batch_id):
"""Start monitoring a download batch"""
self.monitored_batches.add(batch_id)
if not self.monitoring:
self.monitoring = True
self.monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True)
self.monitor_thread.start()
print(f"🔍 Started download monitor for batch {batch_id}")
def stop_monitoring(self, batch_id):
"""Stop monitoring a specific batch"""
self.monitored_batches.discard(batch_id)
if not self.monitored_batches:
self.monitoring = False
print(f"🛑 Stopped download monitor (no active batches)")
def _monitor_loop(self):
"""Main monitoring loop - checks downloads every 1 second for responsive web UX"""
while self.monitoring and self.monitored_batches:
try:
self._check_all_downloads()
time.sleep(1) # 1-second polling for fast web UI updates
except Exception as e:
# If we get shutdown errors, stop monitoring gracefully
if "interpreter shutdown" in str(e) or "cannot schedule new futures" in str(e):
print(f"🛑 Monitor detected shutdown, stopping gracefully")
self.monitoring = False
break
print(f"❌ Download monitor error: {e}")
print(f"🔍 Download monitor loop ended")
def _check_all_downloads(self):
"""Check all active downloads for timeouts and failures"""
current_time = time.time()
# Get live transfer data from slskd
live_transfers_lookup = self._get_live_transfers()
with tasks_lock:
# Check all monitored batches for timeouts and errors
for batch_id in list(self.monitored_batches):
if batch_id not in download_batches:
self.monitored_batches.discard(batch_id)
continue
for task_id in download_batches[batch_id].get('queue', []):
task = download_tasks.get(task_id)
if not task or task['status'] not in ['downloading', 'queued']:
continue
# Check for timeouts and errors - retries handled directly in _should_retry_task
self._should_retry_task(task_id, task, live_transfers_lookup, current_time)
# ENHANCED: Add worker count validation to detect ghost workers
self._validate_worker_counts()
def _get_live_transfers(self):
"""Get current transfer status from slskd API"""
try:
# Check if we should stop due to shutdown
if not self.monitoring:
return {}
transfers_data = asyncio.run(soulseek_client._make_request('GET', 'transfers/downloads'))
if not transfers_data:
return {}
live_transfers = {}
for user_data in transfers_data:
username = user_data.get('username', 'Unknown')
if 'directories' in user_data:
for directory in user_data['directories']:
if 'files' in directory:
for file_info in directory['files']:
key = f"{username}::{extract_filename(file_info.get('filename', ''))}"
live_transfers[key] = file_info
return live_transfers
except Exception as e:
# If we get shutdown-related errors, stop monitoring immediately
if ("interpreter shutdown" in str(e) or
"cannot schedule new futures" in str(e) or
"Event loop is closed" in str(e)):
print(f"🛑 Monitor detected shutdown, stopping immediately")
self.monitoring = False
return {}
else:
print(f"⚠️ Monitor: Could not fetch live transfers: {e}")
return {}
def _should_retry_task(self, task_id, task, live_transfers_lookup, current_time):
"""Determine if a task should be retried due to timeout (matches GUI logic)"""
task_filename = task.get('filename') or task['track_info'].get('filename')
task_username = task.get('username') or task['track_info'].get('username')
if not task_filename or not task_username:
return False
lookup_key = f"{task_username}::{extract_filename(task_filename)}"
live_info = live_transfers_lookup.get(lookup_key)
if not live_info:
# Task not in live transfers but status is downloading/queued - likely stuck
if current_time - task.get('status_change_time', current_time) > 90:
return True
return False
state_str = live_info.get('state', '')
progress = live_info.get('percentComplete', 0)
# IMMEDIATE ERROR RETRY: Check for errored downloads first (no timeout needed)
if 'Errored' in state_str or 'Failed' in state_str:
retry_count = task.get('error_retry_count', 0)
last_retry = task.get('last_error_retry_time', 0)
# Don't retry too frequently (wait at least 5 seconds between error retries)
if retry_count < 3 and (current_time - last_retry) > 5: # Max 3 error retry attempts
print(f"🚨 Task errored (state: {state_str}) - immediate retry {retry_count + 1}/3")
task['error_retry_count'] = retry_count + 1
task['last_error_retry_time'] = current_time
# CRITICAL: Cancel the errored download in slskd before retry
username = task.get('username') or task['track_info'].get('username')
filename = task.get('filename') or task['track_info'].get('filename')
download_id = task.get('download_id')
if username and download_id:
try:
print(f"🚫 Cancelling errored download: {download_id} from {username}")
asyncio.run(soulseek_client.cancel_download(download_id, username, remove=True))
print(f"✅ Successfully cancelled errored download {download_id}")
except Exception as cancel_error:
print(f"⚠️ Warning: Failed to cancel errored download {download_id}: {cancel_error}")
# Mark current source as used to prevent retry loops
if username and filename:
used_sources = task.get('used_sources', set())
source_key = f"{username}_{os.path.basename(filename)}"
used_sources.add(source_key)
task['used_sources'] = used_sources
print(f"🚫 Marked errored source as used: {source_key}")
# Clear download info since we cancelled it
task.pop('download_id', None)
task.pop('username', None)
task.pop('filename', None)
# Reset task state for immediate retry
task['status'] = 'searching'
task.pop('queued_start_time', None)
task.pop('downloading_start_time', None)
task['status_change_time'] = current_time
print(f"🔄 Task {task.get('track_info', {}).get('name', 'Unknown')} reset for error retry")
# CRITICAL: Immediately restart worker for error retry - don't rely on normal queue processing
batch_id = task.get('batch_id')
if task_id and batch_id:
try:
print(f"🚀 [Error Retry] Immediately restarting worker for task {task_id}")
missing_download_executor.submit(_download_track_worker, task_id, batch_id)
print(f"✅ [Error Retry] Successfully restarted worker for task {task_id}")
except Exception as restart_error:
print(f"❌ [Error Retry] Failed to restart worker for task {task_id}: {restart_error}")
task['status'] = 'failed'
task['error_message'] = f'Failed to restart worker: {restart_error}'
return False
elif retry_count < 3:
# Wait a bit before next error retry
return False
else:
# Too many error retries, mark as failed
print(f"❌ Task failed after 3 error retry attempts")
task['status'] = 'failed'
task['error_message'] = 'Failed after multiple error retries'
return False
# Check for queued timeout (90 seconds like GUI)
elif 'Queued' in state_str or task['status'] == 'queued':
if 'queued_start_time' not in task:
task['queued_start_time'] = current_time
return False
else:
queue_time = current_time - task['queued_start_time']
# Use context-aware timeouts like GUI:
# - 15 seconds for artist album downloads (streaming context)
# - 90 seconds for background playlist downloads
is_streaming_context = task.get('track_info', {}).get('is_album_download', False)
timeout_threshold = 15.0 if is_streaming_context else 90.0
if queue_time > timeout_threshold:
# Track retry attempts to prevent rapid loops
retry_count = task.get('stuck_retry_count', 0)
last_retry = task.get('last_retry_time', 0)
# Don't retry too frequently (wait at least 30 seconds between retries)
if retry_count < 3 and (current_time - last_retry) > 30: # Max 3 retry attempts
print(f"⚠️ Task stuck in queue for {queue_time:.1f}s - immediate retry {retry_count + 1}/3")
task['stuck_retry_count'] = retry_count + 1
task['last_retry_time'] = current_time
# CRITICAL: Cancel the stuck download in slskd before retry
username = task.get('username') or task['track_info'].get('username')
filename = task.get('filename') or task['track_info'].get('filename')
download_id = task.get('download_id')
if username and download_id:
try:
print(f"🚫 Cancelling stuck queued download: {download_id} from {username}")
asyncio.run(soulseek_client.cancel_download(download_id, username, remove=True))
print(f"✅ Successfully cancelled stuck download {download_id}")
except Exception as cancel_error:
print(f"⚠️ Warning: Failed to cancel stuck download {download_id}: {cancel_error}")
# UNIFIED RETRY LOGIC: Handle timeout retry exactly like error retry
# Mark current source as used to prevent retry loops
if username and filename:
used_sources = task.get('used_sources', set())
source_key = f"{username}_{os.path.basename(filename)}"
used_sources.add(source_key)
task['used_sources'] = used_sources
print(f"🚫 Marked timeout source as used: {source_key}")
# Clear download info since we cancelled it
task.pop('download_id', None)
task.pop('username', None)
task.pop('filename', None)
# Reset task state for immediate retry (like error retry)
task['status'] = 'searching'
task.pop('queued_start_time', None)
task.pop('downloading_start_time', None)
task['status_change_time'] = current_time
print(f"🔄 Task {task.get('track_info', {}).get('name', 'Unknown')} reset for timeout retry")
# CRITICAL: Immediately restart worker for timeout retry - don't rely on normal queue processing
batch_id = task.get('batch_id')
if task_id and batch_id:
try:
print(f"🚀 [Timeout Retry] Immediately restarting worker for task {task_id}")
missing_download_executor.submit(_download_track_worker, task_id, batch_id)
print(f"✅ [Timeout Retry] Successfully restarted worker for task {task_id}")
except Exception as restart_error:
print(f"❌ [Timeout Retry] Failed to restart worker for task {task_id}: {restart_error}")
task['status'] = 'failed'
task['error_message'] = f'Failed to restart worker: {restart_error}'
return False
elif retry_count < 3:
# Wait longer before next retry
return False
else:
# Too many retries, mark as failed
print(f"❌ Task failed after 3 retry attempts (queue timeout)")
task['status'] = 'failed'
task['error_message'] = 'Failed after multiple queue timeout retries'
# Clear timers to prevent further retry loops
task.pop('queued_start_time', None)
task.pop('downloading_start_time', None)
return False
# Check for downloading at 0% timeout (90 seconds like GUI)
elif 'InProgress' in state_str and progress < 1:
if 'downloading_start_time' not in task:
task['downloading_start_time'] = current_time
return False
else:
download_time = current_time - task['downloading_start_time']
# Use context-aware timeouts like GUI:
# - 15 seconds for artist album downloads (streaming context)
# - 90 seconds for background playlist downloads
is_streaming_context = task.get('track_info', {}).get('is_album_download', False)
timeout_threshold = 15.0 if is_streaming_context else 90.0
if download_time > timeout_threshold:
retry_count = task.get('stuck_retry_count', 0)
last_retry = task.get('last_retry_time', 0)
# Don't retry too frequently (wait at least 30 seconds between retries)
if retry_count < 3 and (current_time - last_retry) > 30: # Max 3 retry attempts
print(f"⚠️ Task stuck at 0% for {download_time:.1f}s - immediate retry {retry_count + 1}/3")
task['stuck_retry_count'] = retry_count + 1
task['last_retry_time'] = current_time
# CRITICAL: Cancel the stuck download in slskd before retry
username = task.get('username') or task['track_info'].get('username')
filename = task.get('filename') or task['track_info'].get('filename')
download_id = task.get('download_id')
if username and download_id:
try:
print(f"🚫 Cancelling stuck 0% download: {download_id} from {username}")
asyncio.run(soulseek_client.cancel_download(download_id, username, remove=True))
print(f"✅ Successfully cancelled stuck 0% download {download_id}")
except Exception as cancel_error:
print(f"⚠️ Warning: Failed to cancel stuck 0% download {download_id}: {cancel_error}")
# UNIFIED RETRY LOGIC: Handle 0% timeout retry exactly like error retry
# Mark current source as used to prevent retry loops
if username and filename:
used_sources = task.get('used_sources', set())
source_key = f"{username}_{os.path.basename(filename)}"
used_sources.add(source_key)
task['used_sources'] = used_sources
print(f"🚫 Marked 0% progress source as used: {source_key}")
# Clear download info since we cancelled it
task.pop('download_id', None)
task.pop('username', None)
task.pop('filename', None)
# Reset task state for immediate retry (like error retry)
task['status'] = 'searching'
task.pop('queued_start_time', None)
task.pop('downloading_start_time', None)
task['status_change_time'] = current_time
print(f"🔄 Task {task.get('track_info', {}).get('name', 'Unknown')} reset for 0% retry")
# CRITICAL: Immediately restart worker for 0% retry - don't rely on normal queue processing
batch_id = task.get('batch_id')
if task_id and batch_id:
try:
print(f"🚀 [0% Retry] Immediately restarting worker for task {task_id}")
missing_download_executor.submit(_download_track_worker, task_id, batch_id)
print(f"✅ [0% Retry] Successfully restarted worker for task {task_id}")
except Exception as restart_error:
print(f"❌ [0% Retry] Failed to restart worker for task {task_id}: {restart_error}")
task['status'] = 'failed'
task['error_message'] = f'Failed to restart worker: {restart_error}'
return False
elif retry_count < 3:
# Wait longer before next retry
return False
else:
print(f"❌ Task failed after 3 retry attempts (0% progress timeout)")
task['status'] = 'failed'
task['error_message'] = 'Failed after multiple 0% progress retries'
# Clear timers to prevent further retry loops
task.pop('queued_start_time', None)
task.pop('downloading_start_time', None)
return False
else:
# Progress being made, reset timers and retry counts
task.pop('queued_start_time', None)
task.pop('downloading_start_time', None)
task.pop('stuck_retry_count', None)
return False
def _validate_worker_counts(self):
"""
Validate worker counts to detect and fix ghost workers or orphaned tasks.
This prevents the modal from showing wrong worker counts permanently.
"""
try:
with tasks_lock:
for batch_id in list(self.monitored_batches):
if batch_id not in download_batches:
continue
batch = download_batches[batch_id]
reported_active = batch['active_count']
max_concurrent = batch['max_concurrent']
queue = batch.get('queue', [])
queue_index = batch.get('queue_index', 0)
# Count actually active tasks based on status
actually_active = 0
orphaned_tasks = []
for task_id in queue:
if task_id in download_tasks:
task_status = download_tasks[task_id]['status']
if task_status in ['searching', 'downloading', 'queued']:
actually_active += 1
elif task_status in ['failed', 'complete', 'cancelled'] and task_id in queue[queue_index:]:
# These are orphaned tasks - they're done but still in active queue
orphaned_tasks.append(task_id)
# Check for discrepancies
if reported_active != actually_active or orphaned_tasks:
print(f"🔍 [Worker Validation] Batch {batch_id}: reported={reported_active}, actual={actually_active}, orphaned={len(orphaned_tasks)}")
if orphaned_tasks:
print(f"🧹 [Worker Validation] Found {len(orphaned_tasks)} orphaned tasks to cleanup")
# Fix the active count if it's wrong
if reported_active != actually_active:
old_count = batch['active_count']
batch['active_count'] = actually_active
print(f"✅ [Worker Validation] Fixed active count: {old_count}{actually_active}")
# If we freed up slots and have more work, try to start new workers
if actually_active < max_concurrent and queue_index < len(queue):
print(f"🔄 [Worker Validation] Starting replacement workers")
# Release lock temporarily to avoid deadlock
tasks_lock.release()
try:
_start_next_batch_of_downloads(batch_id)
finally:
tasks_lock.acquire()
except Exception as validation_error:
print(f"❌ Error in worker count validation: {validation_error}")
# Global download monitor instance
download_monitor = WebUIDownloadMonitor()
def validate_and_heal_batch_states():
"""
Periodic validation and healing of batch states to prevent permanent inconsistencies.
This is the server-side equivalent of the frontend's worker count validation.
"""
try:
with tasks_lock:
healed_batches = []
for batch_id, batch_data in list(download_batches.items()):
active_count = batch_data.get('active_count', 0)
queue = batch_data.get('queue', [])
phase = batch_data.get('phase', 'unknown')
# Count actually active tasks
actually_active = 0
orphaned_tasks = []
for task_id in queue:
if task_id in download_tasks:
task_status = download_tasks[task_id]['status']
if task_status in ['searching', 'downloading', 'queued']:
actually_active += 1
elif task_status in ['failed', 'complete', 'cancelled']:
orphaned_tasks.append(task_id)
# Check for inconsistencies
if active_count != actually_active:
print(f"🔧 [Batch Healing] {batch_id}: fixing active count {active_count}{actually_active}")
batch_data['active_count'] = actually_active
healed_batches.append(batch_id)
# If we freed up slots, try to start more workers
if actually_active < batch_data.get('max_concurrent', 3):
queue_index = batch_data.get('queue_index', 0)
if queue_index < len(queue):
print(f"🔄 [Batch Healing] Starting replacement workers for {batch_id}")
# Release lock temporarily to avoid deadlock
tasks_lock.release()
try:
_start_next_batch_of_downloads(batch_id)
finally:
tasks_lock.acquire()
# Clean up orphaned tasks that are blocking progress
if orphaned_tasks and phase == 'downloading':
print(f"🧹 [Batch Healing] Found {len(orphaned_tasks)} orphaned tasks in active batch {batch_id}")
if healed_batches:
print(f"✅ [Batch Healing] Healed {len(healed_batches)} batches: {healed_batches}")
except Exception as healing_error:
print(f"❌ [Batch Healing] Error during validation: {healing_error}")
# Start periodic batch healing (every 30 seconds)
import threading
def start_batch_healing_timer():
"""Start periodic batch state validation and healing"""
try:
validate_and_heal_batch_states()
except Exception as e:
print(f"❌ [Batch Healing Timer] Error: {e}")
finally:
# Schedule next healing cycle
threading.Timer(30.0, start_batch_healing_timer).start()
# Start the healing timer when the server starts
start_batch_healing_timer()
# Cleanup handler for Flask shutdown/reload
import atexit
import signal
import sys
def cleanup_monitor():
"""Clean up background monitor on shutdown"""
if download_monitor.monitoring:
print("🛑 Flask shutdown detected, stopping download monitor...")
download_monitor.monitoring = False
download_monitor.monitored_batches.clear()
# Give the thread a moment to exit cleanly
time.sleep(0.5)
# Clean up batch locks to prevent memory leaks
with tasks_lock:
batch_locks.clear()
print("🧹 Cleaned up batch locks")
def signal_handler(signum, frame):
"""Handle SIGINT (Ctrl+C) and SIGTERM"""
print(f"🛑 Signal {signum} received, cleaning up...")
cleanup_monitor()
sys.exit(0)
# Register cleanup handlers
atexit.register(cleanup_monitor)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
def _handle_failed_download(batch_id, task_id, task, task_status):
"""Handle failed download by triggering retry logic like GUI"""
try:
with tasks_lock:
if task_id not in download_tasks:
return
retry_count = task.get('retry_count', 0)
task['retry_count'] = retry_count + 1
if task['retry_count'] > 2: # Max 3 attempts total (matches GUI)
# All retries exhausted, mark as permanently failed
print(f"❌ Task {task_id} failed after 3 retry attempts")
task_status['status'] = 'failed'
task['status'] = 'failed'
return
# Show retrying status while we process retry
task_status['status'] = 'pending' # Will show as pending until retry kicks in
print(f"🔄 Triggering retry {task['retry_count']}/3 for failed task {task_id}")
# Trigger retry with next candidate (matches GUI retry_parallel_download_with_fallback)
missing_download_executor.submit(download_monitor._retry_task_with_fallback, batch_id, task_id, task)
except Exception as e:
print(f"❌ Error handling failed download {task_id}: {e}")
task_status['status'] = 'failed'
task['status'] = 'failed'
def _update_task_status(task_id, new_status):
"""Helper to update task status and timestamp for timeout tracking"""
with tasks_lock:
if task_id in download_tasks:
download_tasks[task_id]['status'] = new_status
download_tasks[task_id]['status_change_time'] = time.time()
# --- Album Grouping State Management (Ported from GUI) ---
# Thread-safe album grouping for consistent naming across tracks
album_cache_lock = threading.Lock()
album_groups = {} # album_key -> final_album_name
album_artists = {} # album_key -> artist_name
album_editions = {} # album_key -> "standard" or "deluxe"
album_name_cache = {} # album_key -> cached_final_name
def _prepare_stream_task(track_data):
"""
Background streaming task that downloads track to Stream folder and updates global state.
Enhanced version with robust error handling matching the GUI StreamingThread.
"""
loop = None
queue_start_time = None
actively_downloading = False
last_progress_sent = 0.0
try:
print(f"🎵 Starting stream preparation for: {track_data.get('filename')}")
# Update state to loading
with stream_lock:
stream_state.update({
"status": "loading",
"progress": 0,
"track_info": track_data,
"file_path": None,
"error_message": None
})
# Get paths
download_path = docker_resolve_path(config_manager.get('soulseek.download_path', './downloads'))
project_root = os.path.dirname(os.path.abspath(__file__))
stream_folder = os.path.join(project_root, 'Stream')
# Ensure Stream directory exists
os.makedirs(stream_folder, exist_ok=True)
# Clear any existing files in Stream folder (only one file at a time)
for existing_file in glob.glob(os.path.join(stream_folder, '*')):
try:
if os.path.isfile(existing_file):
os.remove(existing_file)
elif os.path.isdir(existing_file):
shutil.rmtree(existing_file)
print(f"🗑️ Cleared old stream file: {existing_file}")
except Exception as e:
print(f"⚠️ Could not remove existing stream file: {e}")
# Start the download using the same mechanism as regular downloads
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
download_result = loop.run_until_complete(soulseek_client.download(
track_data.get('username'),
track_data.get('filename'),
track_data.get('size', 0)
))
if not download_result:
with stream_lock:
stream_state.update({
"status": "error",
"error_message": "Failed to initiate download - uploader may be offline"
})
return
print(f"✓ Download initiated for streaming")
# Enhanced monitoring with queue timeout detection (matching GUI)
max_wait_time = 60 # Increased timeout
poll_interval = 1.5 # More frequent polling
queue_timeout = 15 # Queue timeout like GUI
wait_count = 0
while wait_count * poll_interval < max_wait_time:
wait_count += 1
# Check download progress via slskd API
api_progress = None
download_state = None
download_status = None
try:
transfers_data = loop.run_until_complete(soulseek_client._make_request('GET', 'transfers/downloads'))
download_status = _find_streaming_download_in_transfers(transfers_data, track_data)
if download_status:
api_progress = download_status.get('percentComplete', 0)
download_state = download_status.get('state', '').lower()
original_state = download_status.get('state', '')
print(f"API Download - State: {original_state}, Progress: {api_progress:.1f}%")
# Track queue state timing (matching GUI logic)
is_queued = ('queued' in download_state or 'initializing' in download_state)
is_downloading = ('inprogress' in download_state or 'transferring' in download_state)
is_completed = ('succeeded' in download_state or api_progress >= 100)
# Handle queue state timing
if is_queued and queue_start_time is None:
queue_start_time = time.time()
print(f"📋 Download entered queue state: {original_state}")
with stream_lock:
stream_state["status"] = "queued"
elif is_downloading and not actively_downloading:
actively_downloading = True
queue_start_time = None # Reset queue timer
print(f"🚀 Download started actively downloading: {original_state}")
with stream_lock:
stream_state["status"] = "loading"
# Check for queue timeout (matching GUI)
if is_queued and queue_start_time:
queue_elapsed = time.time() - queue_start_time
if queue_elapsed > queue_timeout:
print(f"⏰ Queue timeout after {queue_elapsed:.1f}s - download stuck in queue")
with stream_lock:
stream_state.update({
"status": "error",
"error_message": "Queue timeout - uploader not responding. Try another source."
})
return
# Update progress
with stream_lock:
if api_progress != last_progress_sent:
stream_state["progress"] = api_progress
last_progress_sent = api_progress
# Check if download is complete
if is_completed:
print(f"✓ Download completed via API status: {original_state}")
# Give file system time to sync
time.sleep(1)
found_file = _find_downloaded_file(download_path, track_data)
# Retry file search a few times (matching GUI logic)
retry_attempts = 5
for attempt in range(retry_attempts):
if found_file:
break
print(f"File not found yet, attempt {attempt + 1}/{retry_attempts}")
time.sleep(1)
found_file = _find_downloaded_file(download_path, track_data)
if found_file:
print(f"✓ Found downloaded file: {found_file}")
# Move file to Stream folder
original_filename = extract_filename(found_file)
stream_path = os.path.join(stream_folder, original_filename)
try:
shutil.move(found_file, stream_path)
print(f"✓ Moved file to stream folder: {stream_path}")
# Clean up empty directories (matching GUI)
_cleanup_empty_directories(download_path, found_file)
# Update state to ready
with stream_lock:
stream_state.update({
"status": "ready",
"progress": 100,
"file_path": stream_path
})
# Clean up download from slskd API
try:
download_id = download_status.get('id', '')
if download_id and track_data.get('username'):
success = loop.run_until_complete(
soulseek_client.signal_download_completion(
download_id, track_data.get('username'), remove=True)
)
if success:
print(f"✓ Cleaned up download {download_id} from API")
except Exception as e:
print(f"⚠️ Error cleaning up download: {e}")
print(f"✅ Stream file ready for playback: {stream_path}")
return # Success!
except Exception as e:
print(f"❌ Error moving file to stream folder: {e}")
with stream_lock:
stream_state.update({
"status": "error",
"error_message": f"Failed to prepare stream file: {e}"
})
return
else:
print("❌ Could not find downloaded file after completion")
with stream_lock:
stream_state.update({
"status": "error",
"error_message": "Download completed but file not found"
})
return
else:
# No transfer found in API - may still be initializing
print(f"No transfer found in API yet... (elapsed: {wait_count * poll_interval}s)")
except Exception as e:
print(f"⚠️ Error checking download progress: {e}")
# Continue to next iteration if API call fails
# Wait before next poll
time.sleep(poll_interval)
# If we get here, download timed out
print(f"❌ Download timed out after {max_wait_time}s")
with stream_lock:
stream_state.update({
"status": "error",
"error_message": "Download timed out - try a different source"
})
except asyncio.CancelledError:
print("🛑 Stream task cancelled")
with stream_lock:
stream_state.update({
"status": "stopped",
"error_message": None
})
finally:
if loop:
try:
# Clean up any pending tasks
pending = asyncio.all_tasks(loop)
if pending:
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
loop.close()
except Exception as e:
print(f"⚠️ Error cleaning up streaming event loop: {e}")
except Exception as e:
print(f"❌ Stream preparation failed: {e}")
with stream_lock:
stream_state.update({
"status": "error",
"error_message": f"Streaming error: {str(e)}"
})
def _find_streaming_download_in_transfers(transfers_data, track_data):
"""Find streaming download in transfer data using same logic as download queue"""
try:
if not transfers_data:
return None
# Flatten the transfers data structure
all_transfers = []
for user_data in transfers_data:
if 'directories' in user_data:
for directory in user_data['directories']:
if 'files' in directory:
all_transfers.extend(directory['files'])
# Look for our specific file by filename and username
target_filename = extract_filename(track_data.get('filename', ''))
target_username = track_data.get('username', '')
for transfer in all_transfers:
transfer_filename = extract_filename(transfer.get('filename', ''))
transfer_username = transfer.get('username', '')
if (transfer_filename == target_filename and
transfer_username == target_username):
return transfer
return None
except Exception as e:
print(f"Error finding streaming download in transfers: {e}")
return None
def _find_downloaded_file(download_path, track_data):
"""Find the downloaded audio file in the downloads directory tree"""
audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'}
target_filename = extract_filename(track_data.get('filename', ''))
try:
# Walk through the downloads directory to find the file
for root, dirs, files in os.walk(download_path):
for file in files:
# Check if this is our target file
if file == target_filename:
file_path = os.path.join(root, file)
# Verify it's an audio file and has content
if (os.path.splitext(file)[1].lower() in audio_extensions and
os.path.getsize(file_path) > 1024): # At least 1KB
return file_path
print(f"❌ Could not find downloaded file: {target_filename}")
return None
except Exception as e:
print(f"Error searching for downloaded file: {e}")
return None
# --- Refactored Logic from GUI Threads ---
# This logic is extracted from your QThread classes to be used directly by Flask.
def run_service_test(service, test_config):
"""
Performs the actual connection test for a given service.
This logic is adapted from your ServiceTestThread.
It temporarily modifies the config, runs the test, then restores the config.
"""
original_config = {}
try:
# 1. Save original config for the specific service
original_config = config_manager.get(service, {})
# 2. Temporarily set the new config for the test (with Docker URL resolution)
for key, value in test_config.items():
# Apply Docker URL resolution for URL/URI fields
if isinstance(value, str) and ('url' in key.lower() or 'uri' in key.lower()):
value = docker_resolve_url(value)
config_manager.set(f"{service}.{key}", value)
# 3. Run the test with the temporary config
if service == "spotify":
temp_client = SpotifyClient()
if temp_client.is_authenticated():
return True, "Spotify connection successful!"
else:
return False, "Spotify authentication failed. Check credentials and complete OAuth flow in browser if prompted."
elif service == "tidal":
temp_client = TidalClient()
if temp_client.is_authenticated():
user_info = temp_client.get_user_info()
username = user_info.get('display_name', 'Tidal User') if user_info else 'Tidal User'
return True, f"Tidal connection successful! Connected as: {username}"
else:
return False, "Tidal authentication failed. Please use the 'Authenticate' button and complete the flow in your browser."
elif service == "plex":
temp_client = PlexClient()
if temp_client.is_connected():
return True, f"Successfully connected to Plex server: {temp_client.server.friendlyName}"
else:
return False, "Could not connect to Plex. Check URL and Token."
elif service == "jellyfin":
temp_client = JellyfinClient()
if temp_client.is_connected():
# FIX: Check if server_info exists before accessing it.
server_name = "Unknown Server"
if hasattr(temp_client, 'server_info') and temp_client.server_info:
server_name = temp_client.server_info.get('ServerName', 'Unknown Server')
return True, f"Successfully connected to Jellyfin server: {server_name}"
else:
return False, "Could not connect to Jellyfin. Check URL and API Key."
elif service == "soulseek":
temp_client = SoulseekClient()
async def check():
return await temp_client.check_connection()
if asyncio.run(check()):
return True, "Successfully connected to slskd."
else:
return False, "Could not connect to slskd. Check URL and API Key."
return False, "Unknown service."
except AttributeError as e:
# This specifically catches the error you reported for Jellyfin
if "'JellyfinClient' object has no attribute 'server_info'" in str(e):
return False, "Connection failed. Please check your Jellyfin URL and API Key."
else:
return False, f"An unexpected error occurred: {e}"
except Exception as e:
import traceback
traceback.print_exc()
return False, str(e)
finally:
# 4. CRITICAL: Restore the original config
if original_config:
for key, value in original_config.items():
config_manager.set(f"{service}.{key}", value)
print(f"✅ Restored original config for '{service}' after test.")
def run_detection(server_type):
"""
Performs comprehensive network detection for a given server type (plex, jellyfin, slskd).
This implements the same scanning logic as the GUI's detection threads.
"""
print(f"Running comprehensive detection for {server_type}...")
def get_network_info():
"""Get comprehensive network information with subnet detection"""
try:
# Get local IP using socket method
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
local_ip = s.getsockname()[0]
s.close()
# Try to get actual subnet mask
try:
if platform.system() == "Windows":
# Windows: Use netsh to get subnet info
result = subprocess.run(['netsh', 'interface', 'ip', 'show', 'config'],
capture_output=True, text=True, timeout=3)
# Parse output for subnet mask (simplified)
subnet_mask = "255.255.255.0" # Default fallback
else:
# Linux/Mac: Try to parse network interfaces
result = subprocess.run(['ip', 'route', 'show'],
capture_output=True, text=True, timeout=3)
subnet_mask = "255.255.255.0" # Default fallback
except:
subnet_mask = "255.255.255.0" # Default /24
# Calculate network range
network = ipaddress.IPv4Network(f"{local_ip}/{subnet_mask}", strict=False)
return str(network.network_address), str(network.netmask), local_ip, network
except Exception as e:
# Fallback to original method
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
local_ip = s.getsockname()[0]
s.close()
# Default to /24 network
network = ipaddress.IPv4Network(f"{local_ip}/24", strict=False)
return str(network.network_address), "255.255.255.0", local_ip, network
def test_plex_server(ip, port=32400):
"""Test if a Plex server is running at the given IP and port"""
try:
url = f"http://{ip}:{port}/web/index.html"
response = requests.get(url, timeout=2, allow_redirects=True)
# Check for Plex-specific indicators
if response.status_code == 200:
# Check if it's actually Plex
if 'plex' in response.text.lower() or 'X-Plex' in str(response.headers):
return f"http://{ip}:{port}"
# Also try the API endpoint
api_url = f"http://{ip}:{port}/identity"
api_response = requests.get(api_url, timeout=1)
if api_response.status_code == 200 and 'MediaContainer' in api_response.text:
return f"http://{ip}:{port}"
except:
pass
return None
def test_jellyfin_server(ip, port=8096):
"""Test if a Jellyfin server is running at the given IP and port"""
try:
# Try the system info endpoint first
url = f"http://{ip}:{port}/System/Info"
response = requests.get(url, timeout=2, allow_redirects=True)
if response.status_code == 200:
# Check if response contains Jellyfin-specific content
if 'jellyfin' in response.text.lower() or 'ServerName' in response.text:
return f"http://{ip}:{port}"
# Also try the web interface
web_url = f"http://{ip}:{port}/web/index.html"
web_response = requests.get(web_url, timeout=1)
if web_response.status_code == 200 and 'jellyfin' in web_response.text.lower():
return f"http://{ip}:{port}"
except:
pass
return None
def test_slskd_server(ip, port=5030):
"""Test if a slskd server is running at the given IP and port"""
try:
# slskd specific API endpoint
url = f"http://{ip}:{port}/api/v0/session"
response = requests.get(url, timeout=2)
# slskd returns 401 when not authenticated, which is still a valid response
if response.status_code in [200, 401]:
return f"http://{ip}:{port}"
except:
pass
return None
try:
network_addr, netmask, local_ip, network = get_network_info()
# Select the appropriate test function
test_functions = {
'plex': test_plex_server,
'jellyfin': test_jellyfin_server,
'slskd': test_slskd_server
}
test_func = test_functions.get(server_type)
if not test_func:
return None
# Priority 1: Test localhost first
print(f"Testing localhost for {server_type}...")
localhost_result = test_func("localhost")
if localhost_result:
print(f"Found {server_type} at localhost!")
return localhost_result
# Priority 1.5: In Docker, try Docker host IP
import os
if os.path.exists('/.dockerenv'):
print(f"Docker detected, testing Docker host for {server_type}...")
try:
# Try host.docker.internal (Windows/Mac)
host_result = test_func("host.docker.internal")
if host_result:
print(f"Found {server_type} at Docker host!")
return host_result.replace("host.docker.internal", "localhost") # Convert back to localhost for config
# Try Docker bridge gateway (Linux)
gateway_result = test_func("172.17.0.1")
if gateway_result:
print(f"Found {server_type} at Docker gateway!")
return gateway_result.replace("172.17.0.1", "localhost") # Convert back to localhost for config
except Exception as e:
print(f"Docker host detection failed: {e}")
# Priority 2: Test local IP
print(f"Testing local IP {local_ip} for {server_type}...")
local_result = test_func(local_ip)
if local_result:
print(f"Found {server_type} at {local_ip}!")
return local_result
# Priority 3: Test common IPs (router gateway, etc.)
common_ips = [
local_ip.rsplit('.', 1)[0] + '.1', # Typical gateway
local_ip.rsplit('.', 1)[0] + '.2', # Alternative gateway
local_ip.rsplit('.', 1)[0] + '.100', # Common static IP
]
print(f"Testing common IPs for {server_type}...")
for ip in common_ips:
print(f" Checking {ip}...")
result = test_func(ip)
if result:
print(f"Found {server_type} at {ip}!")
return result
# Priority 4: Scan the network range (limited to reasonable size)
network_hosts = list(network.hosts())
if len(network_hosts) > 50:
# Limit scan to reasonable size for performance
step = max(1, len(network_hosts) // 50)
network_hosts = network_hosts[::step]
print(f"Scanning network range for {server_type} ({len(network_hosts)} hosts)...")
# Use ThreadPoolExecutor for concurrent scanning (limited for web context)
with ThreadPoolExecutor(max_workers=5) as executor:
# Submit all tasks
future_to_ip = {executor.submit(test_func, str(ip)): str(ip)
for ip in network_hosts}
try:
for future in as_completed(future_to_ip):
ip = future_to_ip[future]
try:
result = future.result()
if result:
print(f"Found {server_type} at {ip}!")
# Cancel all pending futures before returning
for f in future_to_ip:
if not f.done():
f.cancel()
return result
except Exception as e:
print(f"Error testing {ip}: {e}")
continue
except Exception as e:
print(f"Error in concurrent scanning: {e}")
print(f"No {server_type} server found on network")
return None
except Exception as e:
print(f"Error during {server_type} detection: {e}")
return None
# --- Web UI Routes ---
@app.route('/')
def index():
return render_template('index.html')
# --- API Endpoints ---
@app.route('/status')
def get_status():
if not all([spotify_client, plex_client, jellyfin_client, soulseek_client, config_manager]):
return jsonify({"error": "Core services not initialized."}), 500
try:
import time
active_server = config_manager.get_active_media_server()
# Test Spotify with response time
spotify_start = time.time()
spotify_status = spotify_client.is_authenticated()
spotify_response_time = (time.time() - spotify_start) * 1000 # Convert to ms
# Test media server with response time (use fresh client with Docker URL resolution)
media_server_start = time.time()
media_server_status = False
if active_server == "plex":
# Create fresh client to ensure Docker URL resolution
temp_plex_client = PlexClient()
media_server_status = temp_plex_client.is_connected()
elif active_server == "jellyfin":
# Create fresh client to ensure Docker URL resolution
temp_jellyfin_client = JellyfinClient()
media_server_status = temp_jellyfin_client.is_connected()
media_server_response_time = (time.time() - media_server_start) * 1000
# Test Soulseek (just check if configured, no network test)
soulseek_start = time.time()
soulseek_status = soulseek_client.is_configured()
soulseek_response_time = (time.time() - soulseek_start) * 1000
status_data = {
'spotify': {
'connected': spotify_status,
'response_time': round(spotify_response_time, 1)
},
'media_server': {
'connected': media_server_status,
'response_time': round(media_server_response_time, 1),
'type': active_server
},
'soulseek': {
'connected': soulseek_status,
'response_time': round(soulseek_response_time, 1)
},
'active_media_server': active_server
}
return jsonify(status_data)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/system/stats')
def get_system_stats():
"""Get system statistics for dashboard"""
try:
import psutil
import time
from datetime import timedelta
# Calculate uptime
start_time = getattr(app, 'start_time', time.time())
uptime_seconds = time.time() - start_time
uptime = str(timedelta(seconds=int(uptime_seconds)))
# Get memory usage
memory = psutil.virtual_memory()
memory_usage = f"{memory.percent}%"
# Count active downloads from download_batches (batches that are currently downloading)
active_downloads = len([batch_id for batch_id, batch_data in download_batches.items()
if batch_data.get('phase') == 'downloading'])
# Count finished downloads (completed this session)
finished_downloads = len([batch_id for batch_id, batch_data in download_batches.items()
if batch_data.get('phase') == 'complete'])
# Calculate total download speed from active soulseek transfers
total_download_speed = 0.0
try:
transfers_data = asyncio.run(soulseek_client._make_request('GET', 'transfers/downloads'))
if transfers_data:
for user_data in transfers_data:
if 'directories' in user_data:
for directory in user_data['directories']:
if 'files' in directory:
for file_info in directory['files']:
state = file_info.get('state', '').lower()
# Only count actively downloading files
if 'inprogress' in state or 'downloading' in state or 'transferring' in state:
speed = file_info.get('averageSpeed', 0)
if isinstance(speed, (int, float)) and speed > 0:
total_download_speed += float(speed)
except Exception as e:
print(f"Warning: Could not fetch download speeds: {e}")
# Convert bytes/sec to KB/s and format
if total_download_speed > 0:
speed_kb_s = total_download_speed / 1024
if speed_kb_s >= 1024:
speed_mb_s = speed_kb_s / 1024
download_speed_str = f"{speed_mb_s:.1f} MB/s"
else:
download_speed_str = f"{speed_kb_s:.1f} KB/s"
else:
download_speed_str = "0 KB/s"
# Count active syncs (playlists currently syncing)
active_syncs = 0
# Count Spotify playlist syncs
for playlist_id, sync_state in sync_states.items():
if sync_state.get('status') == 'syncing':
active_syncs += 1
# Count YouTube playlist syncs
for url_hash, state in youtube_playlist_states.items():
if state.get('phase') == 'syncing':
active_syncs += 1
# Count Tidal playlist syncs
for playlist_id, state in tidal_discovery_states.items():
if state.get('phase') == 'syncing':
active_syncs += 1
stats_data = {
'active_downloads': active_downloads,
'finished_downloads': finished_downloads,
'download_speed': download_speed_str,
'active_syncs': active_syncs,
'uptime': uptime,
'memory_usage': memory_usage
}
return jsonify(stats_data)
except Exception as e:
return jsonify({'error': str(e)}), 500
# Global activity tracking storage
activity_feed = []
activity_feed_lock = threading.Lock()
@app.route('/api/activity/feed')
def get_activity_feed():
"""Get recent activity feed for dashboard"""
try:
with activity_feed_lock:
# Return last 10 activities in reverse chronological order
return jsonify({'activities': activity_feed[-10:][::-1]})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/activity/toasts')
def get_recent_toasts():
"""Get recent activities that should show toasts"""
try:
import time
current_time = time.time()
with activity_feed_lock:
# Return activities from last 10 seconds that should show toasts
recent_toasts = [
activity for activity in activity_feed
if activity.get('show_toast', True) and
(current_time - activity.get('timestamp', 0)) <= 10
]
return jsonify({'toasts': recent_toasts})
except Exception as e:
return jsonify({'error': str(e)}), 500
def add_activity_item(icon: str, title: str, subtitle: str, time_ago: str = "Now", show_toast: bool = True):
"""Add activity item to the feed (replicates dashboard.py functionality)"""
try:
import time
activity_item = {
'icon': icon,
'title': title,
'subtitle': subtitle,
'time': time_ago,
'timestamp': time.time(),
'show_toast': show_toast
}
with activity_feed_lock:
activity_feed.append(activity_item)
# Keep only last 20 items to prevent memory growth
if len(activity_feed) > 20:
activity_feed.pop(0)
print(f"📝 Activity: {icon} {title} - {subtitle}")
except Exception as e:
print(f"Error adding activity item: {e}")
@app.route('/api/settings', methods=['GET', 'POST'])
def handle_settings():
global tidal_client # Declare that we might modify the global instance
if not config_manager:
return jsonify({"error": "Server configuration manager is not initialized."}), 500
if request.method == 'POST':
try:
new_settings = request.get_json()
if not new_settings:
return jsonify({"success": False, "error": "No data received."}), 400
if 'active_media_server' in new_settings:
config_manager.set_active_media_server(new_settings['active_media_server'])
for service in ['spotify', 'plex', 'jellyfin', 'soulseek', 'settings', 'database', 'metadata_enhancement', 'playlist_sync', 'tidal']:
if service in new_settings:
for key, value in new_settings[service].items():
config_manager.set(f'{service}.{key}', value)
print("✅ Settings saved successfully via Web UI.")
# Add activity for settings save
changed_services = list(new_settings.keys())
services_text = ", ".join(changed_services)
add_activity_item("⚙️", "Settings Updated", f"{services_text} configuration saved", "Now")
spotify_client._setup_client()
plex_client.server = None
jellyfin_client.server = None
soulseek_client._setup_client()
# FIX: Re-instantiate the global tidal_client to pick up new settings
tidal_client = TidalClient()
print("✅ Service clients re-initialized with new settings.")
return jsonify({"success": True, "message": "Settings saved successfully."})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
else: # GET request
try:
return jsonify(config_manager.config_data)
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/api/test-connection', methods=['POST'])
def test_connection_endpoint():
data = request.get_json()
service = data.get('service')
if not service:
return jsonify({"success": False, "error": "No service specified."}), 400
print(f"Received test connection request for: {service}")
# Get the current settings from the main config manager to test with
test_config = config_manager.get(service, {})
# For media servers, the service name might be 'server'
if service == 'server':
active_server = config_manager.get_active_media_server()
test_config = config_manager.get(active_server, {})
service = active_server # use the actual server name for the test
success, message = run_service_test(service, test_config)
# Add activity for connection test
if success:
add_activity_item("", "Connection Test", f"{service.title()} connection successful", "Now")
else:
add_activity_item("", "Connection Test", f"{service.title()} connection failed", "Now")
return jsonify({"success": success, "error": "" if success else message, "message": message if success else ""})
@app.route('/api/test-dashboard-connection', methods=['POST'])
def test_dashboard_connection_endpoint():
"""Test connection from dashboard - creates specific dashboard activity items"""
data = request.get_json()
service = data.get('service')
if not service:
return jsonify({"success": False, "error": "No service specified."}), 400
print(f"Received dashboard test connection request for: {service}")
# Get the current settings from the main config manager to test with
test_config = config_manager.get(service, {})
# For media servers, the service name might be 'server'
if service == 'server':
active_server = config_manager.get_active_media_server()
test_config = config_manager.get(active_server, {})
service = active_server # use the actual server name for the test
success, message = run_service_test(service, test_config)
# Add activity for dashboard connection test (different from settings test)
if success:
add_activity_item("🎛️", "Dashboard Test", f"{service.title()} service verified", "Now")
else:
add_activity_item("⚠️", "Dashboard Test", f"{service.title()} service check failed", "Now")
return jsonify({"success": success, "error": "" if success else message, "message": message if success else ""})
@app.route('/api/detect-media-server', methods=['POST'])
def detect_media_server_endpoint():
data = request.get_json()
server_type = data.get('server_type')
print(f"Received auto-detect request for: {server_type}")
# Add activity for auto-detect start
add_activity_item("🔍", "Auto-Detect Started", f"Searching for {server_type} server", "Now")
found_url = run_detection(server_type)
if found_url:
add_activity_item("", "Auto-Detect Complete", f"{server_type} found at {found_url}", "Now")
return jsonify({"success": True, "found_url": found_url})
else:
add_activity_item("", "Auto-Detect Failed", f"No {server_type} server found", "Now")
return jsonify({"success": False, "error": f"No {server_type} server found on common local addresses."})
@app.route('/api/detect-soulseek', methods=['POST'])
def detect_soulseek_endpoint():
print("Received auto-detect request for slskd")
# Add activity for soulseek auto-detect start
add_activity_item("🔍", "Auto-Detect Started", "Searching for slskd server", "Now")
found_url = run_detection('slskd')
if found_url:
add_activity_item("", "Auto-Detect Complete", f"slskd found at {found_url}", "Now")
return jsonify({"success": True, "found_url": found_url})
else:
add_activity_item("", "Auto-Detect Failed", "No slskd server found", "Now")
return jsonify({"success": False, "error": "No slskd server found on common local addresses."})
# --- Authentication Routes ---
@app.route('/auth/spotify')
def auth_spotify():
"""
Initiates Spotify OAuth authentication flow
"""
try:
# Create a fresh spotify client to trigger OAuth
temp_spotify_client = SpotifyClient()
if temp_spotify_client.sp and temp_spotify_client.sp.auth_manager:
# Get the authorization URL
auth_url = temp_spotify_client.sp.auth_manager.get_authorize_url()
add_activity_item("🔐", "Spotify Auth Started", "Please complete OAuth in browser", "Now")
return f'<h1>🔐 Spotify Authentication</h1><p>Please visit this URL to authenticate:</p><p><a href="{auth_url}" target="_blank">{auth_url}</a></p><p>After authentication, return to the app.</p>'
else:
return "<h1>❌ Spotify Authentication Failed</h1><p>Could not initialize Spotify client. Check your credentials.</p>", 400
except Exception as e:
print(f"🔴 Error starting Spotify auth: {e}")
return f"<h1>❌ Spotify Authentication Error</h1><p>{str(e)}</p>", 500
@app.route('/auth/tidal')
def auth_tidal():
"""
Initiates Tidal OAuth authentication flow
"""
print("🔐🔐🔐 TIDAL AUTH ROUTE CALLED 🔐🔐🔐")
try:
# Create a fresh tidal client to get OAuth URL
from core.tidal_client import TidalClient
temp_tidal_client = TidalClient()
if not temp_tidal_client.client_id:
return "<h1>❌ Tidal Authentication Failed</h1><p>Tidal client ID not configured. Check your credentials.</p>", 400
# Generate PKCE challenge and store globally
temp_tidal_client._generate_pkce_challenge()
# Store PKCE values globally for callback use
global tidal_oauth_state
with tidal_oauth_lock:
tidal_oauth_state["code_verifier"] = temp_tidal_client.code_verifier
tidal_oauth_state["code_challenge"] = temp_tidal_client.code_challenge
print(f"🔐 Stored PKCE - verifier: {temp_tidal_client.code_verifier[:20]}... challenge: {temp_tidal_client.code_challenge[:20]}...")
# Create OAuth URL
import urllib.parse
params = {
'response_type': 'code',
'client_id': temp_tidal_client.client_id,
'redirect_uri': temp_tidal_client.redirect_uri,
'scope': 'user.read playlists.read',
'code_challenge': temp_tidal_client.code_challenge,
'code_challenge_method': 'S256'
}
auth_url = f"{temp_tidal_client.auth_url}?" + urllib.parse.urlencode(params)
print(f"🔗 Generated Tidal OAuth URL: {auth_url}")
print(f"🔗 Redirect URI in URL: {params['redirect_uri']}")
add_activity_item("🔐", "Tidal Auth Started", "Please complete OAuth in browser", "Now")
return f'<h1>🔐 Tidal Authentication</h1><p>Please visit this URL to authenticate:</p><p><a href="{auth_url}" target="_blank">{auth_url}</a></p><p>After authentication, return to the app.</p>'
except Exception as e:
print(f"🔴 Error starting Tidal auth: {e}")
import traceback
print(f"🔴 Full traceback: {traceback.format_exc()}")
return f"<h1>❌ Tidal Authentication Error</h1><p>{str(e)}</p>", 500
@app.route('/tidal/callback')
def tidal_callback():
"""
Handles the callback from Tidal after the user authorizes the application.
It receives an authorization code, exchanges it for an access token,
and saves the token.
"""
global tidal_client # We will re-initialize the global client
auth_code = request.args.get('code')
if not auth_code:
error = request.args.get('error', 'Unknown error')
error_description = request.args.get('error_description', 'No description provided.')
return f"<h1>Tidal Authentication Failed</h1><p>Error: {error}</p><p>{error_description}</p><p>Please close this window and try again.</p>", 400
try:
# Create a temporary client for the token exchange
temp_tidal_client = TidalClient()
success = temp_tidal_client.fetch_token_from_code(auth_code)
if success:
# Re-initialize the main global tidal_client instance with the new token
tidal_client = TidalClient()
return "<h1>✅ Tidal Authentication Successful!</h1><p>You can now close this window and return to the SoulSync application.</p>"
else:
return "<h1>❌ Tidal Authentication Failed</h1><p>Could not exchange authorization code for a token. Please try again.</p>", 400
except Exception as e:
print(f"🔴 Error during Tidal token exchange: {e}")
return f"<h1>❌ An Error Occurred</h1><p>An unexpected error occurred during the authentication process: {e}</p>", 500
# --- Placeholder API Endpoints for Other Pages ---
@app.route('/api/activity')
def get_activity():
# Placeholder: returns mock activity data
mock_activity = [
{"time": "1 min ago", "text": "Service status checked."},
{"time": "5 min ago", "text": "Application server started."}
]
return jsonify({"activities": mock_activity})
@app.route('/api/playlists')
def get_playlists():
# Placeholder: returns mock playlist data
if spotify_client and spotify_client.is_authenticated():
# In a real implementation, you would call spotify_client.get_user_playlists()
mock_playlists = [
{"id": "1", "name": "Chill Vibes"},
{"id": "2", "name": "Workout Mix"},
{"id": "3", "name": "Liked Songs"}
]
return jsonify({"playlists": mock_playlists})
return jsonify({"playlists": [], "error": "Spotify not authenticated."})
@app.route('/api/sync', methods=['POST'])
def start_sync():
# Placeholder: simulates starting a sync
return jsonify({"success": True, "message": "Sync process started."})
@app.route('/api/search', methods=['POST'])
def search_music():
"""Real search using soulseek_client"""
data = request.get_json()
query = data.get('query')
if not query:
return jsonify({"error": "No search query provided."}), 400
print(f"Web UI Search for: '{query}'")
# Add activity for search start
add_activity_item("🔍", "Search Started", f"'{query}'", "Now")
try:
tracks, albums = asyncio.run(soulseek_client.search(query))
# Convert to dictionaries for JSON response
processed_albums = []
for album in albums:
album_dict = album.__dict__.copy()
album_dict["tracks"] = [track.__dict__ for track in album.tracks]
album_dict["result_type"] = "album"
processed_albums.append(album_dict)
processed_tracks = []
for track in tracks:
track_dict = track.__dict__.copy()
track_dict["result_type"] = "track"
processed_tracks.append(track_dict)
# Sort by quality score
all_results = sorted(processed_albums + processed_tracks, key=lambda x: x.get('quality_score', 0), reverse=True)
# Add activity for search completion
total_results = len(all_results)
add_activity_item("", "Search Complete", f"'{query}' - {total_results} results", "Now")
return jsonify({"results": all_results})
except Exception as e:
print(f"Search error: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/download', methods=['POST'])
def start_download():
"""Simple download route"""
data = request.get_json()
if not data:
return jsonify({"error": "No download data provided."}), 400
try:
result_type = data.get('result_type', 'track')
if result_type == 'album':
tracks = data.get('tracks', [])
if not tracks:
return jsonify({"error": "No tracks found in album."}), 400
started_downloads = 0
for track_data in tracks:
try:
download_id = asyncio.run(soulseek_client.download(
track_data.get('username'),
track_data.get('filename'),
track_data.get('size', 0)
))
if download_id:
started_downloads += 1
except Exception as e:
print(f"Failed to start track download: {e}")
continue
# Add activity for album download start
album_name = data.get('album_name', 'Unknown Album')
add_activity_item("📥", "Album Download Started", f"'{album_name}' - {started_downloads} tracks", "Now")
return jsonify({
"success": True,
"message": f"Started {started_downloads} downloads from album"
})
else:
# Single track download
username = data.get('username')
filename = data.get('filename')
file_size = data.get('size', 0)
if not username or not filename:
return jsonify({"error": "Missing username or filename."}), 400
download_id = asyncio.run(soulseek_client.download(username, filename, file_size))
if download_id:
# Extract track name from filename for activity
track_name = filename.split('/')[-1] if '/' in filename else filename.split('\\')[-1] if '\\' in filename else filename
add_activity_item("📥", "Track Download Started", f"'{track_name}'", "Now")
return jsonify({"success": True, "message": "Download started"})
else:
return jsonify({"error": "Failed to start download"}), 500
except Exception as e:
print(f"Download error: {e}")
return jsonify({"error": str(e)}), 500
def _find_completed_file_robust(download_dir, api_filename, transfer_dir=None):
"""
Robustly finds a completed file on disk, accounting for name variations and
unexpected subdirectories. This version uses the superior normalization logic
from the GUI's matching_engine.py to ensure consistency.
First searches in download_dir, then optionally searches in transfer_dir if provided.
Returns tuple (file_path, location) where location is 'downloads' or 'transfer'.
"""
import re
import os
from difflib import SequenceMatcher
from unidecode import unidecode
def normalize_for_finding(text: str) -> str:
"""A powerful normalization function adapted from matching_engine.py."""
if not text: return ""
text = unidecode(text).lower()
# Replace common separators with spaces to preserve word boundaries
text = re.sub(r'[._/]', ' ', text)
# Keep alphanumeric, spaces, and hyphens. Remove brackets/parentheses content.
text = re.sub(r'[\[\(].*?[\]\)]', '', text)
text = re.sub(r'[^a-z0-9\s-]', '', text)
# Consolidate multiple spaces
return ' '.join(text.split()).strip()
def search_in_directory(search_dir, location_name):
"""Search for the file in a specific directory."""
print(f" searching for normalized filename '{normalized_target}' in '{search_dir}'...")
best_match_path = None
highest_similarity = 0.0
# Walk through the entire directory
for root, _, files in os.walk(search_dir):
for file in files:
# Direct match is the best case
if os.path.basename(file) == target_basename:
file_path = os.path.join(root, file)
print(f"Found exact match: {file_path}")
return file_path, 1.0
# Fuzzy matching for variations
normalized_file = normalize_for_finding(file)
similarity = SequenceMatcher(None, normalized_target, normalized_file).ratio()
if similarity > highest_similarity:
highest_similarity = similarity
best_match_path = os.path.join(root, file)
return best_match_path, highest_similarity
# Extract filename using the helper function
target_basename = extract_filename(api_filename)
normalized_target = normalize_for_finding(target_basename)
# First search in downloads directory
best_downloads_path, downloads_similarity = search_in_directory(download_dir, 'downloads')
# Use a reasonable confidence threshold for fuzzy matches
if downloads_similarity > 0.60:
location = 'downloads'
if downloads_similarity == 1.0:
print(f"✅ Found exact match in downloads: {best_downloads_path}")
else:
print(f"✅ Found best fuzzy match in downloads with similarity {downloads_similarity:.2f}: {best_downloads_path}")
return (best_downloads_path, location)
# If not found in downloads and transfer_dir is provided, search there
transfer_similarity = 0.0 # Initialize transfer_similarity
if transfer_dir and os.path.exists(transfer_dir):
print(f"🔍 File not found in downloads, checking transfer folder...")
best_transfer_path, transfer_similarity = search_in_directory(transfer_dir, 'transfer')
if transfer_similarity > 0.60:
location = 'transfer'
if transfer_similarity == 1.0:
print(f"✅ Found exact match in transfer: {best_transfer_path}")
else:
print(f"✅ Found best fuzzy match in transfer with similarity {transfer_similarity:.2f}: {best_transfer_path}")
return (best_transfer_path, location)
print(f"❌ Could not find a confident match for '{target_basename}' in any location. Best similarity was {max(downloads_similarity, transfer_similarity):.2f}.")
return (None, None)
@app.route('/api/downloads/status')
def get_download_status():
"""
A robust status checker that correctly finds completed files by searching
the entire download directory with fuzzy matching, mirroring the logic from downloads.py.
"""
if not soulseek_client:
return jsonify({"transfers": []})
try:
global _processed_download_ids
transfers_data = asyncio.run(soulseek_client._make_request('GET', 'transfers/downloads'))
if not transfers_data:
return jsonify({"transfers": []})
all_transfers = []
completed_matched_downloads = []
# This logic now correctly processes the nested structure from the slskd API
for user_data in transfers_data:
username = user_data.get('username', 'Unknown')
if 'directories' in user_data:
for directory in user_data['directories']:
if 'files' in directory:
for file_info in directory['files']:
file_info['username'] = username
all_transfers.append(file_info)
state = file_info.get('state', '').lower()
# Check for completion state
if ('succeeded' in state or 'completed' in state) and 'errored' not in state:
filename_from_api = file_info.get('filename')
if not filename_from_api: continue
# Check if this completed download has a matched context
context_key = f"{username}::{filename_from_api}"
with matched_context_lock:
context = matched_downloads_context.get(context_key)
if context and context_key not in _processed_download_ids:
download_dir = docker_resolve_path(config_manager.get('soulseek.download_path', './downloads'))
# Use the new robust file finder (only search downloads for post-processing candidates)
found_result = _find_completed_file_robust(download_dir, filename_from_api)
found_path = found_result[0] if found_result and found_result[0] else None
if found_path:
print(f"🎯 Found completed matched file on disk: {found_path}")
completed_matched_downloads.append((context_key, context, found_path))
# Don't add to _processed_download_ids yet - wait until thread starts successfully
else:
print(f"❌ CRITICAL: Could not find '{os.path.basename(filename_from_api)}' on disk. Post-processing skipped.")
# Mark as processed to prevent endless retries
_processed_download_ids.add(context_key)
# If we found completed matched downloads, start processing them in background threads
if completed_matched_downloads:
def process_completed_downloads():
for context_key, context, found_path in completed_matched_downloads:
try:
print(f"🚀 Starting post-processing thread for: {context_key}")
# Start the post-processing in a separate thread
thread = threading.Thread(target=_post_process_matched_download, args=(context_key, context, found_path))
thread.daemon = True
thread.start()
# Only mark as processed AFTER thread starts successfully
_processed_download_ids.add(context_key)
print(f"✅ Marked as processed: {context_key}")
# DON'T remove context immediately - verification worker needs it
# Context will be cleaned up by verification worker after both processors complete
print(f"💾 Keeping context for verification worker: {context_key}")
except Exception as e:
print(f"❌ Error starting post-processing thread for {context_key}: {e}")
# Don't add to processed set if thread failed to start
print(f"⚠️ Will retry {context_key} on next check")
# Start a single thread to manage the launching of all processing threads
processing_thread = threading.Thread(target=process_completed_downloads)
processing_thread.daemon = True
processing_thread.start()
return jsonify({"transfers": all_transfers})
except Exception as e:
print(f"Error fetching download status: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/downloads/cancel', methods=['POST'])
def cancel_download():
"""
Cancel a specific download transfer, matching GUI functionality.
"""
data = request.get_json()
if not data:
return jsonify({"success": False, "error": "No data provided."}), 400
download_id = data.get('download_id')
username = data.get('username')
if not all([download_id, username]):
return jsonify({"success": False, "error": "Missing download_id or username."}), 400
try:
# Call the same client method the GUI uses
success = asyncio.run(soulseek_client.cancel_download(download_id, username, remove=True))
if success:
return jsonify({"success": True, "message": "Download cancelled."})
else:
return jsonify({"success": False, "error": "Failed to cancel download via slskd."}), 500
except Exception as e:
print(f"Error cancelling download: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/downloads/clear-finished', methods=['POST'])
def clear_finished_downloads():
"""
Clear all terminal (completed, cancelled, failed) downloads from slskd.
"""
try:
# This single client call handles clearing everything that is no longer active
success = asyncio.run(soulseek_client.clear_all_completed_downloads())
if success:
return jsonify({"success": True, "message": "Finished downloads cleared."})
else:
return jsonify({"success": False, "error": "Backend failed to clear downloads."}), 500
except Exception as e:
print(f"Error clearing finished downloads: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/searches/clear-all', methods=['POST'])
def clear_all_searches():
"""
Clear all searches from slskd search history.
"""
try:
success = asyncio.run(soulseek_client.clear_all_searches())
if success:
add_activity_item("🧹", "Search Cleanup", "All search history cleared manually", "Now")
return jsonify({"success": True, "message": "All searches cleared."})
else:
return jsonify({"success": False, "error": "Backend failed to clear searches."}), 500
except Exception as e:
print(f"Error clearing searches: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/searches/maintain', methods=['POST'])
def maintain_search_history():
"""
Maintain search history by keeping only recent searches.
"""
try:
data = request.get_json() or {}
keep_searches = data.get('keep_searches', 50)
trigger_threshold = data.get('trigger_threshold', 200)
success = asyncio.run(soulseek_client.maintain_search_history_with_buffer(
keep_searches=keep_searches, trigger_threshold=trigger_threshold
))
if success:
add_activity_item("🧹", "Search Maintenance", f"Search history maintained (keeping {keep_searches} searches)", "Now")
return jsonify({"success": True, "message": f"Search history maintained (keeping {keep_searches} searches)."})
else:
return jsonify({"success": False, "error": "Backend failed to maintain search history."}), 500
except Exception as e:
print(f"Error maintaining search history: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/artists')
def get_artists():
# Placeholder: returns mock artist data
mock_artists = [
{"name": "Queen", "album_count": 15, "image": None},
{"name": "Led Zeppelin", "album_count": 9, "image": None}
]
return jsonify({"artists": mock_artists})
@app.route('/api/artist/<artist_id>/discography', methods=['GET'])
def get_artist_discography(artist_id):
"""Get an artist's complete discography (albums and singles)"""
try:
if not spotify_client or not spotify_client.is_authenticated():
return jsonify({"error": "Spotify not authenticated"}), 401
print(f"🎤 Fetching discography for artist: {artist_id}")
# Get artist's albums and singles (temporarily include appears_on for debugging)
albums = spotify_client.get_artist_albums(artist_id, album_type='album,single', limit=50)
print(f"📊 Raw albums returned from Spotify: {len(albums)}")
if not albums:
return jsonify({
"albums": [],
"singles": []
})
# Separate albums from singles/EPs
album_list = []
singles_list = []
# Track seen albums to avoid duplicates (especially for "appears_on")
seen_albums = set()
for album in albums:
# Skip duplicates
if album.id in seen_albums:
continue
seen_albums.add(album.id)
# Debug: Check artist information
print(f"🔍 Checking album: {album.name}")
if hasattr(album, 'artists') and album.artists:
primary_artist_id = album.artists[0].id if hasattr(album.artists[0], 'id') else None
primary_artist_name = album.artists[0].name if hasattr(album.artists[0], 'name') else None
print(f" Primary artist: {primary_artist_name} (ID: {primary_artist_id})")
print(f" Requested artist ID: {artist_id}")
# Skip if the primary artist doesn't match our requested artist
if primary_artist_id and primary_artist_id != artist_id:
print(f"🚫 Skipping '{album.name}' - primary artist mismatch")
continue
elif not primary_artist_id:
print(f"⚠️ No primary artist ID found for '{album.name}' - including anyway")
else:
print(f"⚠️ No artists found for '{album.name}' - including anyway")
album_data = {
"id": album.id,
"name": album.name,
"release_date": album.release_date if hasattr(album, 'release_date') else None,
"album_type": album.album_type if hasattr(album, 'album_type') else 'album',
"image_url": album.image_url if hasattr(album, 'image_url') else None,
"total_tracks": album.total_tracks if hasattr(album, 'total_tracks') else 0,
"external_urls": album.external_urls if hasattr(album, 'external_urls') else {}
}
# Skip obvious compilation issues but be more lenient for now
if hasattr(album, 'album_type') and album.album_type == 'compilation':
print(f"📀 Found compilation: '{album.name}' - including for now")
# Categorize by album type
if hasattr(album, 'album_type'):
if album.album_type in ['single', 'ep']:
singles_list.append(album_data)
else: # 'album' or approved 'compilation'
album_list.append(album_data)
else:
# Default to album if no type specified
album_list.append(album_data)
# Sort by release date (newest first)
def get_release_year(item):
if item['release_date']:
try:
# Handle different date formats (YYYY, YYYY-MM, YYYY-MM-DD)
return int(item['release_date'][:4])
except (ValueError, IndexError):
return 0
return 0
album_list.sort(key=get_release_year, reverse=True)
singles_list.sort(key=get_release_year, reverse=True)
print(f"✅ Found {len(album_list)} albums and {len(singles_list)} singles for artist {artist_id}")
# Debug: Log the final album list
for album in album_list:
print(f"📀 Album: {album['name']} ({album['album_type']}) - {album['release_date']}")
for single in singles_list:
print(f"🎵 Single/EP: {single['name']} ({single['album_type']}) - {single['release_date']}")
return jsonify({
"albums": album_list,
"singles": singles_list
})
except Exception as e:
print(f"❌ Error fetching artist discography: {e}")
import traceback
traceback.print_exc()
return jsonify({"error": str(e)}), 500
@app.route('/api/artist/<artist_id>/album/<album_id>/tracks', methods=['GET'])
def get_artist_album_tracks(artist_id, album_id):
"""Get tracks for specific album formatted for download missing tracks modal"""
try:
if not spotify_client or not spotify_client.is_authenticated():
return jsonify({"error": "Spotify not authenticated"}), 401
print(f"🎵 Fetching tracks for album: {album_id} by artist: {artist_id}")
# Get album information first
album_data = spotify_client.get_album(album_id)
if not album_data:
return jsonify({"error": "Album not found"}), 404
# Get album tracks
tracks_data = spotify_client.get_album_tracks(album_id)
if not tracks_data or 'items' not in tracks_data:
return jsonify({"error": "No tracks found for album"}), 404
# Handle both dict and object responses from spotify_client.get_album()
if isinstance(album_data, dict):
album_info = {
'id': album_data.get('id'),
'name': album_data.get('name'),
'image_url': album_data.get('images', [{}])[0].get('url') if album_data.get('images') else None,
'release_date': album_data.get('release_date'),
'album_type': album_data.get('album_type'),
'total_tracks': album_data.get('total_tracks')
}
else:
# Handle Album object case
album_info = {
'id': album_data.id,
'name': album_data.name,
'image_url': album_data.image_url,
'release_date': album_data.release_date,
'album_type': album_data.album_type,
'total_tracks': album_data.total_tracks
}
# Format tracks for download missing tracks modal compatibility
formatted_tracks = []
for track_item in tracks_data['items']:
# Create track object compatible with download missing tracks modal
formatted_track = {
'id': track_item['id'],
'name': track_item['name'],
'artists': [artist['name'] for artist in track_item['artists']],
'duration_ms': track_item['duration_ms'],
'track_number': track_item['track_number'],
'disc_number': track_item.get('disc_number', 1),
'explicit': track_item.get('explicit', False),
'preview_url': track_item.get('preview_url'),
'external_urls': track_item.get('external_urls', {}),
'uri': track_item['uri'],
# Add album context for virtual playlist
'album': album_info
}
formatted_tracks.append(formatted_track)
print(f"✅ Successfully formatted {len(formatted_tracks)} tracks for album: {album_info['name']}")
return jsonify({
'success': True,
'album': album_info,
'tracks': formatted_tracks
})
except Exception as e:
print(f"❌ Error fetching album tracks: {e}")
import traceback
traceback.print_exc()
return jsonify({"error": str(e)}), 500
@app.route('/api/artist/<artist_id>/completion', methods=['POST'])
def check_artist_discography_completion(artist_id):
"""Check completion status for artist's albums and singles"""
try:
data = request.get_json()
if not data or 'discography' not in data:
return jsonify({"error": "Missing discography data"}), 400
discography = data['discography']
test_mode = data.get('test_mode', False) # Add test mode for demonstration
albums_completion = []
singles_completion = []
# Get database instance
from database.music_database import MusicDatabase
db = MusicDatabase()
# Get artist name - should be provided by the frontend
artist_name = data.get('artist_name', 'Unknown Artist')
# If no artist name provided, try to infer it from the request
if artist_name == 'Unknown Artist':
print(f"⚠️ No artist name provided in request, attempting to infer from discography data")
# Try to extract from first album's title by using a simple search
all_items = discography.get('albums', []) + discography.get('singles', [])
if all_items and spotify_client and spotify_client.is_authenticated():
try:
first_item = all_items[0]
# Search for the first track to get artist name
search_results = spotify_client.search_tracks(first_item.get('name', ''), limit=1)
if search_results and len(search_results) > 0:
artist_name = search_results[0].artists[0] if search_results[0].artists else "Unknown Artist"
print(f"🎤 Inferred artist name from search: {artist_name}")
except Exception as e:
print(f"⚠️ Could not infer artist name: {e}")
artist_name = "Unknown Artist"
print(f"🎤 Checking completion for artist: {artist_name}")
# Process albums
for album in discography.get('albums', []):
completion_data = _check_album_completion(db, album, artist_name, test_mode)
albums_completion.append(completion_data)
# Process singles/EPs
for single in discography.get('singles', []):
completion_data = _check_single_completion(db, single, artist_name, test_mode)
singles_completion.append(completion_data)
return jsonify({
"albums": albums_completion,
"singles": singles_completion
})
except Exception as e:
print(f"❌ Error checking discography completion: {e}")
import traceback
traceback.print_exc()
return jsonify({"error": str(e)}), 500
def _check_album_completion(db, album_data: dict, artist_name: str, test_mode: bool = False) -> dict:
"""Check completion status for a single album"""
try:
album_name = album_data.get('name', '')
total_tracks = album_data.get('total_tracks', 0)
album_id = album_data.get('id', '')
print(f"🔍 Checking album: '{album_name}' ({total_tracks} tracks)")
if test_mode:
# Generate test data to demonstrate the feature
import random
owned_tracks = random.randint(0, max(1, total_tracks))
expected_tracks = total_tracks
confidence = random.uniform(0.7, 1.0)
db_album = True # Simulate found album
print(f"🧪 TEST MODE: Simulating {owned_tracks}/{expected_tracks} tracks for '{album_name}'")
else:
# Check if album exists in database with completeness info
try:
db_album, confidence, owned_tracks, expected_tracks, is_complete = db.check_album_exists_with_completeness(
title=album_name,
artist=artist_name,
expected_track_count=total_tracks if total_tracks > 0 else None,
confidence_threshold=0.7 # Slightly lower threshold for better matching
)
except Exception as db_error:
print(f"⚠️ Database error for album '{album_name}': {db_error}")
# Return error state for this album
return {
"id": album_id,
"name": album_name,
"status": "error",
"owned_tracks": 0,
"expected_tracks": total_tracks,
"completion_percentage": 0,
"confidence": 0.0,
"found_in_db": False,
"error_message": str(db_error)
}
# Calculate completion percentage
if expected_tracks > 0:
completion_percentage = (owned_tracks / expected_tracks) * 100
elif total_tracks > 0:
completion_percentage = (owned_tracks / total_tracks) * 100
else:
completion_percentage = 100 if owned_tracks > 0 else 0
# Determine completion status based on percentage
if completion_percentage >= 90 and owned_tracks > 0:
status = "completed"
elif completion_percentage >= 60:
status = "nearly_complete"
elif completion_percentage > 0:
status = "partial"
else:
status = "missing"
print(f" 📊 Result: {owned_tracks}/{expected_tracks or total_tracks} tracks ({completion_percentage:.1f}%) - {status}")
return {
"id": album_id,
"name": album_name,
"status": status,
"owned_tracks": owned_tracks,
"expected_tracks": expected_tracks or total_tracks,
"completion_percentage": round(completion_percentage, 1),
"confidence": round(confidence, 2) if confidence else 0.0,
"found_in_db": db_album is not None
}
except Exception as e:
print(f"❌ Error checking album completion for '{album_data.get('name', 'Unknown')}': {e}")
return {
"id": album_data.get('id', ''),
"name": album_data.get('name', 'Unknown'),
"status": "error",
"owned_tracks": 0,
"expected_tracks": album_data.get('total_tracks', 0),
"completion_percentage": 0,
"confidence": 0.0,
"found_in_db": False
}
def _check_single_completion(db, single_data: dict, artist_name: str, test_mode: bool = False) -> dict:
"""Check completion status for a single/EP (treat EPs like albums, singles as single tracks)"""
try:
single_name = single_data.get('name', '')
total_tracks = single_data.get('total_tracks', 1)
single_id = single_data.get('id', '')
album_type = single_data.get('album_type', 'single')
print(f"🎵 Checking {album_type}: '{single_name}' ({total_tracks} tracks)")
if test_mode:
# Generate test data for singles/EPs
import random
if album_type == 'ep' or total_tracks > 1:
owned_tracks = random.randint(0, total_tracks)
expected_tracks = total_tracks
confidence = random.uniform(0.7, 1.0)
print(f"🧪 TEST MODE: EP with {owned_tracks}/{expected_tracks} tracks")
else:
owned_tracks = random.choice([0, 1]) # 50/50 chance
expected_tracks = 1
confidence = random.uniform(0.7, 1.0) if owned_tracks else 0.0
print(f"🧪 TEST MODE: Single with {owned_tracks}/{expected_tracks} tracks")
elif album_type == 'ep' or total_tracks > 1:
# Treat EPs like albums
try:
db_album, confidence, owned_tracks, expected_tracks, is_complete = db.check_album_exists_with_completeness(
title=single_name,
artist=artist_name,
expected_track_count=total_tracks,
confidence_threshold=0.7
)
except Exception as db_error:
print(f"⚠️ Database error for EP '{single_name}': {db_error}")
owned_tracks, expected_tracks, confidence = 0, total_tracks, 0.0
# Calculate completion percentage
if expected_tracks > 0:
completion_percentage = (owned_tracks / expected_tracks) * 100
else:
completion_percentage = (owned_tracks / total_tracks) * 100
# Determine status
if completion_percentage >= 90 and owned_tracks > 0:
status = "completed"
elif completion_percentage >= 60:
status = "nearly_complete"
elif completion_percentage > 0:
status = "partial"
else:
status = "missing"
print(f" 📊 EP Result: {owned_tracks}/{expected_tracks or total_tracks} tracks ({completion_percentage:.1f}%) - {status}")
else:
# Single track - just check if the track exists
try:
db_track, confidence = db.check_track_exists(
title=single_name,
artist=artist_name,
confidence_threshold=0.7
)
except Exception as db_error:
print(f"⚠️ Database error for single '{single_name}': {db_error}")
db_track, confidence = None, 0.0
owned_tracks = 1 if db_track else 0
expected_tracks = 1
completion_percentage = 100 if db_track else 0
status = "completed" if db_track else "missing"
print(f" 🎵 Single Result: {owned_tracks}/1 tracks ({completion_percentage:.1f}%) - {status}")
return {
"id": single_id,
"name": single_name,
"status": status,
"owned_tracks": owned_tracks,
"expected_tracks": expected_tracks or total_tracks,
"completion_percentage": round(completion_percentage, 1),
"confidence": round(confidence, 2) if confidence else 0.0,
"found_in_db": (db_album if album_type == 'ep' or total_tracks > 1 else db_track) is not None,
"type": album_type
}
except Exception as e:
print(f"❌ Error checking single/EP completion for '{single_data.get('name', 'Unknown')}': {e}")
return {
"id": single_data.get('id', ''),
"name": single_data.get('name', 'Unknown'),
"status": "error",
"owned_tracks": 0,
"expected_tracks": single_data.get('total_tracks', 1),
"completion_percentage": 0,
"confidence": 0.0,
"found_in_db": False,
"type": single_data.get('album_type', 'single')
}
@app.route('/api/artist/<artist_id>/completion-stream', methods=['POST'])
def check_artist_discography_completion_stream(artist_id):
"""Stream completion status for artist's albums and singles one by one"""
# Capture request data BEFORE the generator function
try:
data = request.get_json()
if not data or 'discography' not in data:
return jsonify({"error": "Missing discography data"}), 400
except Exception as e:
return jsonify({"error": "Invalid request data"}), 400
# Extract data for the generator
discography = data['discography']
test_mode = data.get('test_mode', False)
artist_name = data.get('artist_name', 'Unknown Artist')
def generate_completion_stream():
try:
print(f"🎤 Starting streaming completion check for artist: {artist_name}")
# Get database instance
from database.music_database import MusicDatabase
db = MusicDatabase()
# Process albums one by one
total_items = len(discography.get('albums', [])) + len(discography.get('singles', []))
processed_count = 0
# Send initial status
yield f"data: {json.dumps({'type': 'start', 'total_items': total_items, 'artist_name': artist_name})}\n\n"
# Process albums
for album in discography.get('albums', []):
try:
completion_data = _check_album_completion(db, album, artist_name, test_mode)
completion_data['type'] = 'album_completion'
completion_data['container_type'] = 'albums'
processed_count += 1
completion_data['progress'] = round((processed_count / total_items) * 100, 1)
yield f"data: {json.dumps(completion_data)}\n\n"
# Small delay to make the streaming effect visible
time.sleep(0.1) # 100ms delay between items
except Exception as e:
error_data = {
'type': 'error',
'container_type': 'albums',
'id': album.get('id', ''),
'name': album.get('name', 'Unknown'),
'error': str(e)
}
yield f"data: {json.dumps(error_data)}\n\n"
# Process singles/EPs
for single in discography.get('singles', []):
try:
completion_data = _check_single_completion(db, single, artist_name, test_mode)
completion_data['type'] = 'single_completion'
completion_data['container_type'] = 'singles'
processed_count += 1
completion_data['progress'] = round((processed_count / total_items) * 100, 1)
yield f"data: {json.dumps(completion_data)}\n\n"
# Small delay to make the streaming effect visible
time.sleep(0.1) # 100ms delay between items
except Exception as e:
error_data = {
'type': 'error',
'container_type': 'singles',
'id': single.get('id', ''),
'name': single.get('name', 'Unknown'),
'error': str(e)
}
yield f"data: {json.dumps(error_data)}\n\n"
# Send completion signal
yield f"data: {json.dumps({'type': 'complete', 'processed_count': processed_count})}\n\n"
except Exception as e:
print(f"❌ Error in streaming completion check: {e}")
import traceback
traceback.print_exc()
yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n"
return Response(
generate_completion_stream(),
content_type='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control'
}
)
@app.route('/api/stream/start', methods=['POST'])
def stream_start():
"""Start streaming a track in the background"""
global stream_background_task
data = request.get_json()
if not data:
return jsonify({"success": False, "error": "No track data provided"}), 400
print(f"🎵 Web UI Stream request for: {data.get('filename')}")
try:
# Stop any existing streaming task
if stream_background_task and not stream_background_task.done():
stream_background_task.cancel()
# Reset stream state
with stream_lock:
stream_state.update({
"status": "stopped",
"progress": 0,
"track_info": None,
"file_path": None,
"error_message": None
})
# Start new background streaming task
stream_background_task = stream_executor.submit(_prepare_stream_task, data)
return jsonify({"success": True, "message": "Streaming started"})
except Exception as e:
print(f"❌ Error starting stream: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/stream/status')
def stream_status():
"""Get current streaming status and progress"""
try:
with stream_lock:
# Return copy of current stream state
return jsonify({
"status": stream_state["status"],
"progress": stream_state["progress"],
"track_info": stream_state["track_info"],
"error_message": stream_state["error_message"]
})
except Exception as e:
print(f"❌ Error getting stream status: {e}")
return jsonify({
"status": "error",
"progress": 0,
"track_info": None,
"error_message": str(e)
}), 500
@app.route('/stream/audio')
def stream_audio():
"""Serve the audio file from the Stream folder with range request support"""
try:
with stream_lock:
if stream_state["status"] != "ready" or not stream_state["file_path"]:
return jsonify({"error": "No audio file ready for streaming"}), 404
file_path = stream_state["file_path"]
if not os.path.exists(file_path):
return jsonify({"error": "Audio file not found"}), 404
print(f"🎵 Serving audio file: {os.path.basename(file_path)}")
# Determine MIME type based on file extension
file_ext = os.path.splitext(file_path)[1].lower()
mime_types = {
'.mp3': 'audio/mpeg',
'.flac': 'audio/flac',
'.ogg': 'audio/ogg',
'.aac': 'audio/aac',
'.m4a': 'audio/mp4',
'.wav': 'audio/wav',
'.wma': 'audio/x-ms-wma'
}
mimetype = mime_types.get(file_ext, 'audio/mpeg')
# Get file size
file_size = os.path.getsize(file_path)
# Handle range requests (important for HTML5 audio seeking)
range_header = request.headers.get('Range', None)
if range_header:
byte_start = 0
byte_end = file_size - 1
# Parse range header (format: "bytes=start-end")
try:
range_match = re.match(r'bytes=(\d*)-(\d*)', range_header)
if range_match:
start_str, end_str = range_match.groups()
if start_str:
byte_start = int(start_str)
if end_str:
byte_end = int(end_str)
else:
# If no end specified, serve from start to end of file
byte_end = file_size - 1
except (ValueError, AttributeError):
# Invalid range header, serve full file
pass
# Ensure valid range
byte_start = max(0, byte_start)
byte_end = min(file_size - 1, byte_end)
content_length = byte_end - byte_start + 1
# Create response with partial content
def generate():
with open(file_path, 'rb') as f:
f.seek(byte_start)
remaining = content_length
while remaining:
chunk_size = min(8192, remaining) # 8KB chunks
chunk = f.read(chunk_size)
if not chunk:
break
remaining -= len(chunk)
yield chunk
response = Response(generate(),
status=206, # Partial Content
mimetype=mimetype,
direct_passthrough=True)
# Set range headers
response.headers.add('Content-Range', f'bytes {byte_start}-{byte_end}/{file_size}')
response.headers.add('Accept-Ranges', 'bytes')
response.headers.add('Content-Length', str(content_length))
response.headers.add('Cache-Control', 'no-cache')
return response
else:
# No range request, serve entire file
response = send_file(file_path, as_attachment=False, mimetype=mimetype)
response.headers.add('Accept-Ranges', 'bytes')
response.headers.add('Content-Length', str(file_size))
response.headers.add('Cache-Control', 'no-cache')
return response
except Exception as e:
print(f"❌ Error serving audio file: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/stream/stop', methods=['POST'])
def stream_stop():
"""Stop streaming and clean up"""
global stream_background_task
try:
# Cancel background task
if stream_background_task and not stream_background_task.done():
stream_background_task.cancel()
# Clear Stream folder
project_root = os.path.dirname(os.path.abspath(__file__))
stream_folder = os.path.join(project_root, 'Stream')
if os.path.exists(stream_folder):
for filename in os.listdir(stream_folder):
file_path = os.path.join(stream_folder, filename)
if os.path.isfile(file_path):
os.remove(file_path)
print(f"🗑️ Removed stream file: {filename}")
# Reset stream state
with stream_lock:
stream_state.update({
"status": "stopped",
"progress": 0,
"track_info": None,
"file_path": None,
"error_message": None
})
return jsonify({"success": True, "message": "Stream stopped"})
except Exception as e:
print(f"❌ Error stopping stream: {e}")
return jsonify({"success": False, "error": str(e)}), 500
# --- Matched Downloads API Endpoints ---
def _generate_artist_suggestions(search_result, is_album=False, album_result=None):
"""
Port of ArtistSuggestionThread.generate_artist_suggestions() from GUI
Generate artist suggestions using multiple strategies
"""
if not spotify_client or not matching_engine:
return []
try:
print(f"🔍 Generating artist suggestions for: {search_result.get('artist', '')} - {search_result.get('title', '')}")
suggestions = []
# Special handling for albums - use album title to find artist
if is_album and album_result and album_result.get('album_title'):
print(f"🎵 Album mode detected - using album title for artist search")
album_title = album_result.get('album_title', '')
# Clean album title (remove year prefixes like "(2005)")
import re
clean_album_title = re.sub(r'^\(\d{4}\)\s*', '', album_title).strip()
print(f" clean_album_title: '{clean_album_title}'")
# Search tracks using album title to find the artist
tracks = spotify_client.search_tracks(clean_album_title, limit=20)
print(f"📊 Found {len(tracks)} tracks from album search")
# Collect unique artists and their associated tracks/albums
unique_artists = {} # artist_name -> list of (track, album) tuples
for track in tracks:
for artist_name in track.artists:
if artist_name not in unique_artists:
unique_artists[artist_name] = []
unique_artists[artist_name].append((track, track.album))
# Batch fetch artist objects for speed
from concurrent.futures import ThreadPoolExecutor, as_completed
artist_objects = {} # artist_name -> Artist object
def fetch_artist(artist_name):
try:
matches = spotify_client.search_artists(artist_name, limit=1)
if matches:
return artist_name, matches[0]
except Exception as e:
print(f"⚠️ Error fetching artist '{artist_name}': {e}")
return artist_name, None
# Use limited concurrency to respect rate limits
with ThreadPoolExecutor(max_workers=3) as executor:
future_to_artist = {executor.submit(fetch_artist, name): name for name in unique_artists.keys()}
for future in as_completed(future_to_artist):
artist_name, artist_obj = future.result()
if artist_obj:
artist_objects[artist_name] = artist_obj
# Calculate confidence scores for each artist
artist_scores = {}
for artist_name, track_album_pairs in unique_artists.items():
if artist_name not in artist_objects:
continue
artist = artist_objects[artist_name]
best_confidence = 0
# Find the best confidence score across all albums for this artist
for track, album in track_album_pairs:
confidence = matching_engine.similarity_score(
matching_engine.normalize_string(clean_album_title),
matching_engine.normalize_string(album)
)
if confidence > best_confidence:
best_confidence = confidence
artist_scores[artist_name] = (artist, best_confidence)
# Create suggestions from top matches
for artist_name, (artist, confidence) in sorted(artist_scores.items(), key=lambda x: x[1][1], reverse=True)[:8]:
suggestions.append({
"artist": {
"id": artist.id,
"name": artist.name,
"image_url": getattr(artist, 'image_url', None),
"genres": getattr(artist, 'genres', []),
"popularity": getattr(artist, 'popularity', 0)
},
"confidence": confidence
})
else:
# Single track mode - search by artist name
search_artist = search_result.get('artist', '')
if not search_artist:
return []
print(f"🎵 Single track mode - searching for artist: '{search_artist}'")
# Search for artists directly
artist_matches = spotify_client.search_artists(search_artist, limit=10)
for artist in artist_matches:
# Calculate confidence based on artist name similarity
confidence = matching_engine.similarity_score(
matching_engine.normalize_string(search_artist),
matching_engine.normalize_string(artist.name)
)
suggestions.append({
"artist": {
"id": artist.id,
"name": artist.name,
"image_url": getattr(artist, 'image_url', None),
"genres": getattr(artist, 'genres', []),
"popularity": getattr(artist, 'popularity', 0)
},
"confidence": confidence
})
# Sort by confidence and return top results
suggestions.sort(key=lambda x: x['confidence'], reverse=True)
return suggestions[:4]
except Exception as e:
print(f"❌ Error generating artist suggestions: {e}")
return []
def _generate_album_suggestions(selected_artist, search_result):
"""
Port of AlbumSuggestionThread logic from GUI
Generate album suggestions for a selected artist
"""
if not spotify_client or not matching_engine:
return []
try:
print(f"🔍 Generating album suggestions for artist: {selected_artist['name']}")
# Determine target album name from search result
target_album_name = search_result.get('album', '') or search_result.get('album_title', '')
if not target_album_name:
print("⚠️ No album name found in search result")
return []
# Clean target album name
import re
clean_target = re.sub(r'^\(\d{4}\)\s*', '', target_album_name).strip()
print(f" target_album: '{clean_target}'")
# Get artist's albums from Spotify
artist_albums = spotify_client.get_artist_albums(selected_artist['id'], limit=50)
print(f"📊 Found {len(artist_albums)} albums for artist")
album_matches = []
for album in artist_albums:
# Calculate confidence based on album name similarity
confidence = matching_engine.similarity_score(
matching_engine.normalize_string(clean_target),
matching_engine.normalize_string(album.name)
)
album_matches.append({
"album": {
"id": album.id,
"name": album.name,
"release_date": getattr(album, 'release_date', ''),
"album_type": getattr(album, 'album_type', 'album'),
"image_url": getattr(album, 'image_url', None),
"total_tracks": getattr(album, 'total_tracks', 0)
},
"confidence": confidence
})
# Sort by confidence and return top results
album_matches.sort(key=lambda x: x['confidence'], reverse=True)
return album_matches[:4]
except Exception as e:
print(f"❌ Error generating album suggestions: {e}")
return []
@app.route('/api/match/suggestions', methods=['POST'])
def get_match_suggestions():
"""Get AI-powered suggestions for artist or album matching"""
try:
data = request.get_json()
search_result = data.get('search_result', {})
context = data.get('context', 'artist') # 'artist' or 'album'
if context == 'artist':
is_album = data.get('is_album', False)
album_result = data.get('album_result', None) if is_album else None
suggestions = _generate_artist_suggestions(search_result, is_album, album_result)
elif context == 'album':
selected_artist = data.get('selected_artist', {})
suggestions = _generate_album_suggestions(selected_artist, search_result)
else:
return jsonify({"error": "Invalid context. Must be 'artist' or 'album'"}), 400
return jsonify({"suggestions": suggestions})
except Exception as e:
print(f"❌ Error in match suggestions: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/match/search', methods=['POST'])
def search_match():
"""Manual search for artists or albums"""
try:
data = request.get_json()
query = data.get('query', '').strip()
context = data.get('context', 'artist') # 'artist' or 'album'
if not query:
return jsonify({"results": []})
if context == 'artist':
# Search for artists
artist_matches = spotify_client.search_artists(query, limit=8)
results = []
for artist in artist_matches:
# Calculate confidence based on search similarity
confidence = matching_engine.similarity_score(
matching_engine.normalize_string(query),
matching_engine.normalize_string(artist.name)
)
results.append({
"artist": {
"id": artist.id,
"name": artist.name,
"image_url": getattr(artist, 'image_url', None),
"genres": getattr(artist, 'genres', []),
"popularity": getattr(artist, 'popularity', 0)
},
"confidence": confidence
})
return jsonify({"results": results})
elif context == 'album':
# Search for albums by specific artist
artist_id = data.get('artist_id')
if not artist_id:
return jsonify({"error": "Artist ID required for album search"}), 400
# Get artist's albums and filter by query
artist_albums = spotify_client.get_artist_albums(artist_id, limit=50)
results = []
for album in artist_albums:
# Calculate confidence based on query similarity
confidence = matching_engine.similarity_score(
matching_engine.normalize_string(query),
matching_engine.normalize_string(album.name)
)
# Only include results with reasonable similarity
if confidence > 0.3:
results.append({
"album": {
"id": album.id,
"name": album.name,
"release_date": getattr(album, 'release_date', ''),
"album_type": getattr(album, 'album_type', 'album'),
"image_url": getattr(album, 'image_url', None),
"total_tracks": getattr(album, 'total_tracks', 0)
},
"confidence": confidence
})
# Sort by confidence
results.sort(key=lambda x: x['confidence'], reverse=True)
return jsonify({"results": results[:8]})
else:
return jsonify({"error": "Invalid context. Must be 'artist' or 'album'"}), 400
except Exception as e:
print(f"❌ Error in match search: {e}")
return jsonify({"error": str(e)}), 500
def _start_album_download_tasks(album_result, spotify_artist, spotify_album):
"""
This final version now fetches the official Spotify tracklist and uses it to
match and correct the metadata for each individual track before downloading,
ensuring perfect tagging and naming.
"""
print(f"🎵 Processing matched album download for '{spotify_album['name']}' with {len(album_result.get('tracks', []))} tracks.")
tracks_to_download = album_result.get('tracks', [])
if not tracks_to_download:
print("⚠️ Album result contained no tracks. Aborting.")
return 0
# --- THIS IS THE NEW LOGIC ---
# Fetch the official tracklist from Spotify ONCE for the entire album.
official_spotify_tracks = _get_spotify_album_tracks(spotify_album)
if not official_spotify_tracks:
print("⚠️ Could not fetch official tracklist from Spotify. Metadata may be inaccurate.")
# --- END OF NEW LOGIC ---
started_count = 0
for track_data in tracks_to_download:
try:
username = track_data.get('username') or album_result.get('username')
filename = track_data.get('filename')
size = track_data.get('size', 0)
if not username or not filename:
continue
# Pre-parse the filename to get a baseline for metadata
parsed_meta = _parse_filename_metadata(filename)
# --- THIS IS THE CRITICAL MATCHING STEP ---
# Match the parsed metadata against the official Spotify tracklist
corrected_meta = _match_track_to_spotify_title(parsed_meta, official_spotify_tracks)
# --- END OF CRITICAL STEP ---
# Create a clean context object using the CORRECTED metadata
individual_track_context = {
'username': username,
'filename': filename,
'size': size,
'title': corrected_meta.get('title'),
'artist': corrected_meta.get('artist') or spotify_artist['name'],
'album': spotify_album['name'],
'track_number': corrected_meta.get('track_number')
}
download_id = asyncio.run(soulseek_client.download(username, filename, size))
if download_id:
context_key = f"{username}::{filename}"
with matched_context_lock:
# Enhanced context storage with Spotify clean titles (GUI parity)
enhanced_context = individual_track_context.copy()
enhanced_context['spotify_clean_title'] = individual_track_context.get('title', '')
matched_downloads_context[context_key] = {
"spotify_artist": spotify_artist,
"spotify_album": spotify_album,
"original_search_result": enhanced_context, # Contains corrected data + clean title
"is_album_download": True
}
print(f" + Queued track: {filename} (Matched to: '{corrected_meta.get('title')}')")
started_count += 1
else:
print(f" - Failed to queue track: {filename}")
except Exception as e:
print(f"❌ Error processing track in album batch: {track_data.get('filename')}. Error: {e}")
continue
return started_count
@app.route('/api/download/matched', methods=['POST'])
def start_matched_download():
"""
Starts a matched download. This version corrects a bug where album context
was being discarded for individual album track downloads, ensuring they are
processed identically to single track downloads.
"""
try:
data = request.get_json()
download_payload = data.get('search_result', {})
spotify_artist = data.get('spotify_artist', {})
spotify_album = data.get('spotify_album', None)
if not download_payload or not spotify_artist:
return jsonify({"success": False, "error": "Missing download payload or artist data"}), 400
# This check is for full album downloads (when the main album card button is clicked)
is_full_album_download = bool(spotify_album and download_payload.get('result_type') == 'album')
if is_full_album_download:
# This logic for full album downloads is correct and remains unchanged.
started_count = _start_album_download_tasks(download_payload, spotify_artist, spotify_album)
if started_count > 0:
return jsonify({"success": True, "message": f"Queued {started_count} tracks for matched album download."})
else:
return jsonify({"success": False, "error": "Failed to queue any tracks from the album."}), 500
else:
# This block handles BOTH regular singles AND individual tracks from an album card.
username = download_payload.get('username')
filename = download_payload.get('filename')
size = download_payload.get('size', 0)
if not username or not filename:
return jsonify({"success": False, "error": "Missing username or filename"}), 400
parsed_meta = _parse_filename_metadata(filename)
download_payload['title'] = parsed_meta.get('title') or download_payload.get('title')
download_payload['artist'] = parsed_meta.get('artist') or download_payload.get('artist')
download_id = asyncio.run(soulseek_client.download(username, filename, size))
if download_id:
context_key = f"{username}::{filename}"
with matched_context_lock:
# THE FIX: We preserve the spotify_album context if it was provided.
# For a regular single, spotify_album will be None.
# For an album track, it will contain the album's data.
# Enhanced context storage with Spotify clean titles (GUI parity)
enhanced_payload = download_payload.copy()
enhanced_payload['spotify_clean_title'] = download_payload.get('title', '')
matched_downloads_context[context_key] = {
"spotify_artist": spotify_artist,
"spotify_album": spotify_album, # PRESERVE album context
"original_search_result": enhanced_payload,
"is_album_download": False # It's a single track download, not a full album job.
}
return jsonify({"success": True, "message": "Matched download started"})
else:
return jsonify({"success": False, "error": "Failed to start download via slskd"}), 500
except Exception as e:
import traceback
traceback.print_exc()
return jsonify({"success": False, "error": str(e)}), 500
def _parse_filename_metadata(filename: str) -> dict:
"""
A direct port of the metadata parsing logic from the GUI's soulseek_client.py.
This is the crucial missing step that cleans filenames BEFORE Spotify matching.
"""
import re
import os
metadata = {
'artist': None,
'title': None,
'album': None,
'track_number': None
}
# Get just the filename without extension and path
base_name = os.path.splitext(os.path.basename(filename))[0]
# --- Logic from soulseek_client.py ---
patterns = [
# Pattern: 01 - Artist - Title
r'^(?P<track_number>\d{1,2})\s*[-\.]\s*(?P<artist>.+?)\s*[-]\s*(?P<title>.+)$',
# Pattern: Artist - Title
r'^(?P<artist>.+?)\s*[-]\s*(?P<title>.+)$',
# Pattern: 01 - Title
r'^(?P<track_number>\d{1,2})\s*[-\.]\s*(?P<title>.+)$',
]
for pattern in patterns:
match = re.match(pattern, base_name)
if match:
match_dict = match.groupdict()
metadata['track_number'] = int(match_dict['track_number']) if match_dict.get('track_number') else None
metadata['artist'] = match_dict.get('artist', '').strip() or None
metadata['title'] = match_dict.get('title', '').strip() or None
break # Stop after first successful match
# If title is still missing, use the whole base_name
if not metadata['title']:
metadata['title'] = base_name.strip()
# Fallback for underscore formats like 'Artist_Album_01_Title'
if not metadata['artist'] and '_' in base_name:
parts = base_name.split('_')
if len(parts) >= 3:
# A common pattern is Artist_Album_TrackNum_Title
if parts[-2].isdigit():
metadata['artist'] = parts[0].strip()
metadata['title'] = parts[-1].strip()
metadata['track_number'] = int(parts[-2])
metadata['album'] = parts[1].strip()
# Final cleanup on title if it contains the artist
if metadata['artist'] and metadata['title'] and metadata['artist'].lower() in metadata['title'].lower():
metadata['title'] = metadata['title'].replace(metadata['artist'], '').lstrip(' -_').strip()
# Try to extract album from the full directory path
if '/' in filename or '\\' in filename:
path_parts = filename.replace('\\', '/').split('/')
if len(path_parts) >= 2:
# The parent directory is often the album
potential_album = path_parts[-2]
# Clean common prefixes like '2024 - '
cleaned_album = re.sub(r'^\d{4}\s*-\s*', '', potential_album).strip()
metadata['album'] = cleaned_album
print(f"🧠 Parsed Filename '{base_name}': Artist='{metadata['artist']}', Title='{metadata['title']}', Album='{metadata['album']}', Track#='{metadata['track_number']}'")
return metadata
# ===================================================================
# NEW POST-PROCESSING HELPERS (Ported from downloads.py)
# ===================================================================
def _sanitize_filename(filename: str) -> str:
"""Sanitize filename for file system compatibility."""
import re
sanitized = re.sub(r'[<>:"/\\|?*]', '_', filename)
sanitized = re.sub(r'\s+', ' ', sanitized).strip()
return sanitized[:200]
def _clean_track_title(track_title: str, artist_name: str) -> str:
"""Clean up track title by removing artist prefix and other noise."""
import re
original = track_title.strip()
cleaned = original
cleaned = re.sub(r'^\d{1,2}[\.\s\-]+', '', cleaned)
artist_pattern = re.escape(artist_name) + r'\s*-\s*'
cleaned = re.sub(f'^{artist_pattern}', '', cleaned, flags=re.IGNORECASE)
cleaned = re.sub(r'^[A-Za-z0-9\.]+\s*-\s*\d{1,2}\s*-\s*', '', cleaned)
quality_patterns = [r'\s*[\[\(][0-9]+\s*kbps[\]\)]\s*', r'\s*[\[\(]flac[\]\)]\s*', r'\s*[\[\(]mp3[\]\)]\s*']
for pattern in quality_patterns:
cleaned = re.sub(pattern, '', cleaned, flags=re.IGNORECASE)
cleaned = re.sub(r'^[-\s\.]+', '', cleaned)
cleaned = re.sub(r'[-\s\.]+$', '', cleaned)
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
return cleaned if cleaned else original
def _extract_track_number_from_filename(filename: str, title: str = None) -> int:
"""Extract track number from filename or title, returns 1 if not found."""
import re
import os
text_to_check = f"{title or ''} {os.path.splitext(os.path.basename(filename))[0]}"
match = re.match(r'^\d{1,2}', text_to_check.strip())
if match:
return int(match.group(0))
return 1
def _search_track_in_album_context(original_search: dict, artist: dict) -> dict:
"""
Searches for a track within its album context to avoid matching promotional singles.
This is a direct port from downloads.py for web server use.
"""
try:
album_name = original_search.get('album')
track_title = original_search.get('title')
if not all([album_name, track_title, artist]):
return None
clean_album = _clean_track_title(album_name, artist['name']) # Use track cleaner for album too
clean_track = _clean_track_title(track_title, artist['name'])
album_query = f"album:\"{clean_album}\" artist:\"{artist['name']}\""
albums = spotify_client.search_albums(album_query, limit=1)
if not albums:
return None
spotify_album = albums[0]
album_tracks_data = spotify_client.get_album_tracks(spotify_album.id)
if not album_tracks_data or 'items' not in album_tracks_data:
return None
for track_data in album_tracks_data['items']:
similarity = matching_engine.similarity_score(
matching_engine.normalize_string(clean_track),
matching_engine.normalize_string(track_data['name'])
)
if similarity > 0.7:
print(f"✅ Found track in album context: '{track_data['name']}'")
return {
'is_album': True,
'album_name': spotify_album.name,
'track_number': track_data['track_number'],
'clean_track_name': track_data['name'],
'album_image_url': spotify_album.image_url
}
return None
except Exception as e:
print(f"❌ Error in _search_track_in_album_context: {e}")
return None
def _detect_album_info_web(context: dict, artist: dict) -> dict:
"""
Enhanced album detection with GUI parity - multi-priority logic.
(Updated to match GUI downloads.py logic exactly)
"""
try:
# Log available data for debugging (GUI PARITY)
original_search = context.get("original_search_result", {})
print(f"\n🔍 [Album Detection] Starting for track: '{original_search.get('title', 'Unknown')}'")
print(f"📊 [Data Available]:")
print(f" - Clean Spotify title: '{original_search.get('spotify_clean_title', 'None')}'")
print(f" - Clean Spotify album: '{original_search.get('spotify_clean_album', 'None')}'")
print(f" - Filename album: '{original_search.get('album', 'None')}'")
print(f" - Artist: '{artist.get('name', 'Unknown')}'")
print(f" - Context has clean data: {context.get('has_clean_spotify_data', False)}")
print(f" - Is album download: {context.get('is_album_download', False)}")
spotify_album_context = context.get("spotify_album")
is_album_download = context.get("is_album_download", False)
artist_name = artist['name']
print(f"🔍 Album detection for '{original_search.get('title', 'Unknown')}' by '{artist_name}':")
print(f" Has album attr: {bool(original_search.get('album'))}")
if original_search.get('album'):
print(f" Album value: '{original_search.get('album')}'")
# --- THIS IS THE CRITICAL FIX ---
# If this is part of a matched album download, we TRUST the context data completely.
# This is the exact logic from downloads.py.
if is_album_download and spotify_album_context:
print("✅ Matched Album context found. Prioritizing pre-matched Spotify data.")
# We exclusively use the track number and title that were matched
# *before* the download started. We do not try to re-parse the filename.
track_number = original_search.get('track_number', 1)
clean_track_name = original_search.get('title', 'Unknown Track')
print(f" -> Using pre-matched Track #{track_number} and Title '{clean_track_name}'")
return {
'is_album': True,
'album_name': spotify_album_context['name'],
'track_number': track_number,
'clean_track_name': clean_track_name,
'album_image_url': spotify_album_context.get('image_url')
}
# PRIORITY 1: Try album-aware search using clean Spotify album name (GUI PARITY)
# Prioritize clean Spotify album name over filename-parsed album
clean_album_name = original_search.get('spotify_clean_album')
fallback_album_name = original_search.get('album')
album_name_to_use = None
album_source = None
if clean_album_name and clean_album_name.strip() and clean_album_name != "Unknown Album":
album_name_to_use = clean_album_name
album_source = "CLEAN_SPOTIFY"
elif fallback_album_name and fallback_album_name.strip() and fallback_album_name != "Unknown Album":
album_name_to_use = fallback_album_name
album_source = "FILENAME_PARSED"
if album_name_to_use:
track_title = original_search.get('spotify_clean_title') or original_search.get('title', 'Unknown')
print(f"🎯 ALBUM-AWARE SEARCH ({album_source}): Looking for '{track_title}' in album '{album_name_to_use}'")
# Temporarily set the album for the search
original_album = original_search.get('album')
original_search['album'] = album_name_to_use
try:
album_result = _search_track_in_album_context_web(context, artist)
if album_result:
print(f"✅ PRIORITY 1 SUCCESS: Found track using {album_source} album name - FORCING album classification")
return album_result
else:
print(f"⚠️ PRIORITY 1 FAILED: Track not found using {album_source} album name")
finally:
# Restore original album value
if original_album is not None:
original_search['album'] = original_album
else:
original_search.pop('album', None)
# PRIORITY 2: Fallback to individual track search for clean metadata
print(f"🔍 Searching Spotify for individual track info (PRIORITY 2)...")
# Clean the track title before searching - remove artist prefix
# Prioritize clean Spotify title over filename-parsed title
track_title_to_use = original_search.get('spotify_clean_title') or original_search.get('title', '')
clean_title = _clean_track_title_web(track_title_to_use, artist_name)
print(f"🧹 Cleaned title: '{track_title_to_use}' -> '{clean_title}'")
# Search for the track by artist and cleaned title
query = f"artist:{artist_name} track:{clean_title}"
tracks = spotify_client.search_tracks(query, limit=5)
# Find the best matching track
best_match = None
best_confidence = 0
if tracks:
from core.matching_engine import MusicMatchingEngine
matching_engine = MusicMatchingEngine()
for track in tracks:
# Calculate confidence based on artist and title similarity
artist_confidence = matching_engine.similarity_score(
matching_engine.normalize_string(artist_name),
matching_engine.normalize_string(track.artists[0] if track.artists else '')
)
title_confidence = matching_engine.similarity_score(
matching_engine.normalize_string(clean_title),
matching_engine.normalize_string(track.name)
)
combined_confidence = (artist_confidence * 0.6 + title_confidence * 0.4)
if combined_confidence > best_confidence and combined_confidence > 0.6: # Lower threshold for better matches
best_match = track
best_confidence = combined_confidence
# If we found a good Spotify match, use it for clean metadata
if best_match and best_confidence > 0.6:
print(f"✅ Found matching Spotify track: '{best_match.name}' - Album: '{best_match.album}' (confidence: {best_confidence:.2f})")
# Get detailed track information using Spotify's track API
detailed_track = None
if hasattr(best_match, 'id') and best_match.id:
print(f"🔍 Getting detailed track info from Spotify API for track ID: {best_match.id}")
detailed_track = spotify_client.get_track_details(best_match.id)
# Use detailed track data if available
if detailed_track:
print(f"✅ Got detailed track data from Spotify API")
album_name = _clean_album_title_web(detailed_track['album']['name'], artist_name)
clean_track_name = detailed_track['name'] # Use Spotify's clean track name
album_type = detailed_track['album'].get('album_type', 'album')
total_tracks = detailed_track['album'].get('total_tracks', 1)
spotify_track_number = detailed_track.get('track_number', 1)
print(f"📀 Spotify album info: '{album_name}' (type: {album_type}, total_tracks: {total_tracks}, track#: {spotify_track_number})")
print(f"🎵 Clean track name from Spotify: '{clean_track_name}'")
# Enhanced album detection using detailed API data (GUI PARITY)
is_album = (
# Album type is 'album' (not 'single')
album_type == 'album' and
# Album has multiple tracks
total_tracks > 1 and
# Album name different from track name
matching_engine.normalize_string(album_name) != matching_engine.normalize_string(clean_track_name) and
# Album name is not just the artist name
matching_engine.normalize_string(album_name) != matching_engine.normalize_string(artist_name)
)
album_image_url = None
if detailed_track['album'].get('images'):
album_image_url = detailed_track['album']['images'][0].get('url')
print(f"📊 Album classification: {is_album} (type={album_type}, tracks={total_tracks})")
return {
'is_album': is_album,
'album_name': album_name,
'track_number': spotify_track_number,
'clean_track_name': clean_track_name,
'album_image_url': album_image_url,
'confidence': best_confidence,
'source': 'spotify_api_detailed'
}
# Fallback: Use original data with basic cleaning
print("⚠️ No good Spotify match found, using original data")
fallback_title = _clean_track_title_web(original_search.get('title', 'Unknown Track'), artist_name)
return {
'is_album': False,
'clean_track_name': fallback_title,
'album_name': fallback_title,
'track_number': 1,
'confidence': 0.0,
'source': 'fallback_original'
}
except Exception as e:
print(f"❌ Error in _detect_album_info_web: {e}")
clean_title = _clean_track_title_web(context.get("original_search_result", {}).get('title', 'Unknown'), artist.get('name', ''))
return {'is_album': False, 'clean_track_name': clean_title, 'album_name': clean_title, 'track_number': 1}
def _cleanup_empty_directories(download_path, moved_file_path):
"""Cleans up empty directories after a file move, ignoring hidden files."""
import os
try:
current_dir = os.path.dirname(moved_file_path)
while current_dir != download_path and current_dir.startswith(download_path):
is_empty = not any(not f.startswith('.') for f in os.listdir(current_dir))
if is_empty:
print(f"Removing empty directory: {current_dir}")
os.rmdir(current_dir)
current_dir = os.path.dirname(current_dir)
else:
break
except Exception as e:
print(f"Warning: An error occurred during directory cleanup: {e}")
# ===================================================================
# ALBUM GROUPING SYSTEM (Ported from GUI downloads.py)
# ===================================================================
def _get_base_album_name(album_name: str) -> str:
"""
Extract the base album name without edition indicators.
E.g., 'good kid, m.A.A.d city (Deluxe Edition)' -> 'good kid, m.A.A.d city'
"""
import re
# Remove common edition suffixes
base_name = album_name
# Remove edition indicators in parentheses or brackets
base_name = re.sub(r'\s*[\[\(](deluxe|special|expanded|extended|bonus|remastered|anniversary|collectors?|limited).*?[\]\)]\s*$', '', base_name, flags=re.IGNORECASE)
# Remove standalone edition words at the end
base_name = re.sub(r'\s+(deluxe|special|expanded|extended|bonus|remastered|anniversary|collectors?|limited)\s*(edition)?\s*$', '', base_name, flags=re.IGNORECASE)
return base_name.strip()
def _detect_deluxe_edition(album_name: str) -> bool:
"""
Detect if an album name indicates a deluxe/special edition.
Returns True if it's a deluxe variant, False for standard.
"""
if not album_name:
return False
album_lower = album_name.lower()
# Check for deluxe indicators
deluxe_indicators = [
'deluxe',
'deluxe edition',
'special edition',
'expanded edition',
'extended edition',
'bonus',
'remastered',
'anniversary',
'collectors edition',
'limited edition'
]
for indicator in deluxe_indicators:
if indicator in album_lower:
print(f"🎯 Detected deluxe edition: '{album_name}' contains '{indicator}'")
return True
return False
def _normalize_base_album_name(base_album: str, artist_name: str) -> str:
"""
Normalize the base album name to handle case variations and known corrections.
"""
import re
# Apply known album corrections for consistent naming
normalized_lower = base_album.lower().strip()
# Handle common album title variations
known_corrections = {
# Add specific album name corrections here as needed
# Example: "good kid maad city": "good kid, m.A.A.d city"
}
# Check for exact matches in our corrections
for variant, correction in known_corrections.items():
if normalized_lower == variant.lower():
print(f"📀 Album correction applied: '{base_album}' -> '{correction}'")
return correction
# Handle punctuation variations
normalized = base_album
# Normalize common punctuation patterns
normalized = re.sub(r'\s*&\s*', ' & ', normalized) # Standardize & spacing
normalized = re.sub(r'\s+', ' ', normalized) # Clean multiple spaces
normalized = normalized.strip()
print(f"📀 Album variant normalization: '{base_album}' -> '{normalized}'")
return normalized
def _resolve_album_group(spotify_artist: dict, album_info: dict, original_album: str = None) -> str:
"""
Smart album grouping: Start with standard, upgrade to deluxe if ANY track is deluxe.
This ensures all tracks from the same album get the same folder name.
(Adapted from GUI downloads.py)
"""
try:
with album_cache_lock:
artist_name = spotify_artist["name"]
detected_album = album_info.get('album_name', '')
# Extract base album name (without edition indicators)
if detected_album:
base_album = _get_base_album_name(detected_album)
elif original_album:
# Clean the original Soulseek album name
cleaned_original = _clean_album_title_web(original_album, artist_name)
base_album = _get_base_album_name(cleaned_original)
else:
base_album = _get_base_album_name(detected_album)
# Normalize the base name (handle case variations, etc.)
base_album = _normalize_base_album_name(base_album, artist_name)
# Create a key for this album group (artist + base album)
album_key = f"{artist_name}::{base_album}"
# Check if we already have a cached result for this album
if album_key in album_name_cache:
cached_name = album_name_cache[album_key]
print(f"🔍 Using cached album name for '{album_key}': '{cached_name}'")
return cached_name
print(f"🔍 Album grouping - Key: '{album_key}', Detected: '{detected_album}'")
# Check if this track indicates a deluxe edition
is_deluxe_track = False
if detected_album:
is_deluxe_track = _detect_deluxe_edition(detected_album)
elif original_album:
is_deluxe_track = _detect_deluxe_edition(original_album)
# Get current edition level for this album group (default to standard)
current_edition = album_editions.get(album_key, "standard")
# SMART ALGORITHM: Upgrade to deluxe if this track is deluxe
if is_deluxe_track and current_edition == "standard":
print(f"🎯 UPGRADE: Album '{base_album}' upgraded from standard to deluxe!")
album_editions[album_key] = "deluxe"
current_edition = "deluxe"
# Build final album name based on edition level
if current_edition == "deluxe":
final_album_name = f"{base_album} (Deluxe Edition)"
else:
final_album_name = base_album
# Store the resolution in both caches
album_groups[album_key] = final_album_name
album_name_cache[album_key] = final_album_name
album_artists[album_key] = artist_name
print(f"🔗 Album resolution: '{detected_album}' -> '{final_album_name}' (edition: {current_edition})")
return final_album_name
except Exception as e:
print(f"❌ Error resolving album group: {e}")
return album_info.get('album_name', 'Unknown Album')
def _clean_album_title_web(album_title: str, artist_name: str) -> str:
"""Clean up album title by removing common prefixes, suffixes, and artist redundancy"""
import re
# Start with the original title
original = album_title.strip()
cleaned = original
print(f"🧹 Album Title Cleaning: '{original}' (artist: '{artist_name}')")
# Remove "Album - " prefix
cleaned = re.sub(r'^Album\s*-\s*', '', cleaned, flags=re.IGNORECASE)
# Remove artist name prefix if it appears at the beginning
# This handles cases like "Kendrick Lamar - good kid, m.A.A.d city"
artist_pattern = re.escape(artist_name) + r'\s*-\s*'
cleaned = re.sub(f'^{artist_pattern}', '', cleaned, flags=re.IGNORECASE)
# Remove common Soulseek suffixes in square brackets and parentheses
# Examples: [Deluxe Edition] [2012] [320 Kbps] [Album+iTunes+Bonus Tracks] [F10]
# (Deluxe Edition) (2012) (320 Kbps) etc.
# Remove year patterns like [2012], (2020), etc.
cleaned = re.sub(r'\s*[\[\(]\d{4}[\]\)]\s*', ' ', cleaned)
# Remove quality/format indicators
quality_patterns = [
r'\s*[\[\(].*?320.*?kbps.*?[\]\)]\s*',
r'\s*[\[\(].*?256.*?kbps.*?[\]\)]\s*',
r'\s*[\[\(].*?flac.*?[\]\)]\s*',
r'\s*[\[\(].*?mp3.*?[\]\)]\s*',
r'\s*[\[\(].*?itunes.*?[\]\)]\s*',
r'\s*[\[\(].*?web.*?[\]\)]\s*',
r'\s*[\[\(].*?cd.*?[\]\)]\s*'
]
for pattern in quality_patterns:
cleaned = re.sub(pattern, ' ', cleaned, flags=re.IGNORECASE)
# Remove common edition indicators (but preserve them for deluxe detection above)
# This happens AFTER deluxe detection to avoid interfering with that logic
# Clean up spacing
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
# Remove leading/trailing punctuation
cleaned = re.sub(r'^[-\s]+|[-\s]+$', '', cleaned)
print(f"🧹 Album Title Result: '{original}' -> '{cleaned}'")
return cleaned if cleaned else original
def _search_track_in_album_context_web(context: dict, spotify_artist: dict) -> dict:
"""
Search for a track within its album context to avoid promotional single confusion.
(Ported from GUI downloads.py)
"""
try:
from core.matching_engine import MusicMatchingEngine
matching_engine = MusicMatchingEngine()
# Get album and track info from context
original_search = context.get("original_search_result", {})
album_name = original_search.get("album")
track_title = original_search.get("title")
artist_name = spotify_artist["name"]
if not album_name or not track_title:
print(f"❌ Album-aware search failed: Missing album ({album_name}) or track ({track_title})")
return None
print(f"🎯 Album-aware search: '{track_title}' in album '{album_name}' by '{artist_name}'")
# Clean the album name for better search results
clean_album = _clean_album_title_web(album_name, artist_name)
clean_track = _clean_track_title_web(track_title, artist_name)
# Search for the specific album first
album_query = f"album:{clean_album} artist:{artist_name}"
print(f"🔍 Searching albums: {album_query}")
albums = spotify_client.search_albums(album_query, limit=5)
if not albums:
print(f"❌ No albums found for query: {album_query}")
return None
# Check each album to see if our track is in it
for album in albums:
print(f"🎵 Checking album: '{album.name}' ({album.total_tracks} tracks)")
# Get tracks from this album
album_tracks_data = spotify_client.get_album_tracks(album.id)
if not album_tracks_data or 'items' not in album_tracks_data:
print(f"❌ Could not get tracks for album: {album.name}")
continue
# Check if our track is in this album
for track_data in album_tracks_data['items']:
track_name = track_data['name']
track_number = track_data['track_number']
# Calculate similarity between our track and this album track
similarity = matching_engine.similarity_score(
matching_engine.normalize_string(clean_track),
matching_engine.normalize_string(track_name)
)
# Use higher threshold for remix matching to ensure precision (GUI PARITY)
is_remix = any(word in clean_track.lower() for word in ['remix', 'mix', 'edit', 'version'])
threshold = 0.9 if is_remix else 0.65 # Lower threshold to favor album matches over singles
if similarity > threshold:
print(f"✅ FOUND: '{track_name}' (track #{track_number}) matches '{clean_track}' (similarity: {similarity:.2f})")
print(f"🎯 Forcing album classification for track in '{album.name}'")
# Return album info - force album classification!
return {
'is_album': True, # Always true - we found it in an album!
'album_name': album.name,
'track_number': track_number,
'clean_track_name': clean_track, # Use the ORIGINAL download title, not the database match
'album_image_url': album.image_url,
'confidence': similarity,
'source': 'album_context_search'
}
print(f"❌ Track '{clean_track}' not found in album '{album.name}'")
print(f"❌ Track '{clean_track}' not found in any matching albums")
return None
except Exception as e:
print(f"❌ Error in album-aware search: {e}")
return None
def _clean_track_title_web(track_title: str, artist_name: str) -> str:
"""Clean up track title by removing artist prefix and common patterns"""
import re
# Start with the original title
original = track_title.strip()
cleaned = original
print(f"🧹 Track Title Cleaning: '{original}' (artist: '{artist_name}')")
# Remove artist name prefix if it appears at the beginning
# This handles cases like "Kendrick Lamar - HUMBLE."
artist_pattern = re.escape(artist_name) + r'\s*-\s*'
cleaned = re.sub(f'^{artist_pattern}', '', cleaned, flags=re.IGNORECASE)
# Remove common prefixes
cleaned = re.sub(r'^Track\s*\d*\s*-\s*', '', cleaned, flags=re.IGNORECASE)
cleaned = re.sub(r'^\d+\.\s*', '', cleaned) # Remove track numbers like "01. "
# Remove quality/format indicators
quality_patterns = [
r'\s*[\[\(].*?320.*?kbps.*?[\]\)]\s*',
r'\s*[\[\(].*?256.*?kbps.*?[\]\)]\s*',
r'\s*[\[\(].*?flac.*?[\]\)]\s*',
r'\s*[\[\(].*?mp3.*?[\]\)]\s*',
r'\s*[\[\(].*?explicit.*?[\]\)]\s*'
]
for pattern in quality_patterns:
cleaned = re.sub(pattern, ' ', cleaned, flags=re.IGNORECASE)
# Clean up spacing
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
# Remove leading/trailing punctuation
cleaned = re.sub(r'^[-\s]+|[-\s]+$', '', cleaned)
print(f"🧹 Track Title Result: '{original}' -> '{cleaned}'")
return cleaned if cleaned else original
# ===================================================================
# YOUTUBE TRACK CLEANING FUNCTIONS (Ported from GUI sync.py)
# ===================================================================
def clean_youtube_track_title(title, artist_name=None):
"""
Aggressively clean YouTube track titles by removing video noise and extracting clean track names
Examples:
'No Way Jose (Official Music Video)''No Way Jose'
'bbno$ - mary poppins (official music video)''mary poppins'
'Beyond (From "Moana 2") (Official Video) ft. Rachel House''Beyond'
'Temporary (feat. Skylar Grey) [Official Music Video]''Temporary'
'ALL MY LOVE (Directors\' Cut)''ALL MY LOVE'
'Espresso Macchiato | Estonia 🇪🇪 | Official Music Video | #Eurovision2025''Espresso Macchiato'
"""
import re
if not title:
return title
original_title = title
# FIRST: Remove artist name if it appears at the start with a dash
# Handle formats like "LITTLE BIG - MOUSTACHE" → "MOUSTACHE"
if artist_name:
# Create a regex pattern to match artist name at the beginning followed by dash
# Use word boundaries and case-insensitive matching for better accuracy
artist_pattern = r'^' + re.escape(artist_name.strip()) + r'\s*[-–—]\s*'
cleaned_title = re.sub(artist_pattern, '', title, flags=re.IGNORECASE).strip()
# Debug logging for artist removal
if cleaned_title != title:
print(f"🎯 Removed artist from title: '{title}' -> '{cleaned_title}' (artist: '{artist_name}')")
title = cleaned_title
# Remove content in brackets/braces of any type SECOND (before general dash removal)
title = re.sub(r'【[^】]*】', '', title) # Japanese brackets
title = re.sub(r'\s*\([^)]*\)', '', title) # Parentheses - removes everything after first (
title = re.sub(r'\s*\(.*$', '', title) # Remove everything after lone ( (unmatched parentheses)
title = re.sub(r'\[[^\]]*\]', '', title) # Square brackets
title = re.sub(r'\{[^}]*\}', '', title) # Curly braces
title = re.sub(r'<[^>]*>', '', title) # Angle brackets
# Remove everything after a dash (often album or extra info)
title = re.sub(r'\s*-\s*.*$', '', title)
# Remove everything after pipes (|) - often used for additional context
title = re.split(r'\s*\|\s*', title)[0].strip()
# Remove common video/platform noise
noise_patterns = [
r'\bapple\s+music\b',
r'\bfull\s+video\b',
r'\bmusic\s+video\b',
r'\bofficial\s+video\b',
r'\bofficial\s+music\s+video\b',
r'\bofficial\b',
r'\bcensored\s+version\b',
r'\buncensored\s+version\b',
r'\bexplicit\s+version\b',
r'\blive\s+version\b',
r'\bversion\b',
r'\btopic\b',
r'\baudio\b',
r'\blyrics?\b',
r'\blyric\s+video\b',
r'\bwith\s+lyrics?\b',
r'\bvisuali[sz]er\b',
r'\bmv\b',
r'\bdirectors?\s+cut\b',
r'\bremaster(ed)?\b',
r'\bremix\b'
]
for pattern in noise_patterns:
title = re.sub(pattern, '', title, flags=re.IGNORECASE)
# Remove artist name from title if present
if artist_name:
# Try removing exact artist name
title = re.sub(rf'\b{re.escape(artist_name)}\b', '', title, flags=re.IGNORECASE)
# Try removing artist name with common separators
title = re.sub(rf'\b{re.escape(artist_name)}\s*[-–—:]\s*', '', title, flags=re.IGNORECASE)
title = re.sub(rf'^{re.escape(artist_name)}\s*[-–—:]\s*', '', title, flags=re.IGNORECASE)
# Remove all quotes and other punctuation
title = re.sub(r'["\'''""„‚‛‹›«»]', '', title)
# Remove featured artist patterns (after removing parentheses)
feat_patterns = [
r'\s+feat\.?\s+.+$', # " feat Artist" at end
r'\s+ft\.?\s+.+$', # " ft Artist" at end
r'\s+featuring\s+.+$', # " featuring Artist" at end
r'\s+with\s+.+$', # " with Artist" at end
]
for pattern in feat_patterns:
title = re.sub(pattern, '', title, flags=re.IGNORECASE).strip()
# Clean up whitespace and punctuation
title = re.sub(r'\s+', ' ', title).strip()
title = re.sub(r'^[-–—:,.\s]+|[-–—:,.\s]+$', '', title).strip()
# If we cleaned too much, return original
if not title.strip() or len(title.strip()) < 2:
title = original_title
if title != original_title:
print(f"🧹 YouTube title cleaned: '{original_title}''{title}'")
return title
def clean_youtube_artist(artist_string):
"""
Clean YouTube artist strings to get primary artist name
Examples:
'Yung Gravy, bbno$ (BABY GRAVY)''Yung Gravy'
'Y2K, bbno$''Y2K'
'LITTLE BIG''LITTLE BIG'
'Artist "Nickname" Name''Artist Nickname Name'
'ArtistVEVO''Artist'
"""
import re
if not artist_string:
return artist_string
original_artist = artist_string
# Remove all quotes - they're usually not part of artist names
artist_string = artist_string.replace('"', '').replace("'", '').replace(''', '').replace(''', '').replace('"', '').replace('"', '')
# Remove anything in parentheses (often group/label names)
artist_string = re.sub(r'\s*\([^)]*\)', '', artist_string).strip()
# Remove anything in brackets (often additional info)
artist_string = re.sub(r'\s*\[[^\]]*\]', '', artist_string).strip()
# Remove common YouTube channel suffixes
channel_suffixes = [
r'\s*VEVO\s*$',
r'\s*Music\s*$',
r'\s*Official\s*$',
r'\s*Records\s*$',
r'\s*Entertainment\s*$',
r'\s*TV\s*$',
r'\s*Channel\s*$'
]
for suffix in channel_suffixes:
artist_string = re.sub(suffix, '', artist_string, flags=re.IGNORECASE).strip()
# Split on common separators and take the first artist
separators = [',', '&', ' and ', ' x ', ' X ', ' feat.', ' ft.', ' featuring', ' with', ' vs ', ' vs.']
for sep in separators:
if sep in artist_string:
parts = artist_string.split(sep)
artist_string = parts[0].strip()
break
# Clean up extra whitespace and punctuation
artist_string = re.sub(r'\s+', ' ', artist_string).strip()
artist_string = re.sub(r'^\-\s*|\s*\-$', '', artist_string).strip() # Remove leading/trailing dashes
artist_string = re.sub(r'^,\s*|\s*,$', '', artist_string).strip() # Remove leading/trailing commas
# If we cleaned too much, return original
if not artist_string.strip():
artist_string = original_artist
if artist_string != original_artist:
print(f"🧹 YouTube artist cleaned: '{original_artist}''{artist_string}'")
return artist_string
def parse_youtube_playlist(url):
"""
Parse a YouTube Music playlist URL and extract track information using yt-dlp
Uses flat playlist extraction to avoid rate limits and get all tracks
Returns a list of track dictionaries compatible with our Track structure
"""
try:
# Configure yt-dlp options for flat playlist extraction (avoids rate limits)
ydl_opts = {
'quiet': True,
'no_warnings': True,
'extract_flat': True, # Only extract basic info, no individual video metadata
'flat_playlist': True, # Extract all playlist entries without hitting API for each video
'skip_download': True, # Don't download, just extract IDs and basic info
# Remove all limits to get complete playlist
}
tracks = []
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
# Extract playlist info
playlist_info = ydl.extract_info(url, download=False)
if not playlist_info:
print("❌ Could not extract playlist information")
return None
playlist_name = playlist_info.get('title', 'Unknown Playlist')
playlist_id = playlist_info.get('id', 'unknown_id')
entries = playlist_info.get('entries', [])
print(f"🎵 Found YouTube playlist: '{playlist_name}' with {len(entries)} entries")
for entry in entries:
if not entry:
continue
# Extract basic information from flat extraction
raw_title = entry.get('title', 'Unknown Track')
raw_uploader = entry.get('uploader', 'Unknown Artist')
duration = entry.get('duration', 0)
video_id = entry.get('id', '')
# Clean the track title and artist using our cleaning functions
cleaned_artist = clean_youtube_artist(raw_uploader)
cleaned_title = clean_youtube_track_title(raw_title, cleaned_artist)
# Create track object matching GUI structure
track_data = {
'id': video_id,
'name': cleaned_title,
'artists': [cleaned_artist],
'duration_ms': duration * 1000 if duration else 0,
'raw_title': raw_title, # Keep original for reference
'raw_artist': raw_uploader, # Keep original for reference
'url': f"https://www.youtube.com/watch?v={video_id}"
}
tracks.append(track_data)
# Create playlist object matching GUI structure
playlist_data = {
'id': playlist_id,
'name': playlist_name,
'tracks': tracks,
'track_count': len(tracks),
'url': url,
'source': 'youtube'
}
print(f"✅ Successfully parsed YouTube playlist: {len(tracks)} tracks extracted")
return playlist_data
except Exception as e:
print(f"❌ Error parsing YouTube playlist: {e}")
return None
# ===================================================================
# METADATA & COVER ART HELPERS (Ported from downloads.py)
# ===================================================================
from mutagen import File as MutagenFile
from mutagen.id3 import ID3, TIT2, TPE1, TALB, TDRC, TRCK, TCON, TPE2, TPOS, TXXX, APIC
from mutagen.flac import FLAC, Picture
from mutagen.mp4 import MP4, MP4Cover
from mutagen.oggvorbis import OggVorbis
import urllib.request
def _enhance_file_metadata(file_path: str, context: dict, artist: dict, album_info: dict) -> bool:
"""
Core function to enhance audio file metadata using Spotify data.
"""
if not config_manager.get('metadata_enhancement.enabled', True):
print("🎵 Metadata enhancement disabled in config.")
return True
print(f"🎵 Enhancing metadata for: {os.path.basename(file_path)}")
try:
audio_file = MutagenFile(file_path, easy=True)
if audio_file is None:
audio_file = MutagenFile(file_path) # Try non-easy mode
if audio_file is None:
print(f"❌ Could not load audio file with Mutagen: {file_path}")
return False
metadata = _extract_spotify_metadata(context, artist, album_info)
if not metadata:
print("⚠️ Could not extract Spotify metadata, preserving original tags.")
return True
# Use 'easy' tags for broad compatibility first
audio_file['title'] = metadata.get('title', '')
audio_file['artist'] = metadata.get('artist', '')
audio_file['albumartist'] = metadata.get('album_artist', '')
audio_file['album'] = metadata.get('album', '')
if metadata.get('date'):
audio_file['date'] = metadata['date']
if metadata.get('genre'):
audio_file['genre'] = metadata['genre']
track_num_str = f"{metadata.get('track_number', 1)}/{metadata.get('total_tracks', 1)}"
audio_file['tracknumber'] = track_num_str
if metadata.get('disc_number'):
audio_file['discnumber'] = str(metadata.get('disc_number'))
audio_file.save()
# Embed album art if enabled
if config_manager.get('metadata_enhancement.embed_album_art', True):
# Re-open in non-easy mode for embedding art
audio_file_art = MutagenFile(file_path)
_embed_album_art_metadata(audio_file_art, metadata)
audio_file_art.save()
print("✅ Metadata enhanced successfully.")
return True
except Exception as e:
print(f"❌ Error enhancing metadata for {file_path}: {e}")
return False
def _extract_spotify_metadata(context: dict, artist: dict, album_info: dict) -> dict:
"""Extracts a comprehensive metadata dictionary from the provided context."""
metadata = {}
original_search = context.get("original_search_result", {})
spotify_album = context.get("spotify_album")
# Priority 1: Spotify clean title from context
if original_search.get('spotify_clean_title'):
metadata['title'] = original_search['spotify_clean_title']
print(f"🎵 Metadata: Using Spotify clean title: '{metadata['title']}'")
# Priority 2: Album info clean name
elif album_info.get('clean_track_name'):
metadata['title'] = album_info['clean_track_name']
print(f"🎵 Metadata: Using album info clean name: '{metadata['title']}'")
# Priority 3: Original title as fallback
else:
metadata['title'] = original_search.get('title', '')
print(f"🎵 Metadata: Using original title as fallback: '{metadata['title']}'")
metadata['artist'] = artist.get('name', '')
metadata['album_artist'] = artist.get('name', '') # Crucial for library organization
if album_info.get('is_album'):
metadata['album'] = album_info.get('album_name', 'Unknown Album')
track_num = album_info.get('track_number', 1)
metadata['track_number'] = track_num
metadata['total_tracks'] = spotify_album.get('total_tracks', 1) if spotify_album else 1
print(f"🎵 [METADATA] Album track - track_number: {track_num}, album: {metadata['album']}")
else:
# SAFEGUARD: If we have spotify_album context, never use track title as album name
# This prevents album tracks from being tagged as singles due to classification errors
if spotify_album and spotify_album.get('name'):
print(f"🛡️ [SAFEGUARD] Using spotify_album name instead of track title for album metadata")
metadata['album'] = spotify_album['name']
# Use corrected track_number from album_info (which should be updated by post-processing)
corrected_track_number = album_info.get('track_number', 1) if album_info else 1
metadata['track_number'] = corrected_track_number
metadata['total_tracks'] = spotify_album.get('total_tracks', 1)
print(f"🛡️ [SAFEGUARD] Using track_number: {corrected_track_number}")
else:
metadata['album'] = metadata['title'] # For true singles, album is the title
metadata['track_number'] = 1
metadata['total_tracks'] = 1
if spotify_album and spotify_album.get('release_date'):
metadata['date'] = spotify_album['release_date'][:4]
if artist.get('genres'):
metadata['genre'] = ', '.join(artist['genres'][:2])
metadata['album_art_url'] = album_info.get('album_image_url')
return metadata
def _embed_album_art_metadata(audio_file, metadata: dict):
"""Downloads and embeds high-quality Spotify album art into the file."""
try:
art_url = metadata.get('album_art_url')
if not art_url:
print("🎨 No album art URL available for embedding.")
return
with urllib.request.urlopen(art_url, timeout=10) as response:
image_data = response.read()
mime_type = response.info().get_content_type()
if not image_data:
print("❌ Failed to download album art data.")
return
# MP3 (ID3)
if isinstance(audio_file.tags, ID3):
audio_file.tags.add(APIC(encoding=3, mime=mime_type, type=3, desc='Cover', data=image_data))
# FLAC
elif isinstance(audio_file, FLAC):
picture = Picture()
picture.data = image_data
picture.type = 3
picture.mime = mime_type
picture.width = 640
picture.height = 640
picture.depth = 24
audio_file.add_picture(picture)
# MP4/M4A
elif isinstance(audio_file, MP4):
fmt = MP4Cover.FORMAT_JPEG if 'jpeg' in mime_type else MP4Cover.FORMAT_PNG
audio_file['covr'] = [MP4Cover(image_data, imageformat=fmt)]
print("🎨 Album art successfully embedded.")
except Exception as e:
print(f"❌ Error embedding album art: {e}")
def _download_cover_art(album_info: dict, target_dir: str):
"""Downloads cover.jpg into the specified directory."""
try:
cover_path = os.path.join(target_dir, "cover.jpg")
if os.path.exists(cover_path):
return
art_url = album_info.get('album_image_url')
if not art_url:
print("📷 No cover art URL available for download.")
return
with urllib.request.urlopen(art_url, timeout=10) as response:
image_data = response.read()
with open(cover_path, 'wb') as f:
f.write(image_data)
print(f"✅ Cover art downloaded to: {cover_path}")
except Exception as e:
print(f"❌ Error downloading cover.jpg: {e}")
def _get_spotify_album_tracks(spotify_album: dict) -> list:
"""Fetches all tracks for a given Spotify album ID."""
if not spotify_album or not spotify_album.get('id'):
return []
try:
tracks_data = spotify_client.get_album_tracks(spotify_album['id'])
if tracks_data and 'items' in tracks_data:
return [{
'name': item.get('name'),
'track_number': item.get('track_number'),
'id': item.get('id')
} for item in tracks_data['items']]
return []
except Exception as e:
print(f"❌ Error fetching Spotify album tracks: {e}")
return []
def _match_track_to_spotify_title(slsk_track_meta: dict, spotify_tracks: list) -> dict:
"""
Intelligently matches a Soulseek track to a track from the official Spotify
tracklist using track numbers and title similarity. Returns the matched Spotify track data.
"""
if not spotify_tracks:
return slsk_track_meta # Return original if no list to match against
# Priority 1: Match by track number
if slsk_track_meta.get('track_number'):
track_num = slsk_track_meta['track_number']
for sp_track in spotify_tracks:
if sp_track.get('track_number') == track_num:
print(f"✅ Matched track by number ({track_num}): '{slsk_track_meta['title']}' -> '{sp_track['name']}'")
# Return a new dict with the corrected title and number
return {
'title': sp_track['name'],
'artist': slsk_track_meta.get('artist'),
'album': slsk_track_meta.get('album'),
'track_number': sp_track['track_number']
}
# Priority 2: Match by title similarity (if track number fails)
best_match = None
best_score = 0.6 # Require a decent similarity
for sp_track in spotify_tracks:
score = matching_engine.similarity_score(
matching_engine.normalize_string(slsk_track_meta.get('title', '')),
matching_engine.normalize_string(sp_track.get('name', ''))
)
if score > best_score:
best_score = score
best_match = sp_track
if best_match:
print(f"✅ Matched track by title similarity ({best_score:.2f}): '{slsk_track_meta['title']}' -> '{best_match['name']}'")
return {
'title': best_match['name'],
'artist': slsk_track_meta.get('artist'),
'album': slsk_track_meta.get('album'),
'track_number': best_match['track_number']
}
print(f"⚠️ Could not confidently match track '{slsk_track_meta['title']}'. Using original metadata.")
return slsk_track_meta # Fallback to original
# --- Post-Processing Logic ---
def _post_process_matched_download_with_verification(context_key, context, file_path, task_id, batch_id):
"""
NEW VERIFICATION WORKFLOW: Enhanced post-processing with file verification.
Only sets task status to 'completed' after successful file verification and move operation.
"""
try:
print(f"🎯 [Verification] Starting enhanced post-processing for: {context_key}")
# Call the existing post-processing logic (but skip its completion callback)
# We'll handle the completion callback ourselves after verification
original_task_id = context.pop('task_id', None) # Temporarily remove to prevent double callback
original_batch_id = context.pop('batch_id', None)
_post_process_matched_download(context_key, context, file_path)
# Restore the IDs for our own callback
if original_task_id:
context['task_id'] = original_task_id
if original_batch_id:
context['batch_id'] = original_batch_id
# CRITICAL VERIFICATION STEP: Verify the final file exists
# Extract the expected final path from the context or reconstruct it
spotify_artist = context.get("spotify_artist")
if not spotify_artist:
raise Exception("Missing spotify_artist context for verification")
is_album_download = context.get("is_album_download", False)
has_clean_spotify_data = context.get("has_clean_spotify_data", False)
# Reconstruct the final path logic (mirrors the logic in _post_process_matched_download)
if is_album_download and has_clean_spotify_data:
original_search = context.get("original_search_result", {})
spotify_album = context.get("spotify_album", {})
clean_track_name = original_search.get('spotify_clean_title', 'Unknown Track')
clean_album_name = original_search.get('spotify_clean_album', 'Unknown Album')
track_number = original_search.get('track_number', 1)
# Construct the expected final path
artist_name_sanitized = spotify_artist.name.replace('/', '_').replace('\\', '_').replace(':', '_').replace('*', '_').replace('?', '_').replace('"', '_').replace('<', '_').replace('>', '_').replace('|', '_')
album_name_sanitized = clean_album_name.replace('/', '_').replace('\\', '_').replace(':', '_').replace('*', '_').replace('?', '_').replace('"', '_').replace('<', '_').replace('>', '_').replace('|', '_')
track_name_sanitized = clean_track_name.replace('/', '_').replace('\\', '_').replace(':', '_').replace('*', '_').replace('?', '_').replace('"', '_').replace('<', '_').replace('>', '_').replace('|', '_')
transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './transfers'))
artist_dir = os.path.join(transfer_dir, artist_name_sanitized)
album_folder_name = f"{clean_album_name} ({spotify_album.get('release_date', '').split('-')[0] if spotify_album.get('release_date') else 'Unknown'})"
album_folder_name = album_folder_name.replace('/', '_').replace('\\', '_').replace(':', '_').replace('*', '_').replace('?', '_').replace('"', '_').replace('<', '_').replace('>', '_').replace('|', '_')
album_dir = os.path.join(artist_dir, album_folder_name)
file_ext = os.path.splitext(file_path)[1]
new_filename = f"{track_number:02d} - {track_name_sanitized}{file_ext}"
expected_final_path = os.path.join(album_dir, new_filename)
else:
# For singles or fallback logic
original_search = context.get("original_search_result", {})
track_name = original_search.get('spotify_clean_title') or original_search.get('title', 'Unknown Track')
track_name_sanitized = track_name.replace('/', '_').replace('\\', '_').replace(':', '_').replace('*', '_').replace('?', '_').replace('"', '_').replace('<', '_').replace('>', '_').replace('|', '_')
transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './transfers'))
artist_name_sanitized = spotify_artist.name.replace('/', '_').replace('\\', '_').replace(':', '_').replace('*', '_').replace('?', '_').replace('"', '_').replace('<', '_').replace('>', '_').replace('|', '_')
artist_dir = os.path.join(transfer_dir, artist_name_sanitized)
single_dir = os.path.join(artist_dir, "Singles")
file_ext = os.path.splitext(file_path)[1]
new_filename = f"{track_name_sanitized}{file_ext}"
expected_final_path = os.path.join(single_dir, new_filename)
# VERIFICATION: Check if file exists at expected final path
if os.path.exists(expected_final_path):
print(f"✅ [Verification] File verified at final path: {expected_final_path}")
# Mark task as completed only after successful verification
with tasks_lock:
if task_id in download_tasks:
download_tasks[task_id]['status'] = 'completed'
download_tasks[task_id]['metadata_enhanced'] = True
print(f"✅ [Verification] Task {task_id} marked as completed with metadata enhanced")
# Clean up context now that both stream processor and verification worker are done
with matched_context_lock:
if context_key in matched_downloads_context:
del matched_downloads_context[context_key]
print(f"🗑️ [Verification] Cleaned up context after successful verification: {context_key}")
# FIXED: Call completion callback now since we prevented original post-processing from calling it
print(f"✅ [Verification] Task {task_id} verification complete - calling batch completion callback")
_on_download_completed(batch_id, task_id, success=True)
else:
print(f"❌ [Verification] File move verification failed - not found at: {expected_final_path}")
with tasks_lock:
if task_id in download_tasks:
download_tasks[task_id]['status'] = 'failed'
download_tasks[task_id]['error_message'] = "File move to transfer folder failed."
# Clean up context even on failure to prevent memory leaks
with matched_context_lock:
if context_key in matched_downloads_context:
del matched_downloads_context[context_key]
print(f"🗑️ [Verification] Cleaned up context after verification failure: {context_key}")
_on_download_completed(batch_id, task_id, success=False)
except Exception as e:
print(f"❌ [Verification] Post-processing with verification failed: {e}")
import traceback
traceback.print_exc()
with tasks_lock:
if task_id in download_tasks:
download_tasks[task_id]['status'] = 'failed'
download_tasks[task_id]['error_message'] = f"Post-processing verification failed: {str(e)}"
# Clean up context even on exception to prevent memory leaks
with matched_context_lock:
if context_key in matched_downloads_context:
del matched_downloads_context[context_key]
print(f"🗑️ [Verification] Cleaned up context after exception: {context_key}")
_on_download_completed(batch_id, task_id, success=False)
def _post_process_matched_download(context_key, context, file_path):
"""
This is the final, corrected post-processing function. It now mirrors the
GUI's logic by trusting the pre-matched context for album downloads, which
solves the track numbering issue.
"""
try:
import os
import shutil
import time
from pathlib import Path
# --- GUI PARITY FIX: Add a delay to prevent file lock race conditions ---
# The GUI app waits 1 second to ensure the file handle is released by
# the download client before attempting to move or modify it.
print(f"⏳ Waiting 1 second for file handle release for: {os.path.basename(file_path)}")
time.sleep(1)
# --- END OF FIX ---
print(f"🎯 Starting robust post-processing for: {context_key}")
spotify_artist = context.get("spotify_artist")
if not spotify_artist:
print(f"❌ Post-processing failed: Missing spotify_artist context.")
return
is_album_download = context.get("is_album_download", False)
has_clean_spotify_data = context.get("has_clean_spotify_data", False)
if is_album_download and has_clean_spotify_data:
# Build album_info directly from clean Spotify metadata (GUI PARITY)
print("✅ Album context with clean Spotify data found - using direct album info")
original_search = context.get("original_search_result", {})
spotify_album = context.get("spotify_album", {})
# Use clean Spotify metadata (matches GUI's SpotifyBasedSearchResult approach)
clean_track_name = original_search.get('spotify_clean_title', 'Unknown Track')
clean_album_name = original_search.get('spotify_clean_album', 'Unknown Album')
# DEBUG: Check what's in original_search
print(f"🔍 [DEBUG] Path 1 - Clean Spotify data path:")
print(f" original_search keys: {list(original_search.keys())}")
print(f" track_number in original_search: {'track_number' in original_search}")
print(f" track_number value: {original_search.get('track_number', 'NOT_FOUND')}")
album_info = {
'is_album': True,
'album_name': clean_album_name, # Use clean Spotify album name
'track_number': original_search.get('track_number', 1),
'clean_track_name': clean_track_name,
'album_image_url': spotify_album.get('image_url'),
'confidence': 1.0, # High confidence since we have clean Spotify data
'source': 'clean_spotify_metadata'
}
print(f"🎯 Using clean Spotify album: '{clean_album_name}' for track: '{clean_track_name}'")
elif is_album_download:
# CRITICAL FIX: Album context without clean Spotify data - still force album treatment
print("⚠️ Album context found but no clean Spotify data - using enhanced fallback")
original_search = context.get("original_search_result", {})
spotify_album = context.get("spotify_album", {})
clean_track_name = original_search.get('spotify_clean_title') or original_search.get('title', 'Unknown Track')
# DEBUG: Check what's in original_search for path 2
print(f"🔍 [DEBUG] Path 2 - Enhanced fallback album context path:")
print(f" original_search keys: {list(original_search.keys())}")
print(f" track_number in original_search: {'track_number' in original_search}")
print(f" track_number value: {original_search.get('track_number', 'NOT_FOUND')}")
print(f" spotify_album name: {spotify_album.get('name', 'NOT_FOUND')}")
# ENHANCEMENT: Use spotify_clean_album if available for consistency
album_name = (original_search.get('spotify_clean_album') or
spotify_album.get('name') or
'Unknown Album')
album_info = {
'is_album': True, # FORCE TRUE - user explicitly selected album for download
'album_name': album_name,
'track_number': original_search.get('track_number', 1),
'clean_track_name': clean_track_name,
'album_image_url': spotify_album.get('image_url'),
'confidence': 0.9, # Higher confidence - user explicitly chose album
'source': 'enhanced_fallback_album_context'
}
print(f"🎯 [FORCED ALBUM] Using album: '{album_name}' for track: '{clean_track_name}'")
else:
# For singles, we still need to detect if they belong to an album.
print("🎵 Single track download - attempting album detection")
album_info = _detect_album_info_web(context, spotify_artist)
# --- CRITICAL FIX: Add GUI album grouping resolution ---
# This ensures consistent album naming across tracks like the GUI
if album_info and album_info['is_album']:
print(f"\n🎯 SMART ALBUM GROUPING for track: '{album_info.get('clean_track_name', 'Unknown')}'")
print(f" Original album: '{album_info.get('album_name', 'None')}'")
# Get original album name from context if available
original_album = None
if context.get("original_search_result", {}).get("album"):
original_album = context["original_search_result"]["album"]
# Use the GUI's smart album grouping algorithm
consistent_album_name = _resolve_album_group(spotify_artist, album_info, original_album)
album_info['album_name'] = consistent_album_name
print(f" Final album name: '{consistent_album_name}'")
print(f"🔗 ✅ Album grouping complete!\n")
# 1. Get transfer path and create artist directory
transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer'))
artist_name_sanitized = _sanitize_filename(spotify_artist["name"])
artist_dir = os.path.join(transfer_dir, artist_name_sanitized)
os.makedirs(artist_dir, exist_ok=True)
file_ext = os.path.splitext(file_path)[1]
# 2. Build the final path using GUI-style track naming with multiple fallback sources
if album_info and album_info['is_album']:
album_name_sanitized = _sanitize_filename(album_info['album_name'])
# --- GUI PARITY: Use multiple sources for clean track name ---
original_search = context.get("original_search_result", {})
clean_track_name = album_info['clean_track_name']
# Priority 1: Spotify clean title from context
if original_search.get('spotify_clean_title'):
clean_track_name = original_search['spotify_clean_title']
print(f"🎵 Using Spotify clean title: '{clean_track_name}'")
# Priority 2: Album info clean name
elif album_info.get('clean_track_name'):
clean_track_name = album_info['clean_track_name']
print(f"🎵 Using album info clean name: '{clean_track_name}'")
# Priority 3: Original title as fallback
else:
clean_track_name = original_search.get('title', 'Unknown Track')
print(f"🎵 Using original title as fallback: '{clean_track_name}'")
final_track_name_sanitized = _sanitize_filename(clean_track_name)
track_number = album_info['track_number']
# DEBUG: Check final track_number values
print(f"🔍 [DEBUG] Final track_number processing:")
print(f" album_info source: {album_info.get('source', 'unknown')}")
print(f" album_info track_number: {album_info.get('track_number', 'NOT_FOUND')}")
print(f" track_number variable: {track_number}")
# Fix: Handle None track_number
if track_number is None:
print(f"⚠️ Track number is None, extracting from filename: {os.path.basename(file_path)}")
track_number = _extract_track_number_from_filename(file_path)
print(f" -> Extracted track number: {track_number}")
# Ensure track_number is valid
if not isinstance(track_number, int) or track_number < 1:
print(f"⚠️ Invalid track number ({track_number}), defaulting to 1")
track_number = 1
print(f"🎯 [DEBUG] FINAL track_number used for filename: {track_number}")
# CRITICAL FIX: Update album_info with corrected track_number for metadata enhancement
album_info['track_number'] = track_number
print(f"✅ [FIX] Updated album_info track_number to {track_number} for consistent metadata")
album_folder_name = f"{artist_name_sanitized} - {album_name_sanitized}"
album_dir = os.path.join(artist_dir, album_folder_name)
os.makedirs(album_dir, exist_ok=True)
# Create track filename with number (just track number + clean title, NO artist)
new_filename = f"{track_number:02d} - {final_track_name_sanitized}{file_ext}"
final_path = os.path.join(album_dir, new_filename)
print(f"📁 Album folder created: '{album_folder_name}'")
print(f"🎵 Track filename: '{new_filename}'")
else:
# Single track structure: Transfer/ARTIST/ARTIST - SINGLE/SINGLE.ext
# --- GUI PARITY: Use multiple sources for clean track name ---
original_search = context.get("original_search_result", {})
clean_track_name = album_info['clean_track_name']
# Priority 1: Spotify clean title from context
if original_search.get('spotify_clean_title'):
clean_track_name = original_search['spotify_clean_title']
print(f"🎵 Using Spotify clean title: '{clean_track_name}'")
# Priority 2: Album info clean name
elif album_info and album_info.get('clean_track_name'):
clean_track_name = album_info['clean_track_name']
print(f"🎵 Using album info clean name: '{clean_track_name}'")
# Priority 3: Original title as fallback
else:
clean_track_name = original_search.get('title', 'Unknown Track')
print(f"🎵 Using original title as fallback: '{clean_track_name}'")
final_track_name_sanitized = _sanitize_filename(clean_track_name)
single_folder_name = f"{artist_name_sanitized} - {final_track_name_sanitized}"
single_dir = os.path.join(artist_dir, single_folder_name)
os.makedirs(single_dir, exist_ok=True)
# Create single filename with clean track name
new_filename = f"{final_track_name_sanitized}{file_ext}"
final_path = os.path.join(single_dir, new_filename)
print(f"📁 Single track: {single_folder_name}/{new_filename}")
# 3. Enhance metadata, move file, download art, and cleanup
_enhance_file_metadata(file_path, context, spotify_artist, album_info)
print(f"🚚 Moving '{os.path.basename(file_path)}' to '{final_path}'")
if os.path.exists(final_path):
# PROTECTION: Check if existing file already has metadata enhancement
# This prevents race conditions where later downloads overwrite properly processed files
try:
from mutagen import File as MutagenFile
existing_file = MutagenFile(final_path)
has_metadata = existing_file is not None and len(existing_file.tags or {}) > 2 # More than basic tags
if has_metadata:
print(f"⚠️ [Protection] Existing file already has metadata enhancement - skipping overwrite: {os.path.basename(final_path)}")
print(f"🗑️ [Protection] Removing redundant download file: {os.path.basename(file_path)}")
os.remove(file_path) # Remove the redundant file
return # Don't overwrite the good file
else:
print(f"🔄 [Protection] Existing file lacks metadata - safe to overwrite: {os.path.basename(final_path)}")
os.remove(final_path)
except Exception as check_error:
print(f"⚠️ [Protection] Error checking existing file metadata, proceeding with overwrite: {check_error}")
os.remove(final_path)
shutil.move(file_path, final_path)
_download_cover_art(album_info, os.path.dirname(final_path))
downloads_path = docker_resolve_path(config_manager.get('soulseek.download_path', './downloads'))
_cleanup_empty_directories(downloads_path, file_path)
print(f"✅ Post-processing complete for: {final_path}")
# WISHLIST REMOVAL: Check if this track should be removed from wishlist after successful download
try:
_check_and_remove_from_wishlist(context)
except Exception as wishlist_error:
print(f"⚠️ [Post-Process] Error checking wishlist removal: {wishlist_error}")
# Call completion callback for missing downloads tasks to start next batch
task_id = context.get('task_id')
batch_id = context.get('batch_id')
if task_id and batch_id:
print(f"🎯 [Post-Process] Calling completion callback for task {task_id} in batch {batch_id}")
# Mark task as stream processed to prevent duplicate stream processing
# NOTE: Verification workflow will still run to ensure file is in transfer folder
with tasks_lock:
if task_id in download_tasks:
download_tasks[task_id]['stream_processed'] = True
print(f"✅ [Post-Process] Marked task {task_id} as stream processed")
_on_download_completed(batch_id, task_id, success=True)
except Exception as e:
import traceback
print(f"\n❌ CRITICAL ERROR in post-processing for {context_key}: {e}")
traceback.print_exc()
# Remove from processed set so it can be retried
if context_key in _processed_download_ids:
_processed_download_ids.remove(context_key)
print(f"🔄 Removed {context_key} from processed set - will retry on next check")
# Re-add to matched context for retry
with matched_context_lock:
if context_key not in matched_downloads_context:
matched_downloads_context[context_key] = context
print(f"♻️ Re-added {context_key} to context for retry")
# Keep track of processed downloads to avoid re-processing
_processed_download_ids = set()
def _check_and_remove_from_wishlist(context):
"""
Check if a successfully downloaded track should be removed from wishlist.
Extracts Spotify track data from download context and removes from wishlist if found.
"""
try:
from core.wishlist_service import get_wishlist_service
wishlist_service = get_wishlist_service()
# Try to extract Spotify track ID from various sources in the context
spotify_track_id = None
# Method 1: Direct track_info with id
track_info = context.get('track_info', {})
if track_info.get('id'):
spotify_track_id = track_info['id']
print(f"📋 [Wishlist] Found Spotify ID from track_info: {spotify_track_id}")
# Method 2: From original search result
elif context.get('original_search_result', {}).get('id'):
spotify_track_id = context['original_search_result']['id']
print(f"📋 [Wishlist] Found Spotify ID from original_search_result: {spotify_track_id}")
# Method 3: Check if this is a wishlist download (context has wishlist_id)
elif 'wishlist_id' in track_info:
wishlist_id = track_info['wishlist_id']
print(f"📋 [Wishlist] Found wishlist_id in context: {wishlist_id}")
# Get the Spotify track ID from the wishlist entry
wishlist_tracks = wishlist_service.get_wishlist_tracks_for_download()
for wl_track in wishlist_tracks:
if wl_track.get('wishlist_id') == wishlist_id:
spotify_track_id = wl_track.get('spotify_track_id') or wl_track.get('id')
print(f"📋 [Wishlist] Found Spotify ID from wishlist entry: {spotify_track_id}")
break
# Method 4: Try to construct ID from track metadata for fuzzy matching
if not spotify_track_id:
track_name = track_info.get('name') or context.get('original_search_result', {}).get('title', '')
artist_name = _get_track_artist_name(track_info) or _get_track_artist_name(context.get('original_search_result', {}))
if track_name and artist_name:
print(f"📋 [Wishlist] No Spotify ID found, checking for fuzzy match: '{track_name}' by '{artist_name}'")
# Get all wishlist tracks and find potential matches
wishlist_tracks = wishlist_service.get_wishlist_tracks_for_download()
for wl_track in wishlist_tracks:
wl_name = wl_track.get('name', '').lower()
wl_artists = wl_track.get('artists', [])
wl_artist_name = ''
# Extract artist name from wishlist track
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()
# Simple fuzzy matching
if (wl_name == track_name.lower() and wl_artist_name == artist_name.lower()):
spotify_track_id = wl_track.get('spotify_track_id') or wl_track.get('id')
print(f"📋 [Wishlist] Found fuzzy match - Spotify ID: {spotify_track_id}")
break
# If we found a Spotify track ID, remove it from wishlist
if spotify_track_id:
print(f"📋 [Wishlist] Attempting to remove track from wishlist: {spotify_track_id}")
removed = wishlist_service.mark_track_download_result(spotify_track_id, success=True)
if removed:
print(f"✅ [Wishlist] Successfully removed track from wishlist: {spotify_track_id}")
else:
print(f" [Wishlist] Track not found in wishlist or already removed: {spotify_track_id}")
else:
print(f" [Wishlist] No Spotify track ID found for wishlist removal check")
except Exception as e:
print(f"❌ [Wishlist] Error in wishlist removal check: {e}")
import traceback
traceback.print_exc()
def _check_and_remove_track_from_wishlist_by_metadata(track_data):
"""
Check if a track found during database analysis should be removed from wishlist.
Uses track metadata (name, artists, id) to find and remove from wishlist.
"""
try:
from core.wishlist_service import get_wishlist_service
wishlist_service = get_wishlist_service()
# Extract track info
track_name = track_data.get('name', '')
track_id = track_data.get('id', '')
artists = track_data.get('artists', [])
print(f"📋 [Analysis] Checking if track should be removed from wishlist: '{track_name}' (ID: {track_id})")
# Method 1: Direct Spotify ID match
if track_id:
removed = wishlist_service.mark_track_download_result(track_id, success=True)
if removed:
print(f"✅ [Analysis] Removed track from wishlist via direct ID match: {track_id}")
return True
# Method 2: Fuzzy matching by name and artist if no direct ID match
if track_name and artists:
# Extract primary artist name
primary_artist = ''
if isinstance(artists[0], dict) and 'name' in artists[0]:
primary_artist = artists[0]['name']
elif isinstance(artists[0], str):
primary_artist = artists[0]
else:
primary_artist = str(artists[0])
print(f"📋 [Analysis] No direct ID match, trying fuzzy match: '{track_name}' by '{primary_artist}'")
# Get all wishlist tracks and find matches
wishlist_tracks = wishlist_service.get_wishlist_tracks_for_download()
for wl_track in wishlist_tracks:
wl_name = wl_track.get('name', '').lower()
wl_artists = wl_track.get('artists', [])
wl_artist_name = ''
# Extract artist name from wishlist track
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()
# Fuzzy matching - normalize strings for comparison
if (wl_name == track_name.lower() and wl_artist_name == primary_artist.lower()):
spotify_track_id = wl_track.get('spotify_track_id') or wl_track.get('id')
if spotify_track_id:
removed = wishlist_service.mark_track_download_result(spotify_track_id, success=True)
if removed:
print(f"✅ [Analysis] Removed track from wishlist via fuzzy match: {spotify_track_id}")
return True
print(f" [Analysis] Track not found in wishlist or already removed: '{track_name}'")
return False
except Exception as e:
print(f"❌ [Analysis] Error checking wishlist removal by metadata: {e}")
import traceback
traceback.print_exc()
return False
def _automatic_wishlist_cleanup_after_db_update():
"""
Automatic wishlist cleanup that runs after database updates.
This is a simplified version of the cleanup API endpoint designed for background execution.
"""
try:
from core.wishlist_service import get_wishlist_service
from database.music_database import MusicDatabase
wishlist_service = get_wishlist_service()
db = MusicDatabase()
active_server = config_manager.get_active_media_server()
print("📋 [Auto Cleanup] Starting automatic wishlist cleanup after database update...")
# Get all wishlist tracks
wishlist_tracks = wishlist_service.get_wishlist_tracks_for_download()
if not wishlist_tracks:
print("📋 [Auto Cleanup] No tracks in wishlist to clean up")
return
print(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')
# Skip if no essential data
if not track_name or not artists or not spotify_track_id:
continue
# Check each artist
found_in_db = False
for artist in artists:
# Handle both string format and dict format
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 = db.check_track_exists(
track_name, artist_name,
confidence_threshold=0.7,
server_source=active_server
)
if db_track and confidence >= 0.7:
found_in_db = True
print(f"📋 [Auto Cleanup] Track found in database: '{track_name}' by {artist_name} (confidence: {confidence:.2f})")
break
except Exception as db_error:
print(f"⚠️ [Auto Cleanup] Error checking database for track '{track_name}': {db_error}")
continue
# If found in database, remove from wishlist
if found_in_db:
try:
removed = wishlist_service.mark_track_download_result(spotify_track_id, success=True)
if removed:
removed_count += 1
print(f"✅ [Auto Cleanup] Removed track from wishlist: '{track_name}' ({spotify_track_id})")
except Exception as remove_error:
print(f"❌ [Auto Cleanup] Error removing track from wishlist: {remove_error}")
print(f"📋 [Auto Cleanup] Completed automatic cleanup: {removed_count} tracks removed from wishlist")
except Exception as e:
print(f"❌ [Auto Cleanup] Error in automatic wishlist cleanup: {e}")
import traceback
traceback.print_exc()
@app.route('/api/version-info', methods=['GET'])
def get_version_info():
"""
Returns version information and release notes, matching the GUI's VersionInfoModal content.
This provides the same data that the GUI version modal displays.
"""
version_data = {
"version": "1.0",
"title": "What's New in SoulSync",
"subtitle": "Version 1.0 - Complete WebUI Rebuild",
"sections": [
{
"title": "🌐 Complete WebUI Transformation",
"description": "SoulSync has been completely rebuilt from the ground up as a modern web application, moving from desktop GUI to web-based interface",
"features": [
"• Full transition from PyQt6 desktop application to responsive web interface",
"• Modern HTML5, CSS3, and JavaScript implementation with premium glassmorphic design",
"• Real-time updates and live status monitoring through WebSocket connections",
"• Cross-platform compatibility - access from any device with a web browser",
"• Mobile-responsive design optimized for tablets and smartphones",
"• Dark theme with sophisticated visual effects and smooth animations",
"• RESTful API architecture enabling future third-party integrations"
],
"usage_note": "Access SoulSync through your web browser at localhost:8888 - no desktop installation required!"
},
{
"title": "🐳 Docker Container Support",
"description": "Complete containerization with Docker for easy deployment and scalability",
"features": [
"• Pre-built Docker images available for instant deployment",
"• Multi-architecture support (AMD64, ARM64) for various server platforms",
"• Volume mounting for persistent configuration and downloads",
"• Environment variable configuration for easy customization",
"• Docker Compose templates for simplified multi-container setups",
"• Automatic health checks and restart policies for reliability",
"• Lightweight Alpine Linux base for minimal resource usage"
]
},
{
"title": "🎵 Enhanced Music Management",
"description": "All beloved features preserved and enhanced with new web-based capabilities",
"features": [
"• Complete Spotify, Tidal, and YouTube Music playlist synchronization",
"• Advanced Soulseek integration with real-time download management",
"• Intelligent music matching engine with improved accuracy",
"• Plex and Jellyfin server integration with automatic library updates",
"• Artist watchlist with automatic new release detection",
"• Comprehensive metadata enhancement with high-quality album artwork",
"• Real-time download progress with detailed logging and status updates"
]
},
{
"title": "🚀 Performance & Reliability",
"description": "Significant improvements in speed, stability, and resource efficiency",
"features": [
"• Asynchronous processing for improved responsiveness",
"• Multi-threaded download management with concurrent processing",
"• Optimized database operations with connection pooling",
"• Intelligent caching system for faster API responses",
"• Robust error handling with automatic retry mechanisms",
"• Memory-efficient architecture suitable for long-running deployments",
"• Comprehensive logging system for easy troubleshooting"
]
}
]
}
return jsonify(version_data)
def _simple_monitor_task():
"""The actual monitoring task that runs in the background thread."""
print("🔄 Simple background monitor started")
last_search_cleanup = 0 # Force initial cleanup on first run
search_cleanup_interval = 3600 # 1 hour
initial_cleanup_done = False
while True:
try:
with matched_context_lock:
pending_count = len(matched_downloads_context)
if pending_count > 0:
# Use app_context to safely call endpoint logic from a thread
with app.app_context():
get_download_status()
# Automatic search cleanup every hour (or initial cleanup)
current_time = time.time()
should_cleanup = (current_time - last_search_cleanup > search_cleanup_interval) or not initial_cleanup_done
if should_cleanup:
try:
if not initial_cleanup_done:
print("🔍 [Auto Cleanup] Performing initial search cleanup in background...")
initial_cleanup_done = True
else:
print("🔍 [Auto Cleanup] Starting scheduled search cleanup...")
success = asyncio.run(soulseek_client.maintain_search_history_with_buffer(
keep_searches=50, trigger_threshold=200
))
if success:
cleanup_type = "Initial search history maintenance" if last_search_cleanup == 0 else "Automatic search history maintenance completed"
add_activity_item("🧹", "Search Cleanup", cleanup_type, "Now")
print("✅ [Auto Cleanup] Search history maintenance completed")
else:
print("⚠️ [Auto Cleanup] Search history maintenance returned false")
last_search_cleanup = current_time
except Exception as cleanup_error:
print(f"❌ [Auto Cleanup] Error in automatic search cleanup: {cleanup_error}")
last_search_cleanup = current_time # Still update to avoid spam
initial_cleanup_done = True # Mark as done even on error to avoid blocking
time.sleep(1)
except Exception as e:
print(f"❌ Simple monitor error: {e}")
time.sleep(10)
def start_simple_background_monitor():
"""Starts the simple background monitor thread."""
monitor_thread = threading.Thread(target=_simple_monitor_task)
monitor_thread.daemon = True
monitor_thread.start()
# ===============================
# == AUTOMATIC WISHLIST PROCESSING ==
# ===============================
def _sanitize_track_data_for_processing(track_data):
"""
Sanitizes track data from wishlist service to ensure consistent format.
Handles album field conversion from dict to string and artist field normalization.
"""
if not isinstance(track_data, dict):
print(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 - convert dictionary to string if needed
raw_album = sanitized.get('album', '')
if isinstance(raw_album, dict) and 'name' in raw_album:
sanitized['album'] = raw_album['name']
print(f"🔧 [Sanitize] Converted album from dict to string: '{raw_album['name']}'")
elif not isinstance(raw_album, str):
sanitized['album'] = str(raw_album)
print(f"🔧 [Sanitize] Converted album to string: '{sanitized['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'])
print(f"🔧 [Sanitize] Converted artist from dict to string: '{artist['name']}'")
else:
processed_artists.append(str(artist))
print(f"🔧 [Sanitize] Converted artist to string: '{str(artist)}'")
sanitized['artists'] = processed_artists
else:
print(f"⚠️ [Sanitize] Unexpected artists format: {type(raw_artists)}")
sanitized['artists'] = [str(raw_artists)] if raw_artists else []
return sanitized
def start_wishlist_auto_processing():
"""Start automatic wishlist processing with 1-minute initial delay."""
global wishlist_auto_timer
# Skip if this is Flask's debug reloader process
import os
if os.environ.get('WERKZEUG_RUN_MAIN') != 'true':
print("🔄 Skipping auto-processing start in Flask debug reloader")
return
with wishlist_timer_lock:
# Stop any existing timer to prevent duplicates
if wishlist_auto_timer is not None:
wishlist_auto_timer.cancel()
print("🔄 Starting automatic wishlist processing system (1 minute initial delay)")
wishlist_auto_timer = threading.Timer(60.0, _process_wishlist_automatically) # 1 minute
wishlist_auto_timer.daemon = True
wishlist_auto_timer.start()
def stop_wishlist_auto_processing():
"""Stop automatic wishlist processing and cleanup timer."""
global wishlist_auto_timer, wishlist_auto_processing
with wishlist_timer_lock:
if wishlist_auto_timer is not None:
wishlist_auto_timer.cancel()
wishlist_auto_timer = None
print("⏹️ Stopped automatic wishlist processing")
wishlist_auto_processing = False
def schedule_next_wishlist_processing():
"""Schedule next automatic wishlist processing in 10 minutes."""
global wishlist_auto_timer
with wishlist_timer_lock:
print("⏰ Scheduling next automatic wishlist processing in 10 minutes")
wishlist_auto_timer = threading.Timer(600.0, _process_wishlist_automatically) # 10 minutes
wishlist_auto_timer.daemon = True
wishlist_auto_timer.start()
def _process_wishlist_automatically():
"""Main automatic processing logic that runs in background thread."""
global wishlist_auto_processing
print("🤖 Starting automatic wishlist processing...")
try:
with wishlist_timer_lock:
if wishlist_auto_processing:
print("⚠️ Wishlist auto-processing already running, skipping.")
schedule_next_wishlist_processing()
return
# Check if watchlist scan is currently running
global watchlist_scan_state
if (watchlist_scan_state and
isinstance(watchlist_scan_state, dict) and
watchlist_scan_state.get('status') == 'scanning'):
print("👁️ Watchlist scan in progress, skipping automatic wishlist processing to avoid conflicts.")
schedule_next_wishlist_processing()
return
wishlist_auto_processing = True
# Use app context for database operations
with app.app_context():
from core.wishlist_service import get_wishlist_service
wishlist_service = get_wishlist_service()
# Check if wishlist has tracks
count = wishlist_service.get_wishlist_count()
if count == 0:
print(" No tracks in wishlist for auto-processing.")
with wishlist_timer_lock:
wishlist_auto_processing = False
schedule_next_wishlist_processing()
return
print(f"🎵 Found {count} tracks in wishlist, starting automatic processing...")
# Check if wishlist processing is already active
playlist_id = "wishlist"
with tasks_lock:
for batch_id, batch_data in download_batches.items():
if (batch_data.get('playlist_id') == playlist_id and
batch_data.get('phase') not in ['complete', 'error', 'cancelled']):
print("⚠️ Wishlist processing already active in another batch, skipping automatic start")
with wishlist_timer_lock:
wishlist_auto_processing = False
schedule_next_wishlist_processing()
return
# Get wishlist tracks for processing
raw_wishlist_tracks = wishlist_service.get_wishlist_tracks_for_download()
if not raw_wishlist_tracks:
print("⚠️ No tracks returned from wishlist service.")
with wishlist_timer_lock:
wishlist_auto_processing = False
schedule_next_wishlist_processing()
return
# SANITIZE: Ensure consistent data format from wishlist service
wishlist_tracks = []
for track in raw_wishlist_tracks:
sanitized_track = _sanitize_track_data_for_processing(track)
wishlist_tracks.append(sanitized_track)
print(f"🔧 [Auto-Wishlist] Sanitized {len(wishlist_tracks)} tracks from wishlist service")
# Create batch for automatic processing
batch_id = str(uuid.uuid4())
playlist_name = "Wishlist (Auto)"
# Create task queue - convert wishlist tracks to expected format
with tasks_lock:
download_batches[batch_id] = {
'phase': 'analysis',
'playlist_id': playlist_id,
'playlist_name': playlist_name,
'queue': [],
'active_count': 0,
'max_concurrent': 3,
'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(),
# Mark as auto-initiated
'auto_initiated': True,
'auto_processing_timestamp': time.time()
}
print(f"🚀 Starting automatic wishlist batch {batch_id} with {len(wishlist_tracks)} tracks")
# Submit the wishlist processing job using existing infrastructure
missing_download_executor.submit(_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:
print(f"❌ Error in automatic wishlist processing: {e}")
import traceback
traceback.print_exc()
with wishlist_timer_lock:
wishlist_auto_processing = False
schedule_next_wishlist_processing()
# ===============================
# == DATABASE UPDATER API ==
# ===============================
def _db_update_progress_callback(current_item, processed, total, percentage):
with db_update_lock:
db_update_state.update({
"current_item": current_item,
"processed": processed,
"total": total,
"progress": percentage
})
def _db_update_phase_callback(phase):
with db_update_lock:
db_update_state["phase"] = phase
def _db_update_finished_callback(total_artists, total_albums, total_tracks, successful, failed):
with db_update_lock:
db_update_state["status"] = "finished"
db_update_state["phase"] = f"Completed: {successful} successful, {failed} failed."
# Add activity for database update completion
summary = f"{total_tracks} tracks, {total_albums} albums, {total_artists} artists processed"
add_activity_item("", "Database Update Complete", summary, "Now")
# WISHLIST CLEANUP: Automatically clean up wishlist after database update
try:
print("📋 [DB Update] Database update completed, starting automatic wishlist cleanup...")
# Run cleanup in background to avoid blocking the UI
missing_download_executor.submit(_automatic_wishlist_cleanup_after_db_update)
except Exception as cleanup_error:
print(f"⚠️ [DB Update] Error starting automatic wishlist cleanup: {cleanup_error}")
def _db_update_error_callback(error_message):
with db_update_lock:
db_update_state["status"] = "error"
db_update_state["error_message"] = error_message
# Add activity for database update error
add_activity_item("", "Database Update Failed", error_message, "Now")
def _run_db_update_task(full_refresh, server_type):
"""The actual function that runs in the background thread."""
global db_update_worker
media_client = None
if server_type == "plex":
media_client = plex_client
elif server_type == "jellyfin":
media_client = jellyfin_client
if not media_client:
_db_update_error_callback(f"Media client for '{server_type}' not available.")
return
with db_update_lock:
db_update_worker = DatabaseUpdateWorker(
media_client=media_client,
full_refresh=full_refresh,
server_type=server_type
)
# Connect signals to callbacks (handle both Qt and headless modes)
try:
# Try Qt signal connection first
db_update_worker.progress_updated.connect(_db_update_progress_callback)
db_update_worker.phase_changed.connect(_db_update_phase_callback)
db_update_worker.finished.connect(_db_update_finished_callback)
db_update_worker.error.connect(_db_update_error_callback)
except AttributeError:
# Headless mode - use callback system
db_update_worker.connect_callback('progress_updated', _db_update_progress_callback)
db_update_worker.connect_callback('phase_changed', _db_update_phase_callback)
db_update_worker.connect_callback('finished', _db_update_finished_callback)
db_update_worker.connect_callback('error', _db_update_error_callback)
# This is a blocking call that runs the QThread's logic
db_update_worker.run()
@app.route('/api/database/stats', methods=['GET'])
def get_database_stats():
"""Endpoint to get current database statistics."""
try:
# This logic is adapted from DatabaseStatsWorker
db = get_database()
stats = db.get_database_info_for_server()
return jsonify(stats)
except Exception as e:
print(f"Error getting database stats: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/wishlist/count', methods=['GET'])
def get_wishlist_count():
"""Endpoint to get current wishlist count."""
try:
from core.wishlist_service import get_wishlist_service
wishlist_service = get_wishlist_service()
count = wishlist_service.get_wishlist_count()
return jsonify({"count": count})
except Exception as e:
print(f"Error getting wishlist count: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/wishlist/tracks', methods=['GET'])
def get_wishlist_tracks():
"""Endpoint to get wishlist tracks for display in modal."""
try:
from core.wishlist_service import get_wishlist_service
wishlist_service = get_wishlist_service()
raw_tracks = wishlist_service.get_wishlist_tracks_for_download()
# SANITIZE: Ensure consistent data format for frontend
sanitized_tracks = []
for track in raw_tracks:
sanitized_track = _sanitize_track_data_for_processing(track)
sanitized_tracks.append(sanitized_track)
return jsonify({"tracks": sanitized_tracks})
except Exception as e:
print(f"Error getting wishlist tracks: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/wishlist/download_missing', methods=['POST'])
def start_wishlist_missing_downloads():
"""
This endpoint fetches wishlist tracks and manages them with batch processing
identical to playlist processing, maintaining exactly 3 concurrent downloads.
"""
try:
from core.wishlist_service import get_wishlist_service
wishlist_service = get_wishlist_service()
# Get wishlist tracks formatted for download modal
raw_wishlist_tracks = wishlist_service.get_wishlist_tracks_for_download()
if not raw_wishlist_tracks:
return jsonify({"success": False, "error": "No tracks in wishlist"}), 400
# SANITIZE: Ensure consistent data format from wishlist service
wishlist_tracks = []
for track in raw_wishlist_tracks:
sanitized_track = _sanitize_track_data_for_processing(track)
wishlist_tracks.append(sanitized_track)
print(f"🔧 [Manual-Wishlist] Sanitized {len(wishlist_tracks)} tracks from wishlist service")
# Add activity for wishlist download start
add_activity_item("📥", "Wishlist Download Started", f"{len(wishlist_tracks)} tracks", "Now")
batch_id = str(uuid.uuid4())
# Use "wishlist" as the playlist_id for consistency in the modal system
playlist_id = "wishlist"
playlist_name = "Wishlist"
# Create task queue for this batch - convert wishlist tracks to the expected format
task_queue = []
with tasks_lock:
download_batches[batch_id] = {
'phase': 'analysis',
'playlist_id': playlist_id,
'playlist_name': playlist_name,
'queue': task_queue,
'active_count': 0,
'max_concurrent': 3,
'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()
}
# Submit the wishlist processing job using the same processing function
missing_download_executor.submit(_run_full_missing_tracks_process, batch_id, playlist_id, wishlist_tracks)
return jsonify({
"success": True,
"batch_id": batch_id
})
except Exception as e:
print(f"Error starting wishlist download process: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/wishlist/clear', methods=['POST'])
def clear_wishlist():
"""Endpoint to clear all tracks from the wishlist."""
try:
from core.wishlist_service import get_wishlist_service
wishlist_service = get_wishlist_service()
success = wishlist_service.clear_wishlist()
if success:
return jsonify({"success": True, "message": "Wishlist cleared successfully"})
else:
return jsonify({"success": False, "error": "Failed to clear wishlist"}), 500
except Exception as e:
print(f"Error clearing wishlist: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/wishlist/cleanup', methods=['POST'])
def cleanup_wishlist():
"""Endpoint to remove tracks from wishlist that already exist in the database."""
try:
from core.wishlist_service import get_wishlist_service
from database.music_database import MusicDatabase
wishlist_service = get_wishlist_service()
db = MusicDatabase()
active_server = config_manager.get_active_media_server()
print("📋 [Wishlist Cleanup] Starting wishlist cleanup process...")
# Get all wishlist tracks
wishlist_tracks = wishlist_service.get_wishlist_tracks_for_download()
if not wishlist_tracks:
return jsonify({"success": True, "message": "No tracks in wishlist to clean up", "removed_count": 0})
print(f"📋 [Wishlist Cleanup] Found {len(wishlist_tracks)} tracks in wishlist")
removed_count = 0
processed_count = 0
for track in wishlist_tracks:
processed_count += 1
track_name = track.get('name', '')
artists = track.get('artists', [])
spotify_track_id = track.get('spotify_track_id') or track.get('id')
# Skip if no essential data
if not track_name or not artists or not spotify_track_id:
continue
print(f"📋 [Wishlist Cleanup] Checking track {processed_count}/{len(wishlist_tracks)}: '{track_name}'")
# Check each artist
found_in_db = False
for artist in artists:
# Handle both string format and dict format
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 = db.check_track_exists(
track_name, artist_name,
confidence_threshold=0.7,
server_source=active_server
)
if db_track and confidence >= 0.7:
found_in_db = True
print(f"📋 [Wishlist Cleanup] Track found in database: '{track_name}' by {artist_name} (confidence: {confidence:.2f})")
break
except Exception as db_error:
print(f"⚠️ [Wishlist Cleanup] Error checking database for track '{track_name}': {db_error}")
continue
# If found in database, remove from wishlist
if found_in_db:
try:
removed = wishlist_service.mark_track_download_result(spotify_track_id, success=True)
if removed:
removed_count += 1
print(f"✅ [Wishlist Cleanup] Removed track from wishlist: '{track_name}' ({spotify_track_id})")
else:
print(f"⚠️ [Wishlist Cleanup] Failed to remove track from wishlist: '{track_name}' ({spotify_track_id})")
except Exception as remove_error:
print(f"❌ [Wishlist Cleanup] Error removing track from wishlist: {remove_error}")
print(f"📋 [Wishlist Cleanup] Completed cleanup: {removed_count} tracks removed from wishlist")
return jsonify({
"success": True,
"message": f"Wishlist cleanup completed: {removed_count} tracks removed",
"removed_count": removed_count,
"processed_count": processed_count
})
except Exception as e:
print(f"Error in wishlist cleanup: {e}")
import traceback
traceback.print_exc()
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/database/update', methods=['POST'])
def start_database_update():
"""Endpoint to start the database update process."""
global db_update_worker
with db_update_lock:
if db_update_state["status"] == "running":
return jsonify({"success": False, "error": "An update is already in progress."}), 409
data = request.get_json()
full_refresh = data.get('full_refresh', False)
active_server = config_manager.get_active_media_server()
db_update_state.update({
"status": "running",
"phase": "Initializing...",
"progress": 0, "current_item": "", "processed": 0, "total": 0, "error_message": ""
})
# Add activity for database update start
update_type = "Full" if full_refresh else "Incremental"
server_name = active_server.capitalize()
add_activity_item("🗄️", "Database Update", f"Starting {update_type.lower()} update from {server_name}...", "Now")
# Submit the worker function to the executor
db_update_executor.submit(_run_db_update_task, full_refresh, active_server)
return jsonify({"success": True, "message": "Database update started."})
@app.route('/api/database/update/status', methods=['GET'])
def get_database_update_status():
"""Endpoint to poll for the current update status."""
with db_update_lock:
return jsonify(db_update_state)
@app.route('/api/database/update/stop', methods=['POST'])
def stop_database_update():
"""Endpoint to stop the current database update."""
global db_update_worker
with db_update_lock:
if db_update_worker and db_update_state["status"] == "running":
db_update_worker.stop()
db_update_state["status"] = "finished"
db_update_state["phase"] = "Update stopped by user."
return jsonify({"success": True, "message": "Stop request sent."})
else:
return jsonify({"success": False, "error": "No update is currently running."}), 404
# ===============================
# == DOWNLOAD MISSING TRACKS ==
# ===============================
def get_valid_candidates(results, spotify_track, query):
"""
This function is a direct port from sync.py. It scores and filters
Soulseek search results against a Spotify track to find the best, most
accurate download candidates.
"""
if not results:
return []
# Uses the existing, powerful matching engine for scoring
initial_candidates = matching_engine.find_best_slskd_matches_enhanced(spotify_track, results)
if not initial_candidates:
return []
verified_candidates = []
spotify_artist_name = spotify_track.artists[0] if spotify_track.artists else ""
normalized_spotify_artist = re.sub(r'[^a-zA-Z0-9]', '', spotify_artist_name).lower()
for candidate in initial_candidates:
# This check is critical: it ensures the artist's name is in the file path,
# preventing downloads from the wrong artist.
normalized_slskd_path = re.sub(r'[^a-zA-Z0-9]', '', candidate.filename).lower()
if normalized_spotify_artist in normalized_slskd_path:
verified_candidates.append(candidate)
return verified_candidates
def _recover_worker_slot(batch_id, task_id):
"""
Emergency worker slot recovery function for when normal completion callback fails.
This prevents permanent worker slot leaks that cause modal to show wrong worker counts.
"""
try:
print(f"🚨 [Worker Recovery] Attempting to recover worker slot for batch {batch_id}, task {task_id}")
# Acquire lock with timeout to prevent deadlock
lock_acquired = tasks_lock.acquire(timeout=3.0)
if not lock_acquired:
print(f"💀 [Worker Recovery] FATAL: Could not acquire lock for recovery - worker slot LEAKED")
return False
try:
# Verify batch still exists
if batch_id not in download_batches:
print(f"⚠️ [Worker Recovery] Batch {batch_id} not found - nothing to recover")
return True
batch = download_batches[batch_id]
old_active = batch['active_count']
# Only decrement if there are active workers to prevent negative counts
if old_active > 0:
batch['active_count'] -= 1
new_active = batch['active_count']
print(f"✅ [Worker Recovery] Recovered worker slot - Active count: {old_active}{new_active}")
# Try to start next worker if queue isn't empty
if batch['queue_index'] < len(batch['queue']) and new_active < batch['max_concurrent']:
print(f"🔄 [Worker Recovery] Attempting to start replacement worker")
# Release lock temporarily to avoid deadlock in _start_next_batch_of_downloads
tasks_lock.release()
try:
_start_next_batch_of_downloads(batch_id)
finally:
# Re-acquire lock for final cleanup
tasks_lock.acquire(timeout=2.0)
return True
else:
print(f"⚠️ [Worker Recovery] Active count already 0 - no recovery needed")
return True
finally:
tasks_lock.release()
except Exception as recovery_error:
print(f"💀 [Worker Recovery] FATAL ERROR in recovery: {recovery_error}")
return False
def _get_batch_lock(batch_id):
"""Get or create a lock for a specific batch to prevent race conditions"""
with tasks_lock:
if batch_id not in batch_locks:
batch_locks[batch_id] = threading.Lock()
return batch_locks[batch_id]
def _start_next_batch_of_downloads(batch_id):
"""Start the next batch of downloads up to the concurrent limit (like GUI)"""
# ENHANCED: Use batch-specific lock to prevent race conditions when multiple threads
# try to start workers for the same batch concurrently
batch_lock = _get_batch_lock(batch_id)
with batch_lock:
with tasks_lock:
if batch_id not in download_batches:
return
batch = download_batches[batch_id]
max_concurrent = batch['max_concurrent']
queue = batch['queue']
queue_index = batch['queue_index']
active_count = batch['active_count']
print(f"🔍 [Batch Lock] Starting workers for {batch_id}: active={active_count}, max={max_concurrent}, queue_pos={queue_index}/{len(queue)}")
# Start downloads up to the concurrent limit
while active_count < max_concurrent and queue_index < len(queue):
task_id = queue[queue_index]
# CRITICAL V2 FIX: Skip cancelled tasks instead of trying to restart them
if task_id in download_tasks:
current_status = download_tasks[task_id]['status']
if current_status == 'cancelled':
print(f"⏭️ [Batch Lock] Skipping cancelled task {task_id} (queue position {queue_index + 1})")
download_batches[batch_id]['queue_index'] += 1
queue_index += 1
continue # Skip to next task without consuming worker slot
# IMPORTANT: Set status to 'searching' BEFORE starting worker (like GUI)
# Must be done INSIDE the lock to prevent race conditions with status polling
download_tasks[task_id]['status'] = 'searching'
download_tasks[task_id]['status_change_time'] = time.time()
print(f"🔧 [Batch Manager] Set task {task_id} status to 'searching'")
else:
print(f"⚠️ [Batch Lock] Task {task_id} not found in download_tasks - skipping")
download_batches[batch_id]['queue_index'] += 1
queue_index += 1
continue
# CRITICAL FIX: Submit to executor BEFORE incrementing counters to prevent ghost workers
try:
# Submit to executor first - this can fail
future = missing_download_executor.submit(_download_track_worker, task_id, batch_id)
# Only increment counters AFTER successful submit
download_batches[batch_id]['active_count'] += 1
download_batches[batch_id]['queue_index'] += 1
print(f"🔄 [Batch Lock] Started download {queue_index + 1}/{len(queue)} - Active: {active_count + 1}/{max_concurrent}")
# Update local counters for next iteration
active_count += 1
queue_index += 1
except Exception as submit_error:
print(f"❌ [Batch Lock] CRITICAL: Failed to submit task {task_id} to executor: {submit_error}")
print(f"🚨 [Batch Lock] Worker slot NOT consumed - preventing ghost worker")
# Reset task status since worker never started
if task_id in download_tasks:
download_tasks[task_id]['status'] = 'failed'
print(f"🔧 [Batch Lock] Set task {task_id} status to 'failed' due to submit failure")
# Don't increment counters - no worker was actually started
# This prevents the "ghost worker" issue where active_count is incremented but no actual worker runs
break # Stop trying to start more workers if executor is failing
print(f"✅ [Batch Lock] Finished starting workers for {batch_id}: final_active={download_batches[batch_id]['active_count']}, max={max_concurrent}")
def _get_track_artist_name(track_info):
"""Extract artist name from track info, handling different data formats (replicating sync.py)"""
if not track_info:
return "Unknown Artist"
# Handle Spotify API format with artists array
artists = track_info.get('artists', [])
if artists and len(artists) > 0:
if isinstance(artists[0], dict) and 'name' in artists[0]:
return artists[0]['name']
elif isinstance(artists[0], str):
return artists[0]
# Fallback to single artist field
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 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': {
'name': track_info.get('album', {}).get('name') if isinstance(track_info.get('album'), dict)
else track_info.get('album', 'Unknown Album')
},
'duration_ms': track_info.get('duration_ms', 0),
'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 _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
from datetime import datetime
print(f"🔍 [Wishlist Processing] Starting wishlist processing for batch {batch_id}")
with tasks_lock:
if batch_id not in download_batches:
print(f"⚠️ [Wishlist Processing] Batch {batch_id} not found")
return {'tracks_added': 0, 'errors': 0}
batch = download_batches[batch_id]
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!)
print(f"🔍 [Wishlist Processing] Checking completed tracks for wishlist removal")
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}
_check_and_remove_from_wishlist(context)
except Exception as e:
print(f"⚠️ [Wishlist Processing] Error removing completed track from wishlist: {e}")
# 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:
print(f"🔍 [Wishlist Processing] Processing {len(cancelled_tracks)} cancelled tracks")
# Process cancelled tracks with safeguard to prevent infinite loops
processed_count = 0
max_process = 100 # Safety limit
with tasks_lock:
for task_id in batch.get('queue', [])[:max_process]: # Limit processing
if task_id in download_tasks:
task = download_tasks[task_id]
track_index = task.get('track_index', 0)
if track_index in cancelled_tracks:
# Check if track was actually missing (not successfully downloaded)
task_status = task.get('status', 'unknown')
if task_status != 'completed':
# Build cancelled track info matching sync.py format
original_track_info = task.get('track_info', {})
spotify_track_data = _ensure_spotify_track_format(original_track_info)
cancelled_track_info = {
'download_index': track_index,
'table_index': track_index,
'track_name': original_track_info.get('name', 'Unknown Track'),
'artist_name': _get_track_artist_name(original_track_info),
'retry_count': 0,
'spotify_track': spotify_track_data, # Properly formatted spotify track
'failure_reason': 'Download cancelled',
'candidates': task.get('cached_candidates', [])
}
# Check if not already in permanently_failed_tracks (sync.py does this check)
if not any(t.get('table_index') == track_index for t in permanently_failed_tracks):
permanently_failed_tracks.append(cancelled_track_info)
processed_count += 1
print(f"🚫 [Wishlist Processing] Added cancelled missing track {cancelled_track_info['track_name']} to failed list for wishlist")
print(f"🔍 [Wishlist Processing] Processed {processed_count} cancelled tracks")
# 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
print(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 = {
'playlist_name': batch.get('playlist_name', 'Unknown Playlist'),
'playlist_id': batch.get('playlist_id', None),
'added_from': 'webui_modal', # Distinguish from sync_page_modal
'timestamp': datetime.now().isoformat()
}
# Process each failed track (matching sync.py's loop) with safety limit
max_failed_tracks = min(len(permanently_failed_tracks), 50) # Safety limit
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}')
print(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='playlist',
source_context=source_context
)
if success:
wishlist_added_count += 1
print(f"✅ [Wishlist Processing] Added {track_name} to wishlist")
else:
print(f"⚠️ [Wishlist Processing] Failed to add {track_name} to wishlist")
except Exception as e:
error_count += 1
print(f"❌ [Wishlist Processing] Exception adding track to wishlist: {e}")
print(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)
print(f"❌ [Wishlist Processing] Critical error adding failed tracks to wishlist: {e}")
import traceback
traceback.print_exc()
else:
print(f" [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
# Phase already set to 'complete' in _on_download_completed
print(f"✅ [Wishlist Processing] Completed wishlist processing for batch {batch_id}")
return completion_summary
except Exception as e:
print(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]['wishlist_summary'] = {
'tracks_added': 0,
'errors': 1,
'total_failed': 0,
'error_message': str(e)
}
except Exception as lock_error:
print(f"❌ [Wishlist Processing] Failed to update batch after error: {lock_error}")
return {'tracks_added': 0, 'errors': 1, 'total_failed': 0}
def _process_failed_tracks_to_wishlist_exact_with_auto_completion(batch_id):
"""
Process failed tracks to wishlist for auto-initiated batches and handle auto-processing completion.
This extends the standard processing with automatic scheduling of the next cycle.
"""
global wishlist_auto_processing
try:
print(f"🤖 [Auto-Wishlist] Processing completion for auto-initiated batch {batch_id}")
# Run standard wishlist processing
completion_summary = _process_failed_tracks_to_wishlist_exact(batch_id)
# Log auto-processing completion
tracks_added = completion_summary.get('tracks_added', 0)
total_failed = completion_summary.get('total_failed', 0)
print(f"🎉 [Auto-Wishlist] Background processing complete: {tracks_added} added to wishlist, {total_failed} failed")
# Add activity for wishlist processing
if tracks_added > 0:
add_activity_item("", "Wishlist Updated", f"{tracks_added} failed tracks added to wishlist", "Now")
# Mark auto-processing as complete
with wishlist_timer_lock:
wishlist_auto_processing = False
# Schedule next automatic processing cycle
print("⏰ [Auto-Wishlist] Scheduling next automatic cycle in 10 minutes")
schedule_next_wishlist_processing()
return completion_summary
except Exception as e:
print(f"❌ [Auto-Wishlist] Error in auto-completion processing: {e}")
import traceback
traceback.print_exc()
# Ensure auto-processing flag is reset even on error
with wishlist_timer_lock:
wishlist_auto_processing = False
# Schedule next cycle even after error to maintain continuity
print("⏰ [Auto-Wishlist] Scheduling next cycle after error (10 minutes)")
schedule_next_wishlist_processing()
return {'tracks_added': 0, 'errors': 1, 'total_failed': 0}
def _on_download_completed(batch_id, task_id, success=True):
"""Called when a download completes to start the next one in queue"""
with tasks_lock:
if batch_id not in download_batches:
print(f"⚠️ [Batch Manager] Batch {batch_id} not found for completed task {task_id}")
return
# Track failed/cancelled tasks in batch state (replicating sync.py)
if not success and task_id in download_tasks:
task = download_tasks[task_id]
task_status = task.get('status', 'unknown')
# Build track_info structure matching sync.py's permanently_failed_tracks format
original_track_info = task.get('track_info', {})
# Ensure spotify_track has proper structure for wishlist service
spotify_track_data = _ensure_spotify_track_format(original_track_info)
track_info = {
'download_index': task.get('track_index', 0),
'table_index': task.get('track_index', 0),
'track_name': original_track_info.get('name', 'Unknown Track'),
'artist_name': _get_track_artist_name(original_track_info),
'retry_count': task.get('retry_count', 0),
'spotify_track': spotify_track_data, # Properly formatted spotify track for wishlist
'failure_reason': 'Download cancelled' if task_status == 'cancelled' else 'Download failed',
'candidates': task.get('cached_candidates', []) # Include search results if available
}
if task_status == 'cancelled':
download_batches[batch_id]['cancelled_tracks'].add(task.get('track_index', 0))
print(f"🚫 [Batch Manager] Added cancelled track to batch tracking: {track_info['track_name']}")
add_activity_item("🚫", "Download Cancelled", f"'{track_info['track_name']}'", "Now")
elif task_status == 'failed':
download_batches[batch_id]['permanently_failed_tracks'].append(track_info)
print(f"❌ [Batch Manager] Added failed track to batch tracking: {track_info['track_name']}")
add_activity_item("", "Download Failed", f"'{track_info['track_name']}'", "Now")
# WISHLIST REMOVAL: Handle successful downloads for wishlist removal
if success and task_id in download_tasks:
try:
task = download_tasks[task_id]
track_info = task.get('track_info', {})
print(f"📋 [Batch Manager] Successful download - checking wishlist removal for task {task_id}")
# Add activity for successful download
track_name = track_info.get('name', 'Unknown Track')
artist_name = track_info.get('artists', [{}])[0].get('name', 'Unknown Artist') if track_info.get('artists') else 'Unknown Artist'
add_activity_item("📥", "Download Complete", f"'{track_name}' by {artist_name}", "Now")
# Try to remove from wishlist using track info
if track_info:
# Create a context-like structure for the wishlist removal function
context = {
'track_info': track_info,
'original_search_result': track_info # fallback
}
_check_and_remove_from_wishlist(context)
except Exception as wishlist_error:
print(f"⚠️ [Batch Manager] Error checking wishlist removal for successful download: {wishlist_error}")
# Decrement active count
old_active = download_batches[batch_id]['active_count']
download_batches[batch_id]['active_count'] -= 1
new_active = download_batches[batch_id]['active_count']
print(f"🔄 [Batch Manager] Task {task_id} completed ({'success' if success else 'failed/cancelled'}). Active workers: {old_active}{new_active}/{download_batches[batch_id]['max_concurrent']}")
# ENHANCED: Always check batch completion after any task completes
# This ensures completion is detected even when mixing normal downloads with cancelled tasks
print(f"🔍 [Batch Manager] Checking batch completion after task {task_id} completed")
# FIXED: Check if batch is truly complete (all tasks finished, not just workers freed)
batch = download_batches[batch_id]
all_tasks_started = batch['queue_index'] >= len(batch['queue'])
no_active_workers = batch['active_count'] == 0
# Count actually finished tasks (completed, failed, or cancelled)
# CRITICAL: Don't include 'post_processing' as finished - it's still in progress!
# CRITICAL: Don't include 'searching' as finished - task is being retried!
finished_count = 0
retrying_count = 0
queue = batch.get('queue', [])
for task_id in queue:
if task_id in download_tasks:
task_status = download_tasks[task_id]['status']
if task_status in ['completed', 'failed', 'cancelled']:
finished_count += 1
elif task_status == 'searching':
retrying_count += 1
all_tasks_truly_finished = finished_count >= len(queue)
has_retrying_tasks = retrying_count > 0
if all_tasks_started and no_active_workers and all_tasks_truly_finished and not has_retrying_tasks:
print(f"🎉 [Batch Manager] Batch {batch_id} truly complete - all {finished_count}/{len(queue)} tasks finished - processing failed tracks to wishlist")
elif all_tasks_started and no_active_workers and has_retrying_tasks:
print(f"🔄 [Batch Manager] Batch {batch_id}: all workers free but {retrying_count} tasks retrying - continuing monitoring")
elif all_tasks_started and no_active_workers:
# This used to incorrectly mark batch as complete!
print(f"📊 [Batch Manager] Batch {batch_id}: all workers free but only {finished_count}/{len(queue)} tasks finished - continuing monitoring")
if all_tasks_started and no_active_workers and all_tasks_truly_finished and not has_retrying_tasks:
# Check if this is an auto-initiated batch
is_auto_batch = batch.get('auto_initiated', False)
# FIXED: Ensure batch is not already marked as complete to prevent duplicate processing
if batch.get('phase') != 'complete':
# Mark batch as complete and process wishlist outside of lock to prevent deadlocks
batch['phase'] = 'complete'
# Add activity for batch completion
playlist_name = batch.get('playlist_name', 'Unknown Playlist')
successful_downloads = finished_count - len(batch.get('permanently_failed_tracks', []))
add_activity_item("", "Download Batch Complete", f"'{playlist_name}' - {successful_downloads} tracks downloaded", "Now")
# Update YouTube playlist phase to 'download_complete' if this is a YouTube playlist
playlist_id = batch.get('playlist_id')
if playlist_id and playlist_id.startswith('youtube_'):
url_hash = playlist_id.replace('youtube_', '')
if url_hash in youtube_playlist_states:
youtube_playlist_states[url_hash]['phase'] = 'download_complete'
print(f"📋 Updated YouTube playlist {url_hash} to download_complete phase")
# Update Tidal playlist phase to 'download_complete' if this is a Tidal playlist
if playlist_id and playlist_id.startswith('tidal_'):
tidal_playlist_id = playlist_id.replace('tidal_', '')
if tidal_playlist_id in tidal_discovery_states:
tidal_discovery_states[tidal_playlist_id]['phase'] = 'download_complete'
print(f"📋 Updated Tidal playlist {tidal_playlist_id} to download_complete phase")
print(f"🎉 [Batch Manager] Batch {batch_id} complete - stopping monitor")
download_monitor.stop_monitoring(batch_id)
# Process wishlist outside of the lock to prevent threading issues
if is_auto_batch:
# For auto-initiated batches, handle completion and schedule next cycle
missing_download_executor.submit(_process_failed_tracks_to_wishlist_exact_with_auto_completion, batch_id)
else:
# For manual batches, use standard wishlist processing
missing_download_executor.submit(_process_failed_tracks_to_wishlist_exact, batch_id)
return # Don't start next batch if we're done
# Start next downloads in queue
print(f"🔄 [Batch Manager] Starting next batch for {batch_id}")
_start_next_batch_of_downloads(batch_id)
def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json):
"""
A master worker that handles the entire missing tracks process:
1. Runs the analysis.
2. If missing tracks are found, it automatically queues them for download.
"""
try:
# PHASE 1: ANALYSIS
with tasks_lock:
if batch_id in download_batches:
download_batches[batch_id]['phase'] = 'analysis'
download_batches[batch_id]['analysis_total'] = len(tracks_json)
download_batches[batch_id]['analysis_processed'] = 0
from database.music_database import MusicDatabase
db = MusicDatabase()
active_server = config_manager.get_active_media_server()
analysis_results = []
for i, track_data in enumerate(tracks_json):
track_name = track_data.get('name', '')
artists = track_data.get('artists', [])
found, confidence = False, 0.0
for artist in artists:
# Handle both string format and Spotify API format {'name': 'Artist Name'}
if isinstance(artist, str):
artist_name = artist
elif isinstance(artist, dict) and 'name' in artist:
artist_name = artist['name']
else:
artist_name = str(artist)
db_track, track_confidence = db.check_track_exists(
track_name, artist_name, confidence_threshold=0.7, server_source=active_server
)
if db_track and track_confidence >= 0.7:
found, confidence = True, track_confidence
break
analysis_results.append({
'track_index': i, 'track': track_data, 'found': found, 'confidence': confidence
})
# WISHLIST REMOVAL: If track is found in database, check if it should be removed from wishlist
if found and confidence >= 0.7:
try:
_check_and_remove_track_from_wishlist_by_metadata(track_data)
except Exception as wishlist_error:
print(f"⚠️ [Analysis] Error checking wishlist removal for found track: {wishlist_error}")
with tasks_lock:
if batch_id in download_batches:
download_batches[batch_id]['analysis_processed'] = i + 1
# Store incremental results for live updates
download_batches[batch_id]['analysis_results'] = analysis_results.copy()
missing_tracks = [res for res in analysis_results if not res['found']]
with tasks_lock:
if batch_id in download_batches:
download_batches[batch_id]['analysis_results'] = analysis_results
# PHASE 2: TRANSITION TO DOWNLOAD (if necessary)
if not missing_tracks:
print(f"✅ Analysis for batch {batch_id} complete. No missing tracks.")
with tasks_lock:
if batch_id in download_batches:
download_batches[batch_id]['phase'] = 'complete'
# Update YouTube playlist phase to 'download_complete' if this is a YouTube playlist
if playlist_id.startswith('youtube_'):
url_hash = playlist_id.replace('youtube_', '')
if url_hash in youtube_playlist_states:
youtube_playlist_states[url_hash]['phase'] = 'download_complete'
print(f"📋 Updated YouTube playlist {url_hash} to download_complete phase (no missing tracks)")
# Update Tidal playlist phase to 'download_complete' if this is a Tidal playlist
if playlist_id.startswith('tidal_'):
tidal_playlist_id = playlist_id.replace('tidal_', '')
if tidal_playlist_id in tidal_discovery_states:
tidal_discovery_states[tidal_playlist_id]['phase'] = 'download_complete'
print(f"📋 Updated Tidal playlist {tidal_playlist_id} to download_complete phase (no missing tracks)")
return
print(f" transitioning batch {batch_id} to download phase with {len(missing_tracks)} tracks.")
with tasks_lock:
if batch_id not in download_batches: return
download_batches[batch_id]['phase'] = 'downloading'
for res in missing_tracks:
task_id = str(uuid.uuid4())
download_tasks[task_id] = {
'status': 'pending', 'track_info': res['track'],
'playlist_id': playlist_id, 'batch_id': batch_id,
'track_index': res['track_index'], 'retry_count': 0,
'cached_candidates': [], 'used_sources': set(),
'status_change_time': time.time(),
'metadata_enhanced': False
}
download_batches[batch_id]['queue'].append(task_id)
download_monitor.start_monitoring(batch_id)
_start_next_batch_of_downloads(batch_id)
except Exception as e:
print(f"❌ Master worker for batch {batch_id} failed: {e}")
import traceback
traceback.print_exc()
with tasks_lock:
if batch_id in download_batches:
download_batches[batch_id]['phase'] = 'error'
download_batches[batch_id]['error'] = str(e)
# Reset YouTube playlist phase to 'discovered' if this is a YouTube playlist on error
if playlist_id.startswith('youtube_'):
url_hash = playlist_id.replace('youtube_', '')
if url_hash in youtube_playlist_states:
youtube_playlist_states[url_hash]['phase'] = 'discovered'
print(f"📋 Reset YouTube playlist {url_hash} to discovered phase (error)")
def _run_post_processing_worker(task_id, batch_id):
"""
NEW VERIFICATION WORKFLOW: Post-processing worker that only sets 'completed' status
after successful file verification and processing. This matches sync.py's reliability.
"""
try:
print(f"🔧 [Post-Processing] Starting verification for task {task_id}")
# Retrieve task details from global state
with tasks_lock:
if task_id not in download_tasks:
print(f"❌ [Post-Processing] Task {task_id} not found in download_tasks")
return
task = download_tasks[task_id].copy()
# Check if task was cancelled during post-processing
if task['status'] == 'cancelled':
print(f"❌ [Post-Processing] Task {task_id} was cancelled, skipping verification")
return
# Extract file information for verification
track_info = task.get('track_info', {})
task_filename = task.get('filename') or track_info.get('filename')
task_username = task.get('username') or track_info.get('username')
if not task_filename or not task_username:
print(f"❌ [Post-Processing] Missing filename or username for task {task_id}")
with tasks_lock:
if task_id in download_tasks:
download_tasks[task_id]['status'] = 'failed'
_on_download_completed(batch_id, task_id, success=False)
return
download_dir = docker_resolve_path(config_manager.get('soulseek.download_path', './downloads'))
transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './transfer'))
# Try to get context for generating the correct final filename
task_basename = extract_filename(task_filename)
context_key = f"{task_username}::{task_basename}"
expected_final_filename = None
print(f"🔍 [Post-Processing] Looking up context with key: {context_key}")
with matched_context_lock:
context = matched_downloads_context.get(context_key)
# Debug: Show all available context keys
available_keys = list(matched_downloads_context.keys())
print(f"🔍 [Post-Processing] Available context keys: {available_keys[:10]}...") # Show first 10 keys
if context:
print(f"✅ [Post-Processing] Found context for key: {context_key}")
try:
original_search = context.get("original_search_result", {})
print(f"🔍 [Post-Processing] original_search keys: {list(original_search.keys())}")
spotify_clean_title = original_search.get('spotify_clean_title')
track_number = original_search.get('track_number')
print(f"🔍 [Post-Processing] spotify_clean_title: '{spotify_clean_title}', track_number: {track_number}")
if spotify_clean_title and track_number:
# Generate expected final filename that stream processor would create
# Pattern: f"{track_number:02d} - {clean_title}.flac"
sanitized_title = spotify_clean_title.replace('/', '_').replace('\\', '_').replace(':', '_').replace('*', '_').replace('?', '_').replace('"', '_').replace('<', '_').replace('>', '_').replace('|', '_')
expected_final_filename = f"{track_number:02d} - {sanitized_title}.flac"
print(f"🎯 [Post-Processing] Generated expected final filename: {expected_final_filename}")
else:
print(f"❌ [Post-Processing] Missing required data - spotify_clean_title: {bool(spotify_clean_title)}, track_number: {bool(track_number)}")
except Exception as e:
print(f"⚠️ [Post-Processing] Error generating expected filename: {e}")
import traceback
traceback.print_exc()
else:
print(f"❌ [Post-Processing] No context found for key: {context_key}")
# Try fuzzy matching with similar keys containing the filename
similar_keys = [k for k in matched_downloads_context.keys() if task_basename in k]
if similar_keys:
# Use the first similar key found
fuzzy_key = similar_keys[0]
context = matched_downloads_context.get(fuzzy_key)
print(f"✅ [Post-Processing] Found context using fuzzy key matching: {fuzzy_key}")
# Generate expected final filename using the found context
try:
original_search = context.get("original_search_result", {})
print(f"🔍 [Post-Processing] fuzzy context original_search keys: {list(original_search.keys())}")
spotify_clean_title = original_search.get('spotify_clean_title')
track_number = original_search.get('track_number')
print(f"🔍 [Post-Processing] fuzzy context spotify_clean_title: '{spotify_clean_title}', track_number: {track_number}")
if spotify_clean_title and track_number:
# Generate expected final filename that stream processor would create
# Pattern: f"{track_number:02d} - {clean_title}.flac"
sanitized_title = spotify_clean_title.replace('/', '_').replace('\\', '_').replace(':', '_').replace('*', '_').replace('?', '_').replace('"', '_').replace('<', '_').replace('>', '_').replace('|', '_')
expected_final_filename = f"{track_number:02d} - {sanitized_title}.flac"
print(f"🎯 [Post-Processing] Generated expected final filename from fuzzy match: {expected_final_filename}")
else:
print(f"❌ [Post-Processing] Missing required data from fuzzy match - spotify_clean_title: {bool(spotify_clean_title)}, track_number: {bool(track_number)}")
except Exception as e:
print(f"⚠️ [Post-Processing] Error generating expected filename from fuzzy match: {e}")
import traceback
traceback.print_exc()
else:
print(f"🔍 [Post-Processing] No similar keys found containing '{task_basename}'")
# Show a sample of what keys actually exist for debugging
sample_keys = list(matched_downloads_context.keys())[:5]
print(f"🔍 [Post-Processing] Sample of existing keys: {sample_keys}")
# RESILIENT FILE-FINDING LOOP: Try up to 3 times with delays
found_file = None
file_location = None
for retry_count in range(3):
print(f"🔍 [Post-Processing] Attempt {retry_count + 1}/3 to find file")
print(f"🔍 [Post-Processing] Original filename: {task_basename}")
if expected_final_filename:
print(f"🔍 [Post-Processing] Expected final filename: {expected_final_filename}")
else:
print(f"⚠️ [Post-Processing] No expected final filename available")
# Strategy 1: Try with original filename in both downloads and transfer
print(f"🔍 [Post-Processing] Strategy 1: Searching with original filename...")
found_file, file_location = _find_completed_file_robust(download_dir, task_filename, transfer_dir)
if found_file:
print(f"✅ [Post-Processing] Strategy 1 SUCCESS: Found file with original filename in {file_location}: {found_file}")
else:
print(f"❌ [Post-Processing] Strategy 1 FAILED: Original filename not found in either location")
# Strategy 2: If not found and we have an expected final filename, try that in transfer folder
if not found_file and expected_final_filename:
print(f"🔍 [Post-Processing] Strategy 2: Searching transfer folder with expected final filename...")
found_result = _find_completed_file_robust(transfer_dir, expected_final_filename)
if found_result and found_result[0]:
found_file, file_location = found_result[0], 'transfer'
print(f"✅ [Post-Processing] Strategy 2 SUCCESS: Found file with expected final filename: {found_file}")
else:
print(f"❌ [Post-Processing] Strategy 2 FAILED: Expected final filename not found in transfer folder")
elif not expected_final_filename:
print(f"⏭️ [Post-Processing] Strategy 2 SKIPPED: No expected final filename available")
if found_file:
print(f"🎯 [Post-Processing] FILE FOUND after {retry_count + 1} attempts in {file_location}: {found_file}")
break
else:
print(f"❌ [Post-Processing] All search strategies failed on attempt {retry_count + 1}/3")
if retry_count < 2: # Don't sleep on final attempt
print(f"⏳ [Post-Processing] Waiting 3 seconds before next attempt...")
time.sleep(3)
if not found_file:
print(f"❌ [Post-Processing] File not found on disk after 3 attempts: {os.path.basename(task_filename)}")
with tasks_lock:
if task_id in download_tasks:
download_tasks[task_id]['status'] = 'failed'
download_tasks[task_id]['error_message'] = "File not found on disk after download completed."
_on_download_completed(batch_id, task_id, success=False)
return
# Handle file found in transfer folder - already completed by stream processor
if file_location == 'transfer':
print(f"🎯 [Post-Processing] File found in transfer folder - already completed by stream processor: {found_file}")
# Check if metadata enhancement was completed
metadata_enhanced = False
with tasks_lock:
if task_id in download_tasks:
metadata_enhanced = download_tasks[task_id].get('metadata_enhanced', False)
if not metadata_enhanced:
print(f"⚠️ [Post-Processing] File in transfer folder missing metadata enhancement - completing now")
# Attempt to complete metadata enhancement using context
if context and expected_final_filename:
try:
# Extract required data from context
original_search = context.get("original_search_result", {})
spotify_artist = context.get("spotify_artist")
spotify_album = context.get("spotify_album")
if spotify_artist and spotify_album:
# CRITICAL FIX: Create album_info dict with proper structure for metadata enhancement
# This must match the format used in main stream processor to ensure consistency
# Extract track number from context (should be available from fuzzy match)
original_search = context.get("original_search_result", {})
track_number = original_search.get('track_number', 1)
# If no track number in context, extract from filename
if track_number == 1 and found_file:
print(f"⚠️ [Verification] No track_number in context, extracting from filename: {os.path.basename(found_file)}")
track_number = _extract_track_number_from_filename(found_file)
print(f" -> Extracted track number: {track_number}")
# Ensure track_number is valid
if not isinstance(track_number, int) or track_number < 1:
print(f"⚠️ [Verification] Invalid track number ({track_number}), defaulting to 1")
track_number = 1
# Get clean track name
clean_track_name = (original_search.get('spotify_clean_title') or
original_search.get('title', 'Unknown Track'))
album_info = {
'is_album': True, # CRITICAL: Mark as album track
'album_name': spotify_album.get('name', 'Unknown Album'), # CORRECT KEY
'track_number': track_number, # CORRECTED TRACK NUMBER
'clean_track_name': clean_track_name,
'album_image_url': spotify_album.get('images', [{}])[0].get('url') if spotify_album.get('images') else None,
'confidence': 0.9,
'source': 'verification_worker_corrected'
}
print(f"🎯 [Verification] Created proper album_info - track_number: {track_number}, album: {spotify_album.get('name')}")
print(f"🎵 [Post-Processing] Attempting metadata enhancement for: {found_file}")
enhancement_success = _enhance_file_metadata(found_file, context, spotify_artist, album_info)
if enhancement_success:
with tasks_lock:
if task_id in download_tasks:
download_tasks[task_id]['metadata_enhanced'] = True
print(f"✅ [Post-Processing] Successfully completed metadata enhancement for: {os.path.basename(found_file)}")
else:
print(f"⚠️ [Post-Processing] Metadata enhancement failed for: {os.path.basename(found_file)}")
else:
print(f"⚠️ [Post-Processing] Missing spotify_artist or spotify_album in context")
except Exception as enhancement_error:
print(f"❌ [Post-Processing] Error during metadata enhancement: {enhancement_error}")
else:
print(f"⚠️ [Post-Processing] Cannot complete metadata enhancement - missing context or expected filename")
else:
print(f"✅ [Post-Processing] File already has metadata enhancement completed")
with tasks_lock:
if task_id in download_tasks:
download_tasks[task_id]['status'] = 'completed'
# Clean up context now that both stream processor and verification worker are done
with matched_context_lock:
if context_key in matched_downloads_context:
del matched_downloads_context[context_key]
print(f"🗑️ [Verification] Cleaned up context after successful verification: {context_key}")
_on_download_completed(batch_id, task_id, success=True)
return
# File found in downloads folder - attempt post-processing
try:
# Create context for post-processing (similar to existing matched download logic)
context_key = f"{task_username}::{task_basename}"
# Check if this download has matched context for post-processing
with matched_context_lock:
context = matched_downloads_context.get(context_key)
if context:
print(f"🎯 [Post-Processing] Found matched context, running full post-processing for: {context_key}")
# Run the existing post-processing logic with verification
_post_process_matched_download_with_verification(context_key, context, found_file, task_id, batch_id)
else:
# No matched context - just mark as completed since file exists
print(f"📁 [Post-Processing] No matched context, marking as completed: {os.path.basename(found_file)}")
with tasks_lock:
if task_id in download_tasks:
download_tasks[task_id]['status'] = 'completed'
# Clean up context if it exists (might be leftover from stream processor)
with matched_context_lock:
if context_key in matched_downloads_context:
del matched_downloads_context[context_key]
print(f"🗑️ [Verification] Cleaned up leftover context: {context_key}")
# Call completion callback since there's no other post-processing to handle it
_on_download_completed(batch_id, task_id, success=True)
except Exception as processing_error:
print(f"❌ [Post-Processing] Processing failed for task {task_id}: {processing_error}")
with tasks_lock:
if task_id in download_tasks:
download_tasks[task_id]['status'] = 'failed'
download_tasks[task_id]['error_message'] = f"Post-processing failed: {str(processing_error)}"
_on_download_completed(batch_id, task_id, success=False)
except Exception as e:
print(f"❌ [Post-Processing] Critical error in post-processing worker for task {task_id}: {e}")
with tasks_lock:
if task_id in download_tasks:
download_tasks[task_id]['status'] = 'failed'
download_tasks[task_id]['error_message'] = f"Critical post-processing error: {str(e)}"
_on_download_completed(batch_id, task_id, success=False)
def _download_track_worker(task_id, batch_id=None):
"""
Enhanced download worker that matches the GUI's exact retry logic.
Implements sequential query retry, fallback candidates, and download failure retry.
"""
try:
# Retrieve task details from global state
with tasks_lock:
if task_id not in download_tasks:
print(f"❌ [Modal Worker] Task {task_id} not found in download_tasks")
return
task = download_tasks[task_id].copy()
# Cancellation Checkpoint 1: Before doing anything
with tasks_lock:
if task_id not in download_tasks:
print(f"❌ [Modal Worker] Task {task_id} was deleted before starting")
return
if download_tasks[task_id]['status'] == 'cancelled':
print(f"❌ [Modal Worker] Task {task_id} cancelled before starting")
# V2 FIX: Don't call _on_download_completed for cancelled V2 tasks
# V2 system handles worker slot freeing in atomic cancel function
task_playlist_id = download_tasks[task_id].get('playlist_id')
if task_playlist_id:
print(f"⏭️ [Modal Worker] V2 task {task_id} cancelled - worker slot already freed by V2 system")
return # V2 system already handled worker slot management
elif batch_id:
# Legacy system - use old completion callback
print(f"⏭️ [Modal Worker] Legacy task {task_id} cancelled - using legacy completion callback")
_on_download_completed(batch_id, task_id, success=False)
return
track_data = task['track_info']
track_name = track_data.get('name', 'Unknown Track')
print(f"🎯 [Modal Worker] Task {task_id} starting search for track: '{track_name}'")
# Recreate a SpotifyTrack object for the matching engine
# Handle both string format and Spotify API format for artists
raw_artists = track_data.get('artists', [])
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))
# Handle album field - extract name if it's a dictionary
raw_album = track_data.get('album', '')
if isinstance(raw_album, dict) and 'name' in raw_album:
album_name = raw_album['name']
elif isinstance(raw_album, str):
album_name = raw_album
else:
album_name = str(raw_album)
track = SpotifyTrack(
id=track_data.get('id', ''),
name=track_data.get('name', ''),
artists=processed_artists,
album=album_name,
duration_ms=track_data.get('duration_ms', 0),
popularity=track_data.get('popularity', 0)
)
print(f"📥 [Modal Worker] Starting download task for: {track.name} by {track.artists[0] if track.artists else 'Unknown'}")
# Initialize task state tracking (like GUI's parallel_search_tracking)
with tasks_lock:
if task_id in download_tasks:
download_tasks[task_id]['status'] = 'searching' # Now actively being processed
download_tasks[task_id]['current_query_index'] = 0
download_tasks[task_id]['current_candidate_index'] = 0
download_tasks[task_id]['retry_count'] = 0
download_tasks[task_id]['candidates'] = []
download_tasks[task_id]['used_sources'] = set()
# 1. Generate multiple search queries (like GUI's generate_smart_search_queries)
artist_name = track.artists[0] if track.artists else None
track_name = track.name
# Start with matching engine queries
search_queries = matching_engine.generate_download_queries(track)
# Add legacy fallback queries (like GUI does)
legacy_queries = []
if artist_name:
# Add first word of artist approach (legacy compatibility)
artist_words = artist_name.split()
if artist_words:
first_word = artist_words[0]
if first_word.lower() == 'the' and len(artist_words) > 1:
first_word = artist_words[1]
if len(first_word) > 1:
legacy_queries.append(f"{track_name} {first_word}".strip())
# Add track-only query
if track_name.strip():
legacy_queries.append(track_name.strip())
# Add traditional cleaned queries
cleaned_name = re.sub(r'\s*\([^)]*\)', '', track_name).strip()
cleaned_name = re.sub(r'\s*\[[^\]]*\]', '', cleaned_name).strip()
if cleaned_name and cleaned_name.lower() != track_name.lower():
legacy_queries.append(cleaned_name.strip())
# Combine enhanced queries with legacy fallbacks
all_queries = search_queries + legacy_queries
# Remove duplicates while preserving order
unique_queries = []
seen = set()
for query in all_queries:
if query and query.lower() not in seen:
unique_queries.append(query)
seen.add(query.lower())
search_queries = unique_queries
print(f"🔍 [Modal Worker] Generated {len(search_queries)} smart search queries for '{track.name}': {search_queries}")
print(f"🔍 [Modal Worker] About to start search loop for task {task_id} (track: '{track.name}')")
# 2. Sequential Query Search (matches GUI's start_search_worker_parallel logic)
for query_index, query in enumerate(search_queries):
# Cancellation check before each query
with tasks_lock:
if task_id not in download_tasks:
print(f"❌ [Modal Worker] Task {task_id} was deleted during query {query_index + 1}")
return
if download_tasks[task_id]['status'] == 'cancelled':
print(f"❌ [Modal Worker] Task {task_id} cancelled during query {query_index + 1}")
# Don't call _on_download_completed for cancelled tasks as it can stop monitoring
return
download_tasks[task_id]['current_query_index'] = query_index
print(f"🔍 [Modal Worker] Query {query_index + 1}/{len(search_queries)}: '{query}'")
print(f"🔍 [DEBUG] About to call soulseek search for task {task_id}")
try:
# Perform search with timeout
tracks_result, _ = asyncio.run(soulseek_client.search(query, timeout=30))
print(f"🔍 [DEBUG] Search completed for task {task_id}, got {len(tracks_result) if tracks_result else 0} results")
# CRITICAL: Check cancellation immediately after search returns
with tasks_lock:
if task_id not in download_tasks:
print(f"❌ [Modal Worker] Task {task_id} was deleted after search returned")
return
if download_tasks[task_id]['status'] == 'cancelled':
print(f"❌ [Modal Worker] Task {task_id} cancelled after search returned - ignoring results")
# Don't call _on_download_completed for cancelled tasks as it can stop monitoring
# The cancellation endpoint already handles batch management properly
return
if tracks_result:
# Validate candidates using GUI's get_valid_candidates logic
candidates = get_valid_candidates(tracks_result, track, query)
if candidates:
print(f"✅ [Modal Worker] Found {len(candidates)} valid candidates for query '{query}'")
# CRITICAL: Check cancellation before processing candidates
with tasks_lock:
if task_id not in download_tasks:
print(f"❌ [Modal Worker] Task {task_id} was deleted before processing candidates")
return
if download_tasks[task_id]['status'] == 'cancelled':
print(f"❌ [Modal Worker] Task {task_id} cancelled before processing candidates")
# Don't call _on_download_completed for cancelled tasks as it can stop monitoring
return
# Store candidates for retry fallback (like GUI)
download_tasks[task_id]['cached_candidates'] = candidates
# Try to download with these candidates
success = _attempt_download_with_candidates(task_id, candidates, track, batch_id)
if success:
# Download initiated successfully - let the download monitoring system handle completion
if batch_id:
print(f"✅ [Modal Worker] Download initiated successfully for task {task_id} - monitoring will handle completion")
return # Success, exit the worker
except Exception as e:
print(f"⚠️ [Modal Worker] Search failed for query '{query}': {e}")
continue
# If we get here, all search queries failed
print(f"❌ [Modal Worker] No valid candidates found for '{track.name}' after trying all {len(search_queries)} queries.")
with tasks_lock:
if task_id in download_tasks:
download_tasks[task_id]['status'] = 'failed'
# Notify batch manager that this task completed (failed) - THREAD SAFE
if batch_id:
try:
_on_download_completed(batch_id, task_id, success=False)
except Exception as completion_error:
print(f"❌ Error in batch completion callback for {task_id}: {completion_error}")
except Exception as e:
import traceback
track_name_safe = locals().get('track_name', 'unknown') # Safe fallback for track_name
print(f"❌ CRITICAL ERROR in download task for '{track_name_safe}' (task_id: {task_id}): {e}")
traceback.print_exc()
# Update task status safely with timeout
try:
lock_acquired = tasks_lock.acquire(timeout=2.0)
if lock_acquired:
try:
if task_id in download_tasks:
download_tasks[task_id]['status'] = 'failed'
print(f"🔧 [Exception Recovery] Set task {task_id} status to 'failed'")
finally:
tasks_lock.release()
else:
print(f"⚠️ [Exception Recovery] Could not acquire lock to update task {task_id} status")
except Exception as status_error:
print(f"❌ Error updating task status in exception handler: {status_error}")
# Notify batch manager that this task completed (failed) - THREAD SAFE with RECOVERY
if batch_id:
try:
_on_download_completed(batch_id, task_id, success=False)
print(f"✅ [Exception Recovery] Successfully freed worker slot for task {task_id}")
except Exception as completion_error:
print(f"❌ [Exception Recovery] Error in batch completion callback for {task_id}: {completion_error}")
# CRITICAL: If batch completion fails, we need to manually recover the worker slot
try:
print(f"🚨 [Exception Recovery] Attempting manual worker slot recovery for batch {batch_id}")
_recover_worker_slot(batch_id, task_id)
except Exception as recovery_error:
print(f"💀 [Exception Recovery] FATAL: Could not recover worker slot: {recovery_error}")
def _attempt_download_with_candidates(task_id, candidates, track, batch_id=None):
"""
Attempts to download with fallback candidate logic (matches GUI's retry_parallel_download_with_fallback).
Returns True if successful, False if all candidates fail.
"""
# Sort candidates by confidence (best first)
candidates.sort(key=lambda r: r.confidence, reverse=True)
with tasks_lock:
task = download_tasks.get(task_id)
if not task:
return False
used_sources = task.get('used_sources', set())
# Try each candidate until one succeeds (like GUI's fallback logic)
for candidate_index, candidate in enumerate(candidates):
# Check cancellation before each attempt
with tasks_lock:
if task_id not in download_tasks:
print(f"❌ [Modal Worker] Task {task_id} was deleted during candidate {candidate_index + 1}")
return False
if download_tasks[task_id]['status'] == 'cancelled':
print(f"❌ [Modal Worker] Task {task_id} cancelled during candidate {candidate_index + 1}")
# Don't call _on_download_completed for cancelled tasks as it can stop monitoring
return False
download_tasks[task_id]['current_candidate_index'] = candidate_index
# Create source key to avoid duplicate attempts (like GUI)
source_key = f"{candidate.username}_{candidate.filename}"
if source_key in used_sources:
print(f"⏭️ [Modal Worker] Skipping already tried source: {source_key}")
continue
# CRITICAL: Add source to used_sources IMMEDIATELY to prevent race conditions
# This must happen BEFORE starting download to prevent multiple retries from picking same source
with tasks_lock:
if task_id in download_tasks:
download_tasks[task_id]['used_sources'].add(source_key)
print(f"🚫 [Modal Worker] Marked source as used before download attempt: {source_key}")
print(f"🎯 [Modal Worker] Trying candidate {candidate_index + 1}/{len(candidates)}: {candidate.filename} (Confidence: {candidate.confidence:.2f})")
try:
# Update task status to downloading
_update_task_status(task_id, 'downloading')
# Prepare download (using existing infrastructure)
spotify_artist_context = {'id': 'from_sync_modal', 'name': track.artists[0] if track.artists else 'Unknown', 'genres': []}
spotify_album_context = {'id': 'from_sync_modal', 'name': track.album, 'release_date': '', 'image_url': None}
download_payload = candidate.__dict__
username = download_payload.get('username')
filename = download_payload.get('filename')
size = download_payload.get('size', 0)
if not username or not filename:
print(f"❌ [Modal Worker] Invalid candidate data: missing username or filename")
continue
# PROTECTION: Check if there's already an active download for this task
current_download_id = None
with tasks_lock:
if task_id in download_tasks:
current_download_id = download_tasks[task_id].get('download_id')
if current_download_id:
print(f"⚠️ [Modal Worker] Task {task_id} already has active download {current_download_id} - skipping new download attempt")
print(f"🔄 [Modal Worker] This prevents race condition where multiple retries start overlapping downloads")
continue
# Initiate download
print(f"🚀 [Modal Worker] Starting download: {username} / {os.path.basename(filename)}")
download_id = asyncio.run(soulseek_client.download(username, filename, size))
if download_id:
# Store context for post-processing with complete Spotify metadata (GUI PARITY)
context_key = f"{username}::{filename}"
with matched_context_lock:
# Create WebUI equivalent of GUI's SpotifyBasedSearchResult data structure
enhanced_payload = download_payload.copy()
# Extract clean Spotify metadata from track object (same as GUI)
has_clean_spotify_data = track and hasattr(track, 'name') and hasattr(track, 'album')
if has_clean_spotify_data:
# Use clean Spotify metadata (matches GUI's SpotifyBasedSearchResult)
enhanced_payload['spotify_clean_title'] = track.name
enhanced_payload['spotify_clean_album'] = track.album
enhanced_payload['spotify_clean_artist'] = track.artists[0] if track.artists else enhanced_payload.get('artist', '')
print(f"✨ [Context] Using clean Spotify metadata - Album: '{track.album}', Title: '{track.name}'")
# CRITICAL FIX: Get track_number from Spotify API like GUI does
if hasattr(track, 'id') and track.id:
try:
detailed_track = spotify_client.get_track_details(track.id)
if detailed_track and 'track_number' in detailed_track:
enhanced_payload['track_number'] = detailed_track['track_number']
print(f"🔢 [Context] Added Spotify track_number: {detailed_track['track_number']}")
else:
enhanced_payload['track_number'] = 1
print(f"⚠️ [Context] No track_number in detailed_track, using fallback: 1")
except Exception as e:
enhanced_payload['track_number'] = 1
print(f"❌ [Context] Error getting track_number, using fallback: {e}")
else:
enhanced_payload['track_number'] = 1
print(f"⚠️ [Context] No track.id available, using fallback track_number: 1")
# Determine if this should be treated as album download based on clean data
is_album_context = (
track.album and
track.album.strip() and
track.album != "Unknown Album" and
track.album.lower() != track.name.lower() # Album different from track
)
else:
# Fallback to original data
enhanced_payload['spotify_clean_title'] = enhanced_payload.get('title', '')
enhanced_payload['spotify_clean_album'] = enhanced_payload.get('album', '')
enhanced_payload['spotify_clean_artist'] = enhanced_payload.get('artist', '')
enhanced_payload['track_number'] = 1 # Fallback when no clean Spotify data
is_album_context = False
print(f"⚠️ [Context] Using fallback data - no clean Spotify metadata available, track_number=1")
matched_downloads_context[context_key] = {
"spotify_artist": spotify_artist_context,
"spotify_album": spotify_album_context,
"original_search_result": enhanced_payload,
"is_album_download": is_album_context, # Critical fix: Use actual album context
"has_clean_spotify_data": has_clean_spotify_data, # Flag for post-processing
"task_id": task_id, # Add task_id for completion callbacks
"batch_id": batch_id # Add batch_id for completion callbacks
}
print(f"🎯 [Context] Set is_album_download: {is_album_context} (has clean data: {has_clean_spotify_data})")
# Update task with successful download info
with tasks_lock:
if task_id in download_tasks:
# PHASE 3: Final cancellation check after download started (GUI PARITY)
if download_tasks[task_id]['status'] == 'cancelled':
print(f"🚫 [Modal Worker] Task {task_id} cancelled after download {download_id} started - attempting to cancel download")
# Try to cancel the download immediately
try:
asyncio.run(soulseek_client.cancel_download(download_id, username, remove=True))
print(f"✅ Successfully cancelled active download {download_id}")
except Exception as cancel_error:
print(f"⚠️ Warning: Failed to cancel active download {download_id}: {cancel_error}")
# Free worker slot
if batch_id:
_on_download_completed(batch_id, task_id, success=False)
return False
# Store download information - use real download ID from soulseek_client
# CRITICAL FIX: Trust the download ID returned by soulseek_client.download()
download_tasks[task_id]['download_id'] = download_id
download_tasks[task_id]['username'] = username
download_tasks[task_id]['filename'] = filename
print(f"✅ [Modal Worker] Download started successfully for '{filename}'. Download ID: {download_id}")
return True # Success!
else:
print(f"❌ [Modal Worker] Failed to start download for '{filename}'")
# Reset status back to searching for next attempt
with tasks_lock:
if task_id in download_tasks:
download_tasks[task_id]['status'] = 'searching'
continue
except Exception as e:
print(f"❌ [Modal Worker] Error attempting download for '{candidate.filename}': {e}")
# Reset status back to searching for next attempt
with tasks_lock:
if task_id in download_tasks:
download_tasks[task_id]['status'] = 'searching'
continue
# All candidates failed
print(f"❌ [Modal Worker] All {len(candidates)} candidates failed for '{track.name}'")
return False
@app.route('/api/playlists/<playlist_id>/download_missing', methods=['POST'])
def start_playlist_missing_downloads(playlist_id):
"""
This endpoint receives the list of missing tracks and manages them with batch processing
like the GUI, maintaining exactly 3 concurrent downloads.
"""
data = request.get_json()
missing_tracks = data.get('missing_tracks', [])
if not missing_tracks:
return jsonify({"success": False, "error": "No missing tracks provided"}), 400
# Add activity for playlist download missing start
playlist_name = data.get('playlist_name', f'Playlist {playlist_id}')
add_activity_item("📥", "Missing Tracks Download Started", f"'{playlist_name}' - {len(missing_tracks)} tracks", "Now")
try:
batch_id = str(uuid.uuid4())
# Create task queue for this batch
task_queue = []
with tasks_lock:
# Initialize batch management
download_batches[batch_id] = {
'queue': [],
'active_count': 0,
'max_concurrent': 3,
'queue_index': 0,
# Track state management (replicating sync.py)
'permanently_failed_tracks': [],
'cancelled_tracks': set()
}
for i, track_entry in enumerate(missing_tracks):
task_id = str(uuid.uuid4())
# Extract track data and original track index from frontend
track_data = track_entry.get('track', track_entry) # Support both old and new format
original_track_index = track_entry.get('track_index', i) # Use original index or fallback to enumeration
download_tasks[task_id] = {
'status': 'pending',
'track_info': track_data,
'playlist_id': playlist_id,
'batch_id': batch_id,
'track_index': original_track_index, # Use original playlist track index
'download_id': None,
'username': None,
'filename': None,
# Retry-related fields (GUI parity)
'retry_count': 0,
'cached_candidates': [],
'used_sources': set(),
'status_change_time': time.time()
}
# Add to batch queue instead of submitting immediately
download_batches[batch_id]['queue'].append(task_id)
# Start background monitoring for timeouts and retries (GUI parity)
download_monitor.start_monitoring(batch_id)
# Start the first batch of downloads (up to 3)
_start_next_batch_of_downloads(batch_id)
return jsonify({"success": True, "batch_id": batch_id, "message": f"Queued {len(missing_tracks)} downloads for processing."})
except Exception as e:
print(f"❌ Error starting missing downloads: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/active-processes', methods=['GET'])
def get_active_processes():
"""
Returns all active processes for frontend rehydration:
- Download batch processes (Spotify playlists)
- YouTube discovery/sync processes (non-fresh phases)
"""
active_processes = []
# Add active download batch processes
with tasks_lock:
for batch_id, batch_data in download_batches.items():
if batch_data.get('phase') not in ['complete', 'error', 'cancelled']:
process_info = {
"type": "batch",
"playlist_id": batch_data.get('playlist_id'),
"playlist_name": batch_data.get('playlist_name'),
"batch_id": batch_id,
"phase": batch_data.get('phase')
}
# Enhanced wishlist information for better frontend state management
if batch_data.get('playlist_id') == 'wishlist':
process_info.update({
"auto_initiated": batch_data.get('auto_initiated', False),
"auto_processing_timestamp": batch_data.get('auto_processing_timestamp'),
"should_show_modal": True, # Wishlist processes should always be visible
"is_background_process": batch_data.get('auto_initiated', False)
})
# Add current auto-processing state for frontend awareness
with wishlist_timer_lock:
process_info["auto_processing_active"] = wishlist_auto_processing
active_processes.append(process_info)
# Add YouTube playlists in non-fresh phases for rehydration
for url_hash, state in youtube_playlist_states.items():
# Include playlists that have progressed beyond fresh phase
if state['phase'] != 'fresh':
active_processes.append({
"type": "youtube_playlist",
"url_hash": url_hash,
"url": state['url'],
"playlist_name": state['playlist']['name'],
"phase": state['phase'],
"status": state['status'],
"discovery_progress": state['discovery_progress'],
"spotify_matches": state['spotify_matches'],
"spotify_total": state['spotify_total'],
"converted_spotify_playlist_id": state.get('converted_spotify_playlist_id'),
"download_process_id": state.get('download_process_id') # batch_id for download modal rehydration
})
print(f"📊 Active processes check: {len([p for p in active_processes if p['type'] == 'batch'])} download batches, {len([p for p in active_processes if p['type'] == 'youtube_playlist'])} YouTube playlists")
return jsonify({"active_processes": active_processes})
def _build_batch_status_data(batch_id, batch, live_transfers_lookup):
"""
Helper function to build status data for a single batch.
Extracted from get_batch_download_status for reuse in batched endpoint.
"""
response_data = {
"phase": batch.get('phase', 'unknown'),
"error": batch.get('error'),
"auto_initiated": batch.get('auto_initiated', False)
}
if response_data["phase"] == 'analysis':
response_data['analysis_progress'] = {
'total': batch.get('analysis_total', 0),
'processed': batch.get('analysis_processed', 0)
}
response_data['analysis_results'] = batch.get('analysis_results', [])
elif response_data["phase"] in ['downloading', 'complete', 'error']:
response_data['analysis_results'] = batch.get('analysis_results', [])
batch_tasks = []
for task_id in batch.get('queue', []):
task = download_tasks.get(task_id)
if not task: continue
task_status = {
'task_id': task_id,
'track_index': task['track_index'],
'status': task['status'],
'track_info': task['track_info'],
'progress': 0,
# V2 SYSTEM: Add persistent state information
'cancel_requested': task.get('cancel_requested', False),
'cancel_timestamp': task.get('cancel_timestamp'),
'ui_state': task.get('ui_state', 'normal'), # normal|cancelling|cancelled
'playlist_id': task.get('playlist_id'), # For V2 system identification
}
task_filename = task.get('filename') or task['track_info'].get('filename')
task_username = task.get('username') or task['track_info'].get('username')
if task_filename and task_username:
lookup_key = f"{task_username}::{extract_filename(task_filename)}"
if lookup_key in live_transfers_lookup:
live_info = live_transfers_lookup[lookup_key]
state_str = live_info.get('state', 'Unknown')
# Don't override tasks that are already in terminal states or post-processing
if task['status'] not in ['completed', 'failed', 'cancelled', 'post_processing']:
# SYNC.PY PARITY: Prioritized state checking (Errored/Cancelled before Completed)
# This prevents "Completed, Errored" states from being marked as completed
if 'Cancelled' in state_str or 'Canceled' in state_str:
task_status['status'] = 'cancelled'
task['status'] = 'cancelled'
elif 'Failed' in state_str or 'Errored' in state_str:
# UNIFIED ERROR HANDLING: Let monitor handle errors for consistency
# Monitor will detect errored state and trigger retry within 5 seconds
print(f"🔍 Task {task_id} API shows error state: {state_str} - letting monitor handle retry")
# Keep task in current status (downloading/queued) so monitor can detect error
# Don't mark as failed here - let the unified retry system handle it
if task['status'] in ['searching', 'downloading', 'queued']:
task_status['status'] = task['status'] # Keep current status for monitor
else:
task_status['status'] = 'downloading' # Default to downloading for error detection
task['status'] = 'downloading'
elif 'Completed' in state_str or 'Succeeded' in state_str:
# NEW VERIFICATION WORKFLOW: Use intermediate post_processing status
# Only set this status once to prevent multiple worker submissions
if task['status'] != 'post_processing':
task_status['status'] = 'post_processing'
task['status'] = 'post_processing'
print(f"🔄 Task {task_id} API reports 'Succeeded' - starting post-processing verification")
# Submit post-processing worker to verify file and complete the task
missing_download_executor.submit(_run_post_processing_worker, task_id, batch_id)
else:
# FIXED: Always require verification workflow - no bypass for stream processed tasks
# Stream processing only handles metadata, not file verification
task_status['status'] = 'post_processing'
print(f"🔄 Task {task_id} waiting for verification worker to complete")
elif 'InProgress' in state_str:
task_status['status'] = 'downloading'
else:
task_status['status'] = 'queued'
task_status['progress'] = live_info.get('percentComplete', 0)
# For completed/post-processing tasks, keep appropriate progress
elif task['status'] == 'completed':
task_status['progress'] = 100
elif task['status'] == 'post_processing':
task_status['progress'] = 95 # Nearly complete, just verifying
else:
# If task is completed but not in live transfers, keep appropriate status
if task['status'] == 'completed':
task_status['progress'] = 100
elif task['status'] == 'post_processing':
task_status['progress'] = 95 # Nearly complete, just verifying
batch_tasks.append(task_status)
batch_tasks.sort(key=lambda x: x['track_index'])
response_data['tasks'] = batch_tasks
# CRITICAL: Add batch worker management metadata (was missing!)
# This is essential for client-side worker validation and prevents false desync warnings
response_data['active_count'] = batch.get('active_count', 0)
response_data['max_concurrent'] = batch.get('max_concurrent', 3)
# Add wishlist summary if batch is complete (matching sync.py behavior)
if response_data["phase"] == 'complete' and 'wishlist_summary' in batch:
response_data['wishlist_summary'] = batch['wishlist_summary']
return response_data
@app.route('/api/playlists/<batch_id>/download_status', methods=['GET'])
def get_batch_download_status(batch_id):
"""
Returns real-time status for a single batch.
Now uses shared helper function for consistency with batched endpoint.
"""
try:
# Use cached transfer data to reduce API calls with multiple concurrent modals
live_transfers_lookup = get_cached_transfer_data()
with tasks_lock:
if batch_id not in download_batches:
return jsonify({"error": "Batch not found"}), 404
batch = download_batches[batch_id]
response_data = _build_batch_status_data(batch_id, batch, live_transfers_lookup)
return jsonify(response_data)
except Exception as e:
import traceback
traceback.print_exc()
return jsonify({"error": str(e)}), 500
@app.route('/api/download_status/batch', methods=['GET'])
def get_batched_download_statuses():
"""
NEW: Returns status for multiple download batches in a single request.
Dramatically reduces API calls when multiple download modals are active.
Query params:
- batch_ids: Optional list of specific batch IDs to include
- If no batch_ids provided, returns all active batches
"""
try:
# Get optional batch ID filtering from query params
requested_batch_ids = request.args.getlist('batch_ids')
# Use shared cached transfer data - single lookup for all batches
live_transfers_lookup = get_cached_transfer_data()
response = {"batches": {}}
with tasks_lock:
# Determine which batches to include
if requested_batch_ids:
# Filter to only requested batch IDs that exist
target_batches = {
bid: batch for bid, batch in download_batches.items()
if bid in requested_batch_ids
}
else:
# Return all active batches
target_batches = download_batches.copy()
# Build status data for each batch using shared helper
for batch_id, batch in target_batches.items():
try:
response["batches"][batch_id] = _build_batch_status_data(
batch_id, batch, live_transfers_lookup
)
except Exception as batch_error:
# Don't fail entire request if one batch has issues
print(f"❌ Error processing batch {batch_id}: {batch_error}")
response["batches"][batch_id] = {"error": str(batch_error)}
# Add metadata for debugging/monitoring
response["metadata"] = {
"total_batches": len(response["batches"]),
"requested_batch_ids": requested_batch_ids,
"timestamp": time.time()
}
# ENHANCED: Add comprehensive debug info for worker tracking
debug_info = {}
for batch_id, batch_status in response["batches"].items():
if "error" not in batch_status:
active_count = batch_status.get("active_count", 0)
max_concurrent = batch_status.get("max_concurrent", 3)
task_count = len(batch_status.get("tasks", []))
active_tasks = len([t for t in batch_status.get("tasks", []) if t.get("status") in ['searching', 'downloading', 'queued']])
debug_info[batch_id] = {
"reported_active": active_count,
"actual_active_tasks": active_tasks,
"max_concurrent": max_concurrent,
"total_tasks": task_count,
"worker_discrepancy": active_count != active_tasks
}
response["debug_info"] = debug_info
print(f"📊 [Batched Status] Returning status for {len(response['batches'])} batches")
# Log worker discrepancies for debugging
discrepancies = [bid for bid, info in debug_info.items() if info.get("worker_discrepancy")]
if discrepancies:
print(f"⚠️ [Batched Status] Worker count discrepancies in batches: {discrepancies}")
return jsonify(response)
except Exception as e:
import traceback
traceback.print_exc()
return jsonify({"error": str(e)}), 500
@app.route('/api/downloads/cancel_task', methods=['POST'])
def cancel_download_task():
"""
Cancels a single, specific download task.
This version is now identical to the GUI, adding the cancelled track to
the wishlist for future automatic retries.
"""
data = request.get_json()
task_id = data.get('task_id')
if not task_id:
return jsonify({"success": False, "error": "Missing task_id"}), 400
try:
with tasks_lock:
if task_id not in download_tasks:
return jsonify({"success": False, "error": "Task not found"}), 404
task = download_tasks[task_id]
# Log current task state for debugging
current_status = task.get('status', 'unknown')
download_id = task.get('download_id')
username = task.get('username')
print(f"🔍 [Cancel Debug] Task {task_id} - Current status: '{current_status}', download_id: {download_id}, username: {username}")
# Immediately mark as cancelled to prevent race conditions
task['status'] = 'cancelled'
# IMPROVED WORKER SLOT MANAGEMENT: Use batch state validation instead of task status
batch_id = task.get('batch_id')
worker_slot_freed = False
if batch_id:
try:
# Check if we need to free a worker slot by examining batch state
with tasks_lock:
if batch_id in download_batches:
batch = download_batches[batch_id]
active_count = batch['active_count']
# Free worker slot if there are active workers and task was actively running
# This is more reliable than checking task status which can be inconsistent
if active_count > 0 and current_status in ['pending', 'searching', 'downloading', 'queued']:
print(f"🔄 [Cancel] Task {task_id} (status: {current_status}) - freeing worker slot for batch {batch_id}")
print(f"🔄 [Cancel] Active count before: {active_count}")
# Use the completion callback with error handling
_on_download_completed(batch_id, task_id, success=False)
worker_slot_freed = True
# Verify slot was actually freed
new_active = download_batches[batch_id]['active_count']
print(f"🔄 [Cancel] Active count after: {new_active}")
elif active_count == 0:
print(f"🚫 [Cancel] Task {task_id} - no active workers to free")
else:
print(f"🚫 [Cancel] Task {task_id} (status: {current_status}) - not actively running, no slot to free")
else:
print(f"🚫 [Cancel] Task {task_id} - batch {batch_id} not found")
except Exception as slot_error:
print(f"❌ [Cancel] Error managing worker slot for {task_id}: {slot_error}")
# Attempt emergency recovery if normal completion failed
if not worker_slot_freed:
try:
print(f"🚨 [Cancel] Attempting emergency worker slot recovery")
_recover_worker_slot(batch_id, task_id)
except Exception as recovery_error:
print(f"💀 [Cancel] FATAL: Emergency recovery failed: {recovery_error}")
else:
print(f"🚫 [Cancel] Task {task_id} cancelled (no batch_id - likely already completed)")
# Optionally try to cancel the Soulseek download (don't block worker progression)
if download_id and username:
try:
# This is an async call, so we run it and wait
asyncio.run(soulseek_client.cancel_download(download_id, username, remove=True))
print(f"✅ Successfully cancelled Soulseek download {download_id} for task {task_id}")
except Exception as e:
print(f"⚠️ Warning: Failed to cancel download on slskd, but worker already moved on. Error: {e}")
### NEW LOGIC START: Add cancelled track to wishlist ###
try:
from core.wishlist_service import get_wishlist_service
wishlist_service = get_wishlist_service()
# The task dictionary contains all the necessary info
track_info = task.get('track_info', {})
# The wishlist service expects a dictionary with specific keys
# We need to properly format the artists to avoid nested structures
artists_data = track_info.get('artists', [])
formatted_artists = []
for artist in artists_data:
if isinstance(artist, str):
# Already a string, use as-is
formatted_artists.append({'name': artist})
elif isinstance(artist, dict):
# Check if it's already in the correct format
if 'name' in artist and isinstance(artist['name'], str):
# Already properly formatted
formatted_artists.append(artist)
elif 'name' in artist and isinstance(artist['name'], dict) and 'name' in artist['name']:
# Nested structure, extract the inner name
formatted_artists.append({'name': artist['name']['name']})
else:
# Fallback: convert to string
formatted_artists.append({'name': str(artist)})
else:
# Fallback for any other type
formatted_artists.append({'name': str(artist)})
spotify_track_data = {
'id': track_info.get('id'),
'name': track_info.get('name'),
'artists': formatted_artists,
'album': {'name': track_info.get('album')},
'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'
}
# Add to wishlist, treating cancellation as a failure
# Pass the spotify data directly instead of creating a fake Track object
success = wishlist_service.add_spotify_track_to_wishlist(
spotify_track_data=spotify_track_data,
failure_reason="Download cancelled by user",
source_type="playlist",
source_context=source_context
)
if success:
print(f"✅ Added cancelled track '{track_info.get('name')}' to wishlist.")
else:
print(f"❌ Failed to add cancelled track '{track_info.get('name')}' to wishlist.")
except Exception as e:
print(f"❌ CRITICAL ERROR adding cancelled track to wishlist: {e}")
### NEW LOGIC END ###
return jsonify({"success": True, "message": "Task cancelled and added to wishlist for retry."})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
# ===============================
# NEW ATOMIC CANCEL SYSTEM V2
# ===============================
def _find_task_by_playlist_track(playlist_id, track_index):
"""
Find task_id by playlist_id and track_index.
This enables the new v2 API to work without requiring task_id from frontend.
"""
for task_id, task in download_tasks.items():
if (task.get('playlist_id') == playlist_id and
task.get('track_index') == track_index):
return task_id, task
return None, None
def _atomic_cancel_task(playlist_id, track_index):
"""
Atomically cancel a single task with proper worker slot management.
This is the core of the new cancel system - everything in one transaction.
Returns: (success: bool, message: str, task_info: dict)
"""
try:
# Find the task to cancel
task_id, task = _find_task_by_playlist_track(playlist_id, track_index)
if not task_id:
return False, f"Task not found for playlist {playlist_id}, track {track_index}", None
# Check if already cancelled
if task.get('status') == 'cancelled':
return False, "Task already cancelled", {'task_id': task_id, 'status': 'cancelled'}
current_status = task.get('status', 'unknown')
original_status = current_status # Store original status before changing it
batch_id = task.get('batch_id')
print(f"🎯 [Atomic Cancel] Starting atomic cancel: playlist={playlist_id}, track={track_index}, task={task_id}, status={current_status}")
# Mark task as cancelled immediately (within same lock context)
task['status'] = 'cancelled'
task['cancel_requested'] = True
task['cancel_timestamp'] = __import__('time').time()
task['ui_state'] = 'cancelled'
# Ensure task has persistent identifiers for V2 system
if 'playlist_id' not in task:
task['playlist_id'] = playlist_id
# Handle worker slot management
worker_slot_freed = False
if batch_id and batch_id in download_batches:
batch = download_batches[batch_id]
active_count = batch['active_count']
# Free worker slot if task was consuming one
# More precise check: only free if task was actually running
if active_count > 0 and current_status in ['pending', 'searching', 'downloading', 'queued']:
print(f"🔄 [Atomic Cancel] Freeing worker slot for {task_id} (was {current_status})")
# CRITICAL: Direct worker slot management to prevent _on_download_completed race
old_active = batch['active_count']
batch['active_count'] = max(0, old_active - 1) # Prevent negative counts
worker_slot_freed = True
print(f"🔄 [Atomic Cancel] Worker count: {old_active}{batch['active_count']}")
# Try to start next task if available (still within lock)
if (batch['queue_index'] < len(batch['queue']) and
batch['active_count'] < batch['max_concurrent']):
print(f"🚀 [Atomic Cancel] Starting next task in queue")
# Call the existing function to start next downloads
# Note: This will be called outside the lock to prevent deadlock
else:
print(f"🚫 [Atomic Cancel] No next task to start (queue_index: {batch['queue_index']}/{len(batch['queue'])}, active: {batch['active_count']}/{batch['max_concurrent']})")
# Build result info
task_info = {
'task_id': task_id,
'status': 'cancelled',
'original_status': original_status, # Pass original status for slskd cancellation
'track_name': task.get('track_info', {}).get('name', 'Unknown'),
'playlist_id': playlist_id,
'track_index': track_index,
'worker_slot_freed': worker_slot_freed
}
print(f"✅ [Atomic Cancel] Successfully cancelled task {task_id}")
return True, "Task cancelled successfully", task_info
except Exception as e:
print(f"❌ [Atomic Cancel] Error in atomic cancel: {e}")
import traceback
traceback.print_exc()
return False, f"Internal error: {str(e)}", None
@app.route('/api/downloads/cancel_task_v2', methods=['POST'])
def cancel_task_v2():
"""
NEW ATOMIC CANCEL SYSTEM V2
Accepts playlist_id and track_index instead of task_id.
Performs atomic cancellation with proper worker slot management.
No race conditions, no dual state management.
"""
data = request.get_json()
playlist_id = data.get('playlist_id')
track_index = data.get('track_index')
if not playlist_id or track_index is None:
return jsonify({
"success": False,
"error": "Missing playlist_id or track_index"
}), 400
try:
# Everything in one atomic operation within the lock
with tasks_lock:
success, message, task_info = _atomic_cancel_task(playlist_id, track_index)
if not success:
return jsonify({"success": False, "error": message}), 400
# Handle post-cancel operations (outside the lock to prevent deadlock)
task_id = task_info['task_id']
task = download_tasks.get(task_id)
# Try to start next batch of downloads (this may start new workers)
if task and task.get('batch_id'):
batch_id = task['batch_id']
# Call existing function to manage batch progression
try:
_start_next_batch_of_downloads(batch_id)
except Exception as e:
print(f"⚠️ [Atomic Cancel] Warning: Could not start next downloads: {e}")
# CRITICAL: Check for batch completion after V2 cancel
# V2 system bypasses _on_download_completed, so we need to check completion manually
try:
_check_batch_completion_v2(batch_id)
except Exception as e:
print(f"⚠️ [Atomic Cancel] Warning: Could not check batch completion: {e}")
# Cancel Soulseek download if active (non-blocking)
if task:
download_id = task.get('download_id')
username = task.get('username')
current_status = task.get('status')
original_status = task_info.get('original_status', current_status) # Get original status from task_info
print(f"🔍 [Atomic Cancel] Task {task_id} state: status='{current_status}', original_status='{original_status}', download_id='{download_id}', username='{username}'")
print(f"🔍 [Atomic Cancel] Download ID type: {type(download_id)}, length: {len(str(download_id)) if download_id else 0}")
backslash = '\\'
print(f"🔍 [Atomic Cancel] Download ID looks like filename: {download_id and ('/' in str(download_id) or backslash in str(download_id))}")
if download_id and username:
# Always try to cancel in slskd - doesn't matter what status it was
# If it's not there or already done, the DELETE request will just fail harmlessly
try:
print(f"🚫 [Atomic Cancel] Attempting to cancel Soulseek download:")
print(f" Username: {username}")
print(f" Download ID: {download_id}")
print(f" Base URL: {soulseek_client.base_url}")
print(f" Expected URL: {soulseek_client.base_url}/transfers/downloads/{username}/{download_id}?remove=true")
# CRITICAL: Must use REAL download ID from slskd, not filename
success = False
real_download_id = None
# Step 1: Always search for real download ID first
print(f"🔍 [Atomic Cancel] Searching slskd transfers for real download ID")
try:
all_transfers = asyncio.run(soulseek_client._make_request('GET', 'transfers/downloads'))
if all_transfers:
# Look through transfers to find matching download
for user_data in all_transfers:
if user_data.get('username') == username:
for directory in user_data.get('directories', []):
for file_data in directory.get('files', []):
file_filename = file_data.get('filename', '')
# Match by filename (our download_id might be filename)
if (file_filename == download_id or
__import__('os').path.basename(file_filename) == __import__('os').path.basename(str(download_id))):
real_download_id = file_data.get('id')
print(f"🎯 [Atomic Cancel] Found real download ID: {real_download_id} for file: {file_filename}")
break
if real_download_id:
break
if real_download_id:
break
except Exception as search_error:
print(f"⚠️ [Atomic Cancel] Error searching transfers: {search_error}")
# Step 2: Try cancellation with real ID if found
if real_download_id:
print(f"🔄 [Atomic Cancel] Attempting cancel with real ID: {real_download_id}")
try:
# Use EXACT format from slskd web UI: DELETE /api/v0/transfers/downloads/{username}/{download_id}?remove=false
endpoint = f'transfers/downloads/{username}/{real_download_id}?remove=true'
print(f"🌐 [Atomic Cancel] Using slskd web UI format: {endpoint}")
response = asyncio.run(soulseek_client._make_request('DELETE', endpoint))
if response is not None:
print(f"✅ [Atomic Cancel] Successfully cancelled with slskd web UI format: {real_download_id}")
success = True
else:
print(f"⚠️ [Atomic Cancel] Web UI format failed, trying alternative formats")
# Fallback: Try without remove parameter
endpoint2 = f'transfers/downloads/{username}/{real_download_id}'
response2 = asyncio.run(soulseek_client._make_request('DELETE', endpoint2))
if response2 is not None:
print(f"✅ [Atomic Cancel] Successfully cancelled without remove param: {real_download_id}")
success = True
else:
# Final fallback: Try simple format (sync.py style)
endpoint3 = f'transfers/downloads/{real_download_id}'
response3 = asyncio.run(soulseek_client._make_request('DELETE', endpoint3))
if response3 is not None:
print(f"✅ [Atomic Cancel] Successfully cancelled with simple format: {real_download_id}")
success = True
else:
print(f"⚠️ [Atomic Cancel] All DELETE formats failed for real ID: {real_download_id}")
except Exception as cancel_error:
print(f"⚠️ [Atomic Cancel] Exception cancelling real ID {real_download_id}: {cancel_error}")
else:
print(f"⚠️ [Atomic Cancel] Could not find real download ID in slskd transfers")
print(f"🔄 [Atomic Cancel] This might be a pending download not yet in slskd - relying on status='cancelled' to prevent it")
# For pending downloads, the status='cancelled' will prevent them from starting
success = True # Consider this success since pending downloads are prevented
if not success:
print(f"❌ [Atomic Cancel] Failed to cancel download in slskd API")
except Exception as e:
print(f"⚠️ [Atomic Cancel] Exception cancelling Soulseek download {download_id}: {e}")
# Print more details about the error
import traceback
print(f"⚠️ [Atomic Cancel] Cancel error traceback: {traceback.format_exc()}")
else:
print(f" [Atomic Cancel] No download_id or username available - skipping slskd cancel")
# Add to wishlist (non-blocking, best effort)
try:
_add_cancelled_task_to_wishlist(task)
except Exception as e:
print(f"⚠️ [Atomic Cancel] Warning: Could not add to wishlist: {e}")
return jsonify({
"success": True,
"message": message,
"task_info": {
'task_id': task_info['task_id'],
'track_name': task_info['track_name'],
'status': 'cancelled'
}
})
except Exception as e:
print(f"❌ [Cancel V2] Unexpected error: {e}")
import traceback
traceback.print_exc()
return jsonify({"success": False, "error": str(e)}), 500
def _check_batch_completion_v2(batch_id):
"""
V2 SYSTEM: Check if batch is complete after worker slot changes.
This is needed because V2 atomic cancel bypasses _on_download_completed,
so we need to manually check for batch completion.
"""
try:
with tasks_lock:
if batch_id not in download_batches:
print(f"⚠️ [Completion Check V2] Batch {batch_id} not found")
return
batch = download_batches[batch_id]
all_tasks_started = batch['queue_index'] >= len(batch['queue'])
no_active_workers = batch['active_count'] == 0
# Count actually finished tasks (completed, failed, or cancelled)
finished_count = 0
retrying_count = 0
queue = batch.get('queue', [])
for task_id in queue:
if task_id in download_tasks:
task_status = download_tasks[task_id]['status']
if task_status in ['completed', 'failed', 'cancelled']:
finished_count += 1
elif task_status == 'searching':
retrying_count += 1
all_tasks_truly_finished = finished_count >= len(queue)
has_retrying_tasks = retrying_count > 0
print(f"🔍 [Completion Check V2] Batch {batch_id}: tasks_started={all_tasks_started}, workers={no_active_workers}, finished={finished_count}/{len(queue)}, retrying={retrying_count}")
if all_tasks_started and no_active_workers and all_tasks_truly_finished and not has_retrying_tasks:
# FIXED: Ensure batch is not already marked as complete to prevent duplicate processing
if batch.get('phase') != 'complete':
print(f"🎉 [Completion Check V2] Batch {batch_id} is complete - marking as finished")
# Check if this is an auto-initiated batch
is_auto_batch = batch.get('auto_initiated', False)
# Mark batch as complete
batch['phase'] = 'complete'
else:
print(f"✅ [Completion Check V2] Batch {batch_id} already marked complete - skipping duplicate processing")
return True # Already complete
# Update YouTube playlist phase to 'download_complete' if this is a YouTube playlist
playlist_id = batch.get('playlist_id')
if playlist_id and playlist_id.startswith('youtube_'):
url_hash = playlist_id.replace('youtube_', '')
if url_hash in youtube_playlist_states:
youtube_playlist_states[url_hash]['phase'] = 'download_complete'
print(f"📋 [Completion Check V2] Updated YouTube playlist {url_hash} to download_complete phase")
# Update Tidal playlist phase to 'download_complete' if this is a Tidal playlist
if playlist_id and playlist_id.startswith('tidal_'):
tidal_playlist_id = playlist_id.replace('tidal_', '')
if tidal_playlist_id in tidal_discovery_states:
tidal_discovery_states[tidal_playlist_id]['phase'] = 'download_complete'
print(f"📋 [Completion Check V2] Updated Tidal playlist {tidal_playlist_id} to download_complete phase")
print(f"🎉 [Completion Check V2] Batch {batch_id} complete - stopping monitor")
download_monitor.stop_monitoring(batch_id)
# Process wishlist outside of the lock to prevent threading issues
if all_tasks_started and no_active_workers and all_tasks_truly_finished and not has_retrying_tasks:
# Call wishlist processing outside the lock
if is_auto_batch:
print(f"🤖 [Completion Check V2] Processing auto-initiated batch completion")
# Use the existing auto-completion function
_process_failed_tracks_to_wishlist_exact_with_auto_completion(batch_id)
else:
print(f"📋 [Completion Check V2] Processing regular batch completion")
# Use the regular completion function
_process_failed_tracks_to_wishlist_exact(batch_id)
return True # Batch was completed
else:
print(f"📊 [Completion Check V2] Batch {batch_id} not yet complete: finished={finished_count}/{len(queue)}, retrying={retrying_count}, workers={batch['active_count']}")
return False # Batch still in progress
except Exception as e:
print(f"❌ [Completion Check V2] Error checking batch completion: {e}")
import traceback
traceback.print_exc()
return False
def _add_cancelled_task_to_wishlist(task):
"""
Helper function to add cancelled task to wishlist.
Separated for clarity and error isolation.
"""
if not task:
return
try:
from core.wishlist_service import get_wishlist_service
wishlist_service = get_wishlist_service()
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)})
spotify_track_data = {
'id': track_info.get('id'),
'name': track_info.get('name'),
'artists': formatted_artists,
'album': {'name': track_info.get('album')},
'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'
}
success = wishlist_service.add_spotify_track_to_wishlist(
spotify_track_data=spotify_track_data,
failure_reason="Download cancelled by user (v2)",
source_type="playlist",
source_context=source_context
)
if success:
print(f"✅ [Atomic Cancel] Added '{track_info.get('name')}' to wishlist")
else:
print(f"❌ [Atomic Cancel] Failed to add '{track_info.get('name')}' to wishlist")
except Exception as e:
print(f"❌ [Atomic Cancel] Critical error adding to wishlist: {e}")
@app.route('/api/playlists/<batch_id>/cancel_batch', methods=['POST'])
def cancel_batch(batch_id):
"""
Cancels an entire batch - useful for cancelling during analysis phase
or cancelling all downloads at once.
"""
try:
with tasks_lock:
if batch_id not in download_batches:
return jsonify({"success": False, "error": "Batch not found"}), 404
# Mark batch as cancelled
download_batches[batch_id]['phase'] = 'cancelled'
# Reset YouTube playlist phase to 'discovered' if this is a YouTube playlist
playlist_id = download_batches[batch_id].get('playlist_id')
if playlist_id and playlist_id.startswith('youtube_'):
url_hash = playlist_id.replace('youtube_', '')
if url_hash in youtube_playlist_states:
youtube_playlist_states[url_hash]['phase'] = 'discovered'
print(f"📋 Reset YouTube playlist {url_hash} to discovered phase (batch cancelled)")
# Cancel all individual tasks in the batch
cancelled_count = 0
for task_id in download_batches[batch_id].get('queue', []):
if task_id in download_tasks:
task = download_tasks[task_id]
if task['status'] not in ['completed', 'cancelled']:
task['status'] = 'cancelled'
cancelled_count += 1
# Add activity for batch cancellation
playlist_name = download_batches[batch_id].get('playlist_name', 'Unknown Playlist')
add_activity_item("🚫", "Batch Cancelled", f"'{playlist_name}' - {cancelled_count} downloads cancelled", "Now")
print(f"✅ Cancelled batch {batch_id} with {cancelled_count} tasks")
return jsonify({"success": True, "cancelled_tasks": cancelled_count})
except Exception as e:
print(f"❌ Error cancelling batch {batch_id}: {e}")
return jsonify({"success": False, "error": str(e)}), 500
# NEW ENDPOINT: Add this function to web_server.py
@app.route('/api/playlists/cleanup_batch', methods=['POST'])
def cleanup_batch():
"""
Cleans up a completed or cancelled batch from the server's in-memory state.
This is called by the client after the user closes a finished modal.
"""
data = request.get_json()
batch_id = data.get('batch_id')
if not batch_id:
return jsonify({"success": False, "error": "Missing batch_id"}), 400
try:
with tasks_lock:
# Check if the batch exists before trying to delete
if batch_id in download_batches:
# Get the list of task IDs before deleting the batch
task_ids_to_remove = download_batches[batch_id].get('queue', [])
# Delete the batch record
del download_batches[batch_id]
# Clean up the associated tasks from the tasks dictionary
for task_id in task_ids_to_remove:
if task_id in download_tasks:
del download_tasks[task_id]
print(f"✅ Cleaned up batch '{batch_id}' and its associated tasks from server state.")
return jsonify({"success": True, "message": f"Batch {batch_id} cleaned up."})
else:
# It's not an error if the batch is already gone
print(f"⚠️ Cleanup requested for non-existent batch '{batch_id}'. Already cleaned up?")
return jsonify({"success": True, "message": "Batch already cleaned up."})
except Exception as e:
print(f"❌ Error during batch cleanup for '{batch_id}': {e}")
return jsonify({"success": False, "error": str(e)}), 500
# ===============================
# == UNIFIED MISSING TRACKS API ==
# ===============================
@app.route('/api/playlists/<playlist_id>/start-missing-process', methods=['POST'])
def start_missing_tracks_process(playlist_id):
"""
A single, robust endpoint to kick off the entire missing tracks workflow.
It creates a batch and starts the master worker in the background.
"""
data = request.get_json()
tracks = data.get('tracks', [])
playlist_name = data.get('playlist_name', 'Unknown Playlist')
if not tracks:
return jsonify({"success": False, "error": "No tracks provided"}), 400
# Limit concurrent analysis processes to prevent resource exhaustion
with tasks_lock:
active_analysis_count = sum(1 for batch in download_batches.values()
if batch.get('phase') == 'analysis')
if active_analysis_count >= 3: # Allow max 3 concurrent analysis processes
return jsonify({
"success": False,
"error": "Too many analysis processes running. Please wait for one to complete."
}), 429
batch_id = str(uuid.uuid4())
with tasks_lock:
download_batches[batch_id] = {
'phase': 'analysis',
'playlist_id': playlist_id,
'playlist_name': playlist_name,
'queue': [],
'active_count': 0,
'max_concurrent': 3,
# Track state management (replicating sync.py)
'permanently_failed_tracks': [],
'cancelled_tracks': set(),
'queue_index': 0,
'analysis_total': len(tracks),
'analysis_processed': 0,
'analysis_results': []
}
# Link YouTube playlist to download process if this is a YouTube playlist
if playlist_id.startswith('youtube_'):
url_hash = playlist_id.replace('youtube_', '')
if url_hash in youtube_playlist_states:
youtube_playlist_states[url_hash]['download_process_id'] = batch_id
youtube_playlist_states[url_hash]['phase'] = 'downloading'
youtube_playlist_states[url_hash]['converted_spotify_playlist_id'] = playlist_id
print(f"🔗 Linked YouTube playlist {url_hash} to download process {batch_id} (converted ID: {playlist_id})")
# Link Tidal playlist to download process if this is a Tidal playlist
if playlist_id.startswith('tidal_'):
tidal_playlist_id = playlist_id.replace('tidal_', '')
if tidal_playlist_id in tidal_discovery_states:
tidal_discovery_states[tidal_playlist_id]['download_process_id'] = batch_id
tidal_discovery_states[tidal_playlist_id]['phase'] = 'downloading'
tidal_discovery_states[tidal_playlist_id]['converted_spotify_playlist_id'] = playlist_id
print(f"🔗 Linked Tidal playlist {tidal_playlist_id} to download process {batch_id} (converted ID: {playlist_id})")
missing_download_executor.submit(_run_full_missing_tracks_process, batch_id, playlist_id, tracks)
return jsonify({
"success": True,
"batch_id": batch_id
})
@app.route('/api/tracks/download_missing', methods=['POST'])
def start_missing_downloads():
"""Legacy endpoint - redirect to new playlist-based endpoint"""
data = request.get_json()
missing_tracks = data.get('missing_tracks', [])
if not missing_tracks:
return jsonify({"success": False, "error": "No missing tracks provided"}), 400
# Use a default playlist_id for legacy compatibility
playlist_id = "legacy_modal"
# Call the new endpoint logic directly
try:
batch_id = str(uuid.uuid4())
# Create task queue for this batch
task_queue = []
with tasks_lock:
# Initialize batch management
download_batches[batch_id] = {
'queue': [],
'active_count': 0,
'max_concurrent': 3,
'queue_index': 0,
# Track state management (replicating sync.py)
'permanently_failed_tracks': [],
'cancelled_tracks': set()
}
for track_index, track_data in enumerate(missing_tracks):
task_id = str(uuid.uuid4())
download_tasks[task_id] = {
'status': 'pending',
'track_info': track_data,
'playlist_id': playlist_id,
'batch_id': batch_id,
'track_index': track_index,
'download_id': None,
'username': None
}
# Add to batch queue instead of submitting immediately
download_batches[batch_id]['queue'].append(task_id)
# Start the first batch of downloads (up to 3)
_start_next_batch_of_downloads(batch_id)
return jsonify({"success": True, "batch_id": batch_id, "message": f"Queued {len(missing_tracks)} downloads for processing."})
except Exception as e:
print(f"❌ Error starting missing downloads: {e}")
return jsonify({"success": False, "error": str(e)}), 500
# ===============================
# == SYNC PAGE API ==
# ===============================
def _load_sync_status_file():
"""Helper function to read the sync status JSON file."""
# Storage folder is at the same level as web_server.py
status_file = os.path.join(os.path.dirname(__file__), 'storage', 'sync_status.json')
print(f"🔍 Loading sync status from: {status_file}")
if not os.path.exists(status_file):
print(f"❌ Sync status file does not exist: {status_file}")
return {}
try:
with open(status_file, 'r') as f:
content = f.read()
if not content:
print(f"⚠️ Sync status file is empty")
return {}
data = json.loads(content)
print(f"✅ Loaded {len(data)} sync statuses from file")
for playlist_id, status in list(data.items())[:3]: # Show first 3
print(f" - {playlist_id}: {status.get('name', 'N/A')} -> {status.get('last_synced', 'N/A')}")
return data
except (json.JSONDecodeError, FileNotFoundError) as e:
print(f"❌ Error loading sync status: {e}")
return {}
def _save_sync_status_file(sync_statuses):
"""Helper function to save the sync status JSON file."""
try:
# Storage folder is at the same level as web_server.py
storage_dir = os.path.join(os.path.dirname(__file__), 'storage')
os.makedirs(storage_dir, exist_ok=True)
status_file = os.path.join(storage_dir, 'sync_status.json')
with open(status_file, 'w') as f:
json.dump(sync_statuses, f, indent=4)
print(f"✅ Sync status saved to {status_file}")
except Exception as e:
print(f"❌ Error saving sync status: {e}")
def _update_and_save_sync_status(playlist_id, playlist_name, playlist_owner, snapshot_id):
"""Updates the sync status for a given playlist and saves to file (same logic as GUI)."""
try:
# Load existing sync statuses
sync_statuses = _load_sync_status_file()
# Update this playlist's sync status
from datetime import datetime
now = datetime.now()
sync_statuses[playlist_id] = {
'name': playlist_name,
'owner': playlist_owner,
'snapshot_id': snapshot_id,
'last_synced': now.isoformat()
}
# Save to file
_save_sync_status_file(sync_statuses)
print(f"🔄 Updated sync status for playlist '{playlist_name}' (ID: {playlist_id})")
except Exception as e:
print(f"❌ Error updating sync status for {playlist_id}: {e}")
@app.route('/api/spotify/playlists', methods=['GET'])
def get_spotify_playlists():
"""Fetches all user playlists from Spotify and enriches them with local sync status."""
if not spotify_client or not spotify_client.is_authenticated():
return jsonify({"error": "Spotify not authenticated."}), 401
try:
playlists = spotify_client.get_user_playlists_metadata_only()
sync_statuses = _load_sync_status_file()
playlist_data = []
for p in playlists:
status_info = sync_statuses.get(p.id, {})
sync_status = "Never Synced"
# Handle snapshot_id safely - may not exist in core Playlist class
playlist_snapshot = getattr(p, 'snapshot_id', '')
print(f"🔍 Processing playlist: {p.name} (ID: {p.id})")
print(f" - Playlist snapshot: '{playlist_snapshot}'")
print(f" - Status info: {status_info}")
if 'last_synced' in status_info:
stored_snapshot = status_info.get('snapshot_id')
last_sync_time = datetime.fromisoformat(status_info['last_synced']).strftime('%b %d, %H:%M')
print(f" - Stored snapshot: '{stored_snapshot}'")
print(f" - Snapshots match: {playlist_snapshot == stored_snapshot}")
if playlist_snapshot != stored_snapshot:
sync_status = f"Last Sync: {last_sync_time}"
print(f" - Result: Needs Sync (showing: {sync_status})")
else:
sync_status = f"Synced: {last_sync_time}"
print(f" - Result: {sync_status}")
else:
print(f" - No last_synced found - Never Synced")
playlist_data.append({
"id": p.id, "name": p.name, "owner": p.owner,
"track_count": p.total_tracks,
"image_url": getattr(p, 'image_url', None),
"sync_status": sync_status,
"snapshot_id": playlist_snapshot
})
return jsonify(playlist_data)
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/api/spotify/playlist/<playlist_id>', methods=['GET'])
def get_playlist_tracks(playlist_id):
"""Fetches full track details for a specific playlist."""
if not spotify_client or not spotify_client.is_authenticated():
return jsonify({"error": "Spotify not authenticated."}), 401
try:
# This reuses the robust track fetching logic from your GUI's sync.py
full_playlist = spotify_client.get_playlist_by_id(playlist_id)
if not full_playlist:
return jsonify({})
# Convert playlist to dict manually since core class doesn't have to_dict method
playlist_dict = {
'id': full_playlist.id,
'name': full_playlist.name,
'description': full_playlist.description,
'owner': full_playlist.owner,
'public': full_playlist.public,
'collaborative': full_playlist.collaborative,
'track_count': full_playlist.total_tracks,
'image_url': getattr(full_playlist, 'image_url', None),
'snapshot_id': getattr(full_playlist, 'snapshot_id', ''),
'tracks': [{'id': t.id, 'name': t.name, 'artists': t.artists, 'album': t.album, 'duration_ms': t.duration_ms, 'popularity': t.popularity} for t in full_playlist.tracks]
}
return jsonify(playlist_dict)
except Exception as e:
return jsonify({"error": str(e)}), 500
# ===================================================================
# TIDAL PLAYLIST API ENDPOINTS
# ===================================================================
@app.route('/api/tidal/playlists', methods=['GET'])
def get_tidal_playlists():
"""Fetches all user playlists from Tidal with full track data (like sync.py)."""
if not tidal_client or not tidal_client.is_authenticated():
return jsonify({"error": "Tidal not authenticated."}), 401
try:
# Use same method as sync.py - this already includes all track data
playlists = tidal_client.get_user_playlists_metadata_only()
playlist_data = []
for p in playlists:
# Get track count from actual tracks if available
track_count = len(p.tracks) if hasattr(p, 'tracks') and p.tracks else 0
playlist_dict = {
"id": p.id,
"name": p.name,
"owner": getattr(p, 'owner', 'Unknown'),
"track_count": track_count,
"image_url": getattr(p, 'image_url', None),
"description": getattr(p, 'description', ''),
"tracks": [] # Add tracks data like sync.py
}
# Include full track data if available (like sync.py has)
if hasattr(p, 'tracks') and p.tracks:
playlist_dict['tracks'] = [{
'id': t.id,
'name': t.name,
'artists': t.artists or [],
'album': getattr(t, 'album', 'Unknown Album'),
'duration_ms': getattr(t, 'duration_ms', 0),
'track_number': getattr(t, 'track_number', 0)
} for t in p.tracks]
playlist_data.append(playlist_dict)
print(f"🎵 Loaded {len(playlist_data)} Tidal playlists with track data")
return jsonify(playlist_data)
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/api/tidal/playlist/<playlist_id>', methods=['GET'])
def get_tidal_playlist_tracks(playlist_id):
"""Fetches full track details for a specific Tidal playlist (matches sync.py pattern)."""
if not tidal_client or not tidal_client.is_authenticated():
return jsonify({"error": "Tidal not authenticated."}), 401
try:
print(f"🎵 Getting full Tidal playlist with tracks for: {playlist_id}")
# First check if this playlist exists in metadata list
try:
metadata_playlists = tidal_client.get_user_playlists_metadata_only()
target_playlist = None
for p in metadata_playlists:
if p.id == playlist_id:
target_playlist = p
break
if not target_playlist:
print(f"❌ Playlist {playlist_id} not found in user's Tidal playlists")
return jsonify({"error": "Playlist not found in your Tidal library"}), 404
print(f"🎵 Found playlist in metadata: {target_playlist.name}")
except Exception as e:
print(f"❌ Error checking playlist metadata: {e}")
# Use same method as sync.py: tidal_client.get_playlist(playlist_id)
full_playlist = tidal_client.get_playlist(playlist_id)
if not full_playlist:
return jsonify({"error": "Unable to access this Tidal playlist. This may be due to privacy settings or Tidal API restrictions. Please try a different playlist."}), 403
if not full_playlist.tracks:
return jsonify({"error": "This playlist appears to have no tracks or they cannot be accessed"}), 403
print(f"🎵 Loaded {len(full_playlist.tracks)} tracks from Tidal playlist: {full_playlist.name}")
# Convert playlist to dict (matches sync.py structure)
playlist_dict = {
'id': full_playlist.id,
'name': full_playlist.name,
'description': getattr(full_playlist, 'description', ''),
'owner': getattr(full_playlist, 'owner', 'Unknown'),
'track_count': len(full_playlist.tracks),
'image_url': getattr(full_playlist, 'image_url', None),
'tracks': []
}
# Convert tracks to dict format (for discovery modal)
playlist_dict['tracks'] = [{
'id': t.id,
'name': t.name,
'artists': t.artists or [],
'album': getattr(t, 'album', 'Unknown Album'),
'duration_ms': getattr(t, 'duration_ms', 0),
'track_number': getattr(t, 'track_number', 0)
} for t in full_playlist.tracks]
return jsonify(playlist_dict)
except Exception as e:
print(f"❌ Error getting Tidal playlist tracks: {e}")
return jsonify({"error": str(e)}), 500
# ===================================================================
# TIDAL DISCOVERY API ENDPOINTS
# ===================================================================
# Global state for Tidal playlist discovery management
tidal_discovery_states = {} # Key: playlist_id, Value: discovery state
tidal_discovery_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="tidal_discovery")
@app.route('/api/tidal/discovery/start/<playlist_id>', methods=['POST'])
def start_tidal_discovery(playlist_id):
"""Start Spotify discovery process for a Tidal playlist"""
try:
# Get playlist data from the initial load
if not tidal_client or not tidal_client.is_authenticated():
return jsonify({"error": "Tidal not authenticated."}), 401
# Get playlist from tidal client
playlists = tidal_client.get_user_playlists_metadata_only()
target_playlist = None
for p in playlists:
if p.id == playlist_id:
target_playlist = p
break
if not target_playlist:
return jsonify({"error": "Tidal playlist not found"}), 404
if not target_playlist.tracks:
return jsonify({"error": "Playlist has no tracks"}), 400
# Initialize discovery state if it doesn't exist, or update existing state
if playlist_id in tidal_discovery_states:
existing_state = tidal_discovery_states[playlist_id]
if existing_state['phase'] == 'discovering':
return jsonify({"error": "Discovery already in progress"}), 400
# Update existing state for discovery
existing_state['phase'] = 'discovering'
existing_state['status'] = 'discovering'
existing_state['last_accessed'] = time.time()
state = existing_state
else:
# Create new state for first-time discovery
state = {
'playlist': target_playlist,
'phase': 'discovering', # fresh -> discovering -> discovered -> syncing -> sync_complete -> downloading -> download_complete
'status': 'discovering',
'discovery_progress': 0,
'spotify_matches': 0,
'spotify_total': len(target_playlist.tracks),
'discovery_results': [],
'sync_playlist_id': None,
'converted_spotify_playlist_id': None,
'download_process_id': None, # Track associated download missing tracks process
'created_at': time.time(),
'last_accessed': time.time(),
'discovery_future': None,
'sync_progress': {}
}
tidal_discovery_states[playlist_id] = state
# Add activity for discovery start
add_activity_item("🔍", "Tidal Discovery Started", f"'{target_playlist.name}' - {len(target_playlist.tracks)} tracks", "Now")
# Start discovery worker
future = tidal_discovery_executor.submit(_run_tidal_discovery_worker, playlist_id)
state['discovery_future'] = future
print(f"🔍 Started Spotify discovery for Tidal playlist: {target_playlist.name}")
return jsonify({"success": True, "message": "Discovery started"})
except Exception as e:
print(f"❌ Error starting Tidal discovery: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/tidal/discovery/status/<playlist_id>', methods=['GET'])
def get_tidal_discovery_status(playlist_id):
"""Get real-time discovery status for a Tidal playlist"""
try:
if playlist_id not in tidal_discovery_states:
return jsonify({"error": "Tidal discovery not found"}), 404
state = tidal_discovery_states[playlist_id]
state['last_accessed'] = time.time() # Update access time
response = {
'phase': state['phase'],
'status': state['status'],
'progress': state['discovery_progress'],
'spotify_matches': state['spotify_matches'],
'spotify_total': state['spotify_total'],
'results': state['discovery_results'],
'complete': state['phase'] == 'discovered'
}
return jsonify(response)
except Exception as e:
print(f"❌ Error getting Tidal discovery status: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/tidal/playlists/states', methods=['GET'])
def get_tidal_playlist_states():
"""Get all stored Tidal playlist discovery states for frontend hydration (similar to YouTube playlists)"""
try:
states = []
current_time = time.time()
for playlist_id, state in tidal_discovery_states.items():
# Update access time when requested
state['last_accessed'] = current_time
# Return essential data for card state recreation
state_info = {
'playlist_id': playlist_id,
'phase': state['phase'],
'status': state['status'],
'discovery_progress': state['discovery_progress'],
'spotify_matches': state['spotify_matches'],
'spotify_total': state['spotify_total'],
'discovery_results': state['discovery_results'],
'converted_spotify_playlist_id': state.get('converted_spotify_playlist_id'),
'download_process_id': state.get('download_process_id'),
'last_accessed': state['last_accessed']
}
states.append(state_info)
print(f"🎵 Returning {len(states)} stored Tidal playlist states for hydration")
return jsonify({"states": states})
except Exception as e:
print(f"❌ Error getting Tidal playlist states: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/tidal/state/<playlist_id>', methods=['GET'])
def get_tidal_playlist_state(playlist_id):
"""Get specific Tidal playlist state (detailed version matching YouTube's state endpoint)"""
try:
if playlist_id not in tidal_discovery_states:
return jsonify({"error": "Tidal playlist not found"}), 404
state = tidal_discovery_states[playlist_id]
state['last_accessed'] = time.time()
# Return full state information (including results for modal hydration)
response = {
'playlist_id': playlist_id,
'playlist': state['playlist'].__dict__ if hasattr(state['playlist'], '__dict__') else state['playlist'],
'phase': state['phase'],
'status': state['status'],
'discovery_progress': state['discovery_progress'],
'spotify_matches': state['spotify_matches'],
'spotify_total': state['spotify_total'],
'discovery_results': state['discovery_results'],
'sync_playlist_id': state.get('sync_playlist_id'),
'converted_spotify_playlist_id': state.get('converted_spotify_playlist_id'),
'download_process_id': state.get('download_process_id'),
'sync_progress': state.get('sync_progress', {}),
'last_accessed': state['last_accessed']
}
return jsonify(response)
except Exception as e:
print(f"❌ Error getting Tidal playlist state: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/tidal/reset/<playlist_id>', methods=['POST'])
def reset_tidal_playlist(playlist_id):
"""Reset Tidal playlist to fresh phase (clear discovery/sync data)"""
try:
if playlist_id not in tidal_discovery_states:
return jsonify({"error": "Tidal playlist not found"}), 404
state = tidal_discovery_states[playlist_id]
# Stop any active discovery
if 'discovery_future' in state and state['discovery_future']:
state['discovery_future'].cancel()
# Reset state to fresh (preserve original playlist data)
state['phase'] = 'fresh'
state['status'] = 'fresh'
state['discovery_results'] = []
state['discovery_progress'] = 0
state['spotify_matches'] = 0
state['sync_playlist_id'] = None
state['converted_spotify_playlist_id'] = None
state['download_process_id'] = None
state['sync_progress'] = {}
state['discovery_future'] = None
state['last_accessed'] = time.time()
print(f"🔄 Reset Tidal playlist to fresh: {playlist_id}")
return jsonify({"success": True, "message": "Playlist reset to fresh phase"})
except Exception as e:
print(f"❌ Error resetting Tidal playlist: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/tidal/delete/<playlist_id>', methods=['POST'])
def delete_tidal_playlist(playlist_id):
"""Delete Tidal playlist state completely"""
try:
if playlist_id not in tidal_discovery_states:
return jsonify({"error": "Tidal playlist not found"}), 404
state = tidal_discovery_states[playlist_id]
# Stop any active discovery
if 'discovery_future' in state and state['discovery_future']:
state['discovery_future'].cancel()
# Remove from state dictionary
del tidal_discovery_states[playlist_id]
print(f"🗑️ Deleted Tidal playlist state: {playlist_id}")
return jsonify({"success": True, "message": "Playlist deleted"})
except Exception as e:
print(f"❌ Error deleting Tidal playlist: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/tidal/update_phase/<playlist_id>', methods=['POST'])
def update_tidal_playlist_phase(playlist_id):
"""Update Tidal playlist phase (used when modal closes to reset from download_complete to discovered)"""
try:
if playlist_id not in tidal_discovery_states:
return jsonify({"error": "Tidal playlist not found"}), 404
data = request.get_json()
if not data or 'phase' not in data:
return jsonify({"error": "Phase not provided"}), 400
new_phase = data['phase']
valid_phases = ['fresh', 'discovering', 'discovered', 'syncing', 'sync_complete', 'downloading', 'download_complete']
if new_phase not in valid_phases:
return jsonify({"error": f"Invalid phase. Must be one of: {', '.join(valid_phases)}"}), 400
state = tidal_discovery_states[playlist_id]
old_phase = state.get('phase', 'unknown')
state['phase'] = new_phase
state['last_accessed'] = time.time()
print(f"🔄 Updated Tidal playlist {playlist_id} phase: {old_phase}{new_phase}")
return jsonify({"success": True, "message": f"Phase updated to {new_phase}", "old_phase": old_phase, "new_phase": new_phase})
except Exception as e:
print(f"❌ Error updating Tidal playlist phase: {e}")
return jsonify({"error": str(e)}), 500
def _run_tidal_discovery_worker(playlist_id):
"""Background worker for Tidal Spotify discovery process (like sync.py)"""
try:
state = tidal_discovery_states[playlist_id]
playlist = state['playlist']
print(f"🎵 Starting Tidal Spotify discovery for: {playlist.name}")
# Import matching engine for validation (like sync.py)
from core.matching_engine import MusicMatchingEngine
matching_engine = MusicMatchingEngine()
successful_discoveries = 0
for i, tidal_track in enumerate(playlist.tracks):
if state.get('cancelled', False):
break
try:
print(f"🔍 [{i+1}/{len(playlist.tracks)}] Searching: {tidal_track.name} by {', '.join(tidal_track.artists)}")
# Use the same search logic as sync.py TidalSpotifyDiscoveryWorker
spotify_track = _search_spotify_for_tidal_track(tidal_track)
# Create result entry
result = {
'tidal_track': {
'id': tidal_track.id,
'name': tidal_track.name,
'artists': tidal_track.artists or [],
'album': getattr(tidal_track, 'album', 'Unknown Album'),
'duration_ms': getattr(tidal_track, 'duration_ms', 0),
},
'spotify_data': None,
'status': 'not_found'
}
if spotify_track:
result['spotify_data'] = {
'id': spotify_track.id,
'name': spotify_track.name,
'artists': spotify_track.artists, # Already a list of strings
'album': spotify_track.album, # Already a string
'duration_ms': spotify_track.duration_ms,
'external_urls': spotify_track.external_urls
}
result['status'] = 'found'
successful_discoveries += 1
state['spotify_matches'] = successful_discoveries
state['discovery_results'].append(result)
state['discovery_progress'] = int(((i + 1) / len(playlist.tracks)) * 100)
# Add delay between requests (like sync.py)
time.sleep(0.1)
except Exception as e:
print(f"❌ Error processing track {i+1}: {e}")
# Add error result
result = {
'tidal_track': {
'name': tidal_track.name,
'artists': tidal_track.artists or [],
},
'spotify_data': None,
'status': 'error',
'error': str(e)
}
state['discovery_results'].append(result)
state['discovery_progress'] = int(((i + 1) / len(playlist.tracks)) * 100)
# Mark as complete
state['phase'] = 'discovered'
state['status'] = 'discovered'
state['discovery_progress'] = 100
# Add activity for discovery completion
add_activity_item("", "Tidal Discovery Complete", f"'{playlist.name}' - {successful_discoveries}/{len(playlist.tracks)} tracks found", "Now")
print(f"✅ Tidal discovery complete: {successful_discoveries}/{len(playlist.tracks)} tracks found")
except Exception as e:
print(f"❌ Error in Tidal discovery worker: {e}")
state['phase'] = 'error'
state['status'] = f'error: {str(e)}'
def _search_spotify_for_tidal_track(tidal_track):
"""Search Spotify for a Tidal track (simplified version of sync.py logic)"""
if not spotify_client or not spotify_client.is_authenticated():
return None
try:
# Construct search query like sync.py does
track_name = tidal_track.name
artists = tidal_track.artists or []
if not artists:
return None
# Try different search combinations (like sync.py TidalSpotifyDiscoveryWorker)
search_queries = [
f'track:"{track_name}" artist:"{artists[0]}"',
f'"{track_name}" "{artists[0]}"',
f'{track_name} {artists[0]}'
]
for query in search_queries:
try:
results = spotify_client.search_tracks(query, limit=5)
if results and len(results) > 0:
# Return first match (could add matching logic like sync.py)
return results[0]
except Exception as e:
print(f"❌ Search error for query '{query}': {e}")
continue
return None
except Exception as e:
print(f"❌ Error searching Spotify for Tidal track: {e}")
return None
def convert_tidal_results_to_spotify_tracks(discovery_results):
"""Convert Tidal discovery results to Spotify tracks format for sync"""
spotify_tracks = []
for result in discovery_results:
if result.get('spotify_data'):
spotify_data = result['spotify_data']
# Create track object matching the expected format
track = {
'id': spotify_data['id'],
'name': spotify_data['name'],
'artists': spotify_data['artists'],
'album': spotify_data['album'],
'duration_ms': spotify_data['duration_ms']
}
spotify_tracks.append(track)
print(f"🔄 Converted {len(spotify_tracks)} Tidal matches to Spotify tracks for sync")
return spotify_tracks
# ===================================================================
# TIDAL SYNC API ENDPOINTS
# ===================================================================
@app.route('/api/tidal/sync/start/<playlist_id>', methods=['POST'])
def start_tidal_sync(playlist_id):
"""Start sync process for a Tidal playlist using discovered Spotify tracks"""
try:
if playlist_id not in tidal_discovery_states:
return jsonify({"error": "Tidal playlist not found"}), 404
state = tidal_discovery_states[playlist_id]
state['last_accessed'] = time.time() # Update access time
if state['phase'] not in ['discovered', 'sync_complete']:
return jsonify({"error": "Tidal playlist not ready for sync"}), 400
# Convert discovery results to Spotify tracks format
spotify_tracks = convert_tidal_results_to_spotify_tracks(state['discovery_results'])
if not spotify_tracks:
return jsonify({"error": "No Spotify matches found for sync"}), 400
# Create a temporary playlist ID for sync tracking
sync_playlist_id = f"tidal_{playlist_id}"
playlist_name = state['playlist'].name # Tidal playlist object has .name attribute
# Add activity for sync start
add_activity_item("🔄", "Tidal Sync Started", f"'{playlist_name}' - {len(spotify_tracks)} tracks", "Now")
# Update Tidal state
state['phase'] = 'syncing'
state['sync_playlist_id'] = sync_playlist_id
state['sync_progress'] = {}
# Start the sync using existing sync infrastructure
sync_data = {
'playlist_id': sync_playlist_id,
'playlist_name': f"[Tidal] {playlist_name}",
'tracks': spotify_tracks
}
with sync_lock:
sync_states[sync_playlist_id] = {"status": "starting", "progress": {}}
# Submit sync task
future = sync_executor.submit(_run_sync_task, sync_playlist_id, sync_data['playlist_name'], spotify_tracks)
active_sync_workers[sync_playlist_id] = future
print(f"🔄 Started Tidal sync for: {playlist_name} ({len(spotify_tracks)} tracks)")
return jsonify({"success": True, "sync_playlist_id": sync_playlist_id})
except Exception as e:
print(f"❌ Error starting Tidal sync: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/tidal/sync/status/<playlist_id>', methods=['GET'])
def get_tidal_sync_status(playlist_id):
"""Get sync status for a Tidal playlist"""
try:
if playlist_id not in tidal_discovery_states:
return jsonify({"error": "Tidal playlist not found"}), 404
state = tidal_discovery_states[playlist_id]
state['last_accessed'] = time.time() # Update access time
sync_playlist_id = state.get('sync_playlist_id')
if not sync_playlist_id:
return jsonify({"error": "No sync in progress"}), 404
# Get sync status from existing sync infrastructure
with sync_lock:
sync_state = sync_states.get(sync_playlist_id, {})
response = {
'phase': state['phase'],
'sync_status': sync_state.get('status', 'unknown'),
'progress': sync_state.get('progress', {}),
'complete': sync_state.get('status') == 'finished',
'error': sync_state.get('error')
}
# Update Tidal state if sync completed
if sync_state.get('status') == 'finished':
state['phase'] = 'sync_complete'
state['sync_progress'] = sync_state.get('progress', {})
# Add activity for sync completion
playlist_name = state.get('playlist', {}).get('name', 'Unknown Playlist')
add_activity_item("🔄", "Sync Complete", f"Tidal playlist '{playlist_name}' synced successfully", "Now")
elif sync_state.get('status') == 'error':
state['phase'] = 'discovered' # Revert on error
playlist_name = state.get('playlist', {}).get('name', 'Unknown Playlist')
add_activity_item("", "Sync Failed", f"Tidal playlist '{playlist_name}' sync failed", "Now")
return jsonify(response)
except Exception as e:
print(f"❌ Error getting Tidal sync status: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/tidal/sync/cancel/<playlist_id>', methods=['POST'])
def cancel_tidal_sync(playlist_id):
"""Cancel sync for a Tidal playlist"""
try:
if playlist_id not in tidal_discovery_states:
return jsonify({"error": "Tidal playlist not found"}), 404
state = tidal_discovery_states[playlist_id]
state['last_accessed'] = time.time() # Update access time
sync_playlist_id = state.get('sync_playlist_id')
if sync_playlist_id:
# Cancel the sync using existing sync infrastructure
with sync_lock:
sync_states[sync_playlist_id] = {"status": "cancelled"}
# Clean up sync worker
if sync_playlist_id in active_sync_workers:
del active_sync_workers[sync_playlist_id]
# Revert Tidal state
state['phase'] = 'discovered'
state['sync_playlist_id'] = None
state['sync_progress'] = {}
return jsonify({"success": True, "message": "Tidal sync cancelled"})
except Exception as e:
print(f"❌ Error cancelling Tidal sync: {e}")
return jsonify({"error": str(e)}), 500
# ===================================================================
# YOUTUBE PLAYLIST API ENDPOINTS
# ===================================================================
# Global state for YouTube playlist management (persistent across page reloads)
youtube_playlist_states = {} # Key: url_hash, Value: persistent playlist state
youtube_discovery_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="youtube_discovery")
@app.route('/api/youtube/parse', methods=['POST'])
def parse_youtube_playlist_endpoint():
"""Parse a YouTube playlist URL and return structured track data"""
try:
data = request.get_json()
url = data.get('url', '').strip()
if not url:
return jsonify({"error": "YouTube URL is required"}), 400
# Validate URL
if not ('youtube.com/playlist' in url or 'music.youtube.com/playlist' in url):
return jsonify({"error": "Invalid YouTube playlist URL"}), 400
print(f"🎬 Parsing YouTube playlist: {url}")
# Parse the playlist using our function
playlist_data = parse_youtube_playlist(url)
if not playlist_data:
return jsonify({"error": "Failed to parse YouTube playlist"}), 500
# Create URL hash for state tracking
url_hash = str(hash(url))
# Initialize persistent playlist state (similar to Spotify download_batches structure)
youtube_playlist_states[url_hash] = {
'playlist': playlist_data,
'phase': 'fresh', # fresh -> discovering -> discovered -> syncing -> sync_complete -> downloading -> download_complete
'discovery_results': [],
'discovery_progress': 0,
'spotify_matches': 0,
'spotify_total': len(playlist_data['tracks']),
'status': 'parsed',
'url': url,
'sync_playlist_id': None,
'converted_spotify_playlist_id': None,
'download_process_id': None, # Track associated download missing tracks process
'created_at': time.time(),
'last_accessed': time.time(),
'discovery_future': None,
'sync_progress': {}
}
playlist_data['url_hash'] = url_hash
print(f"✅ YouTube playlist parsed successfully: {playlist_data['name']} ({len(playlist_data['tracks'])} tracks)")
return jsonify(playlist_data)
except Exception as e:
print(f"❌ Error parsing YouTube playlist: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/youtube/discovery/start/<url_hash>', methods=['POST'])
def start_youtube_discovery(url_hash):
"""Start Spotify discovery process for a YouTube playlist"""
try:
if url_hash not in youtube_playlist_states:
return jsonify({"error": "YouTube playlist not found"}), 404
state = youtube_playlist_states[url_hash]
state['last_accessed'] = time.time() # Update access time
if state['phase'] == 'discovering':
return jsonify({"error": "Discovery already in progress"}), 400
# Update phase to discovering
state['phase'] = 'discovering'
state['status'] = 'discovering'
state['discovery_progress'] = 0
state['spotify_matches'] = 0
# Add activity for discovery start
playlist_name = state['playlist']['name']
track_count = len(state['playlist']['tracks'])
add_activity_item("🔍", "YouTube Discovery Started", f"'{playlist_name}' - {track_count} tracks", "Now")
# Start discovery worker
future = youtube_discovery_executor.submit(_run_youtube_discovery_worker, url_hash)
state['discovery_future'] = future
print(f"🔍 Started Spotify discovery for YouTube playlist: {state['playlist']['name']}")
return jsonify({"success": True, "message": "Discovery started"})
except Exception as e:
print(f"❌ Error starting YouTube discovery: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/youtube/discovery/status/<url_hash>', methods=['GET'])
def get_youtube_discovery_status(url_hash):
"""Get real-time discovery status for a YouTube playlist"""
try:
if url_hash not in youtube_playlist_states:
return jsonify({"error": "YouTube playlist not found"}), 404
state = youtube_playlist_states[url_hash]
state['last_accessed'] = time.time() # Update access time
response = {
'phase': state['phase'],
'status': state['status'],
'progress': state['discovery_progress'],
'spotify_matches': state['spotify_matches'],
'spotify_total': state['spotify_total'],
'results': state['discovery_results'],
'complete': state['phase'] == 'discovered'
}
return jsonify(response)
except Exception as e:
print(f"❌ Error getting YouTube discovery status: {e}")
return jsonify({"error": str(e)}), 500
def _run_youtube_discovery_worker(url_hash):
"""Background worker for YouTube Spotify discovery process"""
try:
state = youtube_playlist_states[url_hash]
playlist = state['playlist']
tracks = playlist['tracks']
print(f"🔍 Starting Spotify discovery for {len(tracks)} YouTube tracks...")
if not spotify_client or not spotify_client.is_authenticated():
print("❌ Spotify client not authenticated")
state['status'] = 'error'
state['phase'] = 'fresh'
return
# Process each track for Spotify discovery
for i, track in enumerate(tracks):
try:
# Update progress
state['discovery_progress'] = int((i / len(tracks)) * 100)
# Search for track on Spotify using cleaned data
cleaned_title = track['name']
cleaned_artist = track['artists'][0] if track['artists'] else 'Unknown Artist'
print(f"🔍 Searching Spotify for: '{cleaned_artist}' - '{cleaned_title}'")
# Try multiple search strategies
spotify_track = None
# Strategy 1: Standard search
query = f"artist:{cleaned_artist} track:{cleaned_title}"
spotify_results = spotify_client.search_tracks(query, limit=5)
if spotify_results:
# Find best match using similarity
best_match = None
best_score = 0
for spotify_result in spotify_results:
# Calculate similarity score
title_score = _calculate_similarity(cleaned_title.lower(), spotify_result.name.lower())
artist_score = _calculate_similarity(cleaned_artist.lower(), spotify_result.artists[0].lower())
combined_score = (title_score * 0.7) + (artist_score * 0.3)
if combined_score > best_score and combined_score > 0.6:
best_match = spotify_result
best_score = combined_score
spotify_track = best_match
# Strategy 2: Swapped search (if first failed)
if not spotify_track:
query = f"artist:{cleaned_title} track:{cleaned_artist}"
spotify_results = spotify_client.search_tracks(query, limit=3)
if spotify_results:
spotify_track = spotify_results[0]
# Strategy 3: Raw data search (if still failed)
if not spotify_track:
raw_title = track['raw_title']
raw_artist = track['raw_artist']
query = f"{raw_artist} {raw_title}"
spotify_results = spotify_client.search_tracks(query, limit=3)
if spotify_results:
spotify_track = spotify_results[0]
# Create result entry
result = {
'index': i,
'yt_track': cleaned_title,
'yt_artist': cleaned_artist,
'status': '✅ Found' if spotify_track else '❌ Not Found',
'status_class': 'found' if spotify_track else 'not-found',
'spotify_track': spotify_track.name if spotify_track else '',
'spotify_artist': spotify_track.artists[0] if spotify_track else '',
'spotify_album': spotify_track.album if spotify_track else '',
'duration': f"{track['duration_ms'] // 60000}:{(track['duration_ms'] % 60000) // 1000:02d}" if track['duration_ms'] else '0:00'
}
if spotify_track:
state['spotify_matches'] += 1
result['spotify_data'] = {
'id': spotify_track.id,
'name': spotify_track.name,
'artists': spotify_track.artists,
'album': spotify_track.album,
'duration_ms': spotify_track.duration_ms
}
state['discovery_results'].append(result)
print(f" {'' if spotify_track else ''} Track {i+1}/{len(tracks)}: {result['status']}")
except Exception as e:
print(f"❌ Error processing track {i}: {e}")
# Add failed result
result = {
'index': i,
'yt_track': track['name'],
'yt_artist': track['artists'][0] if track['artists'] else 'Unknown',
'status': '❌ Error',
'status_class': 'error',
'spotify_track': '',
'spotify_artist': '',
'spotify_album': '',
'duration': '0:00'
}
state['discovery_results'].append(result)
# Complete discovery
state['phase'] = 'discovered'
state['status'] = 'complete'
state['discovery_progress'] = 100
# Add activity for discovery completion
playlist_name = playlist['name']
add_activity_item("", "YouTube Discovery Complete", f"'{playlist_name}' - {state['spotify_matches']}/{len(tracks)} tracks found", "Now")
print(f"✅ YouTube discovery complete: {state['spotify_matches']}/{len(tracks)} tracks matched")
except Exception as e:
print(f"❌ Error in YouTube discovery worker: {e}")
state['status'] = 'error'
state['phase'] = 'fresh'
def _calculate_similarity(str1, str2):
"""Calculate string similarity using simple character overlap"""
if not str1 or not str2:
return 0
# Convert to lowercase and remove extra spaces
str1 = str1.lower().strip()
str2 = str2.lower().strip()
if str1 == str2:
return 1.0
# Calculate character overlap
set1 = set(str1.replace(' ', ''))
set2 = set(str2.replace(' ', ''))
if not set1 or not set2:
return 0
intersection = len(set1.intersection(set2))
union = len(set1.union(set2))
return intersection / union if union > 0 else 0
@app.route('/api/youtube/sync/start/<url_hash>', methods=['POST'])
def start_youtube_sync(url_hash):
"""Start sync process for a YouTube playlist using discovered Spotify tracks"""
try:
if url_hash not in youtube_playlist_states:
return jsonify({"error": "YouTube playlist not found"}), 404
state = youtube_playlist_states[url_hash]
state['last_accessed'] = time.time() # Update access time
if state['phase'] not in ['discovered', 'sync_complete']:
return jsonify({"error": "YouTube playlist not ready for sync"}), 400
# Convert discovery results to Spotify tracks format
spotify_tracks = convert_youtube_results_to_spotify_tracks(state['discovery_results'])
if not spotify_tracks:
return jsonify({"error": "No Spotify matches found for sync"}), 400
# Create a temporary playlist ID for sync tracking
sync_playlist_id = f"youtube_{url_hash}"
playlist_name = state['playlist']['name']
# Add activity for sync start
add_activity_item("🔄", "YouTube Sync Started", f"'{playlist_name}' - {len(spotify_tracks)} tracks", "Now")
# Update YouTube state
state['phase'] = 'syncing'
state['sync_playlist_id'] = sync_playlist_id
state['sync_progress'] = {}
# Start the sync using existing sync infrastructure
sync_data = {
'playlist_id': sync_playlist_id,
'playlist_name': f"[YouTube] {playlist_name}",
'tracks': spotify_tracks
}
with sync_lock:
sync_states[sync_playlist_id] = {"status": "starting", "progress": {}}
# Submit sync task
future = sync_executor.submit(_run_sync_task, sync_playlist_id, sync_data['playlist_name'], spotify_tracks)
active_sync_workers[sync_playlist_id] = future
print(f"🔄 Started YouTube sync for: {playlist_name} ({len(spotify_tracks)} tracks)")
return jsonify({"success": True, "sync_playlist_id": sync_playlist_id})
except Exception as e:
print(f"❌ Error starting YouTube sync: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/youtube/sync/status/<url_hash>', methods=['GET'])
def get_youtube_sync_status(url_hash):
"""Get sync status for a YouTube playlist"""
try:
if url_hash not in youtube_playlist_states:
return jsonify({"error": "YouTube playlist not found"}), 404
state = youtube_playlist_states[url_hash]
state['last_accessed'] = time.time() # Update access time
sync_playlist_id = state.get('sync_playlist_id')
if not sync_playlist_id:
return jsonify({"error": "No sync in progress"}), 404
# Get sync status from existing sync infrastructure
with sync_lock:
sync_state = sync_states.get(sync_playlist_id, {})
response = {
'phase': state['phase'],
'sync_status': sync_state.get('status', 'unknown'),
'progress': sync_state.get('progress', {}),
'complete': sync_state.get('status') == 'finished',
'error': sync_state.get('error')
}
# Update YouTube state if sync completed
if sync_state.get('status') == 'finished':
state['phase'] = 'sync_complete'
state['sync_progress'] = sync_state.get('progress', {})
# Add activity for sync completion
playlist_name = state.get('playlist', {}).get('name', 'Unknown Playlist')
add_activity_item("🔄", "Sync Complete", f"YouTube playlist '{playlist_name}' synced successfully", "Now")
elif sync_state.get('status') == 'error':
state['phase'] = 'discovered' # Revert on error
playlist_name = state.get('playlist', {}).get('name', 'Unknown Playlist')
add_activity_item("", "Sync Failed", f"YouTube playlist '{playlist_name}' sync failed", "Now")
return jsonify(response)
except Exception as e:
print(f"❌ Error getting YouTube sync status: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/youtube/sync/cancel/<url_hash>', methods=['POST'])
def cancel_youtube_sync(url_hash):
"""Cancel sync for a YouTube playlist"""
try:
if url_hash not in youtube_playlist_states:
return jsonify({"error": "YouTube playlist not found"}), 404
state = youtube_playlist_states[url_hash]
state['last_accessed'] = time.time() # Update access time
sync_playlist_id = state.get('sync_playlist_id')
if sync_playlist_id:
# Cancel the sync using existing sync infrastructure
with sync_lock:
sync_states[sync_playlist_id] = {"status": "cancelled"}
# Clean up sync worker
if sync_playlist_id in active_sync_workers:
del active_sync_workers[sync_playlist_id]
# Revert YouTube state
state['phase'] = 'discovered'
state['sync_playlist_id'] = None
state['sync_progress'] = {}
return jsonify({"success": True, "message": "YouTube sync cancelled"})
except Exception as e:
print(f"❌ Error cancelling YouTube sync: {e}")
return jsonify({"error": str(e)}), 500
# New YouTube Playlist Management Endpoints (for persistent state)
@app.route('/api/youtube/playlists', methods=['GET'])
def get_all_youtube_playlists():
"""Get all stored YouTube playlists for frontend hydration (similar to Spotify playlists)"""
try:
playlists = []
current_time = time.time()
for url_hash, state in youtube_playlist_states.items():
# Update access time when requested
state['last_accessed'] = current_time
# Return essential data for card recreation
playlist_info = {
'url_hash': url_hash,
'url': state['url'],
'playlist': state['playlist'],
'phase': state['phase'],
'status': state['status'],
'discovery_progress': state['discovery_progress'],
'spotify_matches': state['spotify_matches'],
'spotify_total': state['spotify_total'],
'converted_spotify_playlist_id': state.get('converted_spotify_playlist_id'),
'download_process_id': state.get('download_process_id'),
'created_at': state['created_at'],
'last_accessed': state['last_accessed']
}
playlists.append(playlist_info)
print(f"📋 Returning {len(playlists)} stored YouTube playlists for hydration")
return jsonify({"playlists": playlists})
except Exception as e:
print(f"❌ Error getting YouTube playlists: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/youtube/state/<url_hash>', methods=['GET'])
def get_youtube_playlist_state(url_hash):
"""Get specific YouTube playlist state (detailed version of status endpoint)"""
try:
if url_hash not in youtube_playlist_states:
return jsonify({"error": "YouTube playlist not found"}), 404
state = youtube_playlist_states[url_hash]
state['last_accessed'] = time.time()
# Return full state information (including results for modal hydration)
response = {
'url_hash': url_hash,
'url': state['url'],
'playlist': state['playlist'],
'phase': state['phase'],
'status': state['status'],
'discovery_progress': state['discovery_progress'],
'spotify_matches': state['spotify_matches'],
'spotify_total': state['spotify_total'],
'discovery_results': state['discovery_results'],
'sync_playlist_id': state['sync_playlist_id'],
'converted_spotify_playlist_id': state['converted_spotify_playlist_id'],
'sync_progress': state['sync_progress'],
'created_at': state['created_at'],
'last_accessed': state['last_accessed']
}
return jsonify(response)
except Exception as e:
print(f"❌ Error getting YouTube playlist state: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/youtube/reset/<url_hash>', methods=['POST'])
def reset_youtube_playlist(url_hash):
"""Reset YouTube playlist to fresh phase (clear discovery/sync data)"""
try:
if url_hash not in youtube_playlist_states:
return jsonify({"error": "YouTube playlist not found"}), 404
state = youtube_playlist_states[url_hash]
# Stop any active discovery
if 'discovery_future' in state and state['discovery_future']:
state['discovery_future'].cancel()
# Reset state to fresh (preserve original playlist data)
state['phase'] = 'fresh'
state['status'] = 'parsed'
state['discovery_results'] = []
state['discovery_progress'] = 0
state['spotify_matches'] = 0
state['sync_playlist_id'] = None
state['converted_spotify_playlist_id'] = None
state['sync_progress'] = {}
state['discovery_future'] = None
state['last_accessed'] = time.time()
print(f"🔄 Reset YouTube playlist to fresh phase: {state['playlist']['name']}")
return jsonify({"success": True, "message": "Playlist reset to fresh state"})
except Exception as e:
print(f"❌ Error resetting YouTube playlist: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/youtube/delete/<url_hash>', methods=['DELETE'])
def delete_youtube_playlist(url_hash):
"""Remove YouTube playlist from backend storage entirely"""
try:
if url_hash not in youtube_playlist_states:
return jsonify({"error": "YouTube playlist not found"}), 404
state = youtube_playlist_states[url_hash]
# Stop any active discovery
if 'discovery_future' in state and state['discovery_future']:
state['discovery_future'].cancel()
# Remove from storage
playlist_name = state['playlist']['name']
del youtube_playlist_states[url_hash]
print(f"🗑️ Deleted YouTube playlist from backend: {playlist_name}")
return jsonify({"success": True, "message": f"Playlist '{playlist_name}' deleted"})
except Exception as e:
print(f"❌ Error deleting YouTube playlist: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/youtube/update_phase/<url_hash>', methods=['POST'])
def update_youtube_playlist_phase(url_hash):
"""Update YouTube playlist phase (used when modal closes to reset from download_complete to discovered)"""
try:
if url_hash not in youtube_playlist_states:
return jsonify({"error": "YouTube playlist not found"}), 404
data = request.get_json()
if not data or 'phase' not in data:
return jsonify({"error": "Phase not provided"}), 400
new_phase = data['phase']
valid_phases = ['fresh', 'parsed', 'discovering', 'discovered', 'syncing', 'sync_complete', 'downloading', 'download_complete']
if new_phase not in valid_phases:
return jsonify({"error": f"Invalid phase. Must be one of: {', '.join(valid_phases)}"}), 400
state = youtube_playlist_states[url_hash]
old_phase = state.get('phase', 'unknown')
state['phase'] = new_phase
state['last_accessed'] = time.time()
print(f"🔄 Updated YouTube playlist {url_hash} phase: {old_phase}{new_phase}")
return jsonify({"success": True, "message": f"Phase updated to {new_phase}", "old_phase": old_phase, "new_phase": new_phase})
except Exception as e:
print(f"❌ Error updating YouTube playlist phase: {e}")
return jsonify({"error": str(e)}), 500
def convert_youtube_results_to_spotify_tracks(discovery_results):
"""Convert YouTube discovery results to Spotify tracks format for sync"""
spotify_tracks = []
for result in discovery_results:
if result.get('spotify_data'):
spotify_data = result['spotify_data']
# Create track object matching the expected format
track = {
'id': spotify_data['id'],
'name': spotify_data['name'],
'artists': spotify_data['artists'],
'album': spotify_data['album'],
'duration_ms': spotify_data['duration_ms']
}
spotify_tracks.append(track)
print(f"🔄 Converted {len(spotify_tracks)} YouTube matches to Spotify tracks for sync")
return spotify_tracks
# Add these new endpoints to the end of web_server.py
def _run_sync_task(playlist_id, playlist_name, tracks_json):
"""The actual sync function that runs in the background thread."""
global sync_states, sync_service
task_start_time = time.time()
print(f"🚀 [TIMING] _run_sync_task STARTED for playlist '{playlist_name}' at {time.strftime('%H:%M:%S')}")
print(f"📊 Received {len(tracks_json)} tracks from frontend")
try:
# Recreate a Playlist object from the JSON data sent by the frontend
# This avoids needing to re-fetch it from Spotify
print(f"🔄 Converting JSON tracks to SpotifyTrack objects...")
tracks = []
for i, t in enumerate(tracks_json):
# Handle album field - extract name if it's a dictionary
raw_album = t.get('album', '')
if isinstance(raw_album, dict) and 'name' in raw_album:
album_name = raw_album['name']
elif isinstance(raw_album, str):
album_name = raw_album
else:
album_name = str(raw_album)
# Create SpotifyTrack objects with proper default values for missing fields
track = SpotifyTrack(
id=t.get('id', ''), # Provide default empty string
name=t.get('name', ''),
artists=t.get('artists', []),
album=album_name,
duration_ms=t.get('duration_ms', 0),
popularity=t.get('popularity', 0), # Default value
preview_url=t.get('preview_url'),
external_urls=t.get('external_urls')
)
tracks.append(track)
if i < 3: # Log first 3 tracks for debugging
print(f" Track {i+1}: '{track.name}' by {track.artists}")
print(f"✅ Created {len(tracks)} SpotifyTrack objects")
playlist = SpotifyPlaylist(
id=playlist_id,
name=playlist_name,
description=None, # Not needed for sync
owner="web_user", # Placeholder
public=False, # Default
collaborative=False, # Default
tracks=tracks,
total_tracks=len(tracks)
)
print(f"✅ Created SpotifyPlaylist object: '{playlist.name}' with {playlist.total_tracks} tracks")
first_callback_time = [None] # Use list to allow modification in nested function
def progress_callback(progress):
"""Callback to update the shared state."""
if first_callback_time[0] is None:
first_callback_time[0] = time.time()
first_callback_duration = (first_callback_time[0] - task_start_time) * 1000
print(f"⏱️ [TIMING] FIRST progress callback at {time.strftime('%H:%M:%S')} (took {first_callback_duration:.1f}ms from start)")
print(f"⚡ PROGRESS CALLBACK: {progress.current_step} - {progress.current_track}")
print(f" 📊 Progress: {progress.progress}% ({progress.matched_tracks}/{progress.total_tracks} matched, {progress.failed_tracks} failed)")
with sync_lock:
sync_states[playlist_id] = {
"status": "syncing",
"progress": progress.__dict__ # Convert dataclass to dict
}
print(f" ✅ Updated sync_states for {playlist_id}")
except Exception as setup_error:
print(f"❌ SETUP ERROR in _run_sync_task: {setup_error}")
import traceback
traceback.print_exc()
with sync_lock:
sync_states[playlist_id] = {
"status": "error",
"error": f"Setup error: {str(setup_error)}"
}
return
try:
print(f"🔧 Setting up sync service...")
print(f" sync_service available: {sync_service is not None}")
if sync_service is None:
raise Exception("sync_service is None - not initialized properly")
# Check sync service components
print(f" spotify_client: {sync_service.spotify_client is not None}")
print(f" plex_client: {sync_service.plex_client is not None}")
print(f" jellyfin_client: {sync_service.jellyfin_client is not None}")
# Check media server connection before starting
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
print(f" Active media server: {active_server}")
media_client, server_type = sync_service._get_active_media_client()
print(f" Media client available: {media_client is not None}")
if media_client:
is_connected = media_client.is_connected()
print(f" Media client connected: {is_connected}")
# Check database access
try:
from database.music_database import MusicDatabase
db = MusicDatabase()
print(f" Database initialized: {db is not None}")
except Exception as db_error:
print(f" ❌ Database initialization failed: {db_error}")
print(f"🔄 Attaching progress callback...")
# Attach the progress callback
sync_service.set_progress_callback(progress_callback, playlist.name)
print(f"✅ Progress callback attached for playlist: {playlist.name}")
# CRITICAL FIX: Add database-only fallback for web context
# If media client is not connected, patch the sync service to use database-only matching
if media_client is None or not media_client.is_connected():
print(f"⚠️ Media client not connected - patching sync service for database-only matching")
# Store original method
original_find_track = sync_service._find_track_in_media_server
# Create database-only replacement method
async def database_only_find_track(spotify_track):
print(f"🗃️ Database-only search for: '{spotify_track.name}' by {spotify_track.artists}")
try:
from database.music_database import MusicDatabase
from config.settings import config_manager
db = MusicDatabase()
active_server = config_manager.get_active_media_server()
original_title = spotify_track.name
# Try each artist (same logic as original)
for artist in spotify_track.artists:
artist_name = artist if isinstance(artist, str) else str(artist)
db_track, confidence = db.check_track_exists(
original_title, artist_name,
confidence_threshold=0.7,
server_source=active_server
)
if db_track and confidence >= 0.7:
print(f"✅ Database match: '{db_track.title}' (confidence: {confidence:.2f})")
# Create mock track object for playlist creation
class DatabaseTrackMock:
def __init__(self, db_track):
self.ratingKey = db_track.id
self.title = db_track.title
self.id = db_track.id
# Add any other attributes needed for playlist creation
return DatabaseTrackMock(db_track), confidence
print(f"❌ No database match found for: '{original_title}'")
return None, 0.0
except Exception as e:
print(f"❌ Database search error: {e}")
return None, 0.0
# Patch the method
sync_service._find_track_in_media_server = database_only_find_track
print(f"✅ Patched sync service to use database-only matching")
sync_start_time = time.time()
setup_duration = (sync_start_time - task_start_time) * 1000
print(f"⏱️ [TIMING] Setup completed at {time.strftime('%H:%M:%S')} (took {setup_duration:.1f}ms)")
print(f"🚀 Starting actual sync process with asyncio.run()...")
# Run the sync (this is a blocking call within this thread)
result = asyncio.run(sync_service.sync_playlist(playlist, download_missing=False))
sync_duration = (time.time() - sync_start_time) * 1000
total_duration = (time.time() - task_start_time) * 1000
print(f"⏱️ [TIMING] Sync completed at {time.strftime('%H:%M:%S')} (sync: {sync_duration:.1f}ms, total: {total_duration:.1f}ms)")
print(f"✅ Sync process completed! Result type: {type(result)}")
print(f" Result details: matched={getattr(result, 'matched_tracks', 'N/A')}, total={getattr(result, 'total_tracks', 'N/A')}")
# Update final state on completion
with sync_lock:
sync_states[playlist_id] = {
"status": "finished",
"result": result.__dict__ # Convert dataclass to dict
}
print(f"🏁 Sync finished for {playlist_id} - state updated")
# Save sync status to storage/sync_status.json (same as GUI)
# Handle snapshot_id safely - may not exist in all playlist objects
snapshot_id = getattr(playlist, 'snapshot_id', None)
_update_and_save_sync_status(playlist_id, playlist_name, playlist.owner, snapshot_id)
except Exception as e:
print(f"❌ SYNC FAILED for {playlist_id}: {e}")
import traceback
traceback.print_exc()
with sync_lock:
sync_states[playlist_id] = {
"status": "error",
"error": str(e)
}
finally:
print(f"🧹 Cleaning up progress callback for {playlist.name}")
# Clean up the callback
if sync_service:
sync_service.clear_progress_callback(playlist.name)
print(f"✅ Cleanup completed for {playlist_id}")
@app.route('/api/sync/start', methods=['POST'])
def start_playlist_sync():
"""Starts a new sync process for a given playlist."""
request_start_time = time.time()
print(f"⏱️ [TIMING] Sync request received at {time.strftime('%H:%M:%S')}")
data = request.get_json()
playlist_id = data.get('playlist_id')
playlist_name = data.get('playlist_name')
tracks_json = data.get('tracks') # Pass the full track list
if not all([playlist_id, playlist_name, tracks_json]):
return jsonify({"success": False, "error": "Missing playlist_id, name, or tracks."}), 400
# Add activity for sync start
add_activity_item("🔄", "Spotify Sync Started", f"'{playlist_name}' - {len(tracks_json)} tracks", "Now")
print(f"⏱️ [TIMING] Request parsed at {time.strftime('%H:%M:%S')} (took {(time.time()-request_start_time)*1000:.1f}ms)")
with sync_lock:
if playlist_id in active_sync_workers and not active_sync_workers[playlist_id].done():
return jsonify({"success": False, "error": "Sync is already in progress for this playlist."}), 409
# Initial state
sync_states[playlist_id] = {"status": "starting", "progress": {}}
# Submit the task to the thread pool
thread_submit_time = time.time()
future = sync_executor.submit(_run_sync_task, playlist_id, playlist_name, tracks_json)
active_sync_workers[playlist_id] = future
thread_submit_duration = (time.time() - thread_submit_time) * 1000
print(f"⏱️ [TIMING] Thread submitted at {time.strftime('%H:%M:%S')} (took {thread_submit_duration:.1f}ms)")
total_request_time = (time.time() - request_start_time) * 1000
print(f"⏱️ [TIMING] Request completed at {time.strftime('%H:%M:%S')} (total: {total_request_time:.1f}ms)")
return jsonify({"success": True, "message": "Sync started."})
@app.route('/api/sync/status/<playlist_id>', methods=['GET'])
def get_sync_status(playlist_id):
"""Polls for the status of an ongoing sync."""
with sync_lock:
state = sync_states.get(playlist_id)
if not state:
return jsonify({"status": "not_found"}), 404
# If the task is finished but the state hasn't been updated, check the future
if state['status'] not in ['finished', 'error'] and playlist_id in active_sync_workers:
if active_sync_workers[playlist_id].done():
# The task might have finished between polls, trigger final state update
# This is handled by the _run_sync_task itself
pass
return jsonify(state)
@app.route('/api/sync/cancel', methods=['POST'])
def cancel_playlist_sync():
"""Cancels an ongoing sync process."""
data = request.get_json()
playlist_id = data.get('playlist_id')
if not playlist_id:
return jsonify({"success": False, "error": "Missing playlist_id."}), 400
with sync_lock:
future = active_sync_workers.get(playlist_id)
if not future or future.done():
return jsonify({"success": False, "error": "Sync not running or already complete."}), 404
# The GUI's sync_service has a cancel_sync method. We'll replicate that idea.
# Since we can't easily stop the thread, we'll set a flag.
# The elegant solution is to have the sync_service check for a cancellation flag.
# Your `sync_service.py` already has this logic with `self._cancelled`.
sync_service.cancel_sync()
# We can't guarantee immediate stop, but we can update the state
sync_states[playlist_id] = {"status": "cancelled"}
# It's best practice to let the task finish and clean itself up.
# We don't use future.cancel() as it may not work if the task is already running.
return jsonify({"success": True, "message": "Sync cancellation requested."})
@app.route('/api/sync/test-database', methods=['GET'])
def test_database_access():
"""Test endpoint to verify database connectivity for sync operations"""
try:
print(f"🧪 Testing database access for sync operations...")
# Test database initialization
from database.music_database import MusicDatabase
db = MusicDatabase()
print(f" ✅ Database initialized: {db is not None}")
# Test basic database query
stats = db.get_database_info_for_server()
print(f" ✅ Database stats retrieved: {stats}")
# Test track existence check (like sync service does)
db_track, confidence = db.check_track_exists("test track", "test artist", confidence_threshold=0.7)
print(f" ✅ Track existence check works: found={db_track is not None}, confidence={confidence}")
# Test config manager
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
print(f" ✅ Active media server: {active_server}")
# Test media clients
print(f" Media clients status:")
print(f" plex_client: {plex_client is not None}")
if plex_client:
print(f" plex_client.is_connected(): {plex_client.is_connected()}")
print(f" jellyfin_client: {jellyfin_client is not None}")
if jellyfin_client:
print(f" jellyfin_client.is_connected(): {jellyfin_client.is_connected()}")
return jsonify({
"success": True,
"message": "Database access test successful",
"details": {
"database_initialized": db is not None,
"database_stats": stats,
"active_server": active_server,
"plex_connected": plex_client.is_connected() if plex_client else False,
"jellyfin_connected": jellyfin_client.is_connected() if jellyfin_client else False,
}
})
except Exception as e:
print(f" ❌ Database test failed: {e}")
import traceback
traceback.print_exc()
return jsonify({
"success": False,
"error": str(e),
"message": "Database access test failed"
}), 500
# --- Artist Bubble Snapshot System ---
@app.route('/api/artist_bubbles/snapshot', methods=['POST'])
def save_artist_bubble_snapshot():
"""
Saves a snapshot of current artist bubble state for persistence across page refreshes.
"""
try:
import os
import json
from datetime import datetime
data = request.json
if not data or 'bubbles' not in data:
return jsonify({'success': False, 'error': 'No bubble data provided'}), 400
bubbles = data['bubbles']
# Create snapshot with timestamp
snapshot = {
'bubbles': bubbles,
'timestamp': datetime.now().isoformat(),
'snapshot_id': datetime.now().strftime('%Y%m%d_%H%M%S')
}
# Save to file
snapshot_file = os.path.join(os.path.dirname(__file__), 'artist_bubble_snapshots.json')
with open(snapshot_file, 'w') as f:
json.dump(snapshot, f, indent=2)
bubble_count = len(bubbles)
print(f"📸 Saved artist bubble snapshot: {bubble_count} artists")
return jsonify({
'success': True,
'message': f'Snapshot saved with {bubble_count} artist bubbles',
'timestamp': snapshot['timestamp']
})
except Exception as e:
print(f"❌ Error saving artist bubble snapshot: {e}")
import traceback
traceback.print_exc()
return jsonify({
'success': False,
'error': str(e)
}), 500
@app.route('/api/artist_bubbles/hydrate', methods=['GET'])
def hydrate_artist_bubbles():
"""
Loads artist bubbles with live status by cross-referencing snapshots with active processes.
"""
try:
import os
import json
from datetime import datetime, timedelta
snapshot_file = os.path.join(os.path.dirname(__file__), 'artist_bubble_snapshots.json')
# Load snapshot if it exists
if not os.path.exists(snapshot_file):
return jsonify({
'success': True,
'bubbles': {},
'message': 'No snapshots found'
})
with open(snapshot_file, 'r') as f:
snapshot_data = json.load(f)
saved_bubbles = snapshot_data.get('bubbles', {})
snapshot_time = snapshot_data.get('timestamp', '')
# Clean up old snapshots (older than 48 hours)
try:
if snapshot_time:
snapshot_dt = datetime.fromisoformat(snapshot_time.replace('Z', '+00:00'))
cutoff = datetime.now() - timedelta(hours=48)
if snapshot_dt < cutoff:
print(f"🧹 Cleaning up old snapshot from {snapshot_time}")
os.remove(snapshot_file)
return jsonify({
'success': True,
'bubbles': {},
'message': 'Old snapshot cleaned up'
})
except (ValueError, OSError) as e:
print(f"⚠️ Error checking snapshot age: {e}")
# Get current active download processes for live status
current_processes = {}
try:
with tasks_lock:
for batch_id, batch_data in download_batches.items():
if batch_data.get('phase') not in ['complete', 'error', 'cancelled']:
playlist_id = batch_data.get('playlist_id')
if playlist_id:
current_processes[playlist_id] = {
'status': 'in_progress' if batch_data.get('phase') == 'downloading' else 'analyzing',
'batch_id': batch_id,
'phase': batch_data.get('phase')
}
except Exception as e:
print(f"⚠️ Error fetching active processes for hydration: {e}")
# If no active processes exist, the app likely restarted - clean up snapshots
if not current_processes:
print(f"🧹 No active processes found - app likely restarted, cleaning up snapshot")
try:
os.remove(snapshot_file)
return jsonify({
'success': True,
'bubbles': {},
'message': 'Snapshot cleaned up after app restart'
})
except OSError as e:
print(f"⚠️ Error removing snapshot file: {e}")
# Continue with empty result anyway
return jsonify({
'success': True,
'bubbles': {},
'message': 'No active processes - returning empty bubbles'
})
# Update bubble statuses with live data
hydrated_bubbles = {}
for artist_id, bubble_data in saved_bubbles.items():
hydrated_bubble = {
'artist': bubble_data['artist'],
'downloads': [],
'hasCompletedDownloads': False
}
for download in bubble_data.get('downloads', []):
virtual_playlist_id = download['virtualPlaylistId']
# Determine current live status
if virtual_playlist_id in current_processes:
process_info = current_processes[virtual_playlist_id]
live_status = 'in_progress'
print(f"🔄 Found active process for {download['album']['name']}: {process_info['phase']}")
else:
# No active process - likely completed
live_status = 'view_results'
print(f"✅ No active process for {download['album']['name']} - marking as completed")
# Create updated download entry
updated_download = {
'virtualPlaylistId': virtual_playlist_id,
'album': download['album'],
'albumType': download.get('albumType', 'album'),
'status': live_status,
'startTime': download.get('startTime', datetime.now().isoformat())
}
hydrated_bubble['downloads'].append(updated_download)
# Update hasCompletedDownloads flag
if live_status == 'view_results':
hydrated_bubble['hasCompletedDownloads'] = True
# Only include artists that still have downloads
if hydrated_bubble['downloads']:
hydrated_bubbles[artist_id] = hydrated_bubble
bubble_count = len(hydrated_bubbles)
active_count = sum(1 for bubble in hydrated_bubbles.values()
for download in bubble['downloads']
if download['status'] == 'in_progress')
completed_count = sum(1 for bubble in hydrated_bubbles.values()
for download in bubble['downloads']
if download['status'] == 'view_results')
print(f"🔄 Hydrated {bubble_count} artist bubbles: {active_count} active, {completed_count} completed")
return jsonify({
'success': True,
'bubbles': hydrated_bubbles,
'stats': {
'total_artists': bubble_count,
'active_downloads': active_count,
'completed_downloads': completed_count,
'snapshot_time': snapshot_time
}
})
except Exception as e:
print(f"❌ Error hydrating artist bubbles: {e}")
import traceback
traceback.print_exc()
return jsonify({
'success': False,
'error': str(e)
}), 500
# --- Watchlist API Endpoints ---
@app.route('/api/watchlist/count', methods=['GET'])
def get_watchlist_count():
"""Get the number of artists in the watchlist"""
try:
database = get_database()
count = database.get_watchlist_count()
return jsonify({"success": True, "count": count})
except Exception as e:
print(f"Error getting watchlist count: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/watchlist/artists', methods=['GET'])
def get_watchlist_artists():
"""Get all artists in the watchlist"""
try:
database = get_database()
watchlist_artists = database.get_watchlist_artists()
# Convert to JSON serializable format
artists_data = []
for artist in watchlist_artists:
artists_data.append({
"id": artist.id,
"spotify_artist_id": artist.spotify_artist_id,
"artist_name": artist.artist_name,
"date_added": artist.date_added.isoformat() if artist.date_added else None,
"last_scan_timestamp": artist.last_scan_timestamp.isoformat() if artist.last_scan_timestamp else None,
"created_at": artist.created_at.isoformat() if artist.created_at else None,
"updated_at": artist.updated_at.isoformat() if artist.updated_at else None
})
return jsonify({"success": True, "artists": artists_data})
except Exception as e:
print(f"Error getting watchlist artists: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/watchlist/add', methods=['POST'])
def add_to_watchlist():
"""Add an artist to the watchlist"""
try:
data = request.get_json()
artist_id = data.get('artist_id')
artist_name = data.get('artist_name')
if not artist_id or not artist_name:
return jsonify({"success": False, "error": "Missing artist_id or artist_name"}), 400
database = get_database()
success = database.add_artist_to_watchlist(artist_id, artist_name)
if success:
return jsonify({"success": True, "message": f"Added {artist_name} to watchlist"})
else:
return jsonify({"success": False, "error": "Failed to add artist to watchlist"}), 500
except Exception as e:
print(f"Error adding to watchlist: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/watchlist/remove', methods=['POST'])
def remove_from_watchlist():
"""Remove an artist from the watchlist"""
try:
data = request.get_json()
artist_id = data.get('artist_id')
if not artist_id:
return jsonify({"success": False, "error": "Missing artist_id"}), 400
database = get_database()
success = database.remove_artist_from_watchlist(artist_id)
if success:
return jsonify({"success": True, "message": "Removed artist from watchlist"})
else:
return jsonify({"success": False, "error": "Failed to remove artist from watchlist"}), 500
except Exception as e:
print(f"Error removing from watchlist: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/watchlist/check', methods=['POST'])
def check_watchlist_status():
"""Check if an artist is in the watchlist"""
try:
data = request.get_json()
artist_id = data.get('artist_id')
if not artist_id:
return jsonify({"success": False, "error": "Missing artist_id"}), 400
database = get_database()
is_watching = database.is_artist_in_watchlist(artist_id)
return jsonify({"success": True, "is_watching": is_watching})
except Exception as e:
print(f"Error checking watchlist status: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/watchlist/scan', methods=['POST'])
def start_watchlist_scan():
"""Start a watchlist scan for new releases"""
try:
if not spotify_client or not spotify_client.is_authenticated():
return jsonify({"success": False, "error": "Spotify client not available or not authenticated"}), 400
# Check if wishlist auto-processing is currently running
global wishlist_auto_processing
if wishlist_auto_processing:
return jsonify({"success": False, "error": "Wishlist auto-processing is currently running. Please wait for it to complete before starting a watchlist scan."}), 409
# Start the scan in a background thread
def run_scan():
try:
global watchlist_scan_state
from core.watchlist_scanner import get_watchlist_scanner
from database.music_database import get_database
# Get list of artists to scan
database = get_database()
watchlist_artists = database.get_watchlist_artists()
if not watchlist_artists:
watchlist_scan_state['status'] = 'completed'
watchlist_scan_state['summary'] = {
'total_artists': 0,
'successful_scans': 0,
'new_tracks_found': 0,
'tracks_added_to_wishlist': 0
}
return
scanner = get_watchlist_scanner(spotify_client)
# Initialize detailed progress tracking
watchlist_scan_state.update({
'total_artists': len(watchlist_artists),
'current_artist_index': 0,
'current_artist_name': '',
'current_phase': 'starting',
'albums_to_check': 0,
'albums_checked': 0,
'current_album': '',
'tracks_found_this_scan': 0,
'tracks_added_this_scan': 0
})
scan_results = []
for i, artist in enumerate(watchlist_artists):
try:
# Update progress
watchlist_scan_state.update({
'current_artist_index': i + 1,
'current_artist_name': artist.artist_name,
'current_phase': 'fetching_discography',
'albums_to_check': 0,
'albums_checked': 0,
'current_album': ''
})
# Get artist discography
albums = scanner.get_artist_discography(artist.spotify_artist_id, artist.last_scan_timestamp)
if albums is None:
scan_results.append(type('ScanResult', (), {
'artist_name': artist.artist_name,
'spotify_artist_id': artist.spotify_artist_id,
'albums_checked': 0,
'new_tracks_found': 0,
'tracks_added_to_wishlist': 0,
'success': False,
'error_message': "Failed to get artist discography"
})())
continue
# Update with album count
watchlist_scan_state.update({
'current_phase': 'checking_albums',
'albums_to_check': len(albums),
'albums_checked': 0
})
# Track progress for this artist
artist_new_tracks = 0
artist_added_tracks = 0
# Scan each album
for album_index, album in enumerate(albums):
watchlist_scan_state.update({
'albums_checked': album_index + 1,
'current_album': album.name,
'current_phase': f'checking_album_{album_index + 1}_of_{len(albums)}'
})
try:
# Get album tracks
album_data = scanner.spotify_client.get_album(album.id)
if not album_data or 'tracks' not in album_data:
continue
tracks = album_data['tracks']['items']
# Check each track
for track in tracks:
if scanner.is_track_missing_from_library(track):
artist_new_tracks += 1
watchlist_scan_state['tracks_found_this_scan'] += 1
# Add to wishlist
if scanner.add_track_to_wishlist(track, album_data, artist):
artist_added_tracks += 1
watchlist_scan_state['tracks_added_this_scan'] += 1
# Small delay between albums
import time
time.sleep(0.5)
except Exception as e:
print(f"Error checking album {album.name}: {e}")
continue
# Update scan timestamp
scanner.update_artist_scan_timestamp(artist.spotify_artist_id)
# Store result
scan_results.append(type('ScanResult', (), {
'artist_name': artist.artist_name,
'spotify_artist_id': artist.spotify_artist_id,
'albums_checked': len(albums),
'new_tracks_found': artist_new_tracks,
'tracks_added_to_wishlist': artist_added_tracks,
'success': True,
'error_message': None
})())
print(f"✅ Scanned {artist.artist_name}: {artist_new_tracks} new tracks found, {artist_added_tracks} added to wishlist")
# Delay between artists
if i < len(watchlist_artists) - 1:
watchlist_scan_state['current_phase'] = 'rate_limiting'
time.sleep(2.0)
except Exception as e:
print(f"Error scanning artist {artist.artist_name}: {e}")
scan_results.append(type('ScanResult', (), {
'artist_name': artist.artist_name,
'spotify_artist_id': artist.spotify_artist_id,
'albums_checked': 0,
'new_tracks_found': 0,
'tracks_added_to_wishlist': 0,
'success': False,
'error_message': str(e)
})())
# Store final results
watchlist_scan_state['status'] = 'completed'
watchlist_scan_state['results'] = scan_results
watchlist_scan_state['completed_at'] = datetime.now()
watchlist_scan_state['current_phase'] = 'completed'
# Calculate summary
successful_scans = [r for r in scan_results if r.success]
total_new_tracks = sum(r.new_tracks_found for r in successful_scans)
total_added_to_wishlist = sum(r.tracks_added_to_wishlist for r in successful_scans)
watchlist_scan_state['summary'] = {
'total_artists': len(scan_results),
'successful_scans': len(successful_scans),
'new_tracks_found': total_new_tracks,
'tracks_added_to_wishlist': total_added_to_wishlist
}
print(f"Watchlist scan completed: {len(successful_scans)}/{len(scan_results)} artists scanned successfully")
print(f"Found {total_new_tracks} new tracks, added {total_added_to_wishlist} to wishlist")
except Exception as e:
print(f"Error during watchlist scan: {e}")
watchlist_scan_state['status'] = 'error'
watchlist_scan_state['error'] = str(e)
# Initialize scan state
global watchlist_scan_state
watchlist_scan_state = {
'status': 'scanning',
'started_at': datetime.now(),
'results': [],
'summary': {},
'error': None
}
# Start scan in background
thread = threading.Thread(target=run_scan)
thread.daemon = True
thread.start()
return jsonify({"success": True, "message": "Watchlist scan started"})
except Exception as e:
print(f"Error starting watchlist scan: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/watchlist/scan/status', methods=['GET'])
def get_watchlist_scan_status():
"""Get the current status of watchlist scanning"""
try:
global watchlist_scan_state
if 'watchlist_scan_state' not in globals():
return jsonify({
"success": True,
"status": "idle",
"summary": {}
})
# Convert datetime objects to ISO format for JSON serialization
state = watchlist_scan_state.copy()
if 'started_at' in state and state['started_at']:
state['started_at'] = state['started_at'].isoformat()
if 'completed_at' in state and state['completed_at']:
state['completed_at'] = state['completed_at'].isoformat()
return jsonify({"success": True, **state})
except Exception as e:
print(f"Error getting watchlist scan status: {e}")
return jsonify({"success": False, "error": str(e)}), 500
# --- Watchlist Auto-Scanning System ---
watchlist_scan_state = {
'status': 'idle',
'results': [],
'summary': {},
'error': None
}
def start_watchlist_auto_scanning():
"""Start automatic daily watchlist scanning"""
def daily_scan():
while True:
try:
# Wait 24 hours (86400 seconds)
time.sleep(86400)
# Check if we have artists to scan and Spotify client is available
database = get_database()
watchlist_count = database.get_watchlist_count()
if watchlist_count > 0 and spotify_client and spotify_client.is_authenticated():
# Check if wishlist auto-processing is currently running
global wishlist_auto_processing
if wishlist_auto_processing:
print("👁️ Skipping automatic daily watchlist scan: wishlist auto-processing is currently running")
continue # Skip this cycle, will try again in 24 hours
print(f"Starting automatic daily watchlist scan for {watchlist_count} artists...")
# Update global scan state
global watchlist_scan_state
watchlist_scan_state = {
'status': 'scanning',
'started_at': datetime.now(),
'results': [],
'summary': {},
'error': None
}
# Run the scan
from core.watchlist_scanner import get_watchlist_scanner
scanner = get_watchlist_scanner(spotify_client)
results = scanner.scan_all_watchlist_artists()
# Update state with results
watchlist_scan_state['status'] = 'completed'
watchlist_scan_state['results'] = results
watchlist_scan_state['completed_at'] = datetime.now()
# Calculate summary
successful_scans = [r for r in results if r.success]
total_new_tracks = sum(r.new_tracks_found for r in successful_scans)
total_added_to_wishlist = sum(r.tracks_added_to_wishlist for r in successful_scans)
watchlist_scan_state['summary'] = {
'total_artists': len(results),
'successful_scans': len(successful_scans),
'new_tracks_found': total_new_tracks,
'tracks_added_to_wishlist': total_added_to_wishlist
}
print(f"Automatic watchlist scan completed: {len(successful_scans)}/{len(results)} artists scanned successfully")
print(f"Found {total_new_tracks} new tracks, added {total_added_to_wishlist} to wishlist")
else:
print("Skipping automatic watchlist scan: no artists in watchlist or Spotify client unavailable")
except Exception as e:
print(f"Error during automatic watchlist scan: {e}")
if 'watchlist_scan_state' in globals():
watchlist_scan_state['status'] = 'error'
watchlist_scan_state['error'] = str(e)
# Start the daily scanning thread
thread = threading.Thread(target=daily_scan)
thread.daemon = True
thread.start()
print("✅ Automatic daily watchlist scanning started")
# --- Metadata Updater System ---
from concurrent.futures import ThreadPoolExecutor, as_completed
# Global state for metadata update process
metadata_update_state = {
'status': 'idle',
'current_artist': '',
'processed': 0,
'total': 0,
'percentage': 0.0,
'successful': 0,
'failed': 0,
'started_at': None,
'completed_at': None,
'error': None,
'refresh_interval_days': 30
}
metadata_update_worker = None
metadata_update_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="metadata_update")
@app.route('/api/metadata/start', methods=['POST'])
def start_metadata_update():
"""Start the metadata update process - EXACT copy of dashboard.py logic"""
global metadata_update_worker, metadata_update_state
try:
# Check if already running
if metadata_update_state['status'] == 'running':
return jsonify({"success": False, "error": "Metadata update already running"}), 400
# Get refresh interval from request
data = request.get_json() or {}
refresh_interval_days = data.get('refresh_interval_days', 30)
# Check active server and client availability - EXACTLY like dashboard.py
active_server = config_manager.get_active_media_server()
# Get appropriate media client - EXACTLY like dashboard.py start_metadata_update()
if active_server == "jellyfin":
media_client = jellyfin_client
if not media_client:
add_activity_item("", "Metadata Update", "Jellyfin client not available", "Now")
return jsonify({"success": False, "error": "Jellyfin client not available"}), 400
else: # plex
media_client = plex_client
if not media_client:
add_activity_item("", "Metadata Update", "Plex client not available", "Now")
return jsonify({"success": False, "error": "Plex client not available"}), 400
# DEBUG: Check Plex connection details
print(f"[DEBUG] Active server: {active_server}")
print(f"[DEBUG] Plex client: {media_client}")
if hasattr(media_client, 'server') and media_client.server:
print(f"[DEBUG] Plex server URL: {getattr(media_client.server, '_baseurl', 'NO_URL')}")
print(f"[DEBUG] Plex server name: {getattr(media_client.server, 'friendlyName', 'NO_NAME')}")
# Check available libraries
try:
sections = media_client.server.library.sections()
print(f"[DEBUG] Available Plex libraries: {[(s.title, s.type) for s in sections]}")
except Exception as e:
print(f"[DEBUG] Error getting Plex libraries: {e}")
else:
print(f"[DEBUG] Plex server is NOT connected!")
# Check Spotify client - EXACTLY like dashboard.py
if not spotify_client:
add_activity_item("", "Metadata Update", "Spotify client not available", "Now")
return jsonify({"success": False, "error": "Spotify client not available"}), 400
# Reset state
metadata_update_state.update({
'status': 'running',
'current_artist': 'Loading artists...',
'processed': 0,
'total': 0,
'percentage': 0.0,
'successful': 0,
'failed': 0,
'started_at': datetime.now(),
'completed_at': None,
'error': None,
'refresh_interval_days': refresh_interval_days
})
# Start the metadata update worker - EXACTLY like dashboard.py
def run_metadata_update():
try:
metadata_worker = WebMetadataUpdateWorker(
None, # Artists will be loaded in the worker thread - EXACTLY like dashboard.py
media_client,
spotify_client,
active_server,
refresh_interval_days
)
metadata_worker.run()
except Exception as e:
print(f"Error in metadata update worker: {e}")
metadata_update_state['status'] = 'error'
metadata_update_state['error'] = str(e)
add_activity_item("", "Metadata Error", str(e), "Now")
metadata_update_worker = metadata_update_executor.submit(run_metadata_update)
add_activity_item("🎵", "Metadata Update", "Loading artists from library...", "Now")
return jsonify({"success": True})
except Exception as e:
print(f"Error starting metadata update: {e}")
metadata_update_state['status'] = 'error'
metadata_update_state['error'] = str(e)
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/metadata/stop', methods=['POST'])
def stop_metadata_update():
"""Stop the metadata update process"""
global metadata_update_state
try:
if metadata_update_state['status'] == 'running':
metadata_update_state['status'] = 'stopping'
metadata_update_state['current_artist'] = 'Stopping...'
add_activity_item("⏹️", "Metadata Update", "Stopping metadata update process", "Now")
return jsonify({"success": True})
except Exception as e:
print(f"Error stopping metadata update: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/metadata/status', methods=['GET'])
def get_metadata_update_status():
"""Get current metadata update status"""
try:
# Return a copy of the state with datetime serialization
state_copy = metadata_update_state.copy()
# Convert datetime objects to ISO format for JSON serialization
if state_copy.get('started_at'):
state_copy['started_at'] = state_copy['started_at'].isoformat()
if state_copy.get('completed_at'):
state_copy['completed_at'] = state_copy['completed_at'].isoformat()
return jsonify({"success": True, "status": state_copy})
except Exception as e:
print(f"Error getting metadata update status: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/active-media-server', methods=['GET'])
def get_active_media_server():
"""Get the currently active media server"""
try:
active_server = config_manager.get_active_media_server()
return jsonify({"success": True, "active_server": active_server})
except Exception as e:
print(f"Error getting active media server: {e}")
return jsonify({"success": False, "error": str(e)}), 500
class WebMetadataUpdateWorker:
"""Web-based metadata update worker - EXACT port of dashboard.py MetadataUpdateWorker"""
def __init__(self, artists, media_client, spotify_client, server_type, refresh_interval_days=30):
self.artists = artists
self.media_client = media_client # Can be plex_client or jellyfin_client
self.spotify_client = spotify_client
self.server_type = server_type # "plex" or "jellyfin"
self.matching_engine = MusicMatchingEngine()
self.refresh_interval_days = refresh_interval_days
self.should_stop = False
self.processed_count = 0
self.successful_count = 0
self.failed_count = 0
self.max_workers = 4
self.thread_lock = threading.Lock()
def stop(self):
self.should_stop = True
def get_artist_name(self, artist):
"""Get artist name consistently across Plex and Jellyfin"""
return getattr(artist, 'title', 'Unknown Artist')
def run(self):
"""Process all artists one by one - EXACT copy from dashboard.py"""
global metadata_update_state
try:
# Load artists in background if not provided - EXACTLY like dashboard.py
if self.artists is None:
# Enable lightweight mode for Jellyfin to skip track caching
if self.server_type == "jellyfin":
self.media_client.set_metadata_only_mode(True)
all_artists = self.media_client.get_all_artists()
print(f"[DEBUG] Raw artists returned: {[getattr(a, 'title', 'NO_TITLE') for a in (all_artists or [])]}")
if not all_artists:
metadata_update_state['status'] = 'error'
metadata_update_state['error'] = f"No artists found in {self.server_type.title()} library"
add_activity_item("", "Metadata Update", metadata_update_state['error'], "Now")
return
# Filter artists that need processing
artists_to_process = [artist for artist in all_artists if self.artist_needs_processing(artist)]
self.artists = artists_to_process
# Emit loaded signal equivalent - EXACTLY like dashboard.py
if len(artists_to_process) == 0:
metadata_update_state['status'] = 'completed'
metadata_update_state['completed_at'] = datetime.now()
add_activity_item("", "Metadata Update", "All artists already have good metadata", "Now")
return
else:
add_activity_item("🎵", "Metadata Update", f"Processing {len(artists_to_process)} of {len(all_artists)} artists", "Now")
if not artists_to_process:
metadata_update_state['status'] = 'completed'
metadata_update_state['completed_at'] = datetime.now()
return
total_artists = len(self.artists)
metadata_update_state['total'] = total_artists
# Process artists in parallel using ThreadPoolExecutor - EXACTLY like dashboard.py
def process_single_artist(artist):
"""Process a single artist and return results"""
if self.should_stop or metadata_update_state['status'] == 'stopping':
return None
artist_name = getattr(artist, 'title', 'Unknown Artist')
# Double-check ignore flag right before processing
if self.media_client.is_artist_ignored(artist):
return (artist_name, True, "Skipped (ignored)")
try:
success, details = self.update_artist_metadata(artist)
return (artist_name, success, details)
except Exception as e:
return (artist_name, False, f"Error: {str(e)}")
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
# Submit all tasks
future_to_artist = {executor.submit(process_single_artist, artist): artist
for artist in self.artists}
# Process completed tasks as they finish
for future in as_completed(future_to_artist):
if self.should_stop or metadata_update_state['status'] == 'stopping':
break
result = future.result()
if result is None: # Task was cancelled
continue
artist_name, success, details = result
with self.thread_lock:
self.processed_count += 1
if success:
self.successful_count += 1
else:
self.failed_count += 1
# Update global state - equivalent to progress_updated.emit
progress_percent = (self.processed_count / total_artists) * 100
metadata_update_state.update({
'current_artist': artist_name,
'processed': self.processed_count,
'percentage': progress_percent,
'successful': self.successful_count,
'failed': self.failed_count
})
# Individual artist updates are tracked in progress but not shown as separate activity items
# This prevents spam in the activity feed (unlike dashboard which shows these in a separate widget)
# Mark as completed - equivalent to finished.emit
metadata_update_state['status'] = 'completed'
metadata_update_state['completed_at'] = datetime.now()
metadata_update_state['current_artist'] = 'Completed'
summary = f"Processed {self.processed_count} artists: {self.successful_count} updated, {self.failed_count} failed"
add_activity_item("🎵", "Metadata Complete", summary, "Now")
except Exception as e:
print(f"Metadata update failed: {e}")
metadata_update_state['status'] = 'error'
metadata_update_state['error'] = str(e)
add_activity_item("", "Metadata Error", str(e), "Now")
def artist_needs_processing(self, artist):
"""Check if an artist needs metadata processing using age-based detection - EXACT copy from dashboard.py"""
try:
# Check if artist is manually ignored
if self.media_client.is_artist_ignored(artist):
return False
# Use media client's age-based checking with configured interval
return self.media_client.needs_update_by_age(artist, self.refresh_interval_days)
except Exception as e:
print(f"Error checking artist {getattr(artist, 'title', 'Unknown')}: {e}")
return True # Process if we can't determine status
def update_artist_metadata(self, artist):
"""Update a single artist's metadata - EXACT copy from dashboard.py"""
try:
artist_name = getattr(artist, 'title', 'Unknown Artist')
# 1. Search for top 5 potential artists on Spotify
spotify_artists = self.spotify_client.search_artists(artist_name, limit=5)
if not spotify_artists:
return False, "Not found on Spotify"
# 2. Find the best match using the matching engine
best_match = None
highest_score = 0.0
plex_artist_normalized = self.matching_engine.normalize_string(artist_name)
for spotify_artist in spotify_artists:
spotify_artist_normalized = self.matching_engine.normalize_string(spotify_artist.name)
score = self.matching_engine.similarity_score(plex_artist_normalized, spotify_artist_normalized)
if score > highest_score:
highest_score = score
best_match = spotify_artist
# 3. If no suitable match is found, exit
if not best_match or highest_score < 0.7: # Confidence threshold
return False, f"No confident match found (best: '{getattr(best_match, 'name', 'N/A')}', score: {highest_score:.2f})"
spotify_artist = best_match
changes_made = []
# Update photo if needed
photo_updated = self.update_artist_photo(artist, spotify_artist)
if photo_updated:
changes_made.append("photo")
# Update genres
genres_updated = self.update_artist_genres(artist, spotify_artist)
if genres_updated:
changes_made.append("genres")
# Update album artwork (only for Plex, skip for Jellyfin due to API issues)
if self.server_type == "plex":
albums_updated = self.update_album_artwork(artist, spotify_artist)
if albums_updated > 0:
changes_made.append(f"{albums_updated} album art")
else:
# Skip album artwork for Jellyfin until API issues are resolved
print(f"Skipping album artwork updates for Jellyfin artist: {artist.title}")
if changes_made:
# Update artist biography with timestamp to track last update
biography_updated = self.media_client.update_artist_biography(artist)
if biography_updated:
changes_made.append("timestamp")
details = f"Updated {', '.join(changes_made)} (match: '{spotify_artist.name}', score: {highest_score:.2f})"
return True, details
else:
# Even if no metadata changes, update biography to record we checked this artist
self.media_client.update_artist_biography(artist)
return True, "Already up to date"
except Exception as e:
return False, str(e)
def update_artist_photo(self, artist, spotify_artist):
"""Update artist photo from Spotify - EXACT copy from dashboard.py"""
try:
# Check if artist already has a good photo
if self.artist_has_valid_photo(artist):
return False
# Get the image URL from Spotify
if not spotify_artist.image_url:
return False
image_url = spotify_artist.image_url
# Download and validate image
response = requests.get(image_url, timeout=10)
response.raise_for_status()
# Validate and convert image
image_data = self.validate_and_convert_image(response.content)
if not image_data:
return False
# Upload to media server
return self.upload_artist_poster(artist, image_data)
except Exception as e:
print(f"Error updating photo for {getattr(artist, 'title', 'Unknown')}: {e}")
return False
def update_artist_genres(self, artist, spotify_artist):
"""Update artist genres from Spotify and albums - EXACT copy from dashboard.py"""
try:
# Get existing genres
existing_genres = set(genre.tag if hasattr(genre, 'tag') else str(genre)
for genre in (artist.genres or []))
# Get Spotify artist genres
spotify_genres = set(spotify_artist.genres or [])
# Get genres from all albums
album_genres = set()
try:
for album in artist.albums():
if hasattr(album, 'genres') and album.genres:
album_genres.update(genre.tag if hasattr(genre, 'tag') else str(genre)
for genre in album.genres)
except Exception:
pass # Albums might not be accessible
# Combine all genres (prioritize Spotify genres)
all_genres = spotify_genres.union(album_genres)
# Filter out empty/invalid genres
all_genres = {g for g in all_genres if g and g.strip() and len(g.strip()) > 1}
# Only update if we have new genres and they're different
if all_genres and (not existing_genres or all_genres != existing_genres):
# Convert to list and limit to 10 genres
genre_list = list(all_genres)[:10]
# Use media client API to update genres
success = self.media_client.update_artist_genres(artist, genre_list)
if success:
return True
else:
return False
else:
return False
except Exception as e:
print(f"Error updating genres for {getattr(artist, 'title', 'Unknown')}: {e}")
return False
def update_album_artwork(self, artist, spotify_artist):
"""Update album artwork for all albums by this artist - EXACT copy from dashboard.py"""
try:
updated_count = 0
skipped_count = 0
# Get all albums for this artist
try:
albums = list(artist.albums())
except Exception:
print(f"Could not access albums for artist '{artist.title}'")
return 0
if not albums:
print(f"No albums found for artist '{artist.title}'")
return 0
for album in albums:
try:
album_title = getattr(album, 'title', 'Unknown Album')
# Check if album already has good artwork
if self.album_has_valid_artwork(album):
skipped_count += 1
continue
# Search for this specific album on Spotify
album_query = f"album:{album_title} artist:{spotify_artist.name}"
spotify_albums = self.spotify_client.search_albums(album_query, limit=3)
if not spotify_albums:
continue
# Find the best matching album
best_album = None
highest_score = 0.0
plex_album_normalized = self.matching_engine.normalize_string(album_title)
for spotify_album in spotify_albums:
spotify_album_normalized = self.matching_engine.normalize_string(spotify_album.name)
score = self.matching_engine.similarity_score(plex_album_normalized, spotify_album_normalized)
if score > highest_score:
highest_score = score
best_album = spotify_album
# If we found a good match with artwork, download it
if best_album and highest_score > 0.7 and best_album.image_url:
# Download and upload the artwork
if self.download_and_upload_album_artwork(album, best_album.image_url):
updated_count += 1
except Exception as e:
print(f"Error processing album '{getattr(album, 'title', 'Unknown')}': {e}")
continue
return updated_count
except Exception as e:
print(f"Error updating album artwork for artist '{getattr(artist, 'title', 'Unknown')}': {e}")
return 0
def album_has_valid_artwork(self, album):
"""Check if album has valid artwork - EXACT copy from dashboard.py"""
try:
if not hasattr(album, 'thumb') or not album.thumb:
return False
thumb_url = str(album.thumb)
# Completely empty or None
if not thumb_url or thumb_url.strip() == '':
return False
# Obvious placeholder text in URL
obvious_placeholders = ['no-image', 'placeholder', 'missing', 'default-album', 'blank.jpg', 'empty.png']
thumb_lower = thumb_url.lower()
for placeholder in obvious_placeholders:
if placeholder in thumb_lower:
return False
# Extremely short URLs (likely broken)
if len(thumb_url) < 20:
return False
return True
except Exception as e:
return True
def download_and_upload_album_artwork(self, album, image_url):
"""Download artwork from Spotify and upload to media server - EXACT copy from dashboard.py"""
try:
# Download image from Spotify
response = requests.get(image_url, timeout=10)
response.raise_for_status()
# Validate and convert image
image_data = self.validate_and_convert_image(response.content)
if not image_data:
return False
# Upload using media client
success = self.media_client.update_album_poster(album, image_data)
return success
except Exception as e:
print(f"Error downloading/uploading artwork for album '{getattr(album, 'title', 'Unknown')}': {e}")
return False
def artist_has_valid_photo(self, artist):
"""Check if artist has a valid photo - EXACT copy from dashboard.py"""
try:
if not hasattr(artist, 'thumb') or not artist.thumb:
return False
thumb_url = str(artist.thumb)
if 'default' in thumb_url.lower() or len(thumb_url) < 50:
return False
return True
except Exception:
return False
def validate_and_convert_image(self, image_data):
"""Validate and convert image for media server compatibility - EXACT copy from dashboard.py"""
try:
from PIL import Image
import io
# Open and validate image
image = Image.open(io.BytesIO(image_data))
# Check minimum dimensions
width, height = image.size
if width < 200 or height < 200:
return None
# Convert to JPEG for consistency
if image.format != 'JPEG':
buffer = io.BytesIO()
image.convert('RGB').save(buffer, format='JPEG', quality=95)
return buffer.getvalue()
return image_data
except Exception:
return None
def upload_artist_poster(self, artist, image_data):
"""Upload poster using media client - EXACT copy from dashboard.py"""
try:
# Use media client's update method if available
if hasattr(self.media_client, 'update_artist_poster'):
return self.media_client.update_artist_poster(artist, image_data)
# Fallback for Plex: direct API call
if self.server_type == "plex":
import requests
server = self.media_client.server
upload_url = f"{server._baseurl}/library/metadata/{artist.ratingKey}/posters"
headers = {
'X-Plex-Token': server._token,
'Content-Type': 'image/jpeg'
}
response = requests.post(upload_url, data=image_data, headers=headers)
response.raise_for_status()
# Refresh artist to see changes
artist.refresh()
return True
else:
# For other server types, return False since we only have fallback for Plex
return False
except Exception as e:
print(f"Error uploading poster: {e}")
return False
# --- Docker Helper Functions ---
def docker_resolve_url(url):
"""
Resolve localhost URLs to Docker host when running in container
"""
import os
if os.path.exists('/.dockerenv') and 'localhost' in url:
return url.replace('localhost', 'host.docker.internal')
return url
# --- Main Execution ---
def start_oauth_callback_servers():
"""Start dedicated OAuth callback servers for Spotify and Tidal"""
import threading
from http.server import HTTPServer, BaseHTTPRequestHandler
import urllib.parse
# Spotify callback server
class SpotifyCallbackHandler(BaseHTTPRequestHandler):
def do_GET(self):
print(f"🎵 Spotify callback received: {self.path}")
parsed_url = urllib.parse.urlparse(self.path)
query_params = urllib.parse.parse_qs(parsed_url.query)
if 'code' in query_params:
auth_code = query_params['code'][0]
print(f"🎵 Received Spotify authorization code: {auth_code[:10]}...")
# Manually trigger the token exchange using spotipy's auth manager
try:
from core.spotify_client import SpotifyClient
from spotipy.oauth2 import SpotifyOAuth
from config.settings import config_manager
# Get Spotify config
config = config_manager.get_spotify_config()
# Create auth manager and exchange code for token
auth_manager = SpotifyOAuth(
client_id=config['client_id'],
client_secret=config['client_secret'],
redirect_uri="http://127.0.0.1:8888/callback",
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email",
cache_path='config/.spotify_cache'
)
# Extract the authorization code and exchange it for tokens
token_info = auth_manager.get_access_token(auth_code, as_dict=True)
if token_info:
# Reinitialize the global client with new tokens
global spotify_client
spotify_client = SpotifyClient()
if spotify_client.is_authenticated():
add_activity_item("", "Spotify Auth Complete", "Successfully authenticated with Spotify", "Now")
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
self.wfile.write(b'<h1>Spotify Authentication Successful!</h1><p>You can close this window.</p>')
else:
raise Exception("Token exchange succeeded but authentication validation failed")
else:
raise Exception("Failed to exchange authorization code for access token")
except Exception as e:
print(f"🔴 Spotify token processing error: {e}")
add_activity_item("", "Spotify Auth Failed", f"Token processing failed: {str(e)}", "Now")
self.send_response(400)
self.send_header('Content-type', 'text/html')
self.end_headers()
self.wfile.write(f'<h1>Spotify Authentication Failed</h1><p>{str(e)}</p>'.encode())
else:
error = query_params.get('error', ['Unknown error'])[0]
print(f"🔴 Spotify OAuth error: {error}")
print(f"🔴 Full Spotify callback URL: {self.path}")
print(f"🔴 All query params: {query_params}")
# Only show error toast if it's not just a spurious request
if 'error' in query_params:
add_activity_item("", "Spotify Auth Failed", f"OAuth error: {error}", "Now")
else:
print("🔴 Spurious Spotify callback without code or error - ignoring")
self.send_response(400)
self.send_header('Content-type', 'text/html')
self.end_headers()
self.wfile.write(f'<h1>Spotify Authentication Failed</h1><p>{error}</p>'.encode())
def log_message(self, format, *args):
pass # Suppress server logs
# Start Spotify callback server
def run_spotify_server():
try:
spotify_server = HTTPServer(('0.0.0.0', 8888), SpotifyCallbackHandler)
print("🎵 Started Spotify OAuth callback server on port 8888")
spotify_server.serve_forever()
except Exception as e:
print(f"🔴 Failed to start Spotify callback server: {e}")
# Tidal callback server
class TidalCallbackHandler(BaseHTTPRequestHandler):
def do_GET(self):
print("🎶🎶🎶 TIDAL CALLBACK SERVER RECEIVED REQUEST 🎶🎶🎶")
parsed_url = urllib.parse.urlparse(self.path)
query_params = urllib.parse.parse_qs(parsed_url.query)
print(f"🎶 Callback path: {self.path}")
if 'code' in query_params:
auth_code = query_params['code'][0]
print(f"🎶 Received Tidal authorization code: {auth_code[:10]}...")
# Exchange the authorization code for tokens
try:
from core.tidal_client import TidalClient
# Create a temporary client and set the stored PKCE values
temp_client = TidalClient()
# Restore the PKCE values from the auth request
global tidal_oauth_state
with tidal_oauth_lock:
temp_client.code_verifier = tidal_oauth_state["code_verifier"]
temp_client.code_challenge = tidal_oauth_state["code_challenge"]
print(f"🔐 Restored PKCE - verifier: {temp_client.code_verifier[:20] if temp_client.code_verifier else 'None'}... challenge: {temp_client.code_challenge[:20] if temp_client.code_challenge else 'None'}...")
success = temp_client.fetch_token_from_code(auth_code)
if success:
# Reinitialize the global tidal client with new tokens
global tidal_client
tidal_client = TidalClient()
add_activity_item("", "Tidal Auth Complete", "Successfully authenticated with Tidal", "Now")
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
self.wfile.write(b'<h1>Tidal Authentication Successful!</h1><p>You can close this window.</p>')
else:
raise Exception("Failed to exchange authorization code for tokens")
except Exception as e:
print(f"🔴 Tidal token processing error: {e}")
add_activity_item("", "Tidal Auth Failed", f"Token processing failed: {str(e)}", "Now")
self.send_response(400)
self.send_header('Content-type', 'text/html')
self.end_headers()
self.wfile.write(f'<h1>Tidal Authentication Failed</h1><p>{str(e)}</p>'.encode())
else:
error = query_params.get('error', ['Unknown error'])[0]
print(f"🔴 Tidal OAuth error: {error}")
add_activity_item("", "Tidal Auth Failed", f"OAuth error: {error}", "Now")
self.send_response(400)
self.send_header('Content-type', 'text/html')
self.end_headers()
self.wfile.write(f'<h1>Tidal Authentication Failed</h1><p>{error}</p>'.encode())
def log_message(self, format, *args):
pass # Suppress server logs
def run_tidal_server():
try:
tidal_server = HTTPServer(('0.0.0.0', 8889), TidalCallbackHandler)
print("🎶 Started Tidal OAuth callback server on port 8889")
print(f"🎶 Tidal server listening on all interfaces, port 8889")
tidal_server.serve_forever()
except Exception as e:
print(f"🔴 Failed to start Tidal callback server: {e}")
import traceback
print(f"🔴 Full error: {traceback.format_exc()}")
# Start both servers in background threads
spotify_thread = threading.Thread(target=run_spotify_server, daemon=True)
tidal_thread = threading.Thread(target=run_tidal_server, daemon=True)
spotify_thread.start()
tidal_thread.start()
print("✅ OAuth callback servers started")
if __name__ == '__main__':
print("🚀 Starting SoulSync Web UI Server...")
print("Open your browser and navigate to http://127.0.0.1:8008")
# Start OAuth callback servers
print("🔧 Starting OAuth callback servers...")
start_oauth_callback_servers()
# Start simple background monitor when server starts
print("🔧 Starting simple background monitor...")
start_simple_background_monitor()
print("✅ Simple background monitor started (includes automatic search cleanup)")
# Start automatic wishlist processing when server starts
print("🔧 Starting automatic wishlist processing...")
start_wishlist_auto_processing()
print("✅ Automatic wishlist processing started")
# Start automatic watchlist scanning when server starts
print("🔧 Starting automatic watchlist scanning...")
start_watchlist_auto_scanning()
print("✅ Automatic watchlist scanning started")
# Initialize app start time for uptime tracking
import time
app.start_time = time.time()
# Add startup activity
add_activity_item("🚀", "System Started", "SoulSync Web UI Server initialized", "Now")
# Add a test activity to verify the system is working
add_activity_item("🔧", "Debug Test", "Activity feed system test", "Now")
app.run(host='0.0.0.0', port=8008, debug=False)