Add Download Discography feature on artist detail page

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/<id>/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
This commit is contained in:
Broque Thomas 2026-03-27 22:15:05 -07:00
parent 2909d29614
commit b5b03a2b86
4 changed files with 806 additions and 0 deletions

View file

@ -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/<artist_id>/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/<artist_id>/completion', methods=['POST'])
def check_artist_discography_completion(artist_id):
"""Check completion status for artist's albums and singles"""

View file

@ -2325,6 +2325,11 @@
<div class="artists-hero-genres" id="artists-hero-genres"></div>
<div class="artists-hero-bio" id="artists-hero-bio"></div>
<div class="artists-hero-stats" id="artists-hero-stats"></div>
<button class="discog-download-btn discog-btn-compact" id="discog-download-btn-artists" onclick="openDiscographyModal()" style="display:none;">
<span class="discog-btn-icon"></span>
<span class="discog-btn-text">Download Discography</span>
<span class="discog-btn-shimmer"></span>
</button>
</div>
</div>
<div class="artists-hero-actions">
@ -2609,6 +2614,14 @@
</div>
</div>
<div class="discog-download-wrap" id="discog-download-wrap" style="display:none;">
<button class="discog-download-btn discog-btn-compact" id="discog-download-btn" onclick="openDiscographyModal()">
<span class="discog-btn-icon"></span>
<span class="discog-btn-text">Download Discography</span>
<span class="discog-btn-shimmer"></span>
</button>
</div>
<div class="collection-overview">
<div class="collection-category">
<span class="category-label">Albums</span>

View file

@ -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 = `
<div class="discog-modal">
<div class="discog-modal-hero" ${artistImg ? `style="background-image:url('${artistImg}')"` : ''}>
<div class="discog-modal-hero-overlay"></div>
<div class="discog-modal-hero-content">
<h2 class="discog-modal-title">Download Discography</h2>
<p class="discog-modal-artist">${_esc(artist.name)}</p>
</div>
<button class="discog-modal-close" onclick="closeDiscographyModal()">&times;</button>
</div>
<div class="discog-filter-bar">
<div class="discog-filters">
<button class="discog-filter active" data-type="album" onclick="toggleDiscogFilter(this)">Albums</button>
<button class="discog-filter active" data-type="ep" onclick="toggleDiscogFilter(this)">EPs</button>
<button class="discog-filter active" data-type="single" onclick="toggleDiscogFilter(this)">Singles</button>
</div>
<div class="discog-select-actions">
<button class="discog-select-btn" onclick="discogSelectAll(true)">Select All</button>
<button class="discog-select-btn" onclick="discogSelectAll(false)">Deselect All</button>
</div>
</div>
<div class="discog-grid" id="discog-grid">
${allReleases.map((r, i) => _renderDiscogCard(r, i, completionData)).join('')}
</div>
<div class="discog-progress" id="discog-progress" style="display:none;"></div>
<div class="discog-footer" id="discog-footer">
<div class="discog-footer-info" id="discog-footer-info"></div>
<div class="discog-footer-actions">
<button class="discog-cancel-btn" onclick="closeDiscographyModal()">Cancel</button>
<button class="discog-submit-btn" id="discog-submit-btn">
<span class="discog-submit-icon"></span>
<span id="discog-submit-text">Add to Wishlist</span>
</button>
</div>
</div>
</div>
`;
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 `
<label class="discog-card ${statusClass}" data-type="${release._type}" style="animation-delay:${index * 0.03}s">
<input type="checkbox" class="discog-card-cb" data-album-id="${release.id}" data-tracks="${tracks}" ${checked ? 'checked' : ''} onchange="_updateDiscogFooterCount()">
<div class="discog-card-art">
${img ? `<img src="${img}" alt="" loading="lazy">` : '<div class="discog-card-art-placeholder">🎵</div>'}
${statusIcon ? `<span class="discog-card-status">${statusIcon}</span>` : ''}
</div>
<div class="discog-card-info">
<div class="discog-card-title">${_esc(release.name)}</div>
<div class="discog-card-meta">${year}${year && tracks ? ' · ' : ''}${tracks ? tracks + ' tracks' : ''}</div>
</div>
<div class="discog-card-check"></div>
</label>
`;
}
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 = `
<div class="discog-prog-art">${img ? `<img src="${img}">` : '🎵'}</div>
<div class="discog-prog-info">
<div class="discog-prog-title">${_esc(title)}</div>
<div class="discog-prog-status">Waiting...</div>
</div>
<div class="discog-prog-icon"><div class="discog-spinner"></div></div>
`;
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 ? '<span class="discog-check">✓</span>' : '<span class="discog-skip">—</span>';
item.classList.add(data.tracks_added > 0 ? 'done' : 'skipped');
} else if (data.status === 'error') {
statusEl.textContent = data.message || 'Error';
iconEl.innerHTML = '<span class="discog-error">✗</span>';
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 ? '<span class="discog-check">✓</span>' : '<span class="discog-skip">—</span>';
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 = '<span class="discog-error">✗</span>';
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 = `
<button class="discog-cancel-btn" onclick="closeDiscographyModal()">Close</button>
<button class="discog-submit-btn" onclick="closeDiscographyModal();fetch('/api/wishlist/process',{method:'POST'});showToast('Wishlist processing started','success')">
<span class="discog-submit-icon">🚀</span>
<span>Process Wishlist Now</span>
</button>
`;
} else if (footer) {
footer.innerHTML = '<button class="discog-cancel-btn" onclick="closeDiscographyModal()">Close</button>';
}
}
}
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() {

View file

@ -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;