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:
parent
8bb020840e
commit
184c38bbb7
2 changed files with 426 additions and 0 deletions
197
web_server.py
197
web_server.py
|
|
@ -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__':
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue