From c251edc7098601b5704d958c397cb96dcb87e5b1 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Tue, 6 Jan 2026 16:04:05 -0800 Subject: [PATCH] Improve stuck flag recovery and album classification Adds critical fixes to reschedule timers after stuck flag recovery for wishlist and watchlist processes, preventing deadlocks and ensuring continuity. Refines album classification by prioritizing Spotify's album_type over track count heuristics, and ensures album_type is included in API responses. Updates frontend logic to pass and use fresh album/artist context for discover_album modals, improving metadata accuracy and display. --- web_server.py | 55 +++++++++++++++++++++++++++++++------ webui/static/script.js | 62 ++++++++++++++++++++++++++++++++---------- 2 files changed, 94 insertions(+), 23 deletions(-) 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();