diff --git a/web_server.py b/web_server.py index 79ff1fd1..d5dd98f6 100644 --- a/web_server.py +++ b/web_server.py @@ -8778,6 +8778,13 @@ def check_and_recover_stuck_flags(): with wishlist_timer_lock: wishlist_auto_processing = False wishlist_auto_processing_timestamp = 0 + + # CRITICAL FIX: Reschedule timer after recovery to maintain continuity + print("🔄 [Stuck Recovery] Rescheduling wishlist processing in 30 minutes") + try: + schedule_next_wishlist_processing() + except Exception as e: + print(f"❌ [Stuck Recovery] Failed to reschedule wishlist processing: {e}") return True # Check watchlist flag @@ -8788,6 +8795,13 @@ def check_and_recover_stuck_flags(): with watchlist_timer_lock: watchlist_auto_scanning = False watchlist_auto_scanning_timestamp = 0 + + # CRITICAL FIX: Reschedule timer after recovery to maintain continuity + print("🔄 [Stuck Recovery] Rescheduling watchlist scan in 24 hours") + try: + schedule_next_watchlist_scan() + except Exception as e: + print(f"❌ [Stuck Recovery] Failed to reschedule watchlist scan: {e}") return True return False @@ -8886,13 +8900,21 @@ def _process_wishlist_automatically(): print("🤖 [Auto-Wishlist] Timer triggered - starting automatic wishlist processing...") try: + # CRITICAL FIX: Use smart stuck detection BEFORE acquiring lock + # This prevents deadlock and handles stuck flags (2-hour timeout) + if is_wishlist_actually_processing(): + print("⚠️ [Auto-Wishlist] Already processing (verified with stuck detection), skipping.") + schedule_next_wishlist_processing() + return + # Check conditions and set flag should_skip_already_running = False should_skip_watchlist_conflict = False with wishlist_timer_lock: + # Re-check inside lock to handle race conditions if wishlist_auto_processing: - print("⚠️ Wishlist auto-processing already running, skipping.") + print("⚠️ [Auto-Wishlist] Already processing (race condition check), skipping.") should_skip_already_running = True # Check if watchlist scan is currently running (using smart detection) @@ -9053,19 +9075,25 @@ def _process_wishlist_automatically(): total_tracks = album_data.get('total_tracks') album_type = album_data.get('album_type', 'album').lower() - # Categorize by track count if available, otherwise use album_type - # Single: 1 track, EP: 2-5 tracks, Album: 6+ tracks + # CRITICAL FIX: Prioritize Spotify's album_type (most accurate source) + # Only fall back to track count heuristic if album_type is missing is_single_or_ep = False is_album = False - if total_tracks is not None and total_tracks > 0: - # Use track count (most accurate) + # Use Spotify's album_type classification first + if album_type and album_type in ['single', 'ep', 'album', 'compilation']: + # Spotify's classification is authoritative + is_single_or_ep = album_type in ['single', 'ep'] + is_album = album_type in ['album', 'compilation'] + elif total_tracks is not None and total_tracks > 0: + # Fallback: Use track count heuristic only if album_type unavailable + # Single: 1 track, EP: 2-5 tracks, Album: 6+ tracks is_single_or_ep = total_tracks < 6 is_album = total_tracks >= 6 else: - # Fall back to Spotify's album_type - is_single_or_ep = album_type in ['single', 'ep'] - is_album = album_type == 'album' + # No classification data available - default to album + is_album = True + is_single_or_ep = False if current_cycle == 'singles' and is_single_or_ep: filtered_tracks.append(track) @@ -13883,6 +13911,7 @@ def get_album_tracks(album_id): 'artists': album_data.get('artists', []), 'release_date': album_data.get('release_date', ''), 'total_tracks': album_data.get('total_tracks', 0), + 'album_type': album_data.get('album_type', 'album'), # CRITICAL FIX: Include album_type for correct classification 'images': album_data.get('images', []), 'tracks': tracks } @@ -17470,9 +17499,17 @@ def _process_watchlist_scan_automatically(): print("🤖 [Auto-Watchlist] Timer triggered - starting automatic watchlist scan...") try: + # CRITICAL FIX: Use smart stuck detection BEFORE acquiring lock + # This prevents deadlock and handles stuck flags (2-hour timeout) + if is_watchlist_actually_scanning(): + print("⚠️ [Auto-Watchlist] Already scanning (verified with stuck detection), skipping.") + schedule_next_watchlist_scan() + return + with watchlist_timer_lock: + # Re-check inside lock to handle race conditions if watchlist_auto_scanning: - print("⚠️ Watchlist auto-scanning already running, skipping.") + print("⚠️ [Auto-Watchlist] Already scanning (race condition check), skipping.") schedule_next_watchlist_scan() return diff --git a/webui/static/script.js b/webui/static/script.js index 29a22e91..6b3fc795 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -5667,7 +5667,7 @@ function exportPlaylistAsM3U(playlistId) { console.log(`✅ Exported M3U - Total: ${process.tracks.length}, Available: ${availableCount}, Missing: ${missingCount}`); } -async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks) { +async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks, artist = null, album = null) { showLoadingOverlay('Loading YouTube playlist...'); // Check if a process is already active for this virtual playlist if (activeDownloadProcesses[virtualPlaylistId]) { @@ -5710,7 +5710,9 @@ async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistNam poller: null, batchId: null, playlist: virtualPlaylist, - tracks: spotifyTracks + tracks: spotifyTracks, + artist: artist, // ✅ Store artist context + album: album // ✅ Store album context }; // Generate hero section with dynamic source detection @@ -5726,9 +5728,11 @@ async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistNam // Store metadata for discover download sidebar (will be added when Begin Analysis is clicked) if (source === 'SoulSync' || virtualPlaylistId.startsWith('discover_lb_') || virtualPlaylistId.startsWith('listenbrainz_')) { - // Extract image URL from first track's album cover + // Extract image URL from album context or first track's album cover let imageUrl = null; - if (spotifyTracks && spotifyTracks.length > 0) { + if (album && album.images && album.images.length > 0) { + imageUrl = album.images[0].url; + } else if (spotifyTracks && spotifyTracks.length > 0) { const firstTrack = spotifyTracks[0]; if (firstTrack.album && firstTrack.album.images && firstTrack.album.images.length > 0) { imageUrl = firstTrack.album.images[0].url; @@ -5737,11 +5741,22 @@ async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistNam // Store in process for later use when Begin Analysis is clicked activeDownloadProcesses[virtualPlaylistId].discoverMetadata = { imageUrl: imageUrl, - type: 'album' + type: album ? 'album' : 'playlist' // ✅ Use 'album' if album context provided }; } - const heroContext = { + // CRITICAL FIX: Use album context for discover_album playlists + const isDiscoverAlbum = virtualPlaylistId.startsWith('discover_album_'); + const heroContext = isDiscoverAlbum && album && artist ? { + type: 'album', // ✅ Show as album, not playlist + album: { + name: album.name, + artist: artist.name, // ✅ Use actual artist name, not 'SoulSync' + images: album.images || [] + }, + trackCount: spotifyTracks.length, + playlistId: virtualPlaylistId + } : { type: 'playlist', playlist: { name: playlistName, owner: source }, trackCount: spotifyTracks.length, @@ -32839,10 +32854,10 @@ async function openDownloadModalForRecentAlbum(albumIndex) { throw new Error('No tracks found in album'); } - // Convert to expected format + // Convert to expected format - CRITICAL FIX: Use fresh albumData from Spotify, not cached album const spotifyTracks = albumData.tracks.map(track => { // Normalize artists to array of strings - let artists = track.artists || [{ name: album.artist_name }]; + let artists = track.artists || albumData.artists || [{ name: album.artist_name }]; if (Array.isArray(artists)) { artists = artists.map(a => a.name || a); } @@ -32852,19 +32867,38 @@ async function openDownloadModalForRecentAlbum(albumIndex) { name: track.name, artists: artists, album: { - name: album.album_name, - images: album.album_cover_url ? [{ url: album.album_cover_url }] : [] + id: albumData.id, // ✅ Album ID for proper tracking + name: albumData.name, // ✅ Use fresh data, not cached + album_type: albumData.album_type || 'album', // ✅ Critical: Album type for classification + total_tracks: albumData.total_tracks || 0, // ✅ Total tracks for context + release_date: albumData.release_date || '', // ✅ Release date + images: albumData.images || [] // ✅ Use Spotify images }, - duration_ms: track.duration_ms || 0 + duration_ms: track.duration_ms || 0, + track_number: track.track_number || 0 }; }); // Create virtual playlist ID const virtualPlaylistId = `discover_album_${album.album_spotify_id}`; - const playlistName = `${album.album_name} - ${album.artist_name}`; - // Open download modal - await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks); + // CRITICAL FIX: Pass proper artist/album context for modal display + const artistContext = { + id: album.artist_spotify_id, + name: album.artist_name + }; + + const albumContext = { + id: albumData.id, + name: albumData.name, + album_type: albumData.album_type || 'album', + total_tracks: albumData.total_tracks || 0, + release_date: albumData.release_date || '', + images: albumData.images || [] + }; + + // Open download modal with artist/album context + await openDownloadMissingModalForYouTube(virtualPlaylistId, albumData.name, spotifyTracks, artistContext, albumContext); hideLoadingOverlay();