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:
parent
2909d29614
commit
b5b03a2b86
4 changed files with 806 additions and 0 deletions
133
web_server.py
133
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/<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"""
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()">×</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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue