Add artist bubble snapshot and hydration system

Introduces backend API endpoints and frontend logic to persist and restore artist download bubble state across page refreshes. Snapshots are saved and hydrated with live download status, ensuring continuity of user progress and UI state. Includes debounce logic for efficient snapshot saving and automatic cleanup of outdated or invalid snapshots.
This commit is contained in:
Broque Thomas 2025-09-08 13:53:48 -07:00
parent 8bb020840e
commit 184c38bbb7
2 changed files with 426 additions and 0 deletions

View file

@ -8660,6 +8660,203 @@ def test_database_access():
"message": "Database access test failed"
}), 500
# --- Artist Bubble Snapshot System ---
@app.route('/api/artist_bubbles/snapshot', methods=['POST'])
def save_artist_bubble_snapshot():
"""
Saves a snapshot of current artist bubble state for persistence across page refreshes.
"""
try:
import os
import json
from datetime import datetime
data = request.json
if not data or 'bubbles' not in data:
return jsonify({'success': False, 'error': 'No bubble data provided'}), 400
bubbles = data['bubbles']
# Create snapshot with timestamp
snapshot = {
'bubbles': bubbles,
'timestamp': datetime.now().isoformat(),
'snapshot_id': datetime.now().strftime('%Y%m%d_%H%M%S')
}
# Save to file
snapshot_file = os.path.join(os.path.dirname(__file__), 'artist_bubble_snapshots.json')
with open(snapshot_file, 'w') as f:
json.dump(snapshot, f, indent=2)
bubble_count = len(bubbles)
print(f"📸 Saved artist bubble snapshot: {bubble_count} artists")
return jsonify({
'success': True,
'message': f'Snapshot saved with {bubble_count} artist bubbles',
'timestamp': snapshot['timestamp']
})
except Exception as e:
print(f"❌ Error saving artist bubble snapshot: {e}")
import traceback
traceback.print_exc()
return jsonify({
'success': False,
'error': str(e)
}), 500
@app.route('/api/artist_bubbles/hydrate', methods=['GET'])
def hydrate_artist_bubbles():
"""
Loads artist bubbles with live status by cross-referencing snapshots with active processes.
"""
try:
import os
import json
from datetime import datetime, timedelta
snapshot_file = os.path.join(os.path.dirname(__file__), 'artist_bubble_snapshots.json')
# Load snapshot if it exists
if not os.path.exists(snapshot_file):
return jsonify({
'success': True,
'bubbles': {},
'message': 'No snapshots found'
})
with open(snapshot_file, 'r') as f:
snapshot_data = json.load(f)
saved_bubbles = snapshot_data.get('bubbles', {})
snapshot_time = snapshot_data.get('timestamp', '')
# Clean up old snapshots (older than 48 hours)
try:
if snapshot_time:
snapshot_dt = datetime.fromisoformat(snapshot_time.replace('Z', '+00:00'))
cutoff = datetime.now() - timedelta(hours=48)
if snapshot_dt < cutoff:
print(f"🧹 Cleaning up old snapshot from {snapshot_time}")
os.remove(snapshot_file)
return jsonify({
'success': True,
'bubbles': {},
'message': 'Old snapshot cleaned up'
})
except (ValueError, OSError) as e:
print(f"⚠️ Error checking snapshot age: {e}")
# Get current active download processes for live status
current_processes = {}
try:
with tasks_lock:
for batch_id, batch_data in download_batches.items():
if batch_data.get('phase') not in ['complete', 'error', 'cancelled']:
playlist_id = batch_data.get('playlist_id')
if playlist_id:
current_processes[playlist_id] = {
'status': 'in_progress' if batch_data.get('phase') == 'downloading' else 'analyzing',
'batch_id': batch_id,
'phase': batch_data.get('phase')
}
except Exception as e:
print(f"⚠️ Error fetching active processes for hydration: {e}")
# If no active processes exist, the app likely restarted - clean up snapshots
if not current_processes:
print(f"🧹 No active processes found - app likely restarted, cleaning up snapshot")
try:
os.remove(snapshot_file)
return jsonify({
'success': True,
'bubbles': {},
'message': 'Snapshot cleaned up after app restart'
})
except OSError as e:
print(f"⚠️ Error removing snapshot file: {e}")
# Continue with empty result anyway
return jsonify({
'success': True,
'bubbles': {},
'message': 'No active processes - returning empty bubbles'
})
# Update bubble statuses with live data
hydrated_bubbles = {}
for artist_id, bubble_data in saved_bubbles.items():
hydrated_bubble = {
'artist': bubble_data['artist'],
'downloads': [],
'hasCompletedDownloads': False
}
for download in bubble_data.get('downloads', []):
virtual_playlist_id = download['virtualPlaylistId']
# Determine current live status
if virtual_playlist_id in current_processes:
process_info = current_processes[virtual_playlist_id]
live_status = 'in_progress'
print(f"🔄 Found active process for {download['album']['name']}: {process_info['phase']}")
else:
# No active process - likely completed
live_status = 'view_results'
print(f"✅ No active process for {download['album']['name']} - marking as completed")
# Create updated download entry
updated_download = {
'virtualPlaylistId': virtual_playlist_id,
'album': download['album'],
'albumType': download.get('albumType', 'album'),
'status': live_status,
'startTime': download.get('startTime', datetime.now().isoformat())
}
hydrated_bubble['downloads'].append(updated_download)
# Update hasCompletedDownloads flag
if live_status == 'view_results':
hydrated_bubble['hasCompletedDownloads'] = True
# Only include artists that still have downloads
if hydrated_bubble['downloads']:
hydrated_bubbles[artist_id] = hydrated_bubble
bubble_count = len(hydrated_bubbles)
active_count = sum(1 for bubble in hydrated_bubbles.values()
for download in bubble['downloads']
if download['status'] == 'in_progress')
completed_count = sum(1 for bubble in hydrated_bubbles.values()
for download in bubble['downloads']
if download['status'] == 'view_results')
print(f"🔄 Hydrated {bubble_count} artist bubbles: {active_count} active, {completed_count} completed")
return jsonify({
'success': True,
'bubbles': hydrated_bubbles,
'stats': {
'total_artists': bubble_count,
'active_downloads': active_count,
'completed_downloads': completed_count,
'snapshot_time': snapshot_time
}
})
except Exception as e:
print(f"❌ Error hydrating artist bubbles: {e}")
import traceback
traceback.print_exc()
return jsonify({
'success': False,
'error': str(e)
}), 500
# --- Main Execution ---
if __name__ == '__main__':

View file

@ -1796,6 +1796,9 @@ async function startDownload(index) {
async function loadInitialData() {
try {
// Load artist bubble state first
await hydrateArtistBubblesFromSnapshot();
// Load dashboard data by default
await loadDashboardData();
} catch (error) {
@ -1877,6 +1880,82 @@ async function checkForActiveProcesses() {
}
}
async function rehydrateArtistAlbumModal(virtualPlaylistId, playlistName, batchId) {
/**
* Rehydrates an artist album download modal from backend process data.
* Extracts artist/album info from virtual playlist ID and recreates the modal.
*/
try {
console.log(`💧 Rehydrating artist album modal: ${virtualPlaylistId} (${playlistName})`);
// Extract artist_id and album_id from virtualPlaylistId format: artist_album_[artist_id]_[album_id]
const parts = virtualPlaylistId.split('_');
if (parts.length < 4 || parts[0] !== 'artist' || parts[1] !== 'album') {
console.error(`❌ Invalid virtual playlist ID format: ${virtualPlaylistId}`);
return;
}
const artistId = parts[2];
const albumId = parts.slice(3).join('_'); // Handle album IDs that might contain underscores
console.log(`🔍 Extracted from virtual playlist: artistId=${artistId}, albumId=${albumId}`);
// Fetch the album tracks to get proper artist and album data
try {
const response = await fetch(`/api/artist/${artistId}/album/${albumId}/tracks`);
const data = await response.json();
if (!data.success || !data.album || !data.tracks) {
console.error('❌ Failed to fetch album data for rehydration:', data.error);
return;
}
const album = data.album;
const tracks = data.tracks;
// Extract artist info from the first track (all tracks should have same artist)
const artist = {
id: artistId,
name: tracks[0].artists[0] // Use first artist name from first track
};
console.log(`✅ Retrieved album data: "${album.name}" by ${artist.name} (${tracks.length} tracks)`);
// Create the modal using the same function as normal artist album downloads
await openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, tracks, album, artist);
// Update the rehydrated process with batch info and hide modal for background rehydration
const process = activeDownloadProcesses[virtualPlaylistId];
if (process) {
process.status = 'running';
process.batchId = batchId;
// Update button states to reflect running status
const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Hide the modal - this is background rehydration, not user-requested
if (process.modalElement) {
process.modalElement.style.display = 'none';
console.log(`🔍 Hiding rehydrated modal for background processing: ${album.name}`);
}
console.log(`✅ Rehydrated artist album modal: ${artist.name} - ${album.name}`);
} else {
console.error(`❌ Failed to find rehydrated process for ${virtualPlaylistId}`);
}
} catch (error) {
console.error(`❌ Error fetching album data for rehydration:`, error);
}
} catch (error) {
console.error(`❌ Error rehydrating artist album modal:`, error);
}
}
async function rehydrateModal(processInfo, userRequested = false) {
const { playlist_id, playlist_name, batch_id } = processInfo;
console.log(`💧 Rehydrating modal for "${playlist_name}" (batch: ${batch_id}) - User requested: ${userRequested}`);
@ -1887,6 +1966,13 @@ async function rehydrateModal(processInfo, userRequested = false) {
return;
}
// Handle artist album virtual playlists
if (playlist_id.startsWith('artist_album_')) {
console.log(`💧 Rehydrating artist album virtual playlist: ${playlist_id}`);
await rehydrateArtistAlbumModal(playlist_id, playlist_name, batch_id);
return;
}
// Handle wishlist processes specially
if (playlist_id === "wishlist") {
console.log(`🔍 Current activeDownloadProcesses keys: [${Object.keys(activeDownloadProcesses).join(', ')}]`);
@ -10693,6 +10779,9 @@ function registerArtistDownload(artist, album, virtualPlaylistId, albumType) {
// Show/update the artist downloads section
updateArtistDownloadsSection();
// Save snapshot of current state
saveArtistBubbleSnapshot();
// Monitor this download for completion
monitorArtistDownload(artistId, virtualPlaylistId);
}
@ -10709,6 +10798,140 @@ function updateArtistDownloadsSection() {
}, 300); // 300ms debounce
}
// --- Artist Bubble Snapshot System ---
let snapshotSaveTimeout = null; // Debounce snapshot saves
async function saveArtistBubbleSnapshot() {
/**
* Saves current artistDownloadBubbles state to backend for persistence.
* Debounced to prevent excessive backend calls.
*/
// Clear any existing timeout
if (snapshotSaveTimeout) {
clearTimeout(snapshotSaveTimeout);
}
// Debounce the actual save
snapshotSaveTimeout = setTimeout(async () => {
try {
const bubbleCount = Object.keys(artistDownloadBubbles).length;
// Don't save empty state
if (bubbleCount === 0) {
console.log('📸 Skipping snapshot save - no artist bubbles to save');
return;
}
console.log(`📸 Saving artist bubble snapshot: ${bubbleCount} artists`);
// Prepare snapshot data (clean up DOM references)
const cleanBubbles = {};
for (const [artistId, bubbleData] of Object.entries(artistDownloadBubbles)) {
cleanBubbles[artistId] = {
artist: bubbleData.artist,
downloads: bubbleData.downloads.map(download => ({
virtualPlaylistId: download.virtualPlaylistId,
album: download.album,
albumType: download.albumType,
status: download.status,
startTime: download.startTime instanceof Date ? download.startTime.toISOString() : download.startTime
})),
hasCompletedDownloads: bubbleData.hasCompletedDownloads
};
}
const response = await fetch('/api/artist_bubbles/snapshot', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
bubbles: cleanBubbles
})
});
const data = await response.json();
if (data.success) {
console.log(`✅ Artist bubble snapshot saved: ${bubbleCount} artists`);
} else {
console.error('❌ Failed to save artist bubble snapshot:', data.error);
}
} catch (error) {
console.error('❌ Error saving artist bubble snapshot:', error);
}
}, 1000); // 1 second debounce
}
async function hydrateArtistBubblesFromSnapshot() {
/**
* Hydrates artist download bubbles from backend snapshot with live status.
* Called on page load to restore bubble state.
*/
try {
console.log('🔄 Loading artist bubble snapshot from backend...');
const response = await fetch('/api/artist_bubbles/hydrate');
const data = await response.json();
if (!data.success) {
console.error('❌ Failed to load artist bubble snapshot:', data.error);
return;
}
const bubbles = data.bubbles || {};
const stats = data.stats || {};
console.log(`🔄 Loaded bubble snapshot: ${stats.total_artists || 0} artists, ${stats.active_downloads || 0} active, ${stats.completed_downloads || 0} completed`);
if (Object.keys(bubbles).length === 0) {
console.log(' No artist bubbles to hydrate');
return;
}
// Clear existing state
artistDownloadBubbles = {};
// Restore artistDownloadBubbles with hydrated data
for (const [artistId, bubbleData] of Object.entries(bubbles)) {
artistDownloadBubbles[artistId] = {
artist: bubbleData.artist,
downloads: bubbleData.downloads.map(download => ({
virtualPlaylistId: download.virtualPlaylistId,
album: download.album,
albumType: download.albumType,
status: download.status, // Live status from backend
startTime: new Date(download.startTime)
})),
element: null, // Will be created when UI updates
hasCompletedDownloads: bubbleData.hasCompletedDownloads
};
console.log(`🔄 Hydrated artist: ${bubbleData.artist.name} (${bubbleData.downloads.length} downloads)`);
// Start monitoring for any in-progress downloads
for (const download of bubbleData.downloads) {
if (download.status === 'in_progress') {
console.log(`📡 Starting monitoring for: ${download.album.name}`);
monitorArtistDownload(artistId, download.virtualPlaylistId);
}
}
}
// Update UI to show hydrated bubbles
updateArtistDownloadsSection();
const totalArtists = Object.keys(artistDownloadBubbles).length;
console.log(`✅ Successfully hydrated ${totalArtists} artist download bubbles`);
} catch (error) {
console.error('❌ Error hydrating artist bubbles from snapshot:', error);
}
}
/**
* Show or update the artist downloads section in search state
*/
@ -10863,6 +11086,9 @@ function monitorArtistDownload(artistId, virtualPlaylistId) {
// Update the downloads section
updateArtistDownloadsSection();
// Save snapshot of updated state
saveArtistBubbleSnapshot();
// Check if all downloads for this artist are now completed
const artistDownloads = artistDownloadBubbles[artistId].downloads;
const allCompleted = artistDownloads.every(d => d.status === 'view_results');
@ -11117,6 +11343,9 @@ function cleanupArtistDownload(virtualPlaylistId) {
// Update the downloads section
console.log(`🔄 [CLEANUP] Updating artist downloads section...`);
updateArtistDownloadsSection();
// Save snapshot of updated state
saveArtistBubbleSnapshot();
break;
}
}