Add artist album download management UI

Introduces backend API and frontend integration for downloading missing tracks from artist albums and singles. Adds artist download bubbles, management modal, and related state/UI logic to script.js, with supporting styles in style.css. Also refactors some type hints in web_server.py for consistency.
This commit is contained in:
Broque Thomas 2025-09-04 13:23:54 -07:00
parent 245a8d3270
commit 871dc6cb1c
3 changed files with 1080 additions and 6 deletions

View file

@ -1692,6 +1692,80 @@ def get_artist_discography(artist_id):
traceback.print_exc()
return jsonify({"error": str(e)}), 500
@app.route('/api/artist/<artist_id>/album/<album_id>/tracks', methods=['GET'])
def get_artist_album_tracks(artist_id, album_id):
"""Get tracks for specific album formatted for download missing tracks modal"""
try:
if not spotify_client or not spotify_client.is_authenticated():
return jsonify({"error": "Spotify not authenticated"}), 401
print(f"🎵 Fetching tracks for album: {album_id} by artist: {artist_id}")
# Get album information first
album_data = spotify_client.get_album(album_id)
if not album_data:
return jsonify({"error": "Album not found"}), 404
# Get album tracks
tracks_data = spotify_client.get_album_tracks(album_id)
if not tracks_data or 'items' not in tracks_data:
return jsonify({"error": "No tracks found for album"}), 404
# Handle both dict and object responses from spotify_client.get_album()
if isinstance(album_data, dict):
album_info = {
'id': album_data.get('id'),
'name': album_data.get('name'),
'image_url': album_data.get('images', [{}])[0].get('url') if album_data.get('images') else None,
'release_date': album_data.get('release_date'),
'album_type': album_data.get('album_type'),
'total_tracks': album_data.get('total_tracks')
}
else:
# Handle Album object case
album_info = {
'id': album_data.id,
'name': album_data.name,
'image_url': album_data.image_url,
'release_date': album_data.release_date,
'album_type': album_data.album_type,
'total_tracks': album_data.total_tracks
}
# Format tracks for download missing tracks modal compatibility
formatted_tracks = []
for track_item in tracks_data['items']:
# Create track object compatible with download missing tracks modal
formatted_track = {
'id': track_item['id'],
'name': track_item['name'],
'artists': [artist['name'] for artist in track_item['artists']],
'duration_ms': track_item['duration_ms'],
'track_number': track_item['track_number'],
'disc_number': track_item.get('disc_number', 1),
'explicit': track_item.get('explicit', False),
'preview_url': track_item.get('preview_url'),
'external_urls': track_item.get('external_urls', {}),
'uri': track_item['uri'],
# Add album context for virtual playlist
'album': album_info
}
formatted_tracks.append(formatted_track)
print(f"✅ Successfully formatted {len(formatted_tracks)} tracks for album: {album_info['name']}")
return jsonify({
'success': True,
'album': album_info,
'tracks': formatted_tracks
})
except Exception as e:
print(f"❌ Error fetching album tracks: {e}")
import traceback
traceback.print_exc()
return jsonify({"error": str(e)}), 500
@app.route('/api/artist/<artist_id>/completion', methods=['POST'])
def check_artist_discography_completion(artist_id):
"""Check completion status for artist's albums and singles"""
@ -1752,7 +1826,7 @@ def check_artist_discography_completion(artist_id):
traceback.print_exc()
return jsonify({"error": str(e)}), 500
def _check_album_completion(db: 'MusicDatabase', album_data: dict, artist_name: str, test_mode: bool = False) -> dict:
def _check_album_completion(db, album_data: dict, artist_name: str, test_mode: bool = False) -> dict:
"""Check completion status for a single album"""
try:
album_name = album_data.get('name', '')
@ -1837,7 +1911,7 @@ def _check_album_completion(db: 'MusicDatabase', album_data: dict, artist_name:
"found_in_db": False
}
def _check_single_completion(db: 'MusicDatabase', single_data: dict, artist_name: str, test_mode: bool = False) -> dict:
def _check_single_completion(db, single_data: dict, artist_name: str, test_mode: bool = False) -> dict:
"""Check completion status for a single/EP (treat EPs like albums, singles as single tracks)"""
try:
single_name = single_data.get('name', '')

View file

@ -65,6 +65,10 @@ let artistsPageState = {
},
isInitialized: false // Track if the page has been initialized
};
// --- Artist Downloads Management State ---
let artistDownloadBubbles = {}; // Track artist download bubbles: artistId -> { artist, downloads: [], element }
let artistDownloadModalOpen = false; // Track if artist download modal is open
let artistsSearchTimeout = null;
let artistsSearchController = null;
@ -3115,6 +3119,11 @@ async function closeDownloadMissingModal(playlistId) {
console.log('📱 [Modal State] Cleared wishlist modal state on full close');
}
// Clean up artist download if this is an artist album playlist
if (playlistId.startsWith('artist_album_')) {
cleanupArtistDownload(playlistId);
}
cleanupDownloadProcess(playlistId);
}
}
@ -5254,6 +5263,11 @@ window.handleWishlistButtonClick = handleWishlistButtonClick;
window.escapeHtml = escapeHtml;
window.formatArtists = formatArtists;
// Artist Download Management functions
window.closeArtistDownloadModal = closeArtistDownloadModal;
window.openArtistDownloadProcess = openArtistDownloadProcess;
window.bulkCompleteArtistDownloads = bulkCompleteArtistDownloads;
// APPEND THIS JAVASCRIPT SNIPPET (B)
@ -9523,7 +9537,7 @@ function displayArtistDiscography(discography) {
if (discography.albums?.length > 0) {
albumsContainer.innerHTML = discography.albums.map(album => createAlbumCard(album)).join('');
// Add dynamic glow effects to album cards
// Add dynamic glow effects and click handlers to album cards
albumsContainer.querySelectorAll('.album-card').forEach((card, index) => {
const album = discography.albums[index];
if (album.image_url) {
@ -9531,6 +9545,10 @@ function displayArtistDiscography(discography) {
applyDynamicGlow(card, colors);
});
}
// Add click handler for download missing tracks modal
card.addEventListener('click', () => handleArtistAlbumClick(album, 'albums'));
card.style.cursor = 'pointer';
});
} else {
albumsContainer.innerHTML = `
@ -9548,7 +9566,7 @@ function displayArtistDiscography(discography) {
if (discography.singles?.length > 0) {
singlesContainer.innerHTML = discography.singles.map(single => createAlbumCard(single)).join('');
// Add dynamic glow effects to singles cards
// Add dynamic glow effects and click handlers to singles cards
singlesContainer.querySelectorAll('.album-card').forEach((card, index) => {
const single = discography.singles[index];
if (single.image_url) {
@ -9556,6 +9574,10 @@ function displayArtistDiscography(discography) {
applyDynamicGlow(card, colors);
});
}
// Add click handler for download missing tracks modal
card.addEventListener('click', () => handleArtistAlbumClick(single, 'singles'));
card.style.cursor = 'pointer';
});
} else {
singlesContainer.innerHTML = `
@ -9889,6 +9911,9 @@ function showArtistsSearchState() {
artistsPageState.currentView = 'search';
updateArtistsSearchStatus('default');
// Show artist downloads section if there are active downloads
showArtistDownloadsSection();
}
function showArtistsResultsState() {
@ -10096,6 +10121,615 @@ function showSearchLoadingCards() {
container.innerHTML = loadingCardHtml.repeat(6);
}
// ===============================
// ARTIST ALBUM DOWNLOAD MISSING TRACKS INTEGRATION
// ===============================
/**
* Handle album/single/EP click to open download missing tracks modal
*/
async function handleArtistAlbumClick(album, albumType) {
console.log(`🎵 Album clicked: ${album.name} (${album.album_type}) from artist: ${artistsPageState.selectedArtist?.name}`);
if (!artistsPageState.selectedArtist) {
console.error('❌ No selected artist found');
showToast('Error: No artist selected', 'error');
return;
}
try {
// Create virtual playlist ID
const virtualPlaylistId = `artist_album_${artistsPageState.selectedArtist.id}_${album.id}`;
// Check if modal already exists and show it
if (activeDownloadProcesses[virtualPlaylistId]) {
console.log(`📱 Reopening existing modal for ${album.name}`);
const process = activeDownloadProcesses[virtualPlaylistId];
if (process.modalElement) {
if (process.status === 'complete') {
showToast('Showing previous results. Close this modal to start a new analysis.', 'info');
}
process.modalElement.style.display = 'flex';
return;
}
}
// Create virtual playlist and open modal
await createArtistAlbumVirtualPlaylist(album, albumType);
} catch (error) {
console.error('❌ Error opening download missing tracks modal:', error);
showToast(`Error opening download modal: ${error.message}`, 'error');
}
}
/**
* Create virtual playlist for artist album and open download missing tracks modal
*/
async function createArtistAlbumVirtualPlaylist(album, albumType) {
const artist = artistsPageState.selectedArtist;
const virtualPlaylistId = `artist_album_${artist.id}_${album.id}`;
console.log(`🎵 Creating virtual playlist for: ${artist.name} - ${album.name}`);
try {
// Show loading toast
showToast(`Loading tracks for ${album.name}...`, 'info');
// Fetch album tracks from backend
const response = await fetch(`/api/artist/${artist.id}/album/${album.id}/tracks`);
if (!response.ok) {
if (response.status === 401) {
throw new Error('Spotify not authenticated. Please check your API settings.');
}
throw new Error(`Failed to load album tracks: ${response.status}`);
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
throw new Error('No tracks found for this album');
}
console.log(`✅ Loaded ${data.tracks.length} tracks for ${album.name}`);
// Format playlist name with artist and album info
const playlistName = `[${artist.name}] ${album.name}`;
// Open download missing tracks modal with formatted tracks
await openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, data.tracks, album, artist);
// Track this download for artist bubble management
registerArtistDownload(artist, album, virtualPlaylistId, albumType);
} catch (error) {
console.error('❌ Error creating virtual playlist:', error);
showToast(`Failed to load album: ${error.message}`, 'error');
throw error;
}
}
/**
* Open download missing tracks modal specifically for artist albums
* Similar to openDownloadMissingModalForYouTube but for artist albums
*/
async function openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, spotifyTracks, album, artist) {
// Check if a process is already active for this virtual playlist
if (activeDownloadProcesses[virtualPlaylistId]) {
console.log(`Modal for ${virtualPlaylistId} already exists. Showing it.`);
const process = activeDownloadProcesses[virtualPlaylistId];
if (process.modalElement) {
if (process.status === 'complete') {
showToast('Showing previous results. Close this modal to start a new analysis.', 'info');
}
process.modalElement.style.display = 'flex';
}
return;
}
console.log(`📥 Opening Download Missing Tracks modal for artist album: ${virtualPlaylistId}`);
// Create virtual playlist object for compatibility with existing modal logic
const virtualPlaylist = {
id: virtualPlaylistId,
name: playlistName,
track_count: spotifyTracks.length
};
// Store the tracks in the cache for the modal to use
playlistTrackCache[virtualPlaylistId] = spotifyTracks;
currentPlaylistTracks = spotifyTracks;
currentModalPlaylistId = virtualPlaylistId;
let modal = document.createElement('div');
modal.id = `download-missing-modal-${virtualPlaylistId}`;
modal.className = 'download-missing-modal';
modal.style.display = 'none';
document.body.appendChild(modal);
// Register the new process in our global state tracker using the same structure as other modals
activeDownloadProcesses[virtualPlaylistId] = {
status: 'idle',
modalElement: modal,
poller: null,
batchId: null,
playlist: virtualPlaylist,
tracks: spotifyTracks,
// Additional metadata for artist albums
artist: artist,
album: album,
albumType: album.album_type
};
// Use the exact same modal HTML structure as the existing modals
modal.innerHTML = `
<div class="download-missing-modal-content">
<div class="download-missing-modal-header">
<h2 class="download-missing-modal-title">Download Missing Tracks - ${escapeHtml(playlistName)}</h2>
<span class="download-missing-modal-close" onclick="closeDownloadMissingModal('${virtualPlaylistId}')">&times;</span>
</div>
<div class="download-missing-modal-body">
<div class="download-dashboard-stats">
<div class="dashboard-stat stat-total">
<div class="dashboard-stat-number" id="stat-total-${virtualPlaylistId}">${spotifyTracks.length}</div>
<div class="dashboard-stat-label">Total Tracks</div>
</div>
<div class="dashboard-stat stat-found">
<div class="dashboard-stat-number" id="stat-found-${virtualPlaylistId}">-</div>
<div class="dashboard-stat-label">Found in Library</div>
</div>
<div class="dashboard-stat stat-missing">
<div class="dashboard-stat-number" id="stat-missing-${virtualPlaylistId}">-</div>
<div class="dashboard-stat-label">Missing Tracks</div>
</div>
<div class="dashboard-stat stat-downloaded">
<div class="dashboard-stat-number" id="stat-downloaded-${virtualPlaylistId}">0</div>
<div class="dashboard-stat-label">Downloaded</div>
</div>
</div>
<div class="download-progress-section">
<div class="progress-item">
<div class="progress-label">
🔍 Library Analysis
<span id="analysis-progress-text-${virtualPlaylistId}">Ready to start</span>
</div>
<div class="progress-bar">
<div class="progress-fill analysis" id="analysis-progress-fill-${virtualPlaylistId}"></div>
</div>
</div>
<div class="progress-item">
<div class="progress-label">
Downloads
<span id="download-progress-text-${virtualPlaylistId}">Waiting for analysis</span>
</div>
<div class="progress-bar">
<div class="progress-fill download" id="download-progress-fill-${virtualPlaylistId}"></div>
</div>
</div>
</div>
<div class="download-tracks-section">
<div class="download-tracks-header">
<h3 class="download-tracks-title">📋 Track Analysis & Download Status</h3>
</div>
<div class="download-tracks-table-container">
<table class="download-tracks-table">
<thead>
<tr>
<th>#</th>
<th>Track Name</th>
<th>Artist(s)</th>
<th>Duration</th>
<th>Library Status</th>
<th>Download Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="download-tracks-tbody-${virtualPlaylistId}">
${spotifyTracks.map((track, index) => `
<tr data-track-index="${index}">
<td class="track-number">${index + 1}</td>
<td class="track-name" title="${escapeHtml(track.name)}">${escapeHtml(track.name)}</td>
<td class="track-artist" title="${escapeHtml(track.artists.join(', '))}">${track.artists.join(', ')}</td>
<td class="track-duration">${formatDuration(track.duration_ms)}</td>
<td class="track-match-status match-checking" id="match-${virtualPlaylistId}-${index}">🔍 Pending</td>
<td class="track-download-status" id="download-${virtualPlaylistId}-${index}">-</td>
<td class="track-actions" id="actions-${virtualPlaylistId}-${index}">-</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
</div>
<div class="download-missing-modal-footer">
<div class="download-phase-controls">
<button class="download-control-btn primary" id="begin-analysis-btn-${virtualPlaylistId}" onclick="startMissingTracksProcess('${virtualPlaylistId}')">
Begin Analysis
</button>
<button class="download-control-btn danger" id="cancel-all-btn-${virtualPlaylistId}" onclick="cancelAllOperations('${virtualPlaylistId}')" style="display: none;">
Cancel All
</button>
</div>
<div class="modal-close-section">
<button class="download-control-btn secondary" onclick="closeDownloadMissingModal('${virtualPlaylistId}')">Close</button>
</div>
</div>
</div>
`;
modal.style.display = 'flex';
console.log(`✅ Successfully opened download missing tracks modal for: ${playlistName}`);
}
// ===============================
// ARTIST DOWNLOADS MANAGEMENT SYSTEM
// ===============================
/**
* Register a new artist download for bubble management
*/
function registerArtistDownload(artist, album, virtualPlaylistId, albumType) {
console.log(`📝 Registering artist download: ${artist.name} - ${album.name}`);
const artistId = artist.id;
// Initialize artist bubble if it doesn't exist
if (!artistDownloadBubbles[artistId]) {
artistDownloadBubbles[artistId] = {
artist: artist,
downloads: [],
element: null,
hasCompletedDownloads: false
};
}
// Add this download to the artist's downloads
const downloadInfo = {
virtualPlaylistId: virtualPlaylistId,
album: album,
albumType: albumType,
status: 'in_progress', // 'in_progress', 'completed', 'view_results'
startTime: new Date()
};
artistDownloadBubbles[artistId].downloads.push(downloadInfo);
// Show/update the artist downloads section
showArtistDownloadsSection();
// Monitor this download for completion
monitorArtistDownload(artistId, virtualPlaylistId);
}
/**
* Show or update the artist downloads section in search state
*/
function showArtistDownloadsSection() {
// Only show in search state
if (artistsPageState.currentView !== 'search') {
return;
}
const artistsSearchState = document.getElementById('artists-search-state');
if (!artistsSearchState) return;
let downloadsSection = document.getElementById('artist-downloads-section');
// Create section if it doesn't exist
if (!downloadsSection) {
downloadsSection = document.createElement('div');
downloadsSection.id = 'artist-downloads-section';
downloadsSection.className = 'artist-downloads-section';
// Insert after the search container
const searchContainer = artistsSearchState.querySelector('.artists-search-container');
if (searchContainer) {
searchContainer.insertAdjacentElement('afterend', downloadsSection);
}
}
// Count active artists (those with downloads)
const activeArtists = Object.keys(artistDownloadBubbles).filter(artistId =>
artistDownloadBubbles[artistId].downloads.length > 0
);
if (activeArtists.length === 0) {
downloadsSection.style.display = 'none';
return;
}
// Show and populate the section
downloadsSection.style.display = 'block';
downloadsSection.innerHTML = `
<div class="artist-downloads-header">
<h3 class="artist-downloads-title">Current Downloads</h3>
<p class="artist-downloads-subtitle">Active download processes</p>
</div>
<div class="artist-bubble-container" id="artist-bubble-container">
${activeArtists.map(artistId => createArtistBubbleCard(artistDownloadBubbles[artistId])).join('')}
</div>
`;
// Add event listeners to bubble cards
activeArtists.forEach(artistId => {
const bubbleCard = downloadsSection.querySelector(`[data-artist-id="${artistId}"]`);
if (bubbleCard) {
bubbleCard.addEventListener('click', () => openArtistDownloadModal(artistId));
// Add dynamic glow effect
const artist = artistDownloadBubbles[artistId].artist;
if (artist.image_url) {
extractImageColors(artist.image_url, (colors) => {
applyDynamicGlow(bubbleCard, colors);
});
}
}
});
}
/**
* Create HTML for an artist bubble card
*/
function createArtistBubbleCard(artistBubbleData) {
const { artist, downloads } = artistBubbleData;
const activeCount = downloads.filter(d => d.status === 'in_progress').length;
const completedCount = downloads.filter(d => d.status === 'view_results').length;
const allCompleted = activeCount === 0 && completedCount > 0;
const imageUrl = artist.image_url || '';
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="artist-bubble-card ${allCompleted ? 'all-completed' : ''}"
data-artist-id="${artist.id}"
title="Click to manage downloads for ${escapeHtml(artist.name)}">
<div class="artist-bubble-image" style="${backgroundStyle}"></div>
<div class="artist-bubble-overlay"></div>
<div class="artist-bubble-content">
<div class="artist-bubble-name">${escapeHtml(artist.name)}</div>
<div class="artist-bubble-status">
${activeCount > 0 ? `${activeCount} active` : ''}
${completedCount > 0 ? `${completedCount} completed` : ''}
</div>
</div>
${allCompleted ? `
<div class="bulk-complete-indicator"
onclick="event.stopPropagation(); bulkCompleteArtistDownloads('${artist.id}')"
title="Complete all downloads">
<span class="bulk-complete-icon"></span>
</div>
` : ''}
</div>
`;
}
/**
* Monitor an artist download for completion status changes
*/
function monitorArtistDownload(artistId, virtualPlaylistId) {
// Check if the download process exists and monitor its status
const checkStatus = () => {
const process = activeDownloadProcesses[virtualPlaylistId];
if (!process || !artistDownloadBubbles[artistId]) {
return; // Process or artist bubble no longer exists
}
// Find this download in the artist's downloads
const download = artistDownloadBubbles[artistId].downloads.find(d => d.virtualPlaylistId === virtualPlaylistId);
if (!download) return;
// Update download status based on process status
if (process.status === 'complete' && download.status === 'in_progress') {
download.status = 'view_results';
console.log(`✅ Download completed for ${artistDownloadBubbles[artistId].artist.name} - ${download.album.name}`);
// Update the downloads section
showArtistDownloadsSection();
}
// Continue monitoring if still active
if (process.status !== 'complete') {
setTimeout(checkStatus, 2000); // Check every 2 seconds
}
};
// Start monitoring after a brief delay
setTimeout(checkStatus, 1000);
}
/**
* Open the artist download management modal
*/
function openArtistDownloadModal(artistId) {
const artistBubbleData = artistDownloadBubbles[artistId];
if (!artistBubbleData || artistDownloadModalOpen) return;
console.log(`🎵 Opening artist download modal for: ${artistBubbleData.artist.name}`);
artistDownloadModalOpen = true;
const modal = document.createElement('div');
modal.id = 'artist-download-management-modal';
modal.className = 'artist-download-management-modal';
modal.innerHTML = `
<div class="artist-download-modal-content">
<div class="artist-download-modal-header">
<h2 class="artist-download-modal-title">Downloads for ${escapeHtml(artistBubbleData.artist.name)}</h2>
<span class="artist-download-modal-close" onclick="closeArtistDownloadModal()">&times;</span>
</div>
<div class="artist-download-modal-body">
<div class="artist-download-items" id="artist-download-items-${artistId}">
${artistBubbleData.downloads.map((download, index) => createArtistDownloadItem(download, index)).join('')}
</div>
</div>
</div>
<div class="artist-download-modal-overlay" onclick="closeArtistDownloadModal()"></div>
`;
document.body.appendChild(modal);
modal.style.display = 'flex';
// Monitor for real-time updates
startArtistDownloadModalMonitoring(artistId);
}
/**
* Create HTML for an individual download item in the artist modal
*/
function createArtistDownloadItem(download, index) {
const { album, albumType, status, virtualPlaylistId } = download;
const buttonText = status === 'view_results' ? 'View Results' : 'View Progress';
const buttonClass = status === 'view_results' ? 'completed' : 'active';
return `
<div class="artist-download-item" data-playlist-id="${virtualPlaylistId}">
<div class="download-item-info">
<div class="download-item-name">${escapeHtml(album.name)}</div>
<div class="download-item-type">${albumType === 'album' ? 'Album' : albumType === 'single' ? 'Single' : 'EP'}</div>
</div>
<div class="download-item-actions">
<button class="download-item-btn ${buttonClass}"
onclick="openArtistDownloadProcess('${virtualPlaylistId}')">
${buttonText}
</button>
</div>
</div>
`;
}
/**
* Monitor artist download modal for real-time updates
*/
function startArtistDownloadModalMonitoring(artistId) {
if (!artistDownloadModalOpen) return;
const updateModal = () => {
const modal = document.getElementById('artist-download-management-modal');
const itemsContainer = document.getElementById(`artist-download-items-${artistId}`);
if (!modal || !itemsContainer || !artistDownloadBubbles[artistId]) return;
// Check for completed downloads that need to be removed
const activeDownloads = artistDownloadBubbles[artistId].downloads.filter(download => {
const process = activeDownloadProcesses[download.virtualPlaylistId];
// Keep if process exists or if it's completed but not yet cleaned up
return process !== undefined;
});
// Update the downloads array
artistDownloadBubbles[artistId].downloads = activeDownloads;
// If no downloads left, close modal
if (activeDownloads.length === 0) {
closeArtistDownloadModal();
return;
}
// Update modal content
itemsContainer.innerHTML = activeDownloads.map((download, index) => {
const process = activeDownloadProcesses[download.virtualPlaylistId];
if (process) {
download.status = process.status === 'complete' ? 'view_results' : 'in_progress';
}
return createArtistDownloadItem(download, index);
}).join('');
// Continue monitoring
setTimeout(updateModal, 2000);
};
setTimeout(updateModal, 1000);
}
/**
* Open a specific artist download process modal
*/
function openArtistDownloadProcess(virtualPlaylistId) {
const process = activeDownloadProcesses[virtualPlaylistId];
if (process && process.modalElement) {
// Close artist management modal first
closeArtistDownloadModal();
// Show the download process modal
process.modalElement.style.display = 'flex';
if (process.status === 'complete') {
showToast('Review download results and click "Close" to finish.', 'info');
}
}
}
/**
* Close the artist download management modal
*/
function closeArtistDownloadModal() {
const modal = document.getElementById('artist-download-management-modal');
if (modal) {
modal.remove();
}
artistDownloadModalOpen = false;
}
/**
* Bulk complete all downloads for an artist (when all are in 'view_results' state)
*/
function bulkCompleteArtistDownloads(artistId) {
console.log(`🎯 Bulk completing downloads for artist: ${artistId}`);
const artistBubbleData = artistDownloadBubbles[artistId];
if (!artistBubbleData) return;
// Find all downloads in 'view_results' state
const completedDownloads = artistBubbleData.downloads.filter(d => d.status === 'view_results');
// Programmatically close all completed modals
completedDownloads.forEach(download => {
const process = activeDownloadProcesses[download.virtualPlaylistId];
if (process && process.modalElement) {
// Trigger the close function which handles cleanup
closeDownloadMissingModal(download.virtualPlaylistId);
}
});
showToast(`Completed ${completedDownloads.length} downloads for ${artistBubbleData.artist.name}`, 'success');
}
/**
* Clean up artist download when a modal is closed
*/
function cleanupArtistDownload(virtualPlaylistId) {
// Find which artist this download belongs to
for (const artistId in artistDownloadBubbles) {
const downloads = artistDownloadBubbles[artistId].downloads;
const downloadIndex = downloads.findIndex(d => d.virtualPlaylistId === virtualPlaylistId);
if (downloadIndex !== -1) {
console.log(`🧹 Cleaning up artist download: ${downloads[downloadIndex].album.name}`);
// Remove this download from the artist's downloads
downloads.splice(downloadIndex, 1);
// If no more downloads for this artist, remove the bubble
if (downloads.length === 0) {
delete artistDownloadBubbles[artistId];
console.log(`🧹 Removed artist bubble: ${artistId}`);
}
// Update the downloads section
showArtistDownloadsSection();
break;
}
}
}
/**
* Extract dominant colors from an image for dynamic glow effects
*/

View file

@ -5634,15 +5634,18 @@ body {
/* ARTISTS PAGE STYLES - ELEGANT GLASSMORPHIC DESIGN */
/* ============================================================================ */
/* Artists Search State - Initial centered interface */
/* Artists Search State - Initial centered interface with downloads section support */
.artists-search-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 10vh;
justify-content: center;
min-height: 70vh;
padding: 40px 20px;
opacity: 1;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
gap: 32px;
}
.artists-search-state.fade-out {
@ -5654,6 +5657,14 @@ body {
max-width: 600px;
width: 100%;
text-align: center;
margin-top: auto;
margin-bottom: auto;
}
/* When downloads section is present, adjust positioning */
.artists-search-state:has(.artist-downloads-section) .artists-search-container {
margin-top: 0;
margin-bottom: 0;
}
.artists-welcome-section {
@ -6542,4 +6553,359 @@ body {
overflow-x: auto;
padding-bottom: 5px;
}
}
}
/* ===============================
ARTIST DOWNLOADS MANAGEMENT STYLES
=============================== */
/* Artist Downloads Section */
.artist-downloads-section {
max-width: 800px;
padding: 0 20px;
animation: fadeInUp 0.5s ease-out;
width: 100%;
}
.artist-downloads-header {
margin-bottom: 20px;
text-align: center;
}
.artist-downloads-title {
font-size: 20px;
font-weight: 700;
color: rgba(255, 255, 255, 0.9);
margin: 0 0 8px;
font-family: "SF Pro Display", -apple-system, BlinkMacSystemFont, sans-serif;
}
.artist-downloads-subtitle {
font-size: 14px;
color: rgba(255, 255, 255, 0.6);
margin: 0;
}
.artist-bubble-container {
display: flex;
flex-wrap: wrap;
gap: 16px;
justify-content: center;
align-items: center;
}
/* Artist Bubble Card */
.artist-bubble-card {
position: relative;
width: 80px;
height: 80px;
border-radius: 50%;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
background: linear-gradient(135deg,
rgba(26, 26, 26, 0.95) 0%,
rgba(18, 18, 18, 0.98) 100%);
border: 2px solid rgba(255, 255, 255, 0.1);
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);
}
.artist-bubble-card:hover {
transform: translateY(-3px) scale(1.05);
border-color: rgba(29, 185, 84, 0.3);
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.1),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.artist-bubble-card.all-completed {
border-color: rgba(34, 197, 94, 0.4);
}
.artist-bubble-card.all-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);
}
.artist-bubble-image {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border-radius: 50%;
}
.artist-bubble-overlay {
position: absolute;
inset: 0;
background: linear-gradient(135deg,
rgba(0, 0, 0, 0.3) 0%,
rgba(0, 0, 0, 0.6) 100%);
border-radius: 50%;
}
.artist-bubble-content {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 8px;
text-align: center;
z-index: 2;
}
.artist-bubble-name {
font-size: 10px;
font-weight: 600;
color: rgba(255, 255, 255, 0.95);
line-height: 1.2;
margin-bottom: 2px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.artist-bubble-status {
font-size: 8px;
color: rgba(255, 255, 255, 0.7);
line-height: 1.1;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.bulk-complete-indicator {
position: absolute;
top: -4px;
right: -4px;
width: 24px;
height: 24px;
background: linear-gradient(135deg,
rgba(34, 197, 94, 0.95) 0%,
rgba(21, 128, 61, 0.95) 100%);
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 3;
transition: all 0.2s ease;
box-shadow:
0 2px 8px rgba(34, 197, 94, 0.4),
0 0 0 1px rgba(34, 197, 94, 0.2);
}
.bulk-complete-indicator:hover {
transform: scale(1.1);
box-shadow:
0 4px 12px rgba(34, 197, 94, 0.6),
0 0 0 1px rgba(34, 197, 94, 0.3);
}
.bulk-complete-icon {
font-size: 12px;
color: white;
}
/* Artist Download Management Modal */
.artist-download-management-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.3s ease-out;
}
.artist-download-modal-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(8px);
z-index: 1;
}
.artist-download-modal-content {
position: relative;
background: linear-gradient(135deg,
rgba(20, 20, 20, 0.98) 0%,
rgba(12, 12, 12, 0.98) 100%);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
max-width: 500px;
width: 90%;
max-height: 70vh;
overflow: hidden;
z-index: 2;
box-shadow:
0 20px 40px rgba(0, 0, 0, 0.6),
0 0 0 1px rgba(29, 185, 84, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.artist-download-modal-header {
padding: 24px 24px 0;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
padding-bottom: 16px;
margin-bottom: 20px;
}
.artist-download-modal-title {
font-size: 20px;
font-weight: 700;
color: rgba(255, 255, 255, 0.95);
margin: 0;
flex: 1;
font-family: "SF Pro Display", -apple-system, BlinkMacSystemFont, sans-serif;
}
.artist-download-modal-close {
font-size: 24px;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
padding: 4px;
border-radius: 6px;
transition: all 0.2s ease;
line-height: 1;
}
.artist-download-modal-close:hover {
color: rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.1);
}
.artist-download-modal-body {
padding: 0 24px 24px;
overflow-y: auto;
max-height: calc(70vh - 100px);
}
.artist-download-items {
display: flex;
flex-direction: column;
gap: 12px;
}
.artist-download-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: linear-gradient(135deg,
rgba(26, 26, 26, 0.6) 0%,
rgba(18, 18, 18, 0.8) 100%);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.06);
transition: all 0.2s ease;
}
.artist-download-item:hover {
background: linear-gradient(135deg,
rgba(26, 26, 26, 0.8) 0%,
rgba(18, 18, 18, 0.9) 100%);
border-color: rgba(255, 255, 255, 0.1);
}
.download-item-info {
flex: 1;
}
.download-item-name {
font-size: 14px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 4px;
line-height: 1.3;
}
.download-item-type {
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
text-transform: capitalize;
}
.download-item-actions {
margin-left: 16px;
}
.download-item-btn {
padding: 8px 16px;
border-radius: 8px;
border: none;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
min-width: 100px;
}
.download-item-btn.active {
background: linear-gradient(135deg,
rgba(29, 185, 84, 0.9) 0%,
rgba(24, 156, 71, 0.9) 100%);
color: white;
box-shadow: 0 2px 8px rgba(29, 185, 84, 0.3);
}
.download-item-btn.active:hover {
background: linear-gradient(135deg,
rgba(29, 185, 84, 1.0) 0%,
rgba(24, 156, 71, 1.0) 100%);
box-shadow: 0 4px 12px rgba(29, 185, 84, 0.4);
transform: translateY(-1px);
}
.download-item-btn.completed {
background: linear-gradient(135deg,
rgba(34, 197, 94, 0.9) 0%,
rgba(21, 128, 61, 0.9) 100%);
color: white;
box-shadow: 0 2px 8px rgba(34, 197, 94, 0.3);
}
.download-item-btn.completed:hover {
background: linear-gradient(135deg,
rgba(34, 197, 94, 1.0) 0%,
rgba(21, 128, 61, 1.0) 100%);
box-shadow: 0 4px 12px rgba(34, 197, 94, 0.4);
transform: translateY(-1px);
}
/* Dynamic glow effects for artist bubble cards */
.artist-bubble-card.has-dynamic-glow {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1),
filter 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.artist-bubble-card.has-dynamic-glow:hover {
filter: drop-shadow(0 0 6px var(--glow-color-1, #1db954))
drop-shadow(0 0 12px var(--glow-color-2, #1ed760));
border-color: var(--glow-color-1, rgba(29, 185, 84, 0.4));
}