discover progress

pull/80/head
Broque Thomas 6 months ago
parent f75ffdf6cb
commit 86fe7dde1f

@ -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:

@ -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

@ -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...")

@ -2024,8 +2024,32 @@
</div>
<!-- Generated Playlist Results -->
<div id="build-playlist-results" class="discover-playlist-container compact">
<!-- Generated playlist will appear here -->
<div id="build-playlist-results-wrapper" style="display: none;">
<!-- 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>

@ -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 = `
<div class="build-playlist-metadata">
<p><strong>Total Tracks:</strong> ${metadata.total_tracks}</p>
<p><strong>Similar Artists Used:</strong> ${metadata.similar_artists_count}</p>
<p><strong>Albums Sampled:</strong> ${metadata.albums_count}</p>
</div>
`;
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) {

Loading…
Cancel
Save