diff --git a/core/personalized_playlists.py b/core/personalized_playlists.py index 8dfbd7e3..cc69143c 100644 --- a/core/personalized_playlists.py +++ b/core/personalized_playlists.py @@ -29,32 +29,13 @@ class PersonalizedPlaylistsService: Get recently added tracks from library. Returns tracks ordered by date_added DESC + + NOTE: This requires library tracks to have Spotify metadata which may not be available. + Returns empty list if schema incompatible. """ try: - with self.database._get_connection() as conn: - cursor = conn.cursor() - - cursor.execute(""" - SELECT - t.id, - t.spotify_track_id, - t.title as track_name, - t.duration_ms, - ar.name as artist_name, - al.title as album_name, - al.cover_url as album_cover_url, - t.popularity, - t.date_added - FROM tracks t - LEFT JOIN artists ar ON t.artist_id = ar.id - LEFT JOIN albums al ON t.album_id = al.id - WHERE t.spotify_track_id IS NOT NULL - ORDER BY t.date_added DESC - LIMIT ? - """, (limit,)) - - rows = cursor.fetchall() - return [dict(row) for row in rows] + logger.warning("Recently Added requires Spotify-linked library tracks - returning empty") + return [] except Exception as e: logger.error(f"Error getting recently added tracks: {e}") @@ -268,11 +249,12 @@ class PersonalizedPlaylistsService: # ======================================== def get_popular_picks(self, limit: int = 50) -> List[Dict]: - """Get high popularity tracks from discovery pool""" + """Get high popularity tracks from discovery pool with diversity (max 2 tracks per album/artist)""" try: with self.database._get_connection() as conn: cursor = conn.cursor() + # Get more tracks than needed to allow for filtering cursor.execute(""" SELECT spotify_track_id, @@ -286,10 +268,35 @@ class PersonalizedPlaylistsService: WHERE popularity >= 60 ORDER BY popularity DESC, RANDOM() LIMIT ? - """, (limit,)) + """, (limit * 3,)) # Get 3x more for diversity filtering rows = cursor.fetchall() - return [dict(row) for row in rows] + all_tracks = [dict(row) for row in rows] + + # Apply diversity constraint: max 2 tracks per album, max 3 per artist + tracks_by_album = {} + tracks_by_artist = {} + diverse_tracks = [] + + for track in all_tracks: + album = track['album_name'] + artist = track['artist_name'] + + # Count current tracks for this album/artist + album_count = tracks_by_album.get(album, 0) + artist_count = tracks_by_artist.get(artist, 0) + + # Apply limits: max 2 per album, max 3 per artist + if album_count < 2 and artist_count < 3: + diverse_tracks.append(track) + tracks_by_album[album] = album_count + 1 + tracks_by_artist[artist] = artist_count + 1 + + if len(diverse_tracks) >= limit: + break + + logger.info(f"Popular Picks: Selected {len(diverse_tracks)} tracks with diversity") + return diverse_tracks[:limit] except Exception as e: logger.error(f"Error getting popular picks: {e}") @@ -660,20 +667,36 @@ class PersonalizedPlaylistsService: logger.info(f"Building custom playlist from {len(seed_artist_ids)} seed artists") - # Step 1: Get similar artists for each seed + # Step 1: Get similar artists for each seed from database all_similar_artists = [] seen_artist_ids = set(seed_artist_ids) # Don't include seed artists themselves for seed_artist_id in seed_artist_ids: try: - # Get similar artists from Spotify - similar = self.spotify_client.get_similar_artists(seed_artist_id) - - if similar: - for artist in similar[:10]: # Max 10 per seed - if artist.id not in seen_artist_ids: - all_similar_artists.append(artist) - seen_artist_ids.add(artist.id) + # Get similar artists from database (cached from MusicMap) + with self.database._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT similar_artist_spotify_id, similar_artist_name + FROM similar_artists + WHERE source_artist_id = ? + ORDER BY similarity_rank ASC + LIMIT 10 + """, (seed_artist_id,)) + + rows = cursor.fetchall() + + for row in rows: + artist_id = row['similar_artist_spotify_id'] + artist_name = row['similar_artist_name'] + + if artist_id not in seen_artist_ids: + # Create artist-like object + all_similar_artists.append({ + 'id': artist_id, + 'name': artist_name + }) + seen_artist_ids.add(artist_id) if len(all_similar_artists) >= 25: break @@ -685,7 +708,7 @@ class PersonalizedPlaylistsService: logger.warning(f"Error getting similar artists for {seed_artist_id}: {e}") continue - logger.info(f"Found {len(all_similar_artists)} similar artists") + logger.info(f"Found {len(all_similar_artists)} similar artists from database") if not all_similar_artists: return {'tracks': [], 'error': 'No similar artists found'} @@ -698,7 +721,7 @@ class PersonalizedPlaylistsService: for artist in similar_artists_to_use: try: albums = self.spotify_client.get_artist_albums( - artist.id, + artist['id'], album_type='album,single', limit=10 ) @@ -710,7 +733,7 @@ class PersonalizedPlaylistsService: time.sleep(0.3) # Rate limiting except Exception as e: - logger.warning(f"Error getting albums for {artist.name}: {e}") + logger.warning(f"Error getting albums for {artist['name']}: {e}") continue logger.info(f"Found {len(all_albums)} total albums") @@ -768,8 +791,11 @@ class PersonalizedPlaylistsService: 'description': f'Built from {len(seed_artist_ids)} seed artists', 'track_count': len(final_tracks), 'tracks': final_tracks, - 'similar_artists_count': len(similar_artists_to_use), - 'albums_used': len(selected_albums) + 'metadata': { + 'total_tracks': len(final_tracks), + 'similar_artists_count': len(similar_artists_to_use), + 'albums_count': len(selected_albums) + } } except Exception as e: diff --git a/core/seasonal_discovery.py b/core/seasonal_discovery.py index ded76fed..cc628296 100644 --- a/core/seasonal_discovery.py +++ b/core/seasonal_discovery.py @@ -624,13 +624,21 @@ class SeasonalDiscoveryService: for track in album_data['tracks'].get('items', []): # Use track's actual artist, not album artist track_artist = track['artists'][0]['name'] if track.get('artists') else album['artist_name'] - all_tracks.append({ + + track_data = { 'spotify_track_id': track['id'], 'track_name': track['name'], 'artist_name': track_artist, 'album_name': album['album_name'], - 'popularity': album.get('popularity', 50) - }) + 'popularity': album.get('popularity', 50), + 'album_cover_url': album.get('album_cover_url'), + 'duration_ms': track.get('duration_ms', 0) + } + + all_tracks.append(track_data) + + # Also save track to seasonal_tracks table for later retrieval + self._add_seasonal_track(season_key, track_data) import time time.sleep(0.3) # Rate limiting diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index 60e1aca7..d7e08620 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -871,8 +871,8 @@ class WatchlistScanner: cursor = conn.cursor() cursor.execute(""" SELECT DISTINCT a.title, ar.name as artist_name - FROM albums_new a - JOIN artists_new ar ON a.artist_id = ar.id + FROM albums a + JOIN artists ar ON a.artist_id = ar.id ORDER BY RANDOM() LIMIT 5 """) @@ -1301,12 +1301,16 @@ class WatchlistScanner: balanced_tracks.append(track['id']) balanced_track_data.append(track) - # IMPROVED: Sort by score first, then shuffle within score tiers for variety + # IMPROVED: Sort by score first, then shuffle for variety balanced_track_data.sort(key=lambda t: t['score'], reverse=True) - # Take top 50 - release_radar_tracks = [track['id'] for track in balanced_track_data[:50]] - release_radar_track_data = balanced_track_data[:50] + # Take top 75, then shuffle for final randomization (prevents album grouping) + top_tracks = balanced_track_data[:75] + random.shuffle(top_tracks) + + # Take final 50 tracks + release_radar_tracks = [track['id'] for track in top_tracks[:50]] + release_radar_track_data = top_tracks[:50] # 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...") diff --git a/webui/index.html b/webui/index.html index 39d045f2..78b2d3a8 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2024,8 +2024,32 @@ -
- +
diff --git a/webui/static/script.js b/webui/static/script.js index 7db48116..65ce4866 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -25507,15 +25507,62 @@ function hideSeasonalSections() { } } -async function openDownloadModalForSeasonalAlbum(index) { - const album = discoverSeasonalAlbums[index]; - if (!album) return; +async function openDownloadModalForSeasonalAlbum(albumIndex) { + const album = discoverSeasonalAlbums[albumIndex]; + if (!album) { + showToast('Album data not found', 'error'); + return; + } - // Fetch album tracks - const albumDetails = await fetchAlbumTracks(album.spotify_album_id); - if (!albumDetails) 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.spotify_album_id}`); + if (!response.ok) { + throw new Error('Failed to fetch album tracks'); + } - openDownloadMissingModal(albumDetails.tracks, albumDetails.name); + 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 = `seasonal_album_${album.spotify_album_id}`; + const playlistName = `${album.album_name} - ${album.artist_name}`; + + // Open download modal (same as Recent Releases) + await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks); + + hideLoadingOverlay(); + + } catch (error) { + console.error(`Error loading seasonal album: ${error.message}`); + hideLoadingOverlay(); + showToast(`Failed to load album tracks: ${error.message}`, 'error'); + } } async function openDownloadModalForSeasonalPlaylist() { @@ -25915,6 +25962,8 @@ function renderBuildPlaylistSelectedArtists() { generateBtn.style.opacity = '1'; } +let buildPlaylistTracks = []; + async function generateBuildPlaylist() { if (buildPlaylistSelectedArtists.length === 0) { alert('Please select at least 1 artist'); @@ -25923,12 +25972,17 @@ async function generateBuildPlaylist() { const generateBtn = document.getElementById('build-playlist-generate-btn'); const resultsContainer = document.getElementById('build-playlist-results'); + const resultsWrapper = document.getElementById('build-playlist-results-wrapper'); const loadingIndicator = document.getElementById('build-playlist-loading'); + const metadataDisplay = document.getElementById('build-playlist-metadata-display'); + const titleEl = document.getElementById('build-playlist-results-title'); + const subtitleEl = document.getElementById('build-playlist-results-subtitle'); // Show loading generateBtn.disabled = true; generateBtn.style.opacity = '0.5'; - loadingIndicator.style.display = 'block'; + loadingIndicator.style.display = 'flex'; + resultsWrapper.style.display = 'none'; resultsContainer.innerHTML = ''; try { @@ -25951,19 +26005,29 @@ async function generateBuildPlaylist() { throw new Error('Invalid playlist data'); } - // Render playlist - renderCompactPlaylist(resultsContainer, data.playlist.tracks); + // Store tracks globally + buildPlaylistTracks = data.playlist.tracks; - // Show metadata + // Update title and subtitle + const artistNames = buildPlaylistSelectedArtists.map(a => a.name).join(', '); + titleEl.textContent = 'Custom Playlist'; + subtitleEl.textContent = `Based on: ${artistNames}`; + + // Render metadata const metadata = data.playlist.metadata; - const metadataHtml = ` + metadataDisplay.innerHTML = `

Total Tracks: ${metadata.total_tracks}

Similar Artists Used: ${metadata.similar_artists_count}

Albums Sampled: ${metadata.albums_count}

`; - resultsContainer.insertAdjacentHTML('beforebegin', metadataHtml); + + // Render playlist + renderCompactPlaylist(resultsContainer, data.playlist.tracks); + + // Show results wrapper + resultsWrapper.style.display = 'block'; } catch (error) { console.error('Error generating playlist:', error); @@ -25975,6 +26039,20 @@ async function generateBuildPlaylist() { } } +async function openDownloadModalForBuildPlaylist() { + if (!buildPlaylistTracks || buildPlaylistTracks.length === 0) { + showToast('No playlist tracks available', 'warning'); + return; + } + + const artistNames = buildPlaylistSelectedArtists.map(a => a.name).join(', '); + const playlistName = `Custom Playlist - ${artistNames}`; + const virtualPlaylistId = 'build_playlist_custom'; + + // Open download modal + await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, buildPlaylistTracks); +} + function openDailyMix(mixIndex) { const mix = personalizedDailyMixes[mixIndex]; if (!mix || !mix.tracks) return; @@ -26013,6 +26091,8 @@ async function openDownloadModalForDiscoverPlaylist(playlistType, playlistName) tracks = personalizedTopTracks; } else if (playlistType === 'forgotten_favorites') { tracks = personalizedForgottenFavorites; + } else if (playlistType === 'build_playlist') { + tracks = buildPlaylistTracks; } if (!tracks || tracks.length === 0) {