download bubbles for discoverp age

pull/80/head
Broque Thomas 3 months ago
parent 52c27ce2a9
commit 12c37fa61c

@ -7,8 +7,10 @@
"Bash(grep:*)",
"WebFetch(domain:python-plexapi.readthedocs.io)",
"Bash(git restore:*)",
"Bash(python3:*)"
"Bash(python3:*)",
"Bash(awk:*)",
"Bash(cat:*)"
],
"deny": []
}
}
}

@ -13586,6 +13586,179 @@ def test_database_access():
"message": "Database access test failed"
}), 500
# --- Discover Download Snapshot System ---
@app.route('/api/discover_downloads/snapshot', methods=['POST'])
def save_discover_download_snapshot():
"""
Saves a snapshot of current discover download state for persistence across page refreshes.
"""
try:
import os
import json
from datetime import datetime
data = request.json
if not data or 'downloads' not in data:
return jsonify({'success': False, 'error': 'No download data provided'}), 400
downloads = data['downloads']
# Create snapshot with timestamp
snapshot = {
'downloads': downloads,
'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__), 'discover_download_snapshots.json')
with open(snapshot_file, 'w') as f:
json.dump(snapshot, f, indent=2)
download_count = len(downloads)
print(f"📸 Saved discover download snapshot: {download_count} downloads")
return jsonify({
'success': True,
'message': f'Snapshot saved with {download_count} downloads',
'timestamp': snapshot['timestamp']
})
except Exception as e:
print(f"❌ Error saving discover download snapshot: {e}")
import traceback
traceback.print_exc()
return jsonify({
'success': False,
'error': str(e)
}), 500
@app.route('/api/discover_downloads/hydrate', methods=['GET'])
def hydrate_discover_downloads():
"""
Loads discover downloads 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__), 'discover_download_snapshots.json')
# Load snapshot if it exists
if not os.path.exists(snapshot_file):
return jsonify({
'success': True,
'downloads': {},
'message': 'No snapshots found'
})
with open(snapshot_file, 'r') as f:
snapshot_data = json.load(f)
saved_downloads = snapshot_data.get('downloads', {})
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 discover download snapshot from {snapshot_time}")
os.remove(snapshot_file)
return jsonify({
'success': True,
'downloads': {},
'message': 'Old snapshot cleaned up'
})
except (ValueError, OSError) as e:
print(f"⚠️ Error checking discover 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 discover download 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 discover download snapshot")
try:
os.remove(snapshot_file)
return jsonify({
'success': True,
'downloads': {},
'message': 'Snapshot cleaned up after app restart'
})
except OSError as e:
print(f"⚠️ Error removing discover snapshot file: {e}")
return jsonify({
'success': True,
'downloads': {},
'message': 'No active processes - returning empty downloads'
})
# Update download statuses with live data
hydrated_downloads = {}
for playlist_id, download_data in saved_downloads.items():
# Determine current live status
if playlist_id in current_processes:
process_info = current_processes[playlist_id]
live_status = 'in_progress'
print(f"🔄 Found active process for discover download {playlist_id}: {process_info['phase']}")
else:
# No active process - likely completed
live_status = 'completed'
print(f"✅ No active process for discover download {playlist_id} - marking as completed")
# Create updated download entry
hydrated_downloads[playlist_id] = {
'name': download_data.get('name'),
'type': download_data.get('type'),
'status': live_status,
'virtualPlaylistId': playlist_id,
'imageUrl': download_data.get('imageUrl'),
'startTime': download_data.get('startTime', datetime.now().isoformat())
}
download_count = len(hydrated_downloads)
active_count = sum(1 for d in hydrated_downloads.values() if d['status'] == 'in_progress')
completed_count = sum(1 for d in hydrated_downloads.values() if d['status'] == 'completed')
print(f"✅ Hydrated {download_count} discover downloads: {active_count} active, {completed_count} completed")
return jsonify({
'success': True,
'downloads': hydrated_downloads,
'stats': {
'total_downloads': download_count,
'active_downloads': active_count,
'completed_downloads': completed_count
}
})
except Exception as e:
print(f"❌ Error hydrating discover downloads: {e}")
import traceback
traceback.print_exc()
return jsonify({
'success': False,
'error': str(e)
}), 500
# --- Artist Bubble Snapshot System ---
@app.route('/api/artist_bubbles/snapshot', methods=['POST'])

@ -2814,6 +2814,18 @@
</div>
</div>
<!-- Right Sidebar Download Indicator (Global - outside all containers) -->
<div class="discover-download-sidebar" id="discover-download-sidebar">
<div class="discover-download-sidebar-header">
<span class="discover-download-sidebar-icon">🎵</span>
<span class="discover-download-sidebar-title">Downloads</span>
<span class="discover-download-sidebar-count" id="discover-download-count">0</span>
</div>
<div class="discover-download-bubbles" id="discover-download-bubbles">
<!-- Download bubbles will be added here dynamically -->
</div>
</div>
<script src="{{ url_for('static', filename='script.js') }}"></script>
</body>
</html>

@ -365,7 +365,7 @@ function initializeWatchlist() {
function navigateToPage(pageId) {
if (pageId === currentPage) return;
// Update navigation buttons (only if there's a nav button for this page)
document.querySelectorAll('.nav-button').forEach(btn => {
btn.classList.remove('active');
@ -374,15 +374,33 @@ function navigateToPage(pageId) {
if (navButton) {
navButton.classList.add('active');
}
// Update pages
document.querySelectorAll('.page').forEach(page => {
page.classList.remove('active');
});
document.getElementById(`${pageId}-page`).classList.add('active');
currentPage = pageId;
// Show/hide discover download sidebar based on page
const downloadSidebar = document.getElementById('discover-download-sidebar');
if (downloadSidebar) {
if (pageId === 'discover') {
// Show sidebar on discover page if there are active downloads
const activeDownloads = Object.keys(discoverDownloads || {}).length;
console.log(`📊 [NAVIGATE] Discover page - ${activeDownloads} active downloads`);
if (activeDownloads > 0) {
// Update the sidebar UI to render the bubbles
console.log(`🔄 [NAVIGATE] Updating discover download bar UI`);
updateDiscoverDownloadBar();
}
} else {
// Always hide sidebar on other pages
downloadSidebar.classList.add('hidden');
}
}
// Load page-specific data
loadPageData(pageId);
}
@ -2190,7 +2208,10 @@ async function loadInitialData() {
try {
// Load artist bubble state first
await hydrateArtistBubblesFromSnapshot();
// Load discover download state
await hydrateDiscoverDownloadsFromSnapshot();
// Load dashboard data by default
await loadDashboardData();
} catch (error) {
@ -2359,12 +2380,95 @@ async function rehydrateDiscoverPlaylistModal(virtualPlaylistId, playlistName, b
try {
console.log(`💧 Rehydrating discover playlist modal: ${virtualPlaylistId} (${playlistName})`);
// Handle album downloads from Recent Releases
if (virtualPlaylistId.startsWith('discover_album_')) {
const albumId = virtualPlaylistId.replace('discover_album_', '');
console.log(`💧 Album download - fetching album ${albumId}...`);
try {
const albumResponse = await fetch(`/api/spotify/album/${albumId}`);
if (!albumResponse.ok) {
console.error(`❌ Failed to fetch album: ${albumResponse.status}`);
return;
}
const albumData = await albumResponse.json();
if (!albumData.tracks || albumData.tracks.length === 0) {
console.error(`❌ No tracks in album`);
return;
}
// Convert tracks to expected format
const spotifyTracks = albumData.tracks.map(track => {
let artists = track.artists || [];
if (Array.isArray(artists)) {
artists = artists.map(a => a.name || a);
}
return {
id: track.id,
name: track.name,
artists: artists,
album: {
name: albumData.name || playlistName.split(' - ')[0],
images: albumData.images || []
},
duration_ms: track.duration_ms || 0
};
});
console.log(`✅ Retrieved ${spotifyTracks.length} tracks for album`);
// Create modal
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks);
// Update process
const process = activeDownloadProcesses[virtualPlaylistId];
if (process) {
process.status = 'running';
process.batchId = batchId;
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 modal for background rehydration
if (process.modalElement) {
process.modalElement.style.display = 'none';
console.log(`🔍 Hiding rehydrated modal for background processing: ${playlistName}`);
}
console.log(`✅ Rehydrated album modal: ${playlistName}`);
}
return;
} catch (error) {
console.error(`❌ Error fetching album:`, error);
return;
}
}
// Determine API endpoint based on playlist ID
let apiEndpoint;
if (virtualPlaylistId === 'discover_release_radar') {
apiEndpoint = '/api/discover/release-radar';
} else if (virtualPlaylistId === 'discover_discovery_weekly') {
apiEndpoint = '/api/discover/discovery-weekly';
} else if (virtualPlaylistId === 'discover_seasonal_playlist') {
apiEndpoint = '/api/discover/seasonal-playlist';
} else if (virtualPlaylistId === 'discover_popular_picks') {
apiEndpoint = '/api/discover/popular-picks';
} else if (virtualPlaylistId === 'discover_hidden_gems') {
apiEndpoint = '/api/discover/hidden-gems';
} else if (virtualPlaylistId === 'discover_discovery_shuffle') {
apiEndpoint = '/api/discover/discovery-shuffle';
} else if (virtualPlaylistId === 'discover_familiar_favorites') {
apiEndpoint = '/api/discover/familiar-favorites';
} else if (virtualPlaylistId === 'build_playlist_custom') {
apiEndpoint = '/api/discover/build-playlist';
} else if (virtualPlaylistId.startsWith('discover_lb_')) {
console.log(`💧 ListenBrainz playlist - skipping (no automatic rehydration for ListenBrainz)`);
return;
} else {
console.error(`❌ Unknown discover playlist type: ${virtualPlaylistId}`);
return;
@ -4210,6 +4314,23 @@ async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistNam
virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' :
'YouTube';
// Store metadata for discover download sidebar (will be added when Begin Analysis is clicked)
if (source === 'SoulSync' || virtualPlaylistId.startsWith('discover_lb_')) {
// Extract image URL from first track's album cover
let imageUrl = null;
if (spotifyTracks && spotifyTracks.length > 0) {
const firstTrack = spotifyTracks[0];
if (firstTrack.album && firstTrack.album.images && firstTrack.album.images.length > 0) {
imageUrl = firstTrack.album.images[0].url;
}
}
// Store in process for later use when Begin Analysis is clicked
activeDownloadProcesses[virtualPlaylistId].discoverMetadata = {
imageUrl: imageUrl,
type: 'album'
};
}
const heroContext = {
type: 'playlist',
playlist: { name: playlistName, owner: source },
@ -4494,6 +4615,13 @@ async function closeDownloadMissingModal(playlistId) {
console.log(`✅ [MODAL CLOSE] Artist download cleanup completed for: ${playlistId}`);
}
// Remove from discover download sidebar if this is a discover page download
if (discoverDownloads && discoverDownloads[playlistId]) {
console.log(`🧹 [MODAL CLOSE] Removing discover download bubble: ${playlistId}`);
removeDiscoverDownload(playlistId);
console.log(`✅ [MODAL CLOSE] Discover download bubble removed for: ${playlistId}`);
}
// Automatic cleanup and server operations after successful downloads
await handlePostDownloadAutomation(playlistId, process);
@ -4819,6 +4947,15 @@ async function startMissingTracksProcess(playlistId) {
document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'none';
document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'inline-block';
// Add to discover download sidebar if this is a discover page download
if (process.discoverMetadata) {
const playlistName = process.playlist.name;
const imageUrl = process.discoverMetadata.imageUrl;
const type = process.discoverMetadata.type;
addDiscoverDownload(playlistId, playlistName, type, imageUrl);
console.log(`📥 [BEGIN ANALYSIS] Added discover download: ${playlistName}`);
}
// Check if force download toggle is enabled
const forceDownloadCheckbox = document.getElementById(`force-download-all-${playlistId}`);
const forceDownloadAll = forceDownloadCheckbox ? forceDownloadCheckbox.checked : false;
@ -26635,6 +26772,18 @@ async function startListenBrainzPlaylistSync(identifier, title, playlistId) {
// Use the same sync function as all other discover playlists
await startPlaylistSync(virtualPlaylistId);
// Extract image URL from first track for download bar bubble
let imageUrl = null;
if (spotifyTracks && spotifyTracks.length > 0) {
const firstTrack = spotifyTracks[0];
if (firstTrack.album && firstTrack.album.images && firstTrack.album.images.length > 0) {
imageUrl = firstTrack.album.images[0].url;
}
}
// Add to discover download bar
addDiscoverDownload(virtualPlaylistId, title, 'listenbrainz', imageUrl);
// Start polling for progress updates (using discover playlist pattern)
startListenBrainzSyncPolling(playlistId, virtualPlaylistId);
@ -27812,6 +27961,18 @@ async function startDiscoverPlaylistSync(playlistType, playlistName) {
// Start sync using existing function
await startPlaylistSync(virtualPlaylistId);
// Extract image URL from first track for download bar bubble
let imageUrl = null;
if (spotifyTracks && spotifyTracks.length > 0) {
const firstTrack = spotifyTracks[0];
if (firstTrack.album && firstTrack.album.images && firstTrack.album.images.length > 0) {
imageUrl = firstTrack.album.images[0].url;
}
}
// Add to discover download bar
addDiscoverDownload(virtualPlaylistId, playlistName, playlistType, imageUrl);
// Start polling for progress updates
startDiscoverSyncPolling(playlistType, virtualPlaylistId);
}
@ -27962,3 +28123,622 @@ async function openDownloadModalForRecentAlbum(albumIndex) {
hideLoadingOverlay();
}
}
// ===============================
// DISCOVER DOWNLOAD BAR
// ===============================
// Track discover page downloads
let discoverDownloads = {}; // playlistId -> { name, type, status, virtualPlaylistId, startTime }
/**
* Add a download to the discover download bar
*/
function addDiscoverDownload(playlistId, playlistName, playlistType, imageUrl = null) {
console.log(`📥 [DOWNLOAD SIDEBAR] Adding discover download: ${playlistName} (${playlistId}) type: ${playlistType}, image: ${imageUrl}`);
// Check if download sidebar exists
const downloadSidebar = document.getElementById('discover-download-sidebar');
if (!downloadSidebar) {
console.warn('⚠️ [DOWNLOAD SIDEBAR] Download sidebar element not found - user might not be on discover page');
return;
}
discoverDownloads[playlistId] = {
name: playlistName,
type: playlistType,
status: 'in_progress',
virtualPlaylistId: playlistId,
imageUrl: imageUrl,
startTime: new Date()
};
console.log(`📊 [DOWNLOAD SIDEBAR] Active downloads:`, Object.keys(discoverDownloads));
updateDiscoverDownloadBar();
monitorDiscoverDownload(playlistId);
}
/**
* Monitor a discover download for completion
*/
function monitorDiscoverDownload(playlistId) {
let notFoundCount = 0;
const maxNotFoundAttempts = 5; // Give sync 10 seconds to start (5 checks * 2 seconds)
const checkInterval = setInterval(async () => {
try {
// Check if download still exists
if (!discoverDownloads[playlistId]) {
clearInterval(checkInterval);
return;
}
// First check if there's an active download process (modal-based downloads)
const activeProcess = activeDownloadProcesses[playlistId];
if (activeProcess) {
console.log(`📂 [DOWNLOAD BAR] Found active process for ${playlistId}, status: ${activeProcess.status}`);
if (activeProcess.status === 'complete') {
console.log(`✅ [DOWNLOAD BAR] Process completed: ${discoverDownloads[playlistId].name}`);
discoverDownloads[playlistId].status = 'completed';
updateDiscoverDownloadBar();
clearInterval(checkInterval);
// Auto-remove completed downloads after 30 seconds
setTimeout(() => {
if (discoverDownloads[playlistId] && discoverDownloads[playlistId].status === 'completed') {
removeDiscoverDownload(playlistId);
}
}, 30000);
}
return; // Continue monitoring
}
// Check sync status API (for sync-based downloads)
const response = await fetch(`/api/sync/status/${playlistId}`);
if (response.ok) {
const data = await response.json();
notFoundCount = 0; // Reset counter if found
console.log(`🔄 [DOWNLOAD BAR] Sync status for ${playlistId}: ${data.status}`);
if (data.status === 'complete') {
console.log(`✅ [DOWNLOAD BAR] Sync completed: ${discoverDownloads[playlistId].name}`);
discoverDownloads[playlistId].status = 'completed';
updateDiscoverDownloadBar();
clearInterval(checkInterval);
// Auto-remove completed downloads after 30 seconds
setTimeout(() => {
if (discoverDownloads[playlistId] && discoverDownloads[playlistId].status === 'completed') {
removeDiscoverDownload(playlistId);
}
}, 30000);
}
} else if (response.status === 404) {
notFoundCount++;
console.log(`🔍 [DOWNLOAD BAR] Sync not found for ${playlistId} (attempt ${notFoundCount}/${maxNotFoundAttempts})`);
// Only remove after multiple attempts (give it time to start)
if (notFoundCount >= maxNotFoundAttempts) {
console.log(`⏹️ [DOWNLOAD BAR] Sync not found after ${maxNotFoundAttempts} attempts, removing`);
clearInterval(checkInterval);
removeDiscoverDownload(playlistId);
}
}
} catch (error) {
console.error(`❌ [DOWNLOAD BAR] Error monitoring ${playlistId}:`, error);
}
}, 2000); // Check every 2 seconds
}
/**
* Remove a download from the bar
*/
function removeDiscoverDownload(playlistId) {
console.log(`🗑️ Removing discover download: ${playlistId}`);
delete discoverDownloads[playlistId];
updateDiscoverDownloadBar();
saveDiscoverDownloadSnapshot(); // Save state after removal
}
/**
* Update the discover download sidebar UI
*/
function updateDiscoverDownloadBar() {
const downloadSidebar = document.getElementById('discover-download-sidebar');
const bubblesContainer = document.getElementById('discover-download-bubbles');
const countElement = document.getElementById('discover-download-count');
console.log(`🔄 [DOWNLOAD SIDEBAR] Updating sidebar - found elements:`, {
downloadSidebar: !!downloadSidebar,
bubblesContainer: !!bubblesContainer,
countElement: !!countElement
});
if (!downloadSidebar || !bubblesContainer || !countElement) {
console.warn('⚠️ [DOWNLOAD SIDEBAR] Missing elements, cannot update');
return;
}
const activeDownloads = Object.keys(discoverDownloads);
const count = activeDownloads.length;
console.log(`📊 [DOWNLOAD SIDEBAR] Updating with ${count} active downloads`);
// Update count
countElement.textContent = count;
// Show/hide sidebar
if (count === 0) {
console.log(`👁️ [DOWNLOAD SIDEBAR] No downloads, hiding sidebar`);
downloadSidebar.classList.add('hidden');
return;
} else {
console.log(`👁️ [DOWNLOAD SIDEBAR] ${count} downloads, showing sidebar`);
downloadSidebar.classList.remove('hidden');
}
// Update bubbles
bubblesContainer.innerHTML = activeDownloads.map(playlistId => {
const download = discoverDownloads[playlistId];
const isCompleted = download.status === 'completed';
const icon = isCompleted ? '✅' : '⏳';
// Use image if available, otherwise gradient background
const imageUrl = download.imageUrl || '';
const backgroundStyle = imageUrl ?
`background-image: url('${imageUrl}');` :
`background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);`;
return `
<div class="discover-download-bubble">
<div class="discover-download-bubble-card ${isCompleted ? 'completed' : ''}"
onclick="openDiscoverDownloadModal('${playlistId}')"
title="${escapeHtml(download.name)} - Click to view">
<div class="discover-download-bubble-image" style="${backgroundStyle}"></div>
<div class="discover-download-bubble-overlay"></div>
<div class="discover-download-bubble-content">
<span class="discover-download-bubble-icon">${icon}</span>
</div>
</div>
<div class="discover-download-bubble-name">${escapeHtml(download.name)}</div>
</div>
`;
}).join('');
console.log(`📊 Updated discover download sidebar: ${count} active downloads`);
// Save snapshot after UI update
saveDiscoverDownloadSnapshot();
}
/**
* Open download modal for a discover playlist
*/
async function openDiscoverDownloadModal(playlistId) {
console.log(`📂 [DOWNLOAD BAR] Opening download modal for: ${playlistId}`);
// Check if there's an active download process with modal
let process = activeDownloadProcesses[playlistId];
console.log(`📋 [DOWNLOAD BAR] Process found:`, {
exists: !!process,
hasModalElement: !!(process && process.modalElement),
hasModalId: !!(process && process.modalId)
});
if (process) {
// Try modalElement first (album downloads)
if (process.modalElement) {
console.log(`✅ [DOWNLOAD BAR] Opening modal via modalElement`);
process.modalElement.style.display = 'flex';
return;
}
// Try modalId (sync downloads)
if (process.modalId) {
const modal = document.getElementById(process.modalId);
if (modal) {
console.log(`✅ [DOWNLOAD BAR] Opening modal via modalId: ${process.modalId}`);
modal.style.display = 'flex';
return;
}
}
}
// If no process found, try to rehydrate from backend
console.log(`💧 [DOWNLOAD BAR] No modal found, attempting to rehydrate from backend...`);
const rehydrated = await rehydrateDiscoverDownloadModal(playlistId);
if (rehydrated) {
console.log(`✅ [DOWNLOAD BAR] Successfully rehydrated modal, opening it...`);
// Try again after rehydration
process = activeDownloadProcesses[playlistId];
if (process && process.modalElement) {
process.modalElement.style.display = 'flex';
return;
}
}
// Fallback: show toast
const download = discoverDownloads[playlistId];
if (download) {
console.log(` [DOWNLOAD BAR] No modal found after rehydration attempt, showing toast`);
showToast(`Download: ${download.name} - ${download.status}`, 'info');
} else {
console.warn(`⚠️ [DOWNLOAD BAR] No download or process found for: ${playlistId}`);
}
}
/**
* Initialize discover download sidebar on page load
*/
function initializeDiscoverDownloadBar() {
console.log('🎵 Initializing discover download sidebar...');
// Start with sidebar hidden (will be shown if downloads exist after hydration)
const downloadSidebar = document.getElementById('discover-download-sidebar');
if (downloadSidebar) {
downloadSidebar.classList.add('hidden');
}
}
// --- Discover Download Modal Rehydration ---
async function rehydrateDiscoverDownloadModal(playlistId) {
/**
* Rehydrates a discover download modal from backend process data.
* Fetches tracks from backend API and recreates the modal (user-requested).
*/
try {
console.log(`💧 [REHYDRATE] Attempting to rehydrate modal for: ${playlistId}`);
// Check if there's an active backend process for this playlist
const batchResponse = await fetch(`/api/playlists/batch_info`);
if (!batchResponse.ok) {
console.log(`⚠️ [REHYDRATE] Failed to fetch batch info`);
return false;
}
const batchData = await batchResponse.json();
const batches = batchData.batches || [];
// Find the batch for this playlist
const batch = batches.find(b => b.playlist_id === playlistId);
if (!batch) {
console.log(`⚠️ [REHYDRATE] No active batch found for ${playlistId}`);
return false;
}
console.log(`✅ [REHYDRATE] Found active batch for ${playlistId}:`, batch);
// Get the download metadata from discoverDownloads
const downloadData = discoverDownloads[playlistId];
if (!downloadData) {
console.log(`⚠️ [REHYDRATE] No download metadata found for ${playlistId}`);
return false;
}
// Handle album downloads from Recent Releases
if (playlistId.startsWith('discover_album_')) {
const albumId = playlistId.replace('discover_album_', '');
console.log(`💧 [REHYDRATE] Album download - fetching album ${albumId}...`);
try {
const albumResponse = await fetch(`/api/spotify/album/${albumId}`);
if (!albumResponse.ok) {
console.error(`❌ [REHYDRATE] Failed to fetch album: ${albumResponse.status}`);
return false;
}
const albumData = await albumResponse.json();
if (!albumData.tracks || albumData.tracks.length === 0) {
console.error(`❌ [REHYDRATE] No tracks in album`);
return false;
}
// Convert tracks to expected format
const spotifyTracks = albumData.tracks.map(track => {
let artists = track.artists || [];
if (Array.isArray(artists)) {
artists = artists.map(a => a.name || a);
}
return {
id: track.id,
name: track.name,
artists: artists,
album: {
name: albumData.name || downloadData.name.split(' - ')[0],
images: downloadData.imageUrl ? [{ url: downloadData.imageUrl }] : []
},
duration_ms: track.duration_ms || 0
};
});
console.log(`✅ [REHYDRATE] Retrieved ${spotifyTracks.length} tracks for album`);
// Create modal
await openDownloadMissingModalForYouTube(playlistId, downloadData.name, spotifyTracks);
// Update process
const process = activeDownloadProcesses[playlistId];
if (process) {
process.status = 'running';
process.batchId = batch.batch_id;
const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
console.log(`✅ [REHYDRATE] Successfully rehydrated album modal`);
return true;
}
return false;
} catch (error) {
console.error(`❌ [REHYDRATE] Error fetching album:`, error);
return false;
}
}
// Determine API endpoint based on playlist ID
let apiEndpoint;
if (playlistId === 'discover_release_radar') {
apiEndpoint = '/api/discover/release-radar';
} else if (playlistId === 'discover_discovery_weekly') {
apiEndpoint = '/api/discover/discovery-weekly';
} else if (playlistId === 'discover_seasonal_playlist') {
apiEndpoint = '/api/discover/seasonal-playlist';
} else if (playlistId === 'discover_popular_picks') {
apiEndpoint = '/api/discover/popular-picks';
} else if (playlistId === 'discover_hidden_gems') {
apiEndpoint = '/api/discover/hidden-gems';
} else if (playlistId === 'discover_discovery_shuffle') {
apiEndpoint = '/api/discover/discovery-shuffle';
} else if (playlistId === 'discover_familiar_favorites') {
apiEndpoint = '/api/discover/familiar-favorites';
} else if (playlistId === 'build_playlist_custom') {
apiEndpoint = '/api/discover/build-playlist';
} else if (playlistId.startsWith('discover_lb_')) {
// ListenBrainz playlist - fetch from cache
const identifier = playlistId.replace('discover_lb_', '');
const tracks = listenbrainzTracksCache[identifier];
if (!tracks || tracks.length === 0) {
console.log(`⚠️ [REHYDRATE] No ListenBrainz tracks in cache for ${identifier}`);
return false;
}
// Convert to Spotify format
const spotifyTracks = tracks.map(track => ({
id: null,
name: track.track_name,
artists: [track.artist_name],
album: {
name: track.album_name,
images: track.album_cover_url ? [{ url: track.album_cover_url }] : []
},
duration_ms: track.duration_ms || 0,
mbid: track.mbid
}));
// Create modal and update process
await openDownloadMissingModalForYouTube(playlistId, downloadData.name, spotifyTracks);
const process = activeDownloadProcesses[playlistId];
if (process) {
process.status = 'running';
process.batchId = batch.batch_id;
const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
console.log(`✅ [REHYDRATE] Successfully rehydrated ListenBrainz modal`);
return true;
}
return false;
} else {
console.error(`❌ [REHYDRATE] Unknown discover playlist type: ${playlistId}`);
return false;
}
// Fetch tracks from API
console.log(`📡 [REHYDRATE] Fetching tracks from ${apiEndpoint}...`);
const response = await fetch(apiEndpoint);
if (!response.ok) {
console.error(`❌ [REHYDRATE] Failed to fetch tracks: ${response.status}`);
return false;
}
const data = await response.json();
if (!data.success || !data.tracks) {
console.error(`❌ [REHYDRATE] Invalid track data:`, data);
return false;
}
const tracks = data.tracks;
console.log(`✅ [REHYDRATE] Retrieved ${tracks.length} tracks`);
// Transform tracks to Spotify format
const spotifyTracks = tracks.map(track => {
let spotifyTrack;
if (track.track_data_json) {
spotifyTrack = track.track_data_json;
} else {
spotifyTrack = {
id: track.spotify_track_id,
name: track.track_name,
artists: [{ name: track.artist_name }],
album: {
name: track.album_name,
images: track.album_cover_url ? [{ url: track.album_cover_url }] : []
},
duration_ms: track.duration_ms || 0
};
}
if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) {
spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a);
}
return spotifyTrack;
});
// Create the modal
await openDownloadMissingModalForYouTube(playlistId, downloadData.name, spotifyTracks);
// Update process with batch info
const process = activeDownloadProcesses[playlistId];
if (process) {
process.status = 'running';
process.batchId = batch.batch_id;
// Update button states
const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`);
const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`);
if (beginBtn) beginBtn.style.display = 'none';
if (cancelBtn) cancelBtn.style.display = 'inline-block';
// Don't hide the modal - user clicked to open it
console.log(`✅ [REHYDRATE] Successfully rehydrated modal for ${downloadData.name}`);
return true;
} else {
console.error(`❌ [REHYDRATE] Failed to find rehydrated process for ${playlistId}`);
return false;
}
} catch (error) {
console.error(`❌ [REHYDRATE] Error rehydrating discover download modal:`, error);
return false;
}
}
// --- Discover Download Snapshot System ---
let discoverSnapshotSaveTimeout = null; // Debounce snapshot saves
async function saveDiscoverDownloadSnapshot() {
/**
* Saves current discoverDownloads state to backend for persistence.
* Debounced to prevent excessive backend calls.
*/
// Clear any existing timeout
if (discoverSnapshotSaveTimeout) {
clearTimeout(discoverSnapshotSaveTimeout);
}
// Debounce the actual save
discoverSnapshotSaveTimeout = setTimeout(async () => {
try {
const downloadCount = Object.keys(discoverDownloads).length;
// Don't save empty state
if (downloadCount === 0) {
console.log('📸 Skipping discover snapshot save - no downloads to save');
return;
}
console.log(`📸 Saving discover download snapshot: ${downloadCount} downloads`);
// Prepare snapshot data (clean format)
const cleanDownloads = {};
for (const [playlistId, downloadData] of Object.entries(discoverDownloads)) {
cleanDownloads[playlistId] = {
name: downloadData.name,
type: downloadData.type,
status: downloadData.status,
virtualPlaylistId: downloadData.virtualPlaylistId,
imageUrl: downloadData.imageUrl,
startTime: downloadData.startTime instanceof Date ? downloadData.startTime.toISOString() : downloadData.startTime
};
}
const response = await fetch('/api/discover_downloads/snapshot', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
downloads: cleanDownloads
})
});
const data = await response.json();
if (data.success) {
console.log(`✅ Discover download snapshot saved: ${downloadCount} downloads`);
} else {
console.error('❌ Failed to save discover download snapshot:', data.error);
}
} catch (error) {
console.error('❌ Error saving discover download snapshot:', error);
}
}, 1000); // 1 second debounce
}
async function hydrateDiscoverDownloadsFromSnapshot() {
/**
* Hydrates discover downloads from backend snapshot with live status.
* Called on page load to restore download state.
*/
try {
console.log('🔄 Loading discover download snapshot from backend...');
const response = await fetch('/api/discover_downloads/hydrate');
const data = await response.json();
if (!data.success) {
console.error('❌ Failed to load discover download snapshot:', data.error);
return;
}
const downloads = data.downloads || {};
const stats = data.stats || {};
console.log(`🔄 Loaded discover snapshot: ${stats.total_downloads || 0} downloads, ${stats.active_downloads || 0} active, ${stats.completed_downloads || 0} completed`);
if (Object.keys(downloads).length === 0) {
console.log(' No discover downloads to hydrate');
return;
}
// Clear existing state
discoverDownloads = {};
// Restore discoverDownloads with hydrated data
for (const [playlistId, downloadData] of Object.entries(downloads)) {
discoverDownloads[playlistId] = {
name: downloadData.name,
type: downloadData.type,
status: downloadData.status, // Live status from backend
virtualPlaylistId: downloadData.virtualPlaylistId,
imageUrl: downloadData.imageUrl,
startTime: new Date(downloadData.startTime)
};
console.log(`🔄 Hydrated download: ${downloadData.name} (${downloadData.status})`);
// Start monitoring for any in-progress downloads
if (downloadData.status === 'in_progress') {
console.log(`📡 Starting monitoring for: ${downloadData.name}`);
monitorDiscoverDownload(playlistId);
}
}
// Don't update UI here - it will be updated when user navigates to discover page
// This allows hydration to work even if page loads on a different tab
const totalDownloads = Object.keys(discoverDownloads).length;
console.log(`✅ Successfully hydrated ${totalDownloads} discover downloads (UI will update on discover page navigation)`);
} catch (error) {
console.error('❌ Error hydrating discover downloads from snapshot:', error);
}
}
// Initialize on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeDiscoverDownloadBar);
} else {
initializeDiscoverDownloadBar();
}

@ -17090,6 +17090,225 @@ body {
margin: 0;
}
/* ===============================
DISCOVER DOWNLOAD BAR
=============================== */
/* Fixed bottom download bar */
/* ===============================
DISCOVER DOWNLOAD SIDEBAR
Right sidebar for active downloads
=============================== */
.discover-download-sidebar {
position: fixed;
top: 0;
right: 0;
width: 140px;
height: 100vh;
background: linear-gradient(270deg,
rgba(18, 18, 18, 0.98) 0%,
rgba(12, 12, 12, 0.99) 100%);
border-left: 1px solid rgba(255, 255, 255, 0.08);
padding: 20px 12px;
z-index: 9999;
transform: translateX(0);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.3s ease;
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.4);
backdrop-filter: blur(20px);
opacity: 1;
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
overflow-x: hidden;
}
/* Hidden state */
.discover-download-sidebar.hidden {
transform: translateX(100%);
opacity: 0;
pointer-events: none;
}
/* Sidebar header */
.discover-download-sidebar-header {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.discover-download-sidebar-icon {
font-size: 20px;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.7; transform: scale(1.1); }
}
.discover-download-sidebar-title {
font-size: 12px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif;
text-align: center;
}
.discover-download-sidebar-count {
background: linear-gradient(135deg, #1db954 0%, #1ed760 100%);
color: white;
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
border-radius: 12px;
min-width: 20px;
text-align: center;
}
/* Download bubbles container */
.discover-download-bubbles {
display: flex;
flex-direction: column;
gap: 20px;
align-items: center;
}
/* Individual download bubble - 100x100px circular */
.discover-download-bubble {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
}
.discover-download-bubble-card {
position: relative;
width: 100px;
height: 100px;
border-radius: 50%;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
flex-shrink: 0;
background: linear-gradient(135deg,
rgba(26, 26, 26, 0.95) 0%,
rgba(18, 18, 18, 0.98) 100%);
border: 2px solid rgba(29, 185, 84, 0.3);
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(29, 185, 84, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
overflow: hidden;
}
.discover-download-bubble-card:hover {
transform: scale(1.08);
border-color: rgba(29, 185, 84, 0.5);
box-shadow:
0 8px 20px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(29, 185, 84, 0.2),
0 0 15px rgba(29, 185, 84, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.discover-download-bubble-card.completed {
border-color: rgba(34, 197, 94, 0.4);
}
.discover-download-bubble-card.completed:hover {
border-color: rgba(34, 197, 94, 0.6);
box-shadow:
0 8px 20px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(34, 197, 94, 0.3),
0 0 15px rgba(34, 197, 94, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
/* Bubble card background image */
.discover-download-bubble-image {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border-radius: 50%;
}
/* Bubble card overlay */
.discover-download-bubble-overlay {
position: absolute;
inset: 0;
background: linear-gradient(135deg,
rgba(0, 0, 0, 0.2) 0%,
rgba(0, 0, 0, 0.5) 100%);
border-radius: 50%;
}
/* Bubble card content (icon/status) */
.discover-download-bubble-content {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 2;
padding: 8px;
}
.discover-download-bubble-icon {
font-size: 28px;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.8));
}
/* Bubble name (below the card) */
.discover-download-bubble-name {
font-size: 10px;
font-weight: 600;
color: rgba(255, 255, 255, 0.95);
text-align: center;
line-height: 1.2;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
}
/* Bubble status text (optional, below name) */
.discover-download-bubble-status {
font-size: 8px;
color: rgba(255, 255, 255, 0.7);
text-align: center;
margin-top: 2px;
}
/* Scrollbar styling */
.discover-download-sidebar::-webkit-scrollbar {
width: 4px;
}
.discover-download-sidebar::-webkit-scrollbar-track {
background: transparent;
}
.discover-download-sidebar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
}
.discover-download-sidebar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
/* ===============================
BUILD A PLAYLIST STYLES
=============================== */

Loading…
Cancel
Save