From b5b03a2b86c3d6fbacf2d65939896658c63e0479 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 27 Mar 2026 22:15:05 -0700 Subject: [PATCH] Add Download Discography feature on artist detail page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New "Download Discography" button in artist hero section opens a modal showing the full catalog โ€” albums, EPs, and singles โ€” with filter toggles, select/deselect all, and per-album owned/missing indicators. Modal features: - Glassmorphic design with artist image blurred background header - Filter pills for Albums/EPs/Singles with instant grid filtering - Album cards with cover art, year, track count, and checkbox - Owned albums dimmed and unchecked by default, missing pre-selected - Live NDJSON streaming: each album updates in real-time as processed - "Process Wishlist Now" button after completion - Albums sorted by track count (Deluxe first) to prevent duplicate folder contexts from standard/deluxe edition ordering Backend: NDJSON streaming endpoint POST /api/artist//download-discography - Fetches tracks per album via active metadata client - Adds to wishlist with dedup (no slow fuzzy matching) - Streams one JSON line per album as it completes - Works on both Artists search page and Library artist detail page --- web_server.py | 133 ++++++++++++++++ webui/index.html | 13 ++ webui/static/script.js | 351 +++++++++++++++++++++++++++++++++++++++++ webui/static/style.css | 309 ++++++++++++++++++++++++++++++++++++ 4 files changed, 806 insertions(+) diff --git a/web_server.py b/web_server.py index 76cc8cae..eaf7bec6 100644 --- a/web_server.py +++ b/web_server.py @@ -9622,6 +9622,139 @@ def get_artist_album_tracks(artist_id, album_id): traceback.print_exc() return jsonify({"error": str(e)}), 500 +@app.route('/api/artist//download-discography', methods=['POST']) +def download_discography(artist_id): + """Add selected albums from an artist's discography to the wishlist.""" + try: + data = request.get_json() + if not data or 'album_ids' not in data: + return jsonify({"success": False, "error": "album_ids required"}), 400 + + album_ids = data['album_ids'] + artist_name = data.get('artist_name', 'Unknown Artist') + + from database.music_database import MusicDatabase + db = MusicDatabase() + profile_id = get_current_profile_id() + active_server = config_manager.get_active_media_server() + + # Resolve metadata client + client = None + if spotify_client and spotify_client.is_authenticated(): + client = spotify_client + else: + fallback_src = _get_metadata_fallback_source() + if fallback_src == 'itunes': + from core.itunes_client import iTunesClient + client = iTunesClient() + elif fallback_src == 'deezer': + client = _get_deezer_client() + + if not client: + return jsonify({"success": False, "error": "No metadata source available"}), 500 + + total_added = 0 + total_skipped = 0 + + def generate_ndjson(): + nonlocal total_added, total_skipped + + for album_id in album_ids: + try: + album_data = client.get_album(album_id) + if not album_data: + yield json.dumps({"album_id": album_id, "status": "error", "message": "Album not found"}) + '\n' + continue + + album_name = album_data.get('name', 'Unknown') + album_images = album_data.get('images', []) + album_type = album_data.get('album_type', 'album') + release_date = album_data.get('release_date', '') + album_artists = album_data.get('artists', []) + + tracks = album_data.get('tracks', {}).get('items', []) + if not tracks: + tracks_data = client.get_album_tracks(album_id) + if tracks_data and 'items' in tracks_data: + tracks = tracks_data['items'] + + if not tracks: + yield json.dumps({"album_id": album_id, "name": album_name, "status": "error", "message": "No tracks"}) + '\n' + continue + + added = 0 + skipped = 0 + + for track in tracks: + track_name = track.get('name', '') + track_artists = track.get('artists', []) + track_id = track.get('id', '') + + if not track_name: + continue + + spotify_track_data = { + 'id': track_id, + 'name': track_name, + 'artists': track_artists if isinstance(track_artists, list) else [{'name': str(track_artists)}], + 'album': { + 'id': str(album_id), + 'name': album_name, + 'artists': album_artists, + 'images': album_images, + 'album_type': album_type, + 'release_date': release_date, + 'total_tracks': len(tracks) + }, + 'duration_ms': track.get('duration_ms', 0), + 'explicit': track.get('explicit', False), + 'track_number': track.get('track_number', 0), + 'disc_number': track.get('disc_number', 1), + 'uri': track.get('uri', ''), + 'preview_url': track.get('preview_url'), + 'external_urls': track.get('external_urls', {}), + 'is_local': False + } + + try: + was_added = db.add_to_wishlist( + spotify_track_data=spotify_track_data, + failure_reason="Added via Download Discography", + source_type="discography", + source_info=json.dumps({ + 'artist_name': artist_name, + 'album_name': album_name, + 'album_type': album_type + }), + profile_id=profile_id + ) + if was_added: + added += 1 + else: + skipped += 1 + except Exception: + skipped += 1 + + total_added += added + total_skipped += skipped + print(f"๐Ÿ“ฅ [Discography] {album_name}: {added} added, {skipped} skipped") + yield json.dumps({ + "album_id": album_id, "name": album_name, "status": "done", + "tracks_added": added, "tracks_skipped": skipped, "tracks_total": len(tracks) + }) + '\n' + + except Exception as album_err: + yield json.dumps({"album_id": album_id, "status": "error", "message": str(album_err)}) + '\n' + + print(f"๐Ÿ“ฅ [Discography] Complete for {artist_name}: {total_added} tracks added, {total_skipped} skipped across {len(album_ids)} albums") + yield json.dumps({"status": "complete", "total_added": total_added, "total_skipped": total_skipped, "total_albums": len(album_ids)}) + '\n' + + return app.response_class(generate_ndjson(), mimetype='application/x-ndjson', headers={'X-Accel-Buffering': 'no'}) + + except Exception as e: + print(f"โŒ Error in download discography: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + @app.route('/api/artist//completion', methods=['POST']) def check_artist_discography_completion(artist_id): """Check completion status for artist's albums and singles""" diff --git a/webui/index.html b/webui/index.html index d25519dc..a61279f3 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2325,6 +2325,11 @@
+
@@ -2609,6 +2614,14 @@
+ +
Albums diff --git a/webui/static/script.js b/webui/static/script.js index 0d2a1584..2c48911e 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -32285,6 +32285,13 @@ async function loadArtistDiscography(artistId, artistName = null, sourceOverride function displayArtistDiscography(discography) { console.log(`๐Ÿ“€ Displaying discography: ${discography.albums?.length || 0} albums, ${discography.singles?.length || 0} singles`); + // Show Download Discography button(s) if there are any releases + const _totalReleases = (discography.albums?.length || 0) + (discography.eps?.length || 0) + (discography.singles?.length || 0); + const _discogWrap = document.getElementById('discog-download-wrap'); + if (_discogWrap) _discogWrap.style.display = _totalReleases > 0 ? '' : 'none'; + const _discogBtnArtists = document.getElementById('discog-download-btn-artists'); + if (_discogBtnArtists) _discogBtnArtists.style.display = _totalReleases > 0 ? '' : 'none'; + // Populate albums const albumsContainer = document.getElementById('album-cards-container'); if (albumsContainer) { @@ -39536,6 +39543,13 @@ function updateArtistHeroSection(artist, discography) { updateCategoryStats('eps', discography.eps); updateCategoryStats('singles', discography.singles); + // Show Download Discography button(s) if there are any releases + const _totalReleases = (discography.albums?.length || 0) + (discography.eps?.length || 0) + (discography.singles?.length || 0); + const _discogWrap = document.getElementById('discog-download-wrap'); + if (_discogWrap) _discogWrap.style.display = _totalReleases > 0 ? '' : 'none'; + const _discogBtnArtists = document.getElementById('discog-download-btn-artists'); + if (_discogBtnArtists) _discogBtnArtists.style.display = _totalReleases > 0 ? '' : 'none'; + // Last.fm stats (listeners / playcount) const _fmtNum = (n) => { if (!n || n <= 0) return '0'; @@ -40390,6 +40404,343 @@ function applyDiscographyFilters() { } } +// ==================== Download Discography Modal ==================== + +function openDiscographyModal() { + // Support both Artists search page and Library artist detail page + let artist = artistsPageState.selectedArtist; + let discography = artistsPageState.artistDiscography; + let completionCache = artistsPageState.cache.completionData; + + // Fallback to Library page state if Artists page has no data + if (!artist || !discography) { + const libId = artistDetailPageState.currentArtistId; + const libName = artistDetailPageState.currentArtistName; + if (libId && libName) { + artist = { id: libId, name: libName, image_url: document.getElementById('artist-detail-image')?.src || '' }; + // Library page stores discography in the same artistsPageState when viewing from library + discography = artistsPageState.artistDiscography; + } + } + + if (!artist || !discography) { + showToast('No discography data available', 'error'); + return; + } + + const completionData = (completionCache || {})[artist.id] || {}; + const allReleases = [ + ...(discography.albums || []).map(a => ({ ...a, _type: 'album' })), + ...(discography.eps || []).map(a => ({ ...a, _type: 'ep' })), + ...(discography.singles || []).map(a => ({ ...a, _type: 'single' })), + ]; + + // Build modal + const overlay = document.createElement('div'); + overlay.className = 'discog-modal-overlay'; + overlay.id = 'discog-modal-overlay'; + + const artistImg = artist.image_url || ''; + + overlay.innerHTML = ` +
+
+
+
+

Download Discography

+

${_esc(artist.name)}

+
+ +
+
+
+ + + +
+
+ + +
+
+
+ ${allReleases.map((r, i) => _renderDiscogCard(r, i, completionData)).join('')} +
+ + +
+ `; + + document.body.appendChild(overlay); + requestAnimationFrame(() => overlay.classList.add('visible')); + _updateDiscogFooterCount(); + + // Bind submit button (avoids onclick being intercepted by helper system) + document.getElementById('discog-submit-btn')?.addEventListener('click', (e) => { + e.stopPropagation(); + startDiscographyDownload(); + }); +} + +function _esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } + +function _renderDiscogCard(release, index, completionData) { + const comp = completionData?.albums?.find(c => c.id === release.id) || completionData?.singles?.find(c => c.id === release.id); + const status = comp?.status || 'unknown'; + const isOwned = status === 'completed'; + const isPartial = status === 'partial' || status === 'nearly_complete'; + const year = release.release_date ? release.release_date.substring(0, 4) : ''; + const tracks = release.total_tracks || 0; + const img = release.image_url || ''; + const checked = !isOwned; + const statusClass = isOwned ? 'owned' : isPartial ? 'partial' : ''; + const statusIcon = isOwned ? 'โœ“' : isPartial ? 'โ—' : ''; + + return ` + + `; +} + +function toggleDiscogFilter(btn) { + btn.classList.toggle('active'); + const type = btn.dataset.type; + document.querySelectorAll(`.discog-card[data-type="${type}"]`).forEach(card => { + card.style.display = btn.classList.contains('active') ? '' : 'none'; + }); + _updateDiscogFooterCount(); +} + +function discogSelectAll(select) { + document.querySelectorAll('.discog-card-cb').forEach(cb => { + if (cb.closest('.discog-card').style.display !== 'none') { + cb.checked = select; + } + }); + _updateDiscogFooterCount(); +} + +function _updateDiscogFooterCount() { + const checked = document.querySelectorAll('.discog-card-cb:checked'); + let releases = 0, tracks = 0; + checked.forEach(cb => { + if (cb.closest('.discog-card').style.display !== 'none') { + releases++; + tracks += parseInt(cb.dataset.tracks) || 0; + } + }); + const info = document.getElementById('discog-footer-info'); + const btn = document.getElementById('discog-submit-text'); + if (info) info.textContent = `${releases} release${releases !== 1 ? 's' : ''} ยท ${tracks} tracks`; + if (btn) btn.textContent = releases > 0 ? `Add ${releases} to Wishlist` : 'Select releases'; + const submitBtn = document.getElementById('discog-submit-btn'); + if (submitBtn) submitBtn.disabled = releases === 0; +} + +async function startDiscographyDownload() { + let artist = artistsPageState.selectedArtist; + // Fallback to library page state + if (!artist && artistDetailPageState.currentArtistId) { + artist = { id: artistDetailPageState.currentArtistId, name: artistDetailPageState.currentArtistName || 'Unknown' }; + } + if (!artist || !artist.id) { + showToast('No artist data available', 'error'); + return; + } + + const checked = document.querySelectorAll('.discog-card-cb:checked'); + const albumEntries = []; + checked.forEach(cb => { + if (cb.closest('.discog-card').style.display !== 'none') { + albumEntries.push({ + id: cb.dataset.albumId, + tracks: parseInt(cb.dataset.tracks) || 0 + }); + } + }); + // Sort by track count descending โ€” process Deluxe/expanded editions first + // so their tracks get added before standard editions (which then get deduped) + albumEntries.sort((a, b) => b.tracks - a.tracks); + const albumIds = albumEntries.map(e => e.id); + + if (albumIds.length === 0) return; + + // Switch to progress view + const grid = document.getElementById('discog-grid'); + const progress = document.getElementById('discog-progress'); + const footer = document.getElementById('discog-footer'); + const filterBar = document.querySelector('.discog-filter-bar'); + + if (grid) grid.style.display = 'none'; + if (filterBar) filterBar.style.display = 'none'; + if (progress) { + progress.style.display = ''; + progress.innerHTML = ''; + } + + // Build progress items + const albumMap = {}; + checked.forEach(cb => { + if (cb.closest('.discog-card').style.display !== 'none') { + const card = cb.closest('.discog-card'); + const id = cb.dataset.albumId; + const title = card.querySelector('.discog-card-title')?.textContent || ''; + const img = card.querySelector('.discog-card-art img')?.src || ''; + albumMap[id] = { title, img }; + + const item = document.createElement('div'); + item.className = 'discog-progress-item'; + item.id = `discog-prog-${id}`; + item.innerHTML = ` +
${img ? `` : '๐ŸŽต'}
+
+
${_esc(title)}
+
Waiting...
+
+
+ `; + progress.appendChild(item); + } + }); + + // Update footer + const submitBtn = document.getElementById('discog-submit-btn'); + if (submitBtn) submitBtn.style.display = 'none'; + if (footer) { + const info = document.getElementById('discog-footer-info'); + if (info) info.textContent = 'Processing... this may take a moment'; + } + + // Mark all items as active + document.querySelectorAll('.discog-progress-item').forEach(item => item.classList.add('active')); + + try { + const response = await fetch(`/api/artist/${artist.id}/download-discography`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ album_ids: albumIds, artist_name: artist.name }) + }); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop(); // Keep incomplete line in buffer + + for (const line of lines) { + if (!line.trim()) continue; + try { + const data = JSON.parse(line); + + if (data.status === 'complete') { + _handleDiscogProgress({ type: 'complete', total_added: data.total_added, total_skipped: data.total_skipped }); + } else { + // Per-album update + const item = document.getElementById(`discog-prog-${data.album_id}`); + if (!item) continue; + + const statusEl = item.querySelector('.discog-prog-status'); + const iconEl = item.querySelector('.discog-prog-icon'); + item.classList.remove('active'); + + if (data.status === 'done') { + const parts = []; + if (data.tracks_added > 0) parts.push(`${data.tracks_added} added`); + if (data.tracks_skipped > 0) parts.push(`${data.tracks_skipped} skipped`); + statusEl.textContent = parts.join(', ') || 'No new tracks'; + iconEl.innerHTML = data.tracks_added > 0 ? 'โœ“' : 'โ€”'; + item.classList.add(data.tracks_added > 0 ? 'done' : 'skipped'); + } else if (data.status === 'error') { + statusEl.textContent = data.message || 'Error'; + iconEl.innerHTML = 'โœ—'; + item.classList.add('error'); + } + } + } catch (e) { /* skip malformed line */ } + } + } + } catch (err) { + showToast(`Discography download failed: ${err.message}`, 'error'); + } +} + +function _handleDiscogProgress(data) { + if (data.type === 'album') { + const item = document.getElementById(`discog-prog-${data.album_id}`); + if (!item) return; + + const statusEl = item.querySelector('.discog-prog-status'); + const iconEl = item.querySelector('.discog-prog-icon'); + + if (data.status === 'processing') { + statusEl.textContent = `Processing ${data.tracks_total} tracks...`; + item.classList.add('active'); + } else if (data.status === 'done') { + const parts = []; + if (data.tracks_added > 0) parts.push(`${data.tracks_added} added`); + if (data.tracks_skipped > 0) parts.push(`${data.tracks_skipped} skipped`); + statusEl.textContent = parts.join(', ') || 'No new tracks'; + iconEl.innerHTML = data.tracks_added > 0 ? 'โœ“' : 'โ€”'; + item.classList.remove('active'); + item.classList.add(data.tracks_added > 0 ? 'done' : 'skipped'); + } else if (data.status === 'error') { + statusEl.textContent = data.message || 'Error'; + iconEl.innerHTML = 'โœ—'; + item.classList.add('error'); + } + } else if (data.type === 'complete') { + const info = document.getElementById('discog-footer-info'); + if (info) info.textContent = `Done โ€” ${data.total_added} tracks added, ${data.total_skipped} skipped`; + + // Show "Process Wishlist" button + const footer = document.querySelector('.discog-footer-actions'); + if (footer && data.total_added > 0) { + footer.innerHTML = ` + + + `; + } else if (footer) { + footer.innerHTML = ''; + } + } +} + +function closeDiscographyModal() { + const overlay = document.getElementById('discog-modal-overlay'); + if (overlay) { + overlay.classList.remove('visible'); + setTimeout(() => overlay.remove(), 300); + } +} + // ==================== Enhanced Library Management View ==================== function isEnhancedAdmin() { diff --git a/webui/static/style.css b/webui/static/style.css index e9571e1f..94511574 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -40614,6 +40614,315 @@ textarea.enhanced-meta-field-input { cursor: not-allowed; } +/* โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + DOWNLOAD DISCOGRAPHY โ€” Button + Modal + โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• */ + +.discog-download-wrap { margin: 12px 0 4px; } + +.discog-download-btn { + position: relative; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + padding: 10px 20px; + background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.15) 0%, rgba(var(--accent-rgb), 0.05) 100%); + border: 1px solid rgba(var(--accent-rgb), 0.25); + border-radius: 10px; + color: rgb(var(--accent-light-rgb)); + font-size: 13px; + font-weight: 700; + cursor: pointer; + transition: all 0.25s ease; + overflow: hidden; + font-family: inherit; +} + +.discog-download-btn:hover { + background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.25) 0%, rgba(var(--accent-rgb), 0.1) 100%); + border-color: rgba(var(--accent-rgb), 0.4); + transform: translateY(-1px); + box-shadow: 0 4px 16px rgba(var(--accent-rgb), 0.2); +} + +.discog-btn-icon { font-size: 15px; } + +.discog-btn-compact { + width: auto; + max-width: 220px; + padding: 7px 16px; + font-size: 12px; + border-radius: 8px; + margin-top: 8px; +} + +.discog-btn-compact .discog-btn-icon { font-size: 12px; } + +.discog-btn-shimmer { + position: absolute; + top: 0; left: -100%; width: 50%; height: 100%; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.06), transparent); + animation: discogShimmer 3s ease-in-out infinite; +} + +@keyframes discogShimmer { + 0%, 100% { left: -100%; } + 50% { left: 150%; } +} + +/* Modal overlay */ +.discog-modal-overlay { + position: fixed; inset: 0; z-index: 100000; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(8px); + display: flex; align-items: center; justify-content: center; + opacity: 0; transition: opacity 0.3s ease; +} + +.discog-modal-overlay.visible { opacity: 1; } + +.discog-modal { + width: 680px; max-width: 95vw; max-height: 85vh; + background: rgba(14, 14, 14, 0.97); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 18px; + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.7); + backdrop-filter: blur(24px); + display: flex; flex-direction: column; + overflow: hidden; + transform: scale(0.96) translateY(8px); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.discog-modal-overlay.visible .discog-modal { + transform: scale(1) translateY(0); +} + +/* Hero header */ +.discog-modal-hero { + position: relative; + padding: 28px 24px 20px; + background-size: cover; + background-position: center; + min-height: 80px; +} + +.discog-modal-hero-overlay { + position: absolute; inset: 0; + background: linear-gradient(180deg, rgba(14,14,14,0.6) 0%, rgba(14,14,14,0.95) 100%); + backdrop-filter: blur(20px); +} + +.discog-modal-hero-content { position: relative; z-index: 1; } + +.discog-modal-title { + font-size: 20px; font-weight: 800; color: #fff; + letter-spacing: -0.3px; margin: 0 0 4px; +} + +.discog-modal-artist { + font-size: 14px; color: rgba(var(--accent-light-rgb), 0.8); + margin: 0; font-weight: 600; +} + +.discog-modal-close { + position: absolute; top: 14px; right: 14px; z-index: 2; + background: rgba(255,255,255,0.1); border: none; border-radius: 50%; + width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; + color: rgba(255,255,255,0.5); font-size: 18px; cursor: pointer; + transition: all 0.15s ease; +} + +.discog-modal-close:hover { background: rgba(255,255,255,0.2); color: #fff; } + +/* Filter bar */ +.discog-filter-bar { + display: flex; justify-content: space-between; align-items: center; + padding: 10px 20px; border-bottom: 1px solid rgba(255,255,255,0.06); + flex-shrink: 0; +} + +.discog-filters { display: flex; gap: 6px; } + +.discog-filter { + padding: 5px 14px; border-radius: 20px; font-size: 12px; font-weight: 600; + background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); + color: rgba(255,255,255,0.4); cursor: pointer; transition: all 0.15s ease; + font-family: inherit; +} + +.discog-filter.active { + background: rgba(var(--accent-rgb), 0.12); + border-color: rgba(var(--accent-rgb), 0.25); + color: rgb(var(--accent-light-rgb)); +} + +.discog-select-actions { display: flex; gap: 6px; } + +.discog-select-btn { + padding: 4px 10px; border-radius: 6px; font-size: 11px; font-weight: 600; + background: transparent; border: 1px solid rgba(255,255,255,0.08); + color: rgba(255,255,255,0.4); cursor: pointer; transition: all 0.15s ease; + font-family: inherit; +} + +.discog-select-btn:hover { color: rgba(255,255,255,0.7); border-color: rgba(255,255,255,0.15); } + +/* Album grid */ +.discog-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 8px; padding: 14px 20px; overflow-y: auto; flex: 1; min-height: 0; +} + +.discog-card { + display: flex; align-items: center; gap: 10px; + padding: 8px 10px; border-radius: 10px; + background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.04); + cursor: pointer; transition: all 0.15s ease; + animation: discogCardIn 0.3s ease backwards; +} + +@keyframes discogCardIn { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +.discog-card:hover { background: rgba(var(--accent-rgb), 0.05); border-color: rgba(var(--accent-rgb), 0.12); } + +.discog-card.owned { opacity: 0.5; } +.discog-card.partial { border-left: 3px solid rgba(255, 193, 7, 0.5); } + +.discog-card-cb { display: none; } + +.discog-card-art { + position: relative; width: 44px; height: 44px; border-radius: 6px; + overflow: hidden; flex-shrink: 0; background: rgba(255,255,255,0.04); +} + +.discog-card-art img { width: 100%; height: 100%; object-fit: cover; } +.discog-card-art-placeholder { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 18px; } + +.discog-card-status { + position: absolute; bottom: 2px; right: 2px; + background: rgba(0,0,0,0.7); border-radius: 4px; + font-size: 10px; padding: 1px 4px; color: #4caf50; +} + +.discog-card-info { flex: 1; min-width: 0; } + +.discog-card-title { + font-size: 13px; font-weight: 600; color: #fff; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} + +.discog-card-meta { font-size: 11px; color: rgba(255,255,255,0.35); margin-top: 2px; } + +.discog-card-check { + width: 20px; height: 20px; border-radius: 6px; flex-shrink: 0; + border: 2px solid rgba(255,255,255,0.15); transition: all 0.15s ease; + display: flex; align-items: center; justify-content: center; +} + +.discog-card-cb:checked ~ .discog-card-check { + background: rgb(var(--accent-rgb)); + border-color: rgb(var(--accent-rgb)); +} + +.discog-card-cb:checked ~ .discog-card-check::after { + content: 'โœ“'; color: #fff; font-size: 12px; font-weight: 700; +} + +/* Footer */ +.discog-footer { + display: flex; justify-content: space-between; align-items: center; + padding: 14px 20px; border-top: 1px solid rgba(255,255,255,0.06); + flex-shrink: 0; +} + +.discog-footer-info { font-size: 13px; color: rgba(255,255,255,0.45); font-weight: 600; } +.discog-footer-actions { display: flex; gap: 10px; } + +.discog-cancel-btn { + padding: 8px 18px; border-radius: 8px; font-size: 13px; font-weight: 600; + background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08); + color: rgba(255,255,255,0.6); cursor: pointer; transition: all 0.15s ease; + font-family: inherit; +} + +.discog-cancel-btn:hover { background: rgba(255,255,255,0.1); color: #fff; } + +.discog-submit-btn { + display: flex; align-items: center; gap: 6px; + padding: 8px 20px; border-radius: 8px; font-size: 13px; font-weight: 700; + background: linear-gradient(135deg, rgb(var(--accent-rgb)), rgba(var(--accent-rgb), 0.7)); + border: none; color: #fff; cursor: pointer; + transition: all 0.2s ease; font-family: inherit; +} + +.discog-submit-btn:hover { filter: brightness(1.15); box-shadow: 0 4px 16px rgba(var(--accent-rgb), 0.3); } +.discog-submit-btn:disabled { opacity: 0.4; cursor: not-allowed; filter: none; } +.discog-submit-icon { font-size: 14px; } + +/* Progress view */ +.discog-progress { padding: 14px 20px; overflow-y: auto; flex: 1; min-height: 0; } + +.discog-progress-item { + display: flex; align-items: center; gap: 12px; + padding: 10px 12px; border-radius: 8px; margin-bottom: 6px; + background: rgba(255,255,255,0.02); transition: all 0.3s ease; +} + +.discog-progress-item.active { background: rgba(var(--accent-rgb), 0.06); } +.discog-progress-item.done { background: rgba(76, 175, 80, 0.05); } +.discog-progress-item.skipped { opacity: 0.5; } +.discog-progress-item.error { background: rgba(255, 82, 82, 0.05); } + +.discog-prog-art { + width: 36px; height: 36px; border-radius: 6px; overflow: hidden; flex-shrink: 0; + background: rgba(255,255,255,0.04); display: flex; align-items: center; justify-content: center; + font-size: 16px; +} + +.discog-prog-art img { width: 100%; height: 100%; object-fit: cover; } + +.discog-prog-info { flex: 1; min-width: 0; } + +.discog-prog-title { + font-size: 13px; font-weight: 600; color: #fff; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} + +.discog-prog-status { font-size: 11px; color: rgba(255,255,255,0.4); margin-top: 2px; } + +.discog-prog-icon { width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; } + +.discog-spinner { + width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.1); + border-top-color: rgb(var(--accent-rgb)); border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { to { transform: rotate(360deg); } } + +.discog-check { color: #4caf50; font-size: 16px; font-weight: 700; animation: discogPop 0.3s ease; } +.discog-skip { color: rgba(255,255,255,0.3); font-size: 16px; } +.discog-error { color: #ff5252; font-size: 16px; } + +@keyframes discogPop { + 0% { transform: scale(0); } + 70% { transform: scale(1.2); } + 100% { transform: scale(1); } +} + +/* Mobile */ +@media (max-width: 768px) { + .discog-modal { max-width: 100vw; max-height: 100vh; border-radius: 0; } + .discog-grid { grid-template-columns: 1fr; } + .discog-filter-bar { flex-wrap: wrap; gap: 8px; } +} + .enhanced-enrich-btn.small { padding: 5px 12px; font-size: 11px;