download bubbles for discoverp age
This commit is contained in:
parent
52c27ce2a9
commit
12c37fa61c
5 changed files with 1193 additions and 7 deletions
|
|
@ -7,8 +7,10 @@
|
||||||
"Bash(grep:*)",
|
"Bash(grep:*)",
|
||||||
"WebFetch(domain:python-plexapi.readthedocs.io)",
|
"WebFetch(domain:python-plexapi.readthedocs.io)",
|
||||||
"Bash(git restore:*)",
|
"Bash(git restore:*)",
|
||||||
"Bash(python3:*)"
|
"Bash(python3:*)",
|
||||||
|
"Bash(awk:*)",
|
||||||
|
"Bash(cat:*)"
|
||||||
],
|
],
|
||||||
"deny": []
|
"deny": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
173
web_server.py
173
web_server.py
|
|
@ -13586,6 +13586,179 @@ def test_database_access():
|
||||||
"message": "Database access test failed"
|
"message": "Database access test failed"
|
||||||
}), 500
|
}), 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 ---
|
# --- Artist Bubble Snapshot System ---
|
||||||
|
|
||||||
@app.route('/api/artist_bubbles/snapshot', methods=['POST'])
|
@app.route('/api/artist_bubbles/snapshot', methods=['POST'])
|
||||||
|
|
|
||||||
|
|
@ -2814,6 +2814,18 @@
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<script src="{{ url_for('static', filename='script.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -365,7 +365,7 @@ function initializeWatchlist() {
|
||||||
|
|
||||||
function navigateToPage(pageId) {
|
function navigateToPage(pageId) {
|
||||||
if (pageId === currentPage) return;
|
if (pageId === currentPage) return;
|
||||||
|
|
||||||
// Update navigation buttons (only if there's a nav button for this page)
|
// Update navigation buttons (only if there's a nav button for this page)
|
||||||
document.querySelectorAll('.nav-button').forEach(btn => {
|
document.querySelectorAll('.nav-button').forEach(btn => {
|
||||||
btn.classList.remove('active');
|
btn.classList.remove('active');
|
||||||
|
|
@ -374,15 +374,33 @@ function navigateToPage(pageId) {
|
||||||
if (navButton) {
|
if (navButton) {
|
||||||
navButton.classList.add('active');
|
navButton.classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update pages
|
// Update pages
|
||||||
document.querySelectorAll('.page').forEach(page => {
|
document.querySelectorAll('.page').forEach(page => {
|
||||||
page.classList.remove('active');
|
page.classList.remove('active');
|
||||||
});
|
});
|
||||||
document.getElementById(`${pageId}-page`).classList.add('active');
|
document.getElementById(`${pageId}-page`).classList.add('active');
|
||||||
|
|
||||||
currentPage = pageId;
|
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
|
// Load page-specific data
|
||||||
loadPageData(pageId);
|
loadPageData(pageId);
|
||||||
}
|
}
|
||||||
|
|
@ -2190,7 +2208,10 @@ async function loadInitialData() {
|
||||||
try {
|
try {
|
||||||
// Load artist bubble state first
|
// Load artist bubble state first
|
||||||
await hydrateArtistBubblesFromSnapshot();
|
await hydrateArtistBubblesFromSnapshot();
|
||||||
|
|
||||||
|
// Load discover download state
|
||||||
|
await hydrateDiscoverDownloadsFromSnapshot();
|
||||||
|
|
||||||
// Load dashboard data by default
|
// Load dashboard data by default
|
||||||
await loadDashboardData();
|
await loadDashboardData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -2359,12 +2380,95 @@ async function rehydrateDiscoverPlaylistModal(virtualPlaylistId, playlistName, b
|
||||||
try {
|
try {
|
||||||
console.log(`💧 Rehydrating discover playlist modal: ${virtualPlaylistId} (${playlistName})`);
|
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
|
// Determine API endpoint based on playlist ID
|
||||||
let apiEndpoint;
|
let apiEndpoint;
|
||||||
if (virtualPlaylistId === 'discover_release_radar') {
|
if (virtualPlaylistId === 'discover_release_radar') {
|
||||||
apiEndpoint = '/api/discover/release-radar';
|
apiEndpoint = '/api/discover/release-radar';
|
||||||
} else if (virtualPlaylistId === 'discover_discovery_weekly') {
|
} else if (virtualPlaylistId === 'discover_discovery_weekly') {
|
||||||
apiEndpoint = '/api/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 {
|
} else {
|
||||||
console.error(`❌ Unknown discover playlist type: ${virtualPlaylistId}`);
|
console.error(`❌ Unknown discover playlist type: ${virtualPlaylistId}`);
|
||||||
return;
|
return;
|
||||||
|
|
@ -4210,6 +4314,23 @@ async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistNam
|
||||||
virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' :
|
virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' :
|
||||||
'YouTube';
|
'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 = {
|
const heroContext = {
|
||||||
type: 'playlist',
|
type: 'playlist',
|
||||||
playlist: { name: playlistName, owner: source },
|
playlist: { name: playlistName, owner: source },
|
||||||
|
|
@ -4494,6 +4615,13 @@ async function closeDownloadMissingModal(playlistId) {
|
||||||
console.log(`✅ [MODAL CLOSE] Artist download cleanup completed for: ${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
|
// Automatic cleanup and server operations after successful downloads
|
||||||
await handlePostDownloadAutomation(playlistId, process);
|
await handlePostDownloadAutomation(playlistId, process);
|
||||||
|
|
||||||
|
|
@ -4819,6 +4947,15 @@ async function startMissingTracksProcess(playlistId) {
|
||||||
document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'none';
|
document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'none';
|
||||||
document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'inline-block';
|
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
|
// Check if force download toggle is enabled
|
||||||
const forceDownloadCheckbox = document.getElementById(`force-download-all-${playlistId}`);
|
const forceDownloadCheckbox = document.getElementById(`force-download-all-${playlistId}`);
|
||||||
const forceDownloadAll = forceDownloadCheckbox ? forceDownloadCheckbox.checked : false;
|
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
|
// Use the same sync function as all other discover playlists
|
||||||
await startPlaylistSync(virtualPlaylistId);
|
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)
|
// Start polling for progress updates (using discover playlist pattern)
|
||||||
startListenBrainzSyncPolling(playlistId, virtualPlaylistId);
|
startListenBrainzSyncPolling(playlistId, virtualPlaylistId);
|
||||||
|
|
||||||
|
|
@ -27812,6 +27961,18 @@ async function startDiscoverPlaylistSync(playlistType, playlistName) {
|
||||||
// Start sync using existing function
|
// Start sync using existing function
|
||||||
await startPlaylistSync(virtualPlaylistId);
|
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
|
// Start polling for progress updates
|
||||||
startDiscoverSyncPolling(playlistType, virtualPlaylistId);
|
startDiscoverSyncPolling(playlistType, virtualPlaylistId);
|
||||||
}
|
}
|
||||||
|
|
@ -27962,3 +28123,622 @@ async function openDownloadModalForRecentAlbum(albumIndex) {
|
||||||
hideLoadingOverlay();
|
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;
|
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
|
BUILD A PLAYLIST STYLES
|
||||||
=============================== */
|
=============================== */
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue