discover progress

This commit is contained in:
Broque Thomas 2025-11-16 20:26:19 -08:00
parent f75ffdf6cb
commit 86fe7dde1f
5 changed files with 206 additions and 64 deletions

View file

@ -29,32 +29,13 @@ class PersonalizedPlaylistsService:
Get recently added tracks from library. Get recently added tracks from library.
Returns tracks ordered by date_added DESC 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: try:
with self.database._get_connection() as conn: logger.warning("Recently Added requires Spotify-linked library tracks - returning empty")
cursor = conn.cursor() return []
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]
except Exception as e: except Exception as e:
logger.error(f"Error getting recently added tracks: {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]: 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: try:
with self.database._get_connection() as conn: with self.database._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
# Get more tracks than needed to allow for filtering
cursor.execute(""" cursor.execute("""
SELECT SELECT
spotify_track_id, spotify_track_id,
@ -286,10 +268,35 @@ class PersonalizedPlaylistsService:
WHERE popularity >= 60 WHERE popularity >= 60
ORDER BY popularity DESC, RANDOM() ORDER BY popularity DESC, RANDOM()
LIMIT ? LIMIT ?
""", (limit,)) """, (limit * 3,)) # Get 3x more for diversity filtering
rows = cursor.fetchall() 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: except Exception as e:
logger.error(f"Error getting popular picks: {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") 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 = [] all_similar_artists = []
seen_artist_ids = set(seed_artist_ids) # Don't include seed artists themselves seen_artist_ids = set(seed_artist_ids) # Don't include seed artists themselves
for seed_artist_id in seed_artist_ids: for seed_artist_id in seed_artist_ids:
try: try:
# Get similar artists from Spotify # Get similar artists from database (cached from MusicMap)
similar = self.spotify_client.get_similar_artists(seed_artist_id) 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,))
if similar: rows = cursor.fetchall()
for artist in similar[:10]: # Max 10 per seed
if artist.id not in seen_artist_ids: for row in rows:
all_similar_artists.append(artist) artist_id = row['similar_artist_spotify_id']
seen_artist_ids.add(artist.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: if len(all_similar_artists) >= 25:
break break
@ -685,7 +708,7 @@ class PersonalizedPlaylistsService:
logger.warning(f"Error getting similar artists for {seed_artist_id}: {e}") logger.warning(f"Error getting similar artists for {seed_artist_id}: {e}")
continue 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: if not all_similar_artists:
return {'tracks': [], 'error': 'No similar artists found'} return {'tracks': [], 'error': 'No similar artists found'}
@ -698,7 +721,7 @@ class PersonalizedPlaylistsService:
for artist in similar_artists_to_use: for artist in similar_artists_to_use:
try: try:
albums = self.spotify_client.get_artist_albums( albums = self.spotify_client.get_artist_albums(
artist.id, artist['id'],
album_type='album,single', album_type='album,single',
limit=10 limit=10
) )
@ -710,7 +733,7 @@ class PersonalizedPlaylistsService:
time.sleep(0.3) # Rate limiting time.sleep(0.3) # Rate limiting
except Exception as e: 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 continue
logger.info(f"Found {len(all_albums)} total albums") 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', 'description': f'Built from {len(seed_artist_ids)} seed artists',
'track_count': len(final_tracks), 'track_count': len(final_tracks),
'tracks': final_tracks, 'tracks': final_tracks,
'similar_artists_count': len(similar_artists_to_use), 'metadata': {
'albums_used': len(selected_albums) 'total_tracks': len(final_tracks),
'similar_artists_count': len(similar_artists_to_use),
'albums_count': len(selected_albums)
}
} }
except Exception as e: except Exception as e:

View file

@ -624,13 +624,21 @@ class SeasonalDiscoveryService:
for track in album_data['tracks'].get('items', []): for track in album_data['tracks'].get('items', []):
# Use track's actual artist, not album artist # Use track's actual artist, not album artist
track_artist = track['artists'][0]['name'] if track.get('artists') else album['artist_name'] track_artist = track['artists'][0]['name'] if track.get('artists') else album['artist_name']
all_tracks.append({
track_data = {
'spotify_track_id': track['id'], 'spotify_track_id': track['id'],
'track_name': track['name'], 'track_name': track['name'],
'artist_name': track_artist, 'artist_name': track_artist,
'album_name': album['album_name'], '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 import time
time.sleep(0.3) # Rate limiting time.sleep(0.3) # Rate limiting

View file

@ -871,8 +871,8 @@ class WatchlistScanner:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute("""
SELECT DISTINCT a.title, ar.name as artist_name SELECT DISTINCT a.title, ar.name as artist_name
FROM albums_new a FROM albums a
JOIN artists_new ar ON a.artist_id = ar.id JOIN artists ar ON a.artist_id = ar.id
ORDER BY RANDOM() ORDER BY RANDOM()
LIMIT 5 LIMIT 5
""") """)
@ -1301,12 +1301,16 @@ class WatchlistScanner:
balanced_tracks.append(track['id']) balanced_tracks.append(track['id'])
balanced_track_data.append(track) 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) balanced_track_data.sort(key=lambda t: t['score'], reverse=True)
# Take top 50 # Take top 75, then shuffle for final randomization (prevents album grouping)
release_radar_tracks = [track['id'] for track in balanced_track_data[:50]] top_tracks = balanced_track_data[:75]
release_radar_track_data = balanced_track_data[:50] 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 # 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...") logger.info(f"Adding {len(release_radar_track_data)} Release Radar tracks to discovery pool...")

View file

@ -2024,8 +2024,32 @@
</div> </div>
<!-- Generated Playlist Results --> <!-- Generated Playlist Results -->
<div id="build-playlist-results" class="discover-playlist-container compact"> <div id="build-playlist-results-wrapper" style="display: none;">
<!-- Generated playlist will appear here --> <!-- Playlist Header with Actions -->
<div class="discover-section-header" style="margin-top: 20px;">
<div>
<h3 id="build-playlist-results-title" style="margin: 0; color: #fff; font-size: 18px;">Generated Playlist</h3>
<p id="build-playlist-results-subtitle" style="margin: 4px 0 0 0; color: #999; font-size: 13px;"></p>
</div>
<div class="discover-section-actions">
<button class="action-button secondary" onclick="openDownloadModalForBuildPlaylist()" title="Download missing tracks">
<span class="button-icon"></span>
<span class="button-text">Download</span>
</button>
<button class="action-button primary" id="build-playlist-sync-btn" onclick="startDiscoverPlaylistSync('build_playlist', 'Custom Playlist')" title="Sync to media server">
<span class="button-icon"></span>
<span class="button-text">Sync</span>
</button>
</div>
</div>
<!-- Metadata Display -->
<div id="build-playlist-metadata-display"></div>
<!-- Track List -->
<div id="build-playlist-results" class="discover-playlist-container compact">
<!-- Generated playlist will appear here -->
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -25507,15 +25507,62 @@ function hideSeasonalSections() {
} }
} }
async function openDownloadModalForSeasonalAlbum(index) { async function openDownloadModalForSeasonalAlbum(albumIndex) {
const album = discoverSeasonalAlbums[index]; const album = discoverSeasonalAlbums[albumIndex];
if (!album) return; if (!album) {
showToast('Album data not found', 'error');
return;
}
// Fetch album tracks console.log(`📥 Opening Download Missing Tracks modal for album: ${album.album_name}`);
const albumDetails = await fetchAlbumTracks(album.spotify_album_id); showLoadingOverlay(`Loading tracks for ${album.album_name}...`);
if (!albumDetails) return;
openDownloadMissingModal(albumDetails.tracks, albumDetails.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');
}
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() { async function openDownloadModalForSeasonalPlaylist() {
@ -25915,6 +25962,8 @@ function renderBuildPlaylistSelectedArtists() {
generateBtn.style.opacity = '1'; generateBtn.style.opacity = '1';
} }
let buildPlaylistTracks = [];
async function generateBuildPlaylist() { async function generateBuildPlaylist() {
if (buildPlaylistSelectedArtists.length === 0) { if (buildPlaylistSelectedArtists.length === 0) {
alert('Please select at least 1 artist'); alert('Please select at least 1 artist');
@ -25923,12 +25972,17 @@ async function generateBuildPlaylist() {
const generateBtn = document.getElementById('build-playlist-generate-btn'); const generateBtn = document.getElementById('build-playlist-generate-btn');
const resultsContainer = document.getElementById('build-playlist-results'); const resultsContainer = document.getElementById('build-playlist-results');
const resultsWrapper = document.getElementById('build-playlist-results-wrapper');
const loadingIndicator = document.getElementById('build-playlist-loading'); 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 // Show loading
generateBtn.disabled = true; generateBtn.disabled = true;
generateBtn.style.opacity = '0.5'; generateBtn.style.opacity = '0.5';
loadingIndicator.style.display = 'block'; loadingIndicator.style.display = 'flex';
resultsWrapper.style.display = 'none';
resultsContainer.innerHTML = ''; resultsContainer.innerHTML = '';
try { try {
@ -25951,19 +26005,29 @@ async function generateBuildPlaylist() {
throw new Error('Invalid playlist data'); throw new Error('Invalid playlist data');
} }
// Render playlist // Store tracks globally
renderCompactPlaylist(resultsContainer, data.playlist.tracks); 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 metadata = data.playlist.metadata;
const metadataHtml = ` metadataDisplay.innerHTML = `
<div class="build-playlist-metadata"> <div class="build-playlist-metadata">
<p><strong>Total Tracks:</strong> ${metadata.total_tracks}</p> <p><strong>Total Tracks:</strong> ${metadata.total_tracks}</p>
<p><strong>Similar Artists Used:</strong> ${metadata.similar_artists_count}</p> <p><strong>Similar Artists Used:</strong> ${metadata.similar_artists_count}</p>
<p><strong>Albums Sampled:</strong> ${metadata.albums_count}</p> <p><strong>Albums Sampled:</strong> ${metadata.albums_count}</p>
</div> </div>
`; `;
resultsContainer.insertAdjacentHTML('beforebegin', metadataHtml);
// Render playlist
renderCompactPlaylist(resultsContainer, data.playlist.tracks);
// Show results wrapper
resultsWrapper.style.display = 'block';
} catch (error) { } catch (error) {
console.error('Error generating playlist:', 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) { function openDailyMix(mixIndex) {
const mix = personalizedDailyMixes[mixIndex]; const mix = personalizedDailyMixes[mixIndex];
if (!mix || !mix.tracks) return; if (!mix || !mix.tracks) return;
@ -26013,6 +26091,8 @@ async function openDownloadModalForDiscoverPlaylist(playlistType, playlistName)
tracks = personalizedTopTracks; tracks = personalizedTopTracks;
} else if (playlistType === 'forgotten_favorites') { } else if (playlistType === 'forgotten_favorites') {
tracks = personalizedForgottenFavorites; tracks = personalizedForgottenFavorites;
} else if (playlistType === 'build_playlist') {
tracks = buildPlaylistTracks;
} }
if (!tracks || tracks.length === 0) { if (!tracks || tracks.length === 0) {