From 65cbf6ce286df7d035dcda47fcf09a16dda1ce41 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Tue, 11 Nov 2025 16:00:37 -0800 Subject: [PATCH] discover progress --- core/watchlist_scanner.py | 49 ++++- web_server.py | 85 ++++++--- webui/index.html | 66 ++++++- webui/static/script.js | 366 +++++++++++++++++++++++++++++++++++++- webui/static/style.css | 119 +++++++++++++ 5 files changed, 649 insertions(+), 36 deletions(-) diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index 255afcb8..850886c1 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -1037,27 +1037,66 @@ class WatchlistScanner: albums_by_artist[artist].append(album) # Get tracks from each album, grouped by artist + # Also add these tracks to discovery pool for fast lookup artist_tracks = {} + artist_track_data = {} # Store full track data for discovery pool + for artist, albums in albums_by_artist.items(): artist_tracks[artist] = [] + artist_track_data[artist] = [] + for album in albums: try: album_data = self.spotify_client.get_album(album['album_spotify_id']) if album_data and 'tracks' in album_data: for track in album_data['tracks']['items']: - artist_tracks[artist].append(track['id']) + track_id = track['id'] + artist_tracks[artist].append(track_id) + + # Store full track data for discovery pool + full_track = { + 'id': track_id, + 'name': track['name'], + 'artists': track.get('artists', []), + 'album': album_data, + 'duration_ms': track.get('duration_ms', 0) + } + artist_track_data[artist].append(full_track) + except Exception as e: continue # Balance by artist - max 6 tracks per artist balanced_tracks = [] + balanced_track_data = [] + for artist, tracks in artist_tracks.items(): - random.shuffle(tracks) - balanced_tracks.extend(tracks[:6]) # Max 6 per artist + # Shuffle and get indices + indices = list(range(len(tracks))) + random.shuffle(indices) + selected_indices = indices[:6] + + # Add selected tracks + for idx in selected_indices: + balanced_tracks.append(tracks[idx]) + balanced_track_data.append(artist_track_data[artist][idx]) # Shuffle and limit to 50 - random.shuffle(balanced_tracks) - release_radar_tracks = balanced_tracks[:50] + combined = list(zip(balanced_tracks, balanced_track_data)) + random.shuffle(combined) + combined = combined[:50] + + release_radar_tracks = [track_id for track_id, _ in combined] + release_radar_track_data = [track_data for _, track_data in combined] + + # Add Release Radar tracks to discovery pool so they're available for fast lookup + logger.info(f"Adding {len(release_radar_track_data)} Release Radar tracks to discovery pool...") + for track_data in release_radar_track_data: + try: + self.database.add_to_discovery_pool(track_data, is_new_release=True) + except Exception as e: + logger.warning(f"Failed to add track {track_data['name']} to discovery pool: {e}") + continue self.database.save_curated_playlist('release_radar', release_radar_tracks) logger.info(f"Release Radar curated: {len(release_radar_tracks)} tracks") diff --git a/web_server.py b/web_server.py index 4fa66706..a712000c 100644 --- a/web_server.py +++ b/web_server.py @@ -11613,7 +11613,7 @@ def get_playlist_tracks(playlist_id): 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, @@ -11631,6 +11631,33 @@ def get_playlist_tracks(playlist_id): except Exception as e: return jsonify({"error": str(e)}), 500 +@app.route('/api/spotify/album/', methods=['GET']) +def get_album_tracks(album_id): + """Fetches full track details for a specific album.""" + if not spotify_client or not spotify_client.is_authenticated(): + return jsonify({"error": "Spotify not authenticated."}), 401 + try: + album_data = spotify_client.get_album(album_id) + if not album_data: + return jsonify({"error": "Album not found"}), 404 + + # Extract tracks from album data + tracks = album_data.get('tracks', {}).get('items', []) + + # Format response + album_dict = { + 'id': album_data['id'], + 'name': album_data['name'], + 'artists': album_data.get('artists', []), + 'release_date': album_data.get('release_date', ''), + 'total_tracks': album_data.get('total_tracks', 0), + 'images': album_data.get('images', []), + 'tracks': tracks + } + return jsonify(album_dict) + except Exception as e: + return jsonify({"error": str(e)}), 500 + @app.route('/api/spotify/search_tracks', methods=['GET']) def search_spotify_tracks(): @@ -14477,33 +14504,32 @@ def get_discover_release_radar(): curated_track_ids = database.get_curated_playlist('release_radar') if curated_track_ids: - # Fetch track data directly from Spotify (release radar tracks may not be in discovery pool) + # Use curated selection - fetch track data from discovery pool (same as Discovery Weekly) + discovery_tracks = database.get_discovery_pool_tracks(limit=5000, new_releases_only=False) + tracks_by_id = {track.spotify_track_id: track for track in discovery_tracks} + selected_tracks = [] - for track_id in curated_track_ids: - try: - # Get track data from Spotify - track_data = spotify_client.get_track_details(track_id) + if track_id in tracks_by_id: + track = tracks_by_id[track_id] - if track_data: - # Get album cover from raw_data (enhanced data doesn't include images) - album_cover = None - if track_data.get('raw_data') and track_data['raw_data'].get('album', {}).get('images'): - album_cover = track_data['raw_data']['album']['images'][0]['url'] + # Parse track_data_json if it's a string + track_data = track.track_data_json + if isinstance(track_data, str): + try: + track_data = json.loads(track_data) + except: + track_data = None - selected_tracks.append({ - "spotify_track_id": track_data['id'], - "track_name": track_data['name'], - "artist_name": track_data.get('primary_artist', 'Unknown'), - "album_name": track_data['album']['name'] if track_data.get('album') else 'Unknown', - "album_cover_url": album_cover, - "duration_ms": track_data.get('duration_ms', 0), - "track_data_json": track_data.get('raw_data', track_data) - }) - except Exception as track_error: - # Skip tracks that fail to fetch - print(f"Error fetching track {track_id}: {track_error}") - continue + selected_tracks.append({ + "spotify_track_id": track.spotify_track_id, + "track_name": track.track_name, + "artist_name": track.artist_name, + "album_name": track.album_name, + "album_cover_url": track.album_cover_url, + "duration_ms": track.duration_ms, + "track_data_json": track_data # Now properly parsed + }) return jsonify({"success": True, "tracks": selected_tracks}) @@ -14534,6 +14560,15 @@ def get_discover_weekly(): for track_id in curated_track_ids: if track_id in tracks_by_id: track = tracks_by_id[track_id] + + # Parse track_data_json if it's a string + track_data = track.track_data_json + if isinstance(track_data, str): + try: + track_data = json.loads(track_data) + except: + track_data = None + selected_tracks.append({ "spotify_track_id": track.spotify_track_id, "track_name": track.track_name, @@ -14541,7 +14576,7 @@ def get_discover_weekly(): "album_name": track.album_name, "album_cover_url": track.album_cover_url, "duration_ms": track.duration_ms, - "track_data_json": track.track_data_json + "track_data_json": track_data # Now properly parsed }) return jsonify({"success": True, "tracks": selected_tracks}) diff --git a/webui/index.html b/webui/index.html index c094cb9a..9e0ef013 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1668,8 +1668,37 @@
-

Release Radar

-

Fresh tracks from new releases

+
+

Release Radar

+

Fresh tracks from new releases

+
+
+ + +
+
+ +
@@ -1683,8 +1712,37 @@
-

Discovery Weekly

-

A personalized playlist just for you

+
+

Discovery Weekly

+

A personalized playlist just for you

+
+
+ + +
+
+ +
diff --git a/webui/static/script.js b/webui/static/script.js index 5c075b33..0cf04c93 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -24213,6 +24213,11 @@ let discoverHeroIndex = 0; let discoverHeroArtists = []; let discoverHeroInterval = null; +// Store discover playlist tracks for download/sync functionality +let discoverReleaseRadarTracks = []; +let discoverWeeklyTracks = []; +let discoverRecentAlbums = []; + async function loadDiscoverPage() { console.log('Loading discover page...'); @@ -24224,6 +24229,71 @@ async function loadDiscoverPage() { loadDiscoverWeekly(), loadMoreForYou() ]); + + // Check for active syncs after page load + checkForActiveDiscoverSyncs(); +} + +async function checkForActiveDiscoverSyncs() { + // Check if Release Radar sync is active + try { + const releaseRadarResponse = await fetch('/api/sync/status/discover_release_radar'); + if (releaseRadarResponse.ok) { + const data = await releaseRadarResponse.json(); + if (data.status === 'syncing' || data.status === 'starting') { + console.log('🔄 Resuming Release Radar sync polling after page refresh'); + + // Show status display + const statusDisplay = document.getElementById('release-radar-sync-status'); + if (statusDisplay) { + statusDisplay.style.display = 'block'; + } + + // Disable button + const syncButton = document.getElementById('release-radar-sync-btn'); + if (syncButton) { + syncButton.disabled = true; + syncButton.style.opacity = '0.5'; + syncButton.style.cursor = 'not-allowed'; + } + + // Resume polling + startDiscoverSyncPolling('release_radar', 'discover_release_radar'); + } + } + } catch (error) { + // Sync not active, ignore + } + + // Check if Discovery Weekly sync is active + try { + const discoveryWeeklyResponse = await fetch('/api/sync/status/discover_discovery_weekly'); + if (discoveryWeeklyResponse.ok) { + const data = await discoveryWeeklyResponse.json(); + if (data.status === 'syncing' || data.status === 'starting') { + console.log('🔄 Resuming Discovery Weekly sync polling after page refresh'); + + // Show status display + const statusDisplay = document.getElementById('discovery-weekly-sync-status'); + if (statusDisplay) { + statusDisplay.style.display = 'block'; + } + + // Disable button + const syncButton = document.getElementById('discovery-weekly-sync-btn'); + if (syncButton) { + syncButton.disabled = true; + syncButton.style.opacity = '0.5'; + syncButton.style.cursor = 'not-allowed'; + } + + // Resume polling + startDiscoverSyncPolling('discovery_weekly', 'discover_discovery_weekly'); + } + } + } catch (error) { + // Sync not active, ignore + } } async function loadDiscoverHero() { @@ -24320,12 +24390,15 @@ async function loadDiscoverRecentReleases() { return; } + // Store albums for download functionality + discoverRecentAlbums = data.albums; + // Build carousel HTML let html = ''; - data.albums.forEach(album => { + data.albums.forEach((album, index) => { const coverUrl = album.album_cover_url || '/static/placeholder-album.png'; html += ` -
+
${album.album_name}
@@ -24367,6 +24440,9 @@ async function loadDiscoverReleaseRadar() { return; } + // Store tracks for download/sync functionality + discoverReleaseRadarTracks = data.tracks; + // Build compact playlist HTML let html = '
'; data.tracks.forEach((track, index) => { @@ -24421,6 +24497,9 @@ async function loadDiscoverWeekly() { return; } + // Store tracks for download/sync functionality + discoverWeeklyTracks = data.tracks; + // Build compact playlist HTML let html = '
'; data.tracks.forEach((track, index) => { @@ -24563,3 +24642,286 @@ async function loadMoreForYou() { } } } + +// =============================== +// DISCOVER PLAYLIST ACTIONS +// =============================== + +async function openDownloadModalForDiscoverPlaylist(playlistType, playlistName) { + console.log(`📥 Opening Download Missing Tracks modal for ${playlistName}`); + + try { + // Get tracks based on playlist type + let tracks = []; + if (playlistType === 'release_radar') { + tracks = discoverReleaseRadarTracks; + } else if (playlistType === 'discovery_weekly') { + tracks = discoverWeeklyTracks; + } + + if (!tracks || tracks.length === 0) { + showToast(`No tracks available for ${playlistName}`, 'warning'); + return; + } + + // Convert discover tracks to format expected by download modal + const spotifyTracks = tracks.map(track => { + let spotifyTrack; + + // Use track_data_json if available, otherwise construct from track data + if (track.track_data_json) { + spotifyTrack = track.track_data_json; + } else { + // Fallback: construct track object from available data + spotifyTrack = { + id: track.spotify_track_id, + name: track.track_name, + artists: [{ name: track.artist_name }], + album: { + name: track.album_name, + images: track.album_cover_url ? [{ url: track.album_cover_url }] : [] + }, + duration_ms: track.duration_ms || 0 + }; + } + + // Normalize artists to array of strings for modal compatibility + if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) { + spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a); + } + + return spotifyTrack; + }); + + // Create virtual playlist ID + const virtualPlaylistId = `discover_${playlistType}`; + + // Use existing modal system (same as YouTube/Tidal playlists) + await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks); + + } catch (error) { + console.error('Error opening download modal for discover playlist:', error); + showToast(`Failed to open download modal: ${error.message}`, 'error'); + hideLoadingOverlay(); // Ensure overlay is hidden on error + } +} + +async function startDiscoverPlaylistSync(playlistType, playlistName) { + console.log(`🔄 Starting sync for ${playlistName}`); + + // Get tracks based on playlist type + let tracks = []; + if (playlistType === 'release_radar') { + tracks = discoverReleaseRadarTracks; + } else if (playlistType === 'discovery_weekly') { + tracks = discoverWeeklyTracks; + } + + if (!tracks || tracks.length === 0) { + showToast(`No tracks available for ${playlistName}`, 'warning'); + return; + } + + // Convert to format expected by sync API + const spotifyTracks = tracks.map(track => { + let spotifyTrack; + + // Use track_data_json if available + if (track.track_data_json) { + spotifyTrack = track.track_data_json; + } else { + // Fallback: construct track object + spotifyTrack = { + id: track.spotify_track_id, + name: track.track_name, + artists: [{ name: track.artist_name }], + album: { + name: track.album_name, + images: track.album_cover_url ? [{ url: track.album_cover_url }] : [] + }, + duration_ms: track.duration_ms || 0 + }; + } + + // Normalize artists to array of strings for sync compatibility + if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) { + spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a); + } + + return spotifyTrack; + }); + + // Create virtual playlist ID + const virtualPlaylistId = `discover_${playlistType}`; + + // Store in cache for sync function + playlistTrackCache[virtualPlaylistId] = spotifyTracks; + + // Create virtual playlist object + const virtualPlaylist = { + id: virtualPlaylistId, + name: playlistName, + track_count: spotifyTracks.length + }; + + // Add to spotify playlists array if not already there + if (!spotifyPlaylists.find(p => p.id === virtualPlaylistId)) { + spotifyPlaylists.push(virtualPlaylist); + } + + // Show sync status display + const statusId = playlistType === 'release_radar' ? 'release-radar-sync-status' : 'discovery-weekly-sync-status'; + const statusDisplay = document.getElementById(statusId); + if (statusDisplay) { + statusDisplay.style.display = 'block'; + } + + // Disable sync button to prevent duplicate syncs + const buttonId = playlistType === 'release_radar' ? 'release-radar-sync-btn' : 'discovery-weekly-sync-btn'; + const syncButton = document.getElementById(buttonId); + if (syncButton) { + syncButton.disabled = true; + syncButton.style.opacity = '0.5'; + syncButton.style.cursor = 'not-allowed'; + } + + // Start sync using existing function + await startPlaylistSync(virtualPlaylistId); + + // Start polling for progress updates + startDiscoverSyncPolling(playlistType, virtualPlaylistId); +} + +// Track active discover sync pollers +const discoverSyncPollers = {}; + +function startDiscoverSyncPolling(playlistType, virtualPlaylistId) { + // Stop any existing poller for this playlist type + if (discoverSyncPollers[playlistType]) { + clearInterval(discoverSyncPollers[playlistType]); + } + + console.log(`🔄 Starting sync polling for ${playlistType} (${virtualPlaylistId})`); + + // Poll every 500ms for progress updates + discoverSyncPollers[playlistType] = setInterval(async () => { + try { + const response = await fetch(`/api/sync/status/${virtualPlaylistId}`); + if (!response.ok) { + console.log(`⚠️ Sync status response not OK: ${response.status}`); + return; + } + + const data = await response.json(); + console.log(`📊 Sync status for ${playlistType}:`, data); + + // Update UI with progress (data structure: {status: ..., progress: {...}}) + const prefix = playlistType === 'release_radar' ? 'release-radar' : 'discovery-weekly'; + const progress = data.progress || {}; + + const totalEl = document.getElementById(`${prefix}-sync-total`); + const matchedEl = document.getElementById(`${prefix}-sync-matched`); + const failedEl = document.getElementById(`${prefix}-sync-failed`); + const percentageEl = document.getElementById(`${prefix}-sync-percentage`); + + const total = progress.total_tracks || 0; + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + const processed = matched + failed; + const completionPercentage = total > 0 ? Math.round((processed / total) * 100) : 0; + + if (totalEl) totalEl.textContent = total; + if (matchedEl) matchedEl.textContent = matched; + if (failedEl) failedEl.textContent = failed; + if (percentageEl) percentageEl.textContent = completionPercentage; + + // If complete, stop polling and hide status after delay + if (data.status === 'finished') { + console.log(`✅ Sync complete for ${playlistType}`); + clearInterval(discoverSyncPollers[playlistType]); + delete discoverSyncPollers[playlistType]; + + // Re-enable sync button + const buttonId = playlistType === 'release_radar' ? 'release-radar-sync-btn' : 'discovery-weekly-sync-btn'; + const syncButton = document.getElementById(buttonId); + if (syncButton) { + syncButton.disabled = false; + syncButton.style.opacity = '1'; + syncButton.style.cursor = 'pointer'; + } + + // Show completion toast + showToast(`${playlistType === 'release_radar' ? 'Release Radar' : 'Discovery Weekly'} sync complete!`, 'success'); + + // Hide status display after 3 seconds + setTimeout(() => { + const statusDisplay = document.getElementById(`${prefix}-sync-status`); + if (statusDisplay) { + statusDisplay.style.display = 'none'; + } + }, 3000); + } + + } catch (error) { + console.error(`❌ Error polling sync status for ${playlistType}:`, error); + } + }, 500); +} + +async function openDownloadModalForRecentAlbum(albumIndex) { + const album = discoverRecentAlbums[albumIndex]; + if (!album) { + showToast('Album data not found', 'error'); + return; + } + + console.log(`📥 Opening Download Missing Tracks modal for album: ${album.album_name}`); + showLoadingOverlay(`Loading tracks for ${album.album_name}...`); + + try { + // Fetch album tracks from Spotify API via backend + const response = await fetch(`/api/spotify/album/${album.album_spotify_id}`); + if (!response.ok) { + throw new Error('Failed to fetch album tracks'); + } + + const albumData = await response.json(); + if (!albumData.tracks || albumData.tracks.length === 0) { + throw new Error('No tracks found in album'); + } + + // Convert to expected format + const spotifyTracks = albumData.tracks.map(track => { + // Normalize artists to array of strings + let artists = track.artists || [{ name: album.artist_name }]; + if (Array.isArray(artists)) { + artists = artists.map(a => a.name || a); + } + + return { + id: track.id, + name: track.name, + artists: artists, + album: { + name: album.album_name, + images: album.album_cover_url ? [{ url: album.album_cover_url }] : [] + }, + duration_ms: track.duration_ms || 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); + + hideLoadingOverlay(); + + } catch (error) { + console.error('Error opening album download modal:', error); + showToast(`Failed to load album: ${error.message}`, 'error'); + hideLoadingOverlay(); + } +} diff --git a/webui/static/style.css b/webui/static/style.css index c37d372a..d2dabf05 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -16256,6 +16256,10 @@ body { .discover-section-header { margin-bottom: 24px; + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 20px; } .discover-section-title { @@ -16271,6 +16275,121 @@ body { margin: 0; } +.discover-section-actions { + display: flex; + gap: 10px; + flex-shrink: 0; +} + +.discover-section-actions .action-button { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border-radius: 6px; + border: none; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.discover-section-actions .action-button .button-icon { + font-size: 16px; +} + +.discover-section-actions .action-button.primary { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: #fff; +} + +.discover-section-actions .action-button.primary:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); +} + +.discover-section-actions .action-button.secondary { + background: rgba(255, 255, 255, 0.05); + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.discover-section-actions .action-button.secondary:hover { + background: rgba(255, 255, 255, 0.1); + transform: translateY(-2px); +} + +/* Sync Status Display */ +.discover-sync-status { + margin: 16px 20px 20px 20px; + padding: 16px 20px; + background: rgba(102, 126, 234, 0.1); + border: 1px solid rgba(102, 126, 234, 0.3); + border-radius: 8px; + animation: slideDown 0.3s ease; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.sync-status-content { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; +} + +.sync-status-label { + display: flex; + align-items: center; + gap: 8px; + color: #fff; + font-size: 14px; + font-weight: 500; +} + +.sync-icon { + display: inline-block; + font-size: 18px; + animation: spin 1s linear infinite; + transform-origin: center center; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.sync-status-stats { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + color: #fff; + font-family: 'Courier New', monospace; +} + +.sync-stat { + font-weight: 600; +} + +.sync-separator { + color: rgba(255, 255, 255, 0.3); + font-weight: 300; +} + /* Carousel Styles */ .discover-carousel { display: flex;