discover progress
This commit is contained in:
parent
f75ffdf6cb
commit
86fe7dde1f
5 changed files with 206 additions and 64 deletions
|
|
@ -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)
|
||||
# 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,))
|
||||
|
||||
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)
|
||||
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}...`);
|
||||
|
||||
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() {
|
||||
|
|
@ -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…
Reference in a new issue