// ================================================================================== // PLAYLIST EXPLORER — Visual Discovery Tree // ================================================================================== const _explorer = { initialized: false, mode: 'albums', artists: [], selectedAlbums: new Set(), expandedArtists: new Set(), building: false, playlistId: null, meta: null, _resizeTimer: null, }; function initExplorer() { if (_explorer.initialized) return; _explorer.initialized = true; _explorer._playlists = []; _explorer._activeSource = null; _explorerLoadPlaylists(); // Listen for discovery completion to auto-refresh playlist cards if (typeof socket !== 'undefined') { socket.on('discovery:progress', (data) => { if (!document.getElementById('playlist-explorer-page')?.classList.contains('active')) return; // Match mirrored playlist discovery events if (data.phase === 'discovered' || data.phase === 'sync_complete' || data.complete) { // Discovery finished — refresh playlists after brief delay for DB commit setTimeout(() => _explorerLoadPlaylists(), 1500); } // Live progress update on cards during discovery if (data.id && data.id.startsWith('mirrored_')) { const plId = parseInt(data.id.replace('mirrored_', '')); const card = document.querySelector(`.explorer-picker-card[data-id="${plId}"]`); if (card) { const meta = card.querySelector('.explorer-picker-card-meta'); if (meta && data.progress != null) { meta.innerHTML = `Discovering... ${Math.round(data.progress)}%`; } } } }); } } function _explorerLoadPlaylists() { fetch('/api/mirrored-playlists') .then(r => r.json()) .then(data => { const playlists = Array.isArray(data) ? data : (data.playlists || []); _explorer._playlists = playlists; if (playlists.length === 0) { const scroll = document.getElementById('explorer-picker-scroll'); if (scroll) scroll.innerHTML = '
No mirrored playlists found. Sync a playlist first.
'; return; } // Group by source const groups = {}; playlists.forEach(p => { const src = (p.source || 'other').toLowerCase(); if (!groups[src]) groups[src] = []; groups[src].push(p); }); // Render source tabs const tabsEl = document.getElementById('explorer-picker-tabs'); if (tabsEl) { const sourceNames = { spotify: 'Spotify', tidal: 'Tidal', deezer: 'Deezer', youtube: 'YouTube', beatport: 'Beatport', file: 'File', other: 'Other' }; const sources = Object.keys(groups); if (sources.length <= 1) { tabsEl.style.display = 'none'; } else { tabsEl.innerHTML = sources.map((src, i) => { const label = sourceNames[src] || src.charAt(0).toUpperCase() + src.slice(1); const count = groups[src].length; const isActive = _explorer._activeSource === src || (!_explorer._activeSource && i === 0); return ``; }).join(''); } // Show active or first source const activeSource = _explorer._activeSource || sources[0]; _explorer._activeSource = activeSource; explorerRenderPickerCards(activeSource); } }) .catch(() => { }); } function explorerSwitchPickerTab(source) { _explorer._activeSource = source; document.querySelectorAll('.explorer-picker-tab').forEach(t => t.classList.toggle('active', t.dataset.source === source)); explorerRenderPickerCards(source); } function explorerRenderPickerCards(source) { const scroll = document.getElementById('explorer-picker-scroll'); if (!scroll) return; const filtered = _explorer._playlists.filter(p => (p.source || 'other').toLowerCase() === source); scroll.innerHTML = filtered.map(p => { const img = p.image_url || ''; const total = p.total_count || p.track_count || 0; const discovered = p.discovered_count || 0; const pct = total > 0 ? Math.round((discovered / total) * 100) : 0; const isReady = pct >= 50; const isActive = _explorer.playlistId === p.id; const isFullyDiscovered = pct === 100; const wasExplored = !!(p.explored_at || p.explored); const wishlisted = p.wishlisted_count || 0; const inLibrary = p.in_library_count || 0; // Status badge: checkmark if explored/in-library, star if ready, % if needs discovery let statusBadge = ''; if (inLibrary > 0 && inLibrary >= total * 0.8) { statusBadge = '
'; } else if (wasExplored) { statusBadge = '
'; } else if (wishlisted > 0) { statusBadge = '
'; } else if (isFullyDiscovered) { statusBadge = '
'; } else if (!isReady) { statusBadge = `
${pct}%
`; } // Meta line with status indicators let metaHTML; const statusParts = []; if (inLibrary > 0) statusParts.push(`${inLibrary} in library`); if (wishlisted > 0) statusParts.push(`${wishlisted} wishlisted`); if (isFullyDiscovered) { metaHTML = `${total} tracks · Fully discovered`; } else if (isReady) { metaHTML = `${total} tracks · ${pct}% discovered`; } else { metaHTML = `${total} tracks · ${pct}% discovered`; } if (statusParts.length > 0) { metaHTML += `
${statusParts.join(' · ')}`; } // Discover button for undiscovered playlists (replaces redirect to Sync) const discoverBtn = !isReady ? `` : ''; return `
${img ? `` : '
'}
${p.name || 'Untitled'}
${statusBadge}
${metaHTML}
${discoverBtn ? `
${discoverBtn}
` : ''}
`; }).join(''); } function explorerSelectPlaylist(id, el) { _explorer.playlistId = id; document.querySelectorAll('.explorer-picker-card').forEach(c => c.classList.remove('active')); if (el) el.classList.add('active'); // Update hint text const hint = document.getElementById('explorer-build-hint'); const pl = _explorer._playlists.find(p => p.id === id); if (hint && pl) hint.textContent = `Ready: ${pl.name}`; else if (hint) hint.textContent = ''; } function explorerRedirectToDiscover(playlistId) { showToast('This playlist needs more tracks discovered before exploring. Redirecting to Sync...', 'info'); navigateToPage('sync'); setTimeout(() => { const mirroredBtn = document.querySelector('.sync-tab-button[data-tab="mirrored"]'); if (mirroredBtn) mirroredBtn.click(); }, 200); } async function explorerStartDiscovery(playlistId) { const card = document.querySelector(`.explorer-picker-card[data-id="${playlistId}"]`); const btn = card?.querySelector('.explorer-picker-discover-btn'); if (btn) { btn.disabled = true; btn.textContent = 'Starting...'; } try { if (typeof discoverMirroredPlaylist === 'function') { await discoverMirroredPlaylist(playlistId); if (btn) { btn.disabled = false; btn.textContent = 'Open'; btn.title = 'Reopen discovery modal'; } // Poll for card updates while discovery is in progress _explorerStartDiscoveryPoller(playlistId); } else { explorerRedirectToDiscover(playlistId); } } catch (err) { showToast(`Discovery failed: ${err.message}`, 'error'); if (btn) { btn.disabled = false; btn.textContent = 'Discover'; } } } function _explorerStartDiscoveryPoller(playlistId) { // Poll every 5s to refresh playlist cards until this playlist is ready if (_explorer._discoveryPoller) clearInterval(_explorer._discoveryPoller); _explorer._discoveryPoller = setInterval(async () => { // Stop polling if Explorer page isn't active if (!document.getElementById('playlist-explorer-page')?.classList.contains('active')) { clearInterval(_explorer._discoveryPoller); _explorer._discoveryPoller = null; return; } // Check if the mirrored playlist state shows discovery is done const tempHash = `mirrored_${playlistId}`; const state = youtubePlaylistStates[tempHash]; const isDone = state && (state.phase === 'discovered' || state.phase === 'sync_complete'); // Refresh cards from API await _explorerLoadPlaylists(); // Stop polling once discovery is complete if (isDone) { clearInterval(_explorer._discoveryPoller); _explorer._discoveryPoller = null; } }, 5000); } function explorerSetMode(mode) { _explorer.mode = mode; document.querySelectorAll('.explorer-mode-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.mode === mode); }); } async function explorerBuildTree() { const playlistId = _explorer.playlistId; if (!playlistId) { showToast('Select a playlist first', 'error'); return; } if (_explorer.building) return; _explorer.building = true; _explorer.artists = []; _explorer.selectedAlbums.clear(); _explorer.expandedArtists.clear(); _explorer.playlistId = playlistId; const tree = document.getElementById('explorer-tree'); const svg = document.getElementById('explorer-svg'); const progress = document.getElementById('explorer-progress'); const actionBar = document.getElementById('explorer-action-bar'); const empty = document.getElementById('explorer-empty'); const buildBtn = document.getElementById('explorer-build-btn'); if (empty) empty.style.display = 'none'; if (actionBar) actionBar.style.display = 'none'; if (progress) progress.style.display = 'flex'; if (buildBtn) { buildBtn.disabled = true; buildBtn.textContent = 'Building...'; } // Clear tree but preserve the SVG element (it lives inside the tree) tree.innerHTML = ''; _explorer._zoom = 1; tree.style.transform = ''; try { const response = await fetch('/api/playlist-explorer/build-tree', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ playlist_id: parseInt(playlistId), mode: _explorer.mode }) }); if (!response.ok) { const err = await response.json(); throw new Error(err.error || 'Failed to build tree'); } // Stream NDJSON const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let artistCount = 0; let totalArtists = 0; 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(); for (const line of lines) { if (!line.trim()) continue; try { const data = JSON.parse(line); if (data.type === 'meta') { _explorer.meta = data; totalArtists = data.total_artists; _explorerRenderRoot(data); } else if (data.type === 'artist') { artistCount++; _explorer.artists.push(data); _explorerRenderArtistNode(data, artistCount); // Lines drawn after streaming completes (not during — flex reflow drifts positions) // Update progress const pct = Math.round((artistCount / totalArtists) * 100); const fill = document.getElementById('explorer-progress-fill'); const text = document.getElementById('explorer-progress-text'); if (fill) fill.style.width = pct + '%'; if (text) text.textContent = `Discovering artists... ${artistCount} of ${totalArtists}`; } else if (data.type === 'complete') { // Done } } catch (e) { console.warn('Explorer: failed to parse NDJSON line', e); } } } // Tree built — show action bar, hide progress if (actionBar) actionBar.style.display = 'flex'; if (progress) progress.style.display = 'none'; _explorerUpdateCount(); // Mark playlist as explored (server persists via explored_at; update local copy too) const exploredPl = _explorer._playlists.find(p => p.id === playlistId); if (exploredPl) { exploredPl.explored_at = new Date().toISOString(); // Update card badge without full re-render const card = document.querySelector(`.explorer-picker-card[data-id="${playlistId}"]`); if (card) { card.classList.add('explored'); const oldBadge = card.querySelector('.explorer-picker-card-badge'); const badgeHTML = '
'; if (oldBadge) { oldBadge.outerHTML = badgeHTML; } else { // Insert badge into the name row const nameRow = card.querySelector('.explorer-picker-card-name-row'); if (nameRow) { nameRow.insertAdjacentHTML('beforeend', badgeHTML); } } // Remove discover button if present (no longer needed) const discoverBtn = card.querySelector('.explorer-picker-card-actions'); if (discoverBtn) discoverBtn.remove(); } } // Draw all connections now that the tree is stable setTimeout(() => _explorerRedrawAllConnections(true), 100); } catch (err) { showToast('Explorer: ' + err.message, 'error'); if (empty) { empty.style.display = 'flex'; } if (progress) progress.style.display = 'none'; } finally { _explorer.building = false; if (buildBtn) { buildBtn.disabled = false; buildBtn.textContent = 'Explore'; } } } function _explorerRenderRoot(meta) { const tree = document.getElementById('explorer-tree'); const rootHtml = `
${meta.playlist_image ? `` : '
' }
SOURCE
${meta.playlist_name}
${meta.total_tracks} tracks · ${meta.total_artists} artists
`; tree.insertAdjacentHTML('afterbegin', rootHtml); _explorer._artistRowSizes = []; // Track row capacities: [2, 3, 4, ...] _explorer._artistCount = 0; _explorer._currentRowIndex = 0; } function _explorerGetOrCreateRow() { const container = document.getElementById('explorer-artist-tiers'); if (!container) return null; // Determine row sizes: 2, 3, 4, 5... (tree shape) const rowCapacity = _explorer._currentRowIndex + 2; const existingRows = container.querySelectorAll('.explorer-tier-artists'); let currentRow = existingRows[existingRows.length - 1]; if (!currentRow || currentRow.children.length >= (_explorer._currentRowIndex + 2)) { // Need a new row _explorer._currentRowIndex = existingRows.length; const newRow = document.createElement('div'); newRow.className = 'explorer-tier explorer-tier-artists'; container.appendChild(newRow); return newRow; } return currentRow; } function _explorerRenderArtistNode(artist, index) { const row = _explorerGetOrCreateRow(); if (!row) return; _explorer._artistCount++; const albumCount = artist.albums ? artist.albums.length : 0; const safeKey = (artist.name || '').replace(/[^a-zA-Z0-9]/g, '_'); const hasError = !!artist.error; const html = `
${artist.image_url ? `` : '' }
${artist.name || 'Unknown'}
${hasError ? 'Not found' : albumCount + ' album' + (albumCount !== 1 ? 's' : '')}
${!hasError && albumCount > 0 ? '
' : ''} ${hasError ? '
' : ''}
`; row.insertAdjacentHTML('beforeend', html); } function explorerToggleArtist(key) { const children = document.getElementById(`explorer-children-${key}`); const node = document.getElementById(`explorer-node-${key}`); if (!children || !node) return; const isExpanded = _explorer.expandedArtists.has(key); if (isExpanded) { _explorer.expandedArtists.delete(key); children.innerHTML = ''; node.classList.remove('expanded'); } else { _explorer.expandedArtists.add(key); node.classList.add('expanded'); const artist = _explorer.artists.find(a => (a.name || '').replace(/[^a-zA-Z0-9]/g, '_') === key); if (artist && artist.albums) { const albumsHtml = artist.albums.map((album, i) => { const id = album.spotify_id || `${key}_${i}`; const selected = _explorer.selectedAlbums.has(id); const owned = album.owned; const inPlaylist = album.in_playlist; const typeLabel = album.album_type === 'single' ? 'Single' : album.album_type === 'ep' ? 'EP' : 'Album'; return `
${album.image_url ? `` : '' }
${album.title || 'Unknown'}
${album.year || ''} · ${album.track_count || '?'} tracks
${owned ? '
Owned
' : ''} ${inPlaylist ? '
' : ''}
`; }).join(''); children.innerHTML = albumsHtml; } } // Redraw SVG after DOM settles requestAnimationFrame(() => setTimeout(() => _explorerRedrawAllConnections(), 50)); } async function explorerExpandAlbumTracks(spotifyAlbumId, nodeKey) { if (!spotifyAlbumId) return; const tracksContainer = document.getElementById(`explorer-tracks-${nodeKey}`); if (!tracksContainer) return; // Toggle: if already has content, collapse if (tracksContainer.innerHTML) { tracksContainer.innerHTML = ''; requestAnimationFrame(() => setTimeout(() => _explorerRedrawAllConnections(), 50)); return; } try { const response = await fetch(`/api/playlist-explorer/album-tracks/${spotifyAlbumId}`); const data = await response.json(); if (!data.success || !data.tracks) return; const tracksHtml = data.tracks.map((t, i) => `
${t.track_number}. ${t.name}
${_formatDuration(t.duration_ms)}
`).join(''); tracksContainer.innerHTML = tracksHtml; requestAnimationFrame(() => setTimeout(() => _explorerRedrawAllConnections(), 50)); } catch (e) { console.error('Failed to load album tracks:', e); } } function _formatDuration(ms) { if (!ms) return ''; const m = Math.floor(ms / 60000); const s = Math.floor((ms % 60000) / 1000); return `${m}:${s.toString().padStart(2, '0')}`; } // Track double-click vs single-click on album nodes let _explorerClickTimer = null; let _explorerLastClickId = null; function explorerToggleAlbum(id) { // Double-click detection: expand tracks if (_explorerLastClickId === id && _explorerClickTimer) { clearTimeout(_explorerClickTimer); _explorerClickTimer = null; _explorerLastClickId = null; // Double-click — expand tracks const node = document.querySelector(`.explorer-node-album[data-id="${id}"]`); const spotifyId = id.includes('_') ? '' : id; // Only real IDs, not fallback keys explorerExpandAlbumTracks(spotifyId, id); return; } _explorerLastClickId = id; _explorerClickTimer = setTimeout(() => { _explorerClickTimer = null; _explorerLastClickId = null; // Single click — toggle selection if (_explorer.selectedAlbums.has(id)) { _explorer.selectedAlbums.delete(id); } else { _explorer.selectedAlbums.add(id); } const node = document.querySelector(`.explorer-node-album[data-id="${id}"]`); if (node) { const isSelected = _explorer.selectedAlbums.has(id); node.classList.toggle('selected', isSelected); const check = node.querySelector('.explorer-node-select'); if (check) check.classList.toggle('active', isSelected); } _explorerUpdateCount(); }, 250); _explorerUpdateCount(); } function explorerSelectAll() { _explorer.artists.forEach(a => { (a.albums || []).forEach(album => { if (album.spotify_id && !album.owned) _explorer.selectedAlbums.add(album.spotify_id); }); }); _explorerRefreshAllCards(); _explorerUpdateCount(); } function explorerDeselectAll() { _explorer.selectedAlbums.clear(); _explorerRefreshAllCards(); _explorerUpdateCount(); } function _explorerRefreshAllCards() { document.querySelectorAll('.explorer-node-album').forEach(node => { const id = node.dataset.id; const selected = _explorer.selectedAlbums.has(id); node.classList.toggle('selected', selected); const check = node.querySelector('.explorer-node-select'); if (check) check.classList.toggle('active', selected); }); } function _explorerUpdateCount() { const el = document.getElementById('explorer-selection-count'); const count = _explorer.selectedAlbums.size; if (el) el.textContent = `${count} album${count !== 1 ? 's' : ''} selected`; _explorerRefreshArtistIndicators(); } function _explorerRefreshArtistIndicators() { // For each artist, check if any of their albums are selected — add visual indicator _explorer.artists.forEach(artist => { const key = (artist.name || '').replace(/[^a-zA-Z0-9]/g, '_'); const node = document.getElementById(`explorer-node-${key}`); if (!node) return; const hasSelected = (artist.albums || []).some(a => a.spotify_id && _explorer.selectedAlbums.has(a.spotify_id)); node.classList.toggle('has-selection', hasSelected); }); } function explorerAddToWishlist() { if (_explorer.selectedAlbums.size === 0) { showToast('No albums selected', 'error'); return; } // Group selected albums by artist with full metadata const artistSections = []; for (const artist of _explorer.artists) { const artistId = artist.artist_id || artist.spotify_id; if (!artist.albums) continue; const selected = artist.albums.filter(a => a.spotify_id && _explorer.selectedAlbums.has(a.spotify_id)); if (selected.length === 0) continue; artistSections.push({ artistId, name: artist.name, image: artist.image_url, albums: selected }); } if (artistSections.length === 0) { showToast('No valid albums selected', 'error'); return; } // Build confirmation modal (mirrors discog-modal pattern) const overlay = document.createElement('div'); overlay.className = 'discog-modal-overlay'; overlay.id = 'explorer-wishlist-overlay'; const totalAlbums = artistSections.reduce((s, a) => s + a.albums.length, 0); const totalTracks = artistSections.reduce((s, a) => s + a.albums.reduce((t, al) => t + (al.track_count || 0), 0), 0); let cardsHtml = ''; artistSections.forEach(section => { cardsHtml += `
${_esc(section.name)}
`; section.albums.forEach((album, i) => { const year = album.year || ''; const typeLabel = album.album_type === 'single' ? 'Single' : album.album_type === 'ep' ? 'EP' : 'Album'; cardsHtml += ` `; }); }); overlay.innerHTML = `

Add to Wishlist

${artistSections.length} artist${artistSections.length !== 1 ? 's' : ''} · ${totalAlbums} releases

${cardsHtml}
`; document.body.appendChild(overlay); requestAnimationFrame(() => overlay.classList.add('visible')); _explorerWishlistUpdateCount(); document.getElementById('explorer-wishlist-submit')?.addEventListener('click', () => _explorerWishlistSubmit(artistSections)); } function _explorerWishlistToggleFilter(btn) { btn.classList.toggle('active'); const type = btn.dataset.type; // Scoped to explorer wishlist modal only document.querySelectorAll(`#explorer-wishlist-overlay .discog-card[data-type="${type}"]`).forEach(card => { card.style.display = btn.classList.contains('active') ? '' : 'none'; }); _explorerWishlistUpdateCount(); } function _explorerWishlistUpdateCount() { const checked = document.querySelectorAll('#explorer-wishlist-overlay .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('explorer-wishlist-info'); const btn = document.getElementById('explorer-wishlist-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('explorer-wishlist-submit'); if (submitBtn) submitBtn.disabled = releases === 0; } async function _explorerWishlistSubmit(artistSections) { const grid = document.getElementById('explorer-wishlist-grid'); const progress = document.getElementById('explorer-wishlist-progress'); const filterBar = document.querySelector('#explorer-wishlist-overlay .discog-filter-bar'); const submitBtn = document.getElementById('explorer-wishlist-submit'); // Collect checked albums grouped by artist const byArtist = {}; document.querySelectorAll('#explorer-wishlist-overlay .discog-card-cb:checked').forEach(cb => { if (cb.closest('.discog-card').style.display === 'none') return; const card = cb.closest('.discog-card'); const artistId = card.dataset.artistId; const albumId = cb.dataset.albumId; const title = card.querySelector('.discog-card-title')?.textContent || ''; const img = card.querySelector('.discog-card-art img')?.src || ''; if (!byArtist[artistId]) byArtist[artistId] = { albums: [], name: '' }; byArtist[artistId].albums.push({ id: albumId, title, img, tracks: parseInt(cb.dataset.tracks) || 0 }); }); // Fill in artist names artistSections.forEach(s => { if (byArtist[s.artistId]) byArtist[s.artistId].name = s.name; }); // Switch to progress view if (grid) grid.style.display = 'none'; if (filterBar) filterBar.style.display = 'none'; if (submitBtn) submitBtn.style.display = 'none'; if (progress) { progress.style.display = ''; progress.innerHTML = ''; for (const [artistId, data] of Object.entries(byArtist)) { data.albums.forEach(album => { const item = document.createElement('div'); item.className = 'discog-progress-item active'; item.id = `explorer-prog-${album.id}`; item.innerHTML = `
${album.img ? `` : '♫'}
${_esc(album.title)}
Waiting...
`; progress.appendChild(item); }); } } const info = document.getElementById('explorer-wishlist-info'); if (info) info.textContent = 'Processing...'; let totalAdded = 0; for (const [artistId, data] of Object.entries(byArtist)) { // Sort by track count descending (deluxe editions first) BEFORE extracting IDs data.albums.sort((a, b) => b.tracks - a.tracks); // Per-album metadata so the backend can resolve each album through its // own source even when the explorer doesn't carry per-album source info. const albumsPayload = data.albums.map(a => ({ id: a.id, name: a.title || '', artist_name: data.name, source: null, })); try { const response = await fetch(`/api/artist/${artistId}/download-discography`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ albums: albumsPayload, artist_name: data.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(); for (const line of lines) { if (!line.trim()) continue; try { const result = JSON.parse(line); if (result.status === 'complete') continue; // Summary line, skip const item = document.getElementById(`explorer-prog-${result.album_id}`); if (item) { const statusEl = item.querySelector('.discog-prog-status'); const iconEl = item.querySelector('.discog-prog-icon'); if (result.status === 'done') { const added = result.tracks_added || 0; const skipped = result.tracks_skipped || 0; totalAdded += added; if (statusEl) statusEl.textContent = `Added ${added} track${added !== 1 ? 's' : ''}${skipped > 0 ? `, ${skipped} skipped` : ''}`; if (iconEl) iconEl.innerHTML = ''; item.classList.remove('active'); item.classList.add('done'); } else if (result.status === 'error') { if (statusEl) statusEl.textContent = result.message || 'Error'; if (iconEl) iconEl.innerHTML = ''; item.classList.remove('active'); item.classList.add('error'); } } } catch (e) { } } } } catch (e) { console.error(`Explorer wishlist: failed for ${data.name}:`, e); } } if (info) info.textContent = `Done — ${totalAdded} tracks added to wishlist`; // Change cancel button label to "Close" const cancelBtn = document.querySelector('#explorer-wishlist-overlay .discog-cancel-btn'); if (cancelBtn) cancelBtn.textContent = 'Close'; showToast(`Added ${totalAdded} tracks to wishlist`, 'success'); // Mark albums as added on the tree _explorer.selectedAlbums.forEach(id => { const node = document.querySelector(`.explorer-node-album[data-id="${id}"]`); if (node) { node.classList.add('added'); node.classList.remove('selected'); } }); _explorer.selectedAlbums.clear(); _explorerUpdateCount(); _explorerRefreshArtistIndicators(); } function _explorerEnsureDefs() { const svg = document.getElementById('explorer-svg'); if (!svg || svg.querySelector('defs')) return; // Read accent color from CSS custom property const accentRgb = getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim() || '100,200,255'; const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); defs.innerHTML = ` `; svg.appendChild(defs); } function _explorerDrawConnectionToArtist(artistIndex) { // Incremental: draw ONLY this artist's connection. Don't clear existing. // Flex reflow within the current row may shift siblings, but the visual drift // is minor and gets corrected by the final redraw after streaming completes. const svg = document.getElementById('explorer-svg'); const root = document.getElementById('explorer-root'); if (!svg || !root) return; _explorerEnsureDefs(); _explorerSizeSvg(); const artistNodes = document.querySelectorAll('.explorer-node-artist'); const artistNode = artistNodes[artistIndex]; if (!artistNode) return; const rc = _explorerGetPos(root); const ac = _explorerGetPos(artistNode); _explorerDrawCurve(svg, rc.cx, rc.bottom, ac.cx, ac.top, 'root', true); } function _explorerRedrawAllConnections(animate = false) { const svg = document.getElementById('explorer-svg'); const root = document.getElementById('explorer-root'); if (!svg || !root) return; _explorerEnsureDefs(); _explorerSizeSvg(); // Clear existing lines but keep defs svg.querySelectorAll('path').forEach(p => p.remove()); const rc = _explorerGetPos(root); document.querySelectorAll('.explorer-node-artist').forEach(artistNode => { const ac = _explorerGetPos(artistNode); _explorerDrawCurve(svg, rc.cx, rc.bottom, ac.cx, ac.top, 'root', animate); if (artistNode.classList.contains('expanded')) { const branch = artistNode.closest('.explorer-branch'); if (!branch) return; branch.querySelectorAll(':scope > .explorer-children > .explorer-branch > .explorer-node-album').forEach(albumNode => { const alc = _explorerGetPos(albumNode); _explorerDrawCurve(svg, ac.cx, ac.bottom, alc.cx, alc.top, 'album', animate); const albumBranch = albumNode.closest('.explorer-branch'); if (albumBranch) { albumBranch.querySelectorAll(':scope > .explorer-children > .explorer-branch > .explorer-node-track').forEach(trackNode => { const tc = _explorerGetPos(trackNode); _explorerDrawCurve(svg, alc.cx, alc.bottom, tc.cx, tc.top, 'track', animate); }); } }); } }); } function _explorerSizeSvg() { const svg = document.getElementById('explorer-svg'); const tree = document.getElementById('explorer-tree'); if (!svg || !tree) return; // SVG is inside the tree. Use scrollWidth/scrollHeight which are unscaled. // Add padding to ensure lines near edges aren't clipped. const w = Math.max(tree.scrollWidth, tree.offsetWidth) + 40; const h = Math.max(tree.scrollHeight, tree.offsetHeight) + 40; svg.setAttribute('width', w); svg.setAttribute('height', h); svg.setAttribute('viewBox', `0 0 ${w} ${h}`); } function _explorerGetPos(el) { // SVG is inside the tree — positions are relative to tree, unscaled const tree = document.getElementById('explorer-tree'); if (!tree) return { cx: 0, top: 0, bottom: 0 }; const tRect = tree.getBoundingClientRect(); const r = el.getBoundingClientRect(); const scale = _explorer._zoom || 1; // getBoundingClientRect returns scaled coords; divide by scale to get unscaled tree-space coords return { cx: (r.left + r.width / 2 - tRect.left) / scale, top: (r.top - tRect.top) / scale, bottom: (r.bottom - tRect.top) / scale, }; } function _explorerDrawCurve(svg, x1, y1, x2, y2, type, animate) { const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); const midY = y1 + (y2 - y1) * 0.45; path.setAttribute('d', `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`); if (type === 'root') { path.setAttribute('stroke', 'url(#explorer-grad-root)'); path.setAttribute('stroke-width', '1.5'); } else if (type === 'album') { path.setAttribute('stroke', 'url(#explorer-grad-album)'); path.setAttribute('stroke-width', '1'); } else { path.setAttribute('stroke', 'rgba(255,255,255,0.05)'); path.setAttribute('stroke-width', '0.8'); } path.setAttribute('fill', 'none'); svg.appendChild(path); if (animate) { const len = path.getTotalLength(); path.setAttribute('class', 'explorer-line explorer-line-animated'); path.style.strokeDasharray = len; path.style.strokeDashoffset = len; } else { path.setAttribute('class', 'explorer-line'); } } // ── Zoom & Pan ── _explorer._zoom = 1; _explorer._panX = 0; _explorer._panY = 0; _explorer._isPanning = false; _explorer._panStartX = 0; _explorer._panStartY = 0; _explorer._panStartScrollX = 0; _explorer._panStartScrollY = 0; function _explorerApplyTransform() { const tree = document.getElementById('explorer-tree'); if (tree) { tree.style.transform = `scale(${_explorer._zoom})`; tree.style.transformOrigin = 'top center'; } _explorerSizeSvg(); requestAnimationFrame(() => _explorerRedrawAllConnections()); } function explorerZoom(delta) { _explorer._zoom = Math.max(0.2, Math.min(3, _explorer._zoom + delta)); _explorerApplyTransform(); } function explorerFitToView() { const viewport = document.getElementById('explorer-viewport'); const tree = document.getElementById('explorer-tree'); if (!viewport || !tree) return; // Reset zoom to measure natural size _explorer._zoom = 1; tree.style.transform = 'scale(1)'; requestAnimationFrame(() => { const treeW = tree.scrollWidth; const treeH = tree.scrollHeight; const vpW = viewport.clientWidth - 40; const vpH = viewport.clientHeight - 40; if (treeW > 0 && treeH > 0) { _explorer._zoom = Math.min(vpW / treeW, vpH / treeH, 1.5); _explorer._zoom = Math.max(0.2, Math.min(3, _explorer._zoom)); } _explorerApplyTransform(); viewport.scrollTop = 0; viewport.scrollLeft = Math.max(0, (tree.scrollWidth * _explorer._zoom - vpW) / 2); }); } // Scroll wheel zoom (no modifier needed inside viewport). // IMPORTANT: attach to the viewport element, NOT document. A non-passive wheel // listener on document disables the browser's compositor (async) scrolling for // the ENTIRE app — every wheel/trackpad scroll then runs through the main thread. // Scoping it to the viewport keeps zoom working while the rest of the app keeps // smooth compositor scrolling. (function attachExplorerWheelZoom() { const viewport = document.getElementById('explorer-viewport'); if (!viewport) return; viewport.addEventListener('wheel', (e) => { const page = document.getElementById('playlist-explorer-page'); if (!page || !page.classList.contains('active')) return; e.preventDefault(); const step = e.deltaY > 0 ? -0.08 : 0.08; explorerZoom(step); }, { passive: false }); })(); // Middle-click / right-click drag to pan document.addEventListener('mousedown', (e) => { const viewport = document.getElementById('explorer-viewport'); if (!viewport || !viewport.contains(e.target)) return; // Middle click (button 1) or right click (button 2) if (e.button !== 1 && e.button !== 2) return; e.preventDefault(); _explorer._isPanning = true; _explorer._panStartX = e.clientX; _explorer._panStartY = e.clientY; _explorer._panStartScrollX = viewport.scrollLeft; _explorer._panStartScrollY = viewport.scrollTop; viewport.style.cursor = 'grabbing'; }); document.addEventListener('mousemove', (e) => { if (!_explorer._isPanning) return; const viewport = document.getElementById('explorer-viewport'); if (!viewport) return; const dx = e.clientX - _explorer._panStartX; const dy = e.clientY - _explorer._panStartY; viewport.scrollLeft = _explorer._panStartScrollX - dx; viewport.scrollTop = _explorer._panStartScrollY - dy; }); document.addEventListener('mouseup', (e) => { if (!_explorer._isPanning) return; _explorer._isPanning = false; const viewport = document.getElementById('explorer-viewport'); if (viewport) viewport.style.cursor = ''; }); // Suppress context menu on right-click inside viewport (for panning) document.addEventListener('contextmenu', (e) => { const viewport = document.getElementById('explorer-viewport'); if (viewport && viewport.contains(e.target)) { e.preventDefault(); } }); // Debounced redraw on resize window.addEventListener('resize', () => { if (_explorer.artists.length === 0) return; clearTimeout(_explorer._resizeTimer); _explorer._resizeTimer = setTimeout(() => _explorerRedrawAllConnections(), 150); }); // ================================================================================== // DASHBOARD — Recent Syncs Section // ================================================================================== // ================================================================================== // SERVER PLAYLIST MANAGER — Sync Page Server Tab // ================================================================================== let _serverPlaylists = []; let _serverEditorState = { playlistId: null, playlistName: '', tracks: [] }; async function loadServerPlaylists() { const container = document.getElementById('server-playlist-container'); const editor = document.getElementById('server-editor'); const btn = document.getElementById('server-refresh-btn'); if (editor) editor.style.display = 'none'; if (container) container.style.display = ''; if (btn) { btn.disabled = true; btn.textContent = '🔄 Loading...'; } // Show skeleton loader if (container) { container.innerHTML = `
${Array.from({ length: 6 }, (_, i) => `
`).join('')}
`; } try { // Fetch server playlists, mirrored playlists, and sync history names in parallel const [serverRes, mirroredRes, historyNamesRes] = await Promise.all([ fetch('/api/server/playlists'), fetch('/api/mirrored-playlists'), fetch('/api/sync/history/names'), ]); const data = await serverRes.json(); let mirroredAll = []; try { mirroredAll = await mirroredRes.json(); } catch (_) { } if (!Array.isArray(mirroredAll)) mirroredAll = []; let historyNames = []; try { historyNames = await historyNamesRes.json(); } catch (_) { } if (!Array.isArray(historyNames)) historyNames = []; if (!data.success || !data.playlists) { if (container) container.innerHTML = `
${data.error || 'Could not load server playlists'}
`; return; } // Separate synced vs non-synced playlists const mirroredNames = new Set(mirroredAll.map(p => p.name.trim().toLowerCase())); const syncedNames = new Set(historyNames.map(n => n.trim().toLowerCase())); const synced = []; const unsynced = []; for (const pl of data.playlists) { const key = pl.name.trim().toLowerCase(); if (mirroredNames.has(key) || syncedNames.has(key)) { pl._synced = true; synced.push(pl); } else { pl._synced = false; unsynced.push(pl); } } _serverPlaylists = [...synced, ...unsynced]; const title = document.getElementById('server-tab-title'); const serverName = data.server_type ? data.server_type.charAt(0).toUpperCase() + data.server_type.slice(1) : ''; if (title) title.textContent = `Server Playlists (${serverName})`; if (synced.length === 0 && unsynced.length === 0) { if (container) container.innerHTML = '
No playlists found on your media server.
'; return; } // Server type icon SVG const serverIcons = { plex: '', jellyfin: '', navidrome: '' }; const sIcon = serverIcons[data.server_type] || serverIcons.plex; function _renderPlCard(pl, i, isSynced) { const hue = (i * 37 + 200) % 360; const safeName = _esc(pl.name).replace(/'/g, "\\'"); const cardClass = isSynced ? 'server-pl-card' : 'server-pl-card server-pl-unsynced'; const action = isSynced ? 'Open Editor' : 'View Tracks'; return `
${sIcon}
${_esc(pl.name)}
${pl.track_count} tracks ${isSynced ? 'Synced' : ''}
`; } let html = ''; if (synced.length > 0) { html += `
🔗 Synced Playlists ${synced.length}
${synced.map((pl, i) => _renderPlCard(pl, i, true)).join('')}
`; } if (unsynced.length > 0) { html += `
🎵 Other Server Playlists ${unsynced.length}
${unsynced.map((pl, i) => _renderPlCard(pl, i + synced.length, false)).join('')}
`; } container.innerHTML = html; } catch (e) { if (container) container.innerHTML = `
Error: ${e.message}
`; } finally { if (btn) { btn.disabled = false; btn.textContent = '🔄 Refresh'; } } } async function openServerPlaylistEditor(playlistId, playlistName) { // Step 1: Look up mirrored playlists by name let mirroredPlaylists = []; try { const res = await fetch('/api/mirrored-playlists'); const all = await res.json(); mirroredPlaylists = (Array.isArray(all) ? all : []).filter(p => p.name.trim().toLowerCase() === playlistName.trim().toLowerCase() ); } catch (e) { console.error('Failed to fetch mirrored playlists:', e); } if (mirroredPlaylists.length === 1) { // Single match — go straight to compare _openServerCompareView(playlistId, playlistName, mirroredPlaylists[0]); } else if (mirroredPlaylists.length === 0) { // No match — server-only view _openServerCompareView(playlistId, playlistName, null); } else { // Multiple — disambiguation _showServerDisambig(playlistId, playlistName, mirroredPlaylists); } } // ── Disambiguation ── function _showServerDisambig(playlistId, playlistName, candidates) { const overlay = document.getElementById('server-disambig-overlay'); const list = document.getElementById('server-disambig-list'); const subtitle = document.getElementById('server-disambig-subtitle'); if (!overlay || !list) return; if (subtitle) subtitle.textContent = `"${playlistName}" was found on ${candidates.length} sources. Which one do you want to compare against?`; const sourceIcons = { spotify: '🟢', tidal: '🌊', youtube: '▶️', beatport: '🎛️', deezer: '🟣', file: '📄' }; list.innerHTML = candidates.map((p, i) => { const icon = sourceIcons[p.source] || '📋'; const ago = timeAgo(p.mirrored_at || p.updated_at); return `
${icon}
${_esc(p.name)}
${_esc(p.source)} ${p.track_count || 0} tracks ${p.owner ? `by ${_esc(p.owner)}` : ''} Mirrored ${ago}
`; }).join(''); overlay.classList.remove('hidden'); requestAnimationFrame(() => overlay.classList.add('visible')); // Escape key + click backdrop to close overlay.onclick = e => { if (e.target === overlay) closeServerDisambig(); }; window._disambigEsc = e => { if (e.key === 'Escape') closeServerDisambig(); }; document.addEventListener('keydown', window._disambigEsc); } function closeServerDisambig() { const overlay = document.getElementById('server-disambig-overlay'); if (overlay) { overlay.classList.remove('visible'); setTimeout(() => overlay.classList.add('hidden'), 250); } if (window._disambigEsc) { document.removeEventListener('keydown', window._disambigEsc); window._disambigEsc = null; } } async function selectDisambigPlaylist(playlistId, playlistName, mirroredId) { closeServerDisambig(); try { const res = await fetch(`/api/mirrored-playlists/${mirroredId}`); const mirrored = await res.json(); _openServerCompareView(playlistId, playlistName, mirrored); } catch (e) { showToast('Failed to load mirrored playlist: ' + e.message, 'error'); } } // ── Compare View ── async function _openServerCompareView(playlistId, playlistName, mirroredPlaylist) { const container = document.getElementById('server-playlist-container'); const editor = document.getElementById('server-editor'); if (!editor) return; if (container) container.style.display = 'none'; editor.style.display = ''; const nameEl = document.getElementById('server-editor-name'); const metaEl = document.getElementById('server-editor-meta'); const banner = document.getElementById('server-no-source-banner'); const sourceScroll = document.getElementById('server-col-source-scroll'); const serverScroll = document.getElementById('server-col-server-scroll'); if (nameEl) nameEl.textContent = playlistName; if (metaEl) metaEl.textContent = 'Loading comparison...'; if (banner) banner.style.display = 'none'; if (sourceScroll) sourceScroll.innerHTML = '
Loading...
'; if (serverScroll) serverScroll.innerHTML = '
Loading...
'; // Store state _serverEditorState = { playlistId, playlistName, mirroredPlaylist, tracks: [], }; // Build API URL let url = `/api/server/playlist/${playlistId}/tracks?name=${encodeURIComponent(playlistName)}`; if (mirroredPlaylist && mirroredPlaylist.id) { url += `&mirrored_playlist_id=${mirroredPlaylist.id}`; } try { const response = await fetch(url); const data = await response.json(); if (!data.success) { if (metaEl) metaEl.textContent = data.error || 'Failed to load'; return; } _serverEditorState.tracks = data.tracks || []; _serverEditorState.serverType = data.server_type; // Order status: the columns render in SOURCE order, so a same-tracks-but- // reordered server playlist looks in-sync. order_status flags that drift; // server_order is the server's ACTUAL sequence for the read-only view. _serverEditorState.orderStatus = data.order_status || null; _serverEditorState.serverOrder = data.server_order || []; const tracks = _serverEditorState.tracks; const serverLabel = data.server_type ? data.server_type.charAt(0).toUpperCase() + data.server_type.slice(1) : 'Server'; // Header metadata if (metaEl) metaEl.textContent = `${serverLabel} · ${data.server_track_count || 0} server tracks · ${data.source_track_count || 0} source tracks`; // Show no-source banner if needed if (!mirroredPlaylist && banner) { banner.style.display = ''; } // Stats, filter counts, footer _updateCompareStats(tracks); // Column headers const sourceLabel = mirroredPlaylist ? (mirroredPlaylist.source || 'source').charAt(0).toUpperCase() + (mirroredPlaylist.source || 'source').slice(1) : 'Source'; const sourceIconMap = { spotify: '🟢', tidal: '🌊', youtube: '▶️', beatport: '🎛️', deezer: '🟣', file: '📄' }; const serverIconMap = { plex: '🟠', jellyfin: '🟣', navidrome: '🔵' }; const srcIconEl = document.getElementById('server-col-source-icon'); const srcLabelEl = document.getElementById('server-col-source-label'); const srcCountEl = document.getElementById('server-col-source-count'); const svrIconEl = document.getElementById('server-col-server-icon'); const svrLabelEl = document.getElementById('server-col-server-label'); const svrCountEl = document.getElementById('server-col-server-count'); if (srcIconEl) srcIconEl.textContent = mirroredPlaylist ? (sourceIconMap[mirroredPlaylist.source] || '📋') : '📋'; if (srcLabelEl) srcLabelEl.textContent = sourceLabel; if (srcCountEl) srcCountEl.textContent = `${data.source_track_count || 0} tracks`; if (svrIconEl) svrIconEl.textContent = serverIconMap[data.server_type] || '💻'; if (svrLabelEl) svrLabelEl.textContent = serverLabel; if (svrCountEl) { const os = _serverEditorState.orderStatus; if (os && os.out_of_order) { // Accurate membership but different order than the source. Read-only: // source order is the source of truth; the badge opens the real order. svrCountEl.innerHTML = `${data.server_track_count || 0} tracks ` + ``; } else { svrCountEl.textContent = `${data.server_track_count || 0} tracks`; } } // Render columns _renderCompareColumns(tracks); // Scroll linking _setupScrollLinking(); } catch (e) { if (metaEl) metaEl.textContent = 'Error: ' + e.message; } } function _updateCompareStats(tracks) { const matched = tracks.filter(t => t.match_status === 'matched').length; const missing = tracks.filter(t => t.match_status === 'missing').length; const extra = tracks.filter(t => t.match_status === 'extra').length; const statsEl = document.getElementById('server-editor-stats'); if (statsEl) { statsEl.innerHTML = `
${matched}
Matched
${missing}
Missing
${extra > 0 ? `
${extra}
Extra
` : ''} `; } const editor = document.getElementById('server-editor'); if (editor) { editor.querySelectorAll('.discog-filter').forEach(btn => { const f = btn.dataset.filter; if (f === 'all') btn.textContent = `All (${tracks.length})`; else if (f === 'matched') btn.textContent = `Matched (${matched})`; else if (f === 'missing') btn.textContent = `Missing (${missing})`; else if (f === 'extra') btn.textContent = `Extra (${extra})`; }); } const footer = document.getElementById('server-editor-footer'); if (footer) footer.textContent = `${matched}/${matched + missing} matched${extra > 0 ? ` · ${extra} extra on server` : ''}`; } // Read-only view of the server playlist's ACTUAL order. Source order is the source // of truth; this just lets the user SEE how the server currently differs (no editing). function _showServerOrder() { const esc = typeof _esc === 'function' ? _esc : s => s; const order = (_serverEditorState && _serverEditorState.serverOrder) || []; const serverType = (_serverEditorState && _serverEditorState.serverType) || 'server'; const serverLabel = serverType.charAt(0).toUpperCase() + serverType.slice(1); document.getElementById('server-order-modal')?.remove(); const rows = order.map((t, i) => { const art = t.thumb ? `` : `
`; return `
${i + 1} ${art}
${esc(t.title || 'Unknown')} ${esc(t.artist || '')}
`; }).join(''); // Align actions — reorder the server playlist to the source order. Two choices // for server-only "extra" tracks. Order-only: never adds the missing tracks // (that's the normal sync's job). Supported where reorder is implemented. const canAlign = serverType === 'navidrome' || serverType === 'plex' || serverType === 'jellyfin'; const alignFoot = canAlign ? `
Align this playlist to the source order
Missing tracks aren't added here — run a normal sync for those.
` : ''; const overlay = document.createElement('div'); overlay.id = 'server-order-modal'; overlay.className = 'server-order-overlay'; overlay.innerHTML = `
${esc(serverLabel)} playlist order
the actual order on your server · source order stays the source of truth
${rows || '
No server tracks.
'}
${alignFoot}
`; overlay.onclick = () => overlay.remove(); document.body.appendChild(overlay); } // Align the server playlist's ORDER to the source (the "Align playlists" action). // Sends the matched server-track ids in SOURCE order + the extras choice; the // backend validates they're all in the playlist and rewrites. Order-only. async function _alignPlaylist(keepExtras) { const st = _serverEditorState; if (!st || !st.playlistId) return; const matchedIds = (Array.isArray(st.tracks) ? st.tracks : []) .filter(t => t.match_status === 'matched' && t.server_track && t.server_track.id != null) .map(t => String(t.server_track.id)); if (!matchedIds.length) { if (typeof showToast === 'function') showToast('Nothing to align', 'warning'); return; } try { const resp = await fetch(`/api/server/playlist/${st.playlistId}/align`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ playlist_name: st.playlistName || '', matched_ids: matchedIds, keep_extras: !!keepExtras, }), }); const data = await resp.json(); if (data && data.success) { if (typeof showToast === 'function') showToast(`Playlist order aligned (${data.track_count} tracks)`, 'success'); document.getElementById('server-order-modal')?.remove(); _serverEditorRefresh(); } else { if (typeof showToast === 'function') showToast((data && data.error) || 'Align failed', 'error'); } } catch (e) { if (typeof showToast === 'function') showToast('Align failed: ' + e.message, 'error'); } } function _formatDurationMs(ms) { if (!ms) return ''; const s = Math.round(ms / 1000); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; } function _renderCompareColumns(tracks) { const sourceScroll = document.getElementById('server-col-source-scroll'); const serverScroll = document.getElementById('server-col-server-scroll'); if (!sourceScroll || !serverScroll) return; let sourceHTML = ''; let serverHTML = ''; tracks.forEach((t, i) => { const src = t.source_track; const svr = t.server_track; const status = t.match_status; const pairId = `pair-${i}`; // ── Source (left) column ── if (src) { const dur = _formatDurationMs(src.duration_ms); sourceHTML += `
${src.position != null ? src.position : i + 1}
${src.image_url ? `` : '
'}
${_esc(src.name)}
${_esc(src.artist || '')}
${dur}
`; } else { // Extra track — no source sourceHTML += `
No source track
`; } // ── Server (right) column ── if (svr) { const dur = _formatDurationMs(svr.duration); const conf = t.confidence != null ? t.confidence : null; let confBadge = ''; if (status === 'matched' && conf != null) { const pct = Math.round(conf * 100); const cls = pct >= 100 ? 'exact' : pct >= 90 ? 'high' : 'fuzzy'; confBadge = `${pct}%`; } serverHTML += `
${i + 1}
${svr.thumb ? `` : '
'}
${_esc(svr.title)}
${_esc(svr.artist || '')}
${confBadge}
${dur}
${status === 'matched' ? `` : ''}
`; } else { // Missing on server — clickable empty slot const hint = src ? `${src.artist || ''} — ${src.name}` : ''; serverHTML += `
Find & add ${_esc(hint)}
`; } }); sourceScroll.innerHTML = sourceHTML; serverScroll.innerHTML = serverHTML; } function _setupScrollLinking() { const sourceScroll = document.getElementById('server-col-source-scroll'); const serverScroll = document.getElementById('server-col-server-scroll'); if (!sourceScroll || !serverScroll) return; // Remove old listeners to prevent accumulation on refresh if (window._serverScrollAC) window._serverScrollAC.abort(); window._serverScrollAC = new AbortController(); const signal = window._serverScrollAC.signal; let syncing = false; const syncScroll = (from, to) => { if (syncing) return; syncing = true; const maxFrom = from.scrollHeight - from.clientHeight; const maxTo = to.scrollHeight - to.clientHeight; if (maxFrom > 0 && maxTo > 0) { to.scrollTop = (from.scrollTop / maxFrom) * maxTo; } requestAnimationFrame(() => { syncing = false; }); }; sourceScroll.addEventListener('scroll', () => syncScroll(sourceScroll, serverScroll), { signal }); serverScroll.addEventListener('scroll', () => syncScroll(serverScroll, sourceScroll), { signal }); } function _compareTrackClick(side, index) { const otherSide = side === 'source' ? 'server' : 'source'; const otherScroll = document.getElementById(`server-col-${otherSide}-scroll`); const pairId = `pair-${index}`; // Clear previous highlights document.querySelectorAll('.server-track-item.highlighted').forEach(el => el.classList.remove('highlighted')); // Highlight both paired items document.querySelectorAll(`[data-pair-id="${pairId}"]`).forEach(el => el.classList.add('highlighted')); // Scroll the OTHER column to show the paired item const target = otherScroll?.querySelector(`[data-pair-id="${pairId}"]`); if (target) { target.scrollIntoView({ behavior: 'smooth', block: 'center' }); } } function _serverEditorRefresh() { _openServerCompareView(_serverEditorState.playlistId, _serverEditorState.playlistName, _serverEditorState.mirroredPlaylist); } /** * Export the currently-open server playlist as an M3U file. Takes the tracks * physically present ON the server (matched + extra) and reuses the shared M3U * writer, which resolves each to its real library file path (+ the configured * entry_base_path prefix) so media servers like Music Assistant can read it. * force:true bypasses the auto-save "m3u_export.enabled" gate — this is a manual * on-demand export. */ async function exportServerPlaylistM3U() { const st = _serverEditorState; const btn = document.getElementById('server-editor-export-btn'); const tracks = (st && Array.isArray(st.tracks) ? st.tracks : []) .filter(t => t.server_track) .map(t => ({ name: t.server_track.title, artist: t.server_track.artist || '', duration_ms: t.server_track.duration || 0, })); if (!tracks.length) { showToast('No server tracks to export', 'warning'); return; } const orig = btn ? btn.textContent : ''; if (btn) { btn.disabled = true; btn.textContent = '⏳ Exporting…'; } try { const res = await fetch('/api/generate-playlist-m3u', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ playlist_name: st.playlistName || 'Playlist', tracks, context_type: 'playlist', save_to_disk: true, force: true, }), }); const data = await res.json(); if (!res.ok || data.success === false) { throw new Error(data.error || 'Export failed'); } // Download the .m3u to the browser (same as the other Export-as-M3U // buttons) — force=true also saved it server-side for media servers. const name = (st.playlistName || 'Playlist').replace(/[/\\?%*:|"<>]/g, '-'); const blob = new Blob([data.m3u_content || ''], { type: 'audio/x-mpegurl;charset=utf-8' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `${name}.m3u`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); // `found` = server tracks resolved to a real library file path; any not in // SoulSync's library are skipped (can't write a path for them). const found = data.stats && data.stats.found != null ? data.stats.found : tracks.length; const note = found < tracks.length ? ` (${found}/${tracks.length} in library)` : ` (${found} tracks)`; showToast(`Exported M3U: ${st.playlistName}${note}`, 'success'); } catch (e) { showToast(`M3U export failed: ${e.message}`, 'error'); } finally { if (btn) { btn.disabled = false; btn.textContent = orig || '📋 Export M3U'; } } } function serverEditorBack() { const container = document.getElementById('server-playlist-container'); const editor = document.getElementById('server-editor'); if (editor) editor.style.display = 'none'; if (container) container.style.display = ''; } function _serverEditorFilter(btn, filter) { btn.closest('.server-editor-filters').querySelectorAll('.discog-filter').forEach(b => b.classList.remove('active')); btn.classList.add('active'); // Filter both columns simultaneously ['server-col-source-scroll', 'server-col-server-scroll'].forEach(colId => { document.querySelectorAll(`#${colId} .server-track-item`).forEach(item => { const status = item.dataset.status; item.style.display = (filter === 'all' || status === filter) ? '' : 'none'; }); }); } // ── Track Search / Replace ── async function serverSearchReplace(trackIndex, mode) { const track = _serverEditorState.tracks[trackIndex]; if (!track) return; const src = track.source_track || {}; const svr = track.server_track || {}; // Search by track name only first (more reliable than "artist trackname" blob) const searchQuery = src.name ? src.name.trim() : (svr.title || '').trim(); const contextArtist = src.artist || svr.artist || ''; const contextName = src.name || svr.title || ''; // Pass the source artist as a relevance hint so an exact title+artist match // ranks to the top of the library search instead of being buried under // same-title tracks by other artists (#: "bad guy" by Billie Eilish). _serverEditorState.searchArtist = contextArtist; const existing = document.getElementById('server-search-overlay'); if (existing) existing.remove(); const overlay = document.createElement('div'); overlay.id = 'server-search-overlay'; overlay.className = 'server-search-overlay'; overlay.innerHTML = `
${mode === 'replace' ? 'Swap Track' : 'Add Track to Server'}
${contextName ? `
Source: ${_esc(contextArtist)} ${_esc(contextName)}
` : ''}

Searching...
`; // Click overlay background or press Escape to close overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); }); overlay._escHandler = e => { if (e.key === 'Escape') overlay.remove(); }; document.addEventListener('keydown', overlay._escHandler); // Clean up Escape listener when overlay is removed const obs = new MutationObserver(() => { if (!document.body.contains(overlay)) { document.removeEventListener('keydown', overlay._escHandler); obs.disconnect(); } }); obs.observe(document.body, { childList: true }); const popover = overlay.querySelector('.server-search-popover'); popover.dataset.trackIndex = trackIndex; popover.dataset.mode = mode; document.body.appendChild(overlay); requestAnimationFrame(() => overlay.classList.add('visible')); document.getElementById('server-search-input')?.focus(); document.getElementById('server-search-input')?.select(); _serverSearchExecute(); } async function _serverSearchExecute() { const input = document.getElementById('server-search-input'); const results = document.getElementById('server-search-results'); const resultsHeader = document.getElementById('server-search-results-header'); const popover = document.getElementById('server-search-popover'); if (!input || !results || !popover) return; const query = input.value.trim(); if (!query) { results.innerHTML = '
Type a search query
'; if (resultsHeader) resultsHeader.textContent = ''; return; } results.innerHTML = '
Searching library...
'; if (resultsHeader) resultsHeader.textContent = ''; try { const artistHint = (_serverEditorState && _serverEditorState.searchArtist) || ''; const response = await fetch(`/api/library/search-tracks?q=${encodeURIComponent(query)}&limit=20` + (artistHint ? `&artist=${encodeURIComponent(artistHint)}` : '')); const data = await response.json(); if (!data.success || !data.tracks || data.tracks.length === 0) { results.innerHTML = `

No results found
Try different keywords or a shorter query
`; return; } const trackIndex = parseInt(popover.dataset.trackIndex); const mode = popover.dataset.mode; if (resultsHeader) resultsHeader.textContent = `${data.tracks.length} result${data.tracks.length !== 1 ? 's' : ''}`; results.innerHTML = data.tracks.map((t, i) => { const ext = (t.file_path || '').split('.').pop().toUpperCase(); const format = ['FLAC', 'MP3', 'OPUS', 'OGG', 'M4A', 'AAC', 'WAV'].includes(ext) ? (ext === 'M4A' ? 'AAC' : ext) : ''; const dur = _formatDurationMs(t.duration); const bitrateStr = t.bitrate ? `${t.bitrate}k` : ''; return `
${t.album_thumb_url ? `` : '
'}
${_esc(t.title)}
${_esc(t.artist_name)}${t.album_title ? ` · ${_esc(t.album_title)}` : ''}
${format ? `${format}` : ''} ${bitrateStr ? `${bitrateStr}` : ''} ${dur ? `${dur}` : ''}
`; }).join(''); } catch (e) { results.innerHTML = `
Error: ${e.message}
`; } } async function _serverSelectTrack(trackIndex, mode, newTrackId, el) { const track = _serverEditorState.tracks[trackIndex]; if (!track) return; const btn = el.querySelector('.server-search-select-btn'); if (btn) { btn.disabled = true; btn.textContent = '...'; } try { let response; if (mode === 'replace') { response = await fetch(`/api/server/playlist/${_serverEditorState.playlistId}/replace-track`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ old_track_id: track.server_track?.id, new_track_id: newTrackId, playlist_name: _serverEditorState.playlistName, }) }); } else { // Calculate the server-side position for this track // Count how many server tracks exist before this index let serverPos = 0; for (let k = 0; k < trackIndex; k++) { if (_serverEditorState.tracks[k]?.server_track) serverPos++; } // source_track carries source_track_id (Spotify ID) when this // came from a mirrored playlist — the backend uses it to // persist the Find & Add selection as a permanent match // override so future syncs auto-pair without user action. const srcTrack = track.source_track || {}; response = await fetch(`/api/server/playlist/${_serverEditorState.playlistId}/add-track`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ track_id: newTrackId, playlist_name: _serverEditorState.playlistName, position: serverPos, source_track_id: srcTrack.source_track_id || '', source_title: srcTrack.name || '', source_artist: srcTrack.artist || '', // Provider of the source track, so the durable manual match // (#787) records the right source. Retrieval is source-agnostic. source: srcTrack.source || _serverEditorState.mirroredPlaylist?.source || '', }) }); } const data = await response.json(); if (data.success) { showToast(data.message || 'Track updated', 'success'); document.getElementById('server-search-overlay')?.remove(); // Update playlist ID if server recreated it (Plex deletes+recreates) if (data.new_playlist_id) _serverEditorState.playlistId = data.new_playlist_id; // Re-fetch from server so the compare view reflects the actual server state // and the matching algorithm can correctly wire up the newly added/replaced track _openServerCompareView(_serverEditorState.playlistId, _serverEditorState.playlistName, _serverEditorState.mirroredPlaylist); } else { showToast(data.error || 'Failed to update track', 'error'); if (btn) { btn.disabled = false; btn.textContent = 'Select'; } } } catch (e) { showToast('Error: ' + e.message, 'error'); if (btn) { btn.disabled = false; btn.textContent = 'Select'; } } } async function _serverRemoveTrack(trackIndex, serverTrackId) { if (!serverTrackId) return; const track = _serverEditorState.tracks[trackIndex]; const trackTitle = track?.server_track?.title || 'this track'; if (!await showConfirmDialog({ title: 'Remove Track', message: `Remove "${trackTitle}" from this playlist?`, confirmText: 'Remove', destructive: true })) return; try { const response = await fetch(`/api/server/playlist/${_serverEditorState.playlistId}/remove-track`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ track_id: serverTrackId, playlist_name: _serverEditorState.playlistName, }) }); const data = await response.json(); if (data.success) { showToast(data.message || 'Track removed', 'success'); const pid = data.new_playlist_id || _serverEditorState.playlistId; _serverEditorState.playlistId = pid; _openServerCompareView(pid, _serverEditorState.playlistName, _serverEditorState.mirroredPlaylist); } else { showToast(data.error || 'Failed to remove track', 'error'); } } catch (e) { showToast('Error: ' + e.message, 'error'); } } // Auto-refresh sync cards every 30 seconds when on dashboard setInterval(() => { if (typeof currentPage !== 'undefined' && currentPage === 'dashboard') { loadDashboardSyncHistory(); } }, 30000); async function loadDashboardSyncHistory() { // Don't poll the auth-gated sync-history endpoint while the app is locked — // it would 401 every 30s cycle (the result is discarded anyway). Resumes // automatically on unlock (init.js removes 'app-locked'). if (document.body.classList.contains('app-locked')) return; const container = document.getElementById('sync-history-cards'); if (!container) return; try { const response = await fetch('/api/sync/history?limit=10'); if (response.status === 401) { // Session lapsed (e.g. the server restarted) while this tab still // believed it was unlocked, so the guard above couldn't fire. Surface // the correct unlock screen — both add 'app-locked', which stops the // poll until the user re-authenticates (same as a fresh page load). const info = await response.json().catch(() => ({})); if (info.login_required && typeof showLoginScreen === 'function') { showLoginScreen(); } else if (typeof showLaunchPinScreen === 'function') { showLaunchPinScreen(); } return; } if (!response.ok) return; const data = await response.json(); // Filter to only show playlist syncs — not album downloads or wishlist processing const entries = (data.entries || []).filter(e => e.sync_type === 'playlist' || !e.sync_type); if (entries.length === 0) { container.innerHTML = '
No syncs yet
'; return; } container.innerHTML = entries.map((entry, cardIndex) => { const found = entry.tracks_found || 0; const total = entry.total_tracks || 0; const downloaded = entry.tracks_downloaded || 0; const failed = entry.tracks_failed || 0; const pct = total > 0 ? Math.round((found / total) * 100) : 0; // Health color let healthClass = 'health-good'; if (pct < 50) healthClass = 'health-bad'; else if (pct < 80) healthClass = 'health-warn'; // Source badge const sourceLabels = { spotify: 'Spotify', tidal: 'Tidal', deezer: 'Deezer', youtube: 'YouTube', beatport: 'Beatport', wishlist: 'Wishlist' }; const sourceLabel = sourceLabels[entry.source] || entry.source || 'Unknown'; // Time const timeStr = entry.started_at ? _relativeTime(entry.started_at) : ''; // Name const name = entry.artist_name ? `${entry.artist_name} — ${entry.album_name || entry.playlist_name}` : entry.playlist_name || 'Unknown'; return `
${entry.thumb_url ? `` : '
'}
${typeof _esc === 'function' ? _esc(name) : name}
${sourceLabel} ${timeStr}
${pct}%
${found}/${total} matched${downloaded > 0 ? ` · ${downloaded} ⬇` : ''}${failed > 0 ? ` · ${failed} ✗` : ''}
`; }).join(''); } catch (e) { console.warn('Failed to load sync history for dashboard:', e); } } function _relativeTime(dateStr) { try { const d = new Date(dateStr); const now = new Date(); const diffMs = now - d; const mins = Math.floor(diffMs / 60000); if (mins < 1) return 'just now'; if (mins < 60) return `${mins}m ago`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs}h ago`; const days = Math.floor(hrs / 24); if (days < 7) return `${days}d ago`; return d.toLocaleDateString(); } catch (e) { return ''; } } // Re-add a synced unmatched track to the wishlist from the sync-detail modal, with // the same context the original sync used (resolved server-side from the entry). async function _readdSyncWishlist(entryId, index, el) { if (el && el.dataset.busy) return; if (el) { el.dataset.busy = '1'; el.classList.add('is-busy'); } try { const resp = await fetch(`/api/sync/history/${entryId}/track/${index}/wishlist`, { method: 'POST' }); const data = await resp.json(); if (data && data.success) { if (el) { el.classList.remove('is-busy'); el.classList.add('is-done'); el.disabled = true; el.innerHTML = data.added ? '✓ Re-added' : '✓ On wishlist'; } if (typeof showToast === 'function') { showToast( data.added ? `Re-added "${data.name}" to wishlist` : `"${data.name}" is already on the wishlist`, data.added ? 'success' : 'info', ); } } else { if (el) { delete el.dataset.busy; el.classList.remove('is-busy'); } if (typeof showToast === 'function') showToast((data && data.error) || 'Could not re-add to wishlist', 'error'); } } catch (e) { if (el) { delete el.dataset.busy; el.classList.remove('is-busy'); } if (typeof showToast === 'function') showToast('Could not re-add to wishlist: ' + e.message, 'error'); } } async function openSyncDetailModal(entryId) { try { showLoadingOverlay('Loading sync details...'); const response = await fetch(`/api/sync/history/${entryId}`); const data = await response.json(); hideLoadingOverlay(); if (!data.success || !data.entry) { showToast('Could not load sync details', 'error'); return; } const entry = data.entry; const trackResults = entry.track_results || []; const name = entry.artist_name ? `${entry.artist_name} — ${entry.album_name || entry.playlist_name}` : entry.playlist_name || 'Unknown'; // Build modal const overlay = document.createElement('div'); overlay.className = 'discog-modal-overlay'; overlay.id = 'sync-detail-overlay'; const found = entry.tracks_found || 0; const total = entry.total_tracks || 0; const downloaded = entry.tracks_downloaded || 0; let trackRowsHtml = ''; if (trackResults.length > 0) { trackRowsHtml = trackResults.map((t, i) => { const statusIcon = t.status === 'found' ? '✅' : '❌'; const statusClass = t.status === 'found' ? 'matched' : 'unmatched'; const confPct = Math.round((t.confidence || 0) * 100); const confClass = confPct >= 80 ? 'conf-high' : confPct >= 50 ? 'conf-mid' : 'conf-low'; let dlIcon = ''; if (t.download_status === 'completed') dlIcon = '✅'; else if (t.download_status === 'failed') dlIcon = '❌'; else if (t.download_status === 'not_found') dlIcon = '🔇'; else if (t.download_status === 'cancelled') dlIcon = '🚫'; let dlDisplay = dlIcon; if (!dlDisplay && t.download_status === 'wishlist') { // Wing-it fallback stubs (no real metadata) were never actually // wishlisted by the sync — show them as plain, non-clickable. const isWingIt = String(t.source_track_id || '').startsWith('wing_it_'); if (isWingIt) { dlDisplay = `Unmatched`; } else { // Clickable: re-add this exact track to the wishlist with the // same context the sync originally used. dlDisplay = ``; } } return ` ${i + 1} ${t.image_url ? `` : '
'} ${_esc(t.name || '')} ${_esc(t.artist || '')} ${_esc(t.album || '')} ${statusIcon} ${confPct}% ${dlDisplay} `; }).join(''); } else { // Fallback to tracks_json if no track_results (old syncs before data caching) const tracks = entry.tracks || []; const esc = typeof _esc === 'function' ? _esc : s => s; trackRowsHtml = `
Per-track match data not available for this sync.
Re-sync this playlist to see detailed match results.
` + tracks.map((t, i) => { const artists = t.artists || []; const artistName = artists.length > 0 ? (typeof artists[0] === 'string' ? artists[0] : artists[0]?.name || '') : ''; const albumName = typeof t.album === 'object' ? (t.album?.name || '') : (t.album || ''); return ` ${i + 1} ${esc(t.name || '')} ${esc(artistName)} ${esc(albumName)} `; }).join(''); } // Count stats for filter bar const matchedCount = trackResults.filter(t => t.status === 'found').length; const unmatchedCount = trackResults.filter(t => t.status !== 'found').length; const downloadedCount = trackResults.filter(t => t.download_status === 'completed').length; overlay.innerHTML = `

Sync Details

${_esc(name)}

${downloadedCount > 0 ? `` : ''}
${trackRowsHtml}
# Track Artist Album Match Conf. Status
`; document.body.appendChild(overlay); requestAnimationFrame(() => overlay.classList.add('visible')); } catch (e) { hideLoadingOverlay(); showToast('Failed to load sync details', 'error'); } } async function deleteSyncHistoryCard(entryId, btnEl) { try { const card = btnEl.closest('.sync-history-card'); if (card) { card.style.opacity = '0'; card.style.transform = 'scale(0.9)'; } const resp = await fetch(`/api/sync/history/${entryId}`, { method: 'DELETE' }); if (resp.ok) { setTimeout(() => { if (card) card.remove(); }, 200); } } catch (e) { console.warn('Failed to delete sync entry:', e); } } function _syncDetailFilter(btn, filter) { // Update active button btn.closest('.discog-filters').querySelectorAll('.discog-filter').forEach(b => b.classList.remove('active')); btn.classList.add('active'); // Filter rows document.querySelectorAll('#sync-detail-tbody .sync-detail-row').forEach(row => { if (filter === 'all') { row.style.display = ''; } else if (filter === 'matched') { row.style.display = row.classList.contains('matched') ? '' : 'none'; } else if (filter === 'unmatched') { row.style.display = row.classList.contains('unmatched') ? '' : 'none'; } else if (filter === 'downloaded') { const dlCell = row.querySelector('.sync-detail-dl'); row.style.display = dlCell && dlCell.textContent.trim() === '✅' ? '' : 'none'; } }); } // ============================================ // ACTIVE DOWNLOADS PAGE — Centralized Live View // ============================================ let _adlPoller = null; let _adlFilter = 'all'; let _adlData = []; let _adlBatches = []; let _adlBatchHistory = []; let _adlExpandedBatches = new Set(); let _adlBatchHistoryPoller = null; let _adlFilterBatchId = null; // When set, main list shows only this batch let _adlFetchCount = 0; // used to rate-limit periodic quarantine refresh const _batchColorMap = {}; const _batchCompletedAt = {}; // batch_id -> timestamp when first seen as complete let _batchColorNext = 0; function _getBatchColor(batchId) { if (!batchId) return -1; if (_batchColorMap[batchId] === undefined) { // Deterministic color from batch_id hash for consistency across reloads let hash = 0; for (let i = 0; i < batchId.length; i++) hash = ((hash << 5) - hash + batchId.charCodeAt(i)) | 0; _batchColorMap[batchId] = Math.abs(hash) % 8; } return _batchColorMap[batchId]; } // Per-batch progress samples for a client-side ETA (no backend timing needed // for Phase A). batch_id -> [{t: ms, done: int}], capped to the recent window. const _adlRateSamples = {}; const _ADL_RATE_WINDOW = 8; function _adlSampleRate(batchId, done) { const arr = _adlRateSamples[batchId] || (_adlRateSamples[batchId] = []); const now = Date.now(); const last = arr[arr.length - 1]; if (!last || last.done !== done) arr.push({ t: now, done }); while (arr.length > _ADL_RATE_WINDOW) arr.shift(); // tracks/sec over the sampled window if (arr.length < 2) return 0; const first = arr[0]; const dt = (arr[arr.length - 1].t - first.t) / 1000; const dd = arr[arr.length - 1].done - first.done; return dt > 0 && dd > 0 ? dd / dt : 0; } function _adlFmtDuration(sec) { if (!sec || sec < 0 || !isFinite(sec)) return ''; if (sec < 60) return `${Math.round(sec)}s`; if (sec < 3600) return `${Math.round(sec / 60)}m`; return `${Math.floor(sec / 3600)}h ${Math.round((sec % 3600) / 60)}m`; } // ETA string for a batch's stat line. Album bundles use the downloader's own // speed/size; track batches use the client-side completion rate. function _adlBatchEta(batch) { if (batch.phase === 'album_downloading') { const ab = batch.album_bundle || {}; const bits = []; if (ab.speed) bits.push(ab.speed); if (ab.downloaded && ab.size) bits.push(`${ab.downloaded} / ${ab.size}`); return bits.join(' · '); } if (batch.phase !== 'downloading') return ''; const total = batch.total || 0; const done = (batch.completed || 0) + (batch.failed || 0); const remaining = total - done; if (remaining <= 0) return ''; const rate = _adlSampleRate(batch.batch_id, done); // tracks/sec if (rate <= 0) return ''; return `~${_adlFmtDuration(remaining / rate)} left`; } // Glanceable aggregate strip atop the panel: batches · downloading · queued · // speed · ETA. Hidden when nothing is active. function _adlRenderBatchSummary(activeBatches) { const el = document.getElementById('adl-batch-summary'); if (!el) return; if (!activeBatches.length) { el.style.display = 'none'; return; } let downloading = 0, queued = 0, remaining = 0, rate = 0, bundleSpeed = ''; for (const b of activeBatches) { downloading += (b.active || 0); queued += (b.queued || 0); if (b.phase === 'downloading') { const done = (b.completed || 0) + (b.failed || 0); remaining += Math.max(0, (b.total || 0) - done); rate += _adlSampleRate(b.batch_id, done); } if (b.phase === 'album_downloading' && b.album_bundle && b.album_bundle.speed && !bundleSpeed) { bundleSpeed = b.album_bundle.speed; } } const parts = [`${activeBatches.length} batch${activeBatches.length === 1 ? '' : 'es'}`]; if (downloading) parts.push(`${downloading} downloading`); if (queued) parts.push(`${queued} queued`); if (bundleSpeed) parts.push(_adlEsc(bundleSpeed)); const etaStr = (rate > 0 && remaining > 0) ? `~${_adlFmtDuration(remaining / rate)} left` : ''; el.style.display = ''; el.innerHTML = `${parts.join(' · ')}` + (etaStr ? `${etaStr}` : ''); } function loadActiveDownloadsPage() { _verifLoadConfig(); _adlFetch(); _adlFetchBatchHistory(); // Poll downloads every 2 seconds, history every 60 seconds if (_adlPoller) clearInterval(_adlPoller); _adlPoller = setInterval(() => { if (currentPage === 'active-downloads') _adlFetch(); else { clearInterval(_adlPoller); _adlPoller = null; } }, 2000); if (_adlBatchHistoryPoller) clearInterval(_adlBatchHistoryPoller); _adlBatchHistoryPoller = setInterval(() => { if (currentPage === 'active-downloads') _adlFetchBatchHistory(); else { clearInterval(_adlBatchHistoryPoller); _adlBatchHistoryPoller = null; } }, 60000); } function adlSetFilter(filter) { _adlFilter = filter; document.querySelectorAll('#adl-filter-pills .adl-pill').forEach(p => p.classList.toggle('active', p.dataset.filter === filter)); _adlRender(); } async function _adlFetch() { try { const resp = await fetch('/api/downloads/all?limit=300'); const data = await resp.json(); if (data.success) { _adlData = data.downloads || []; _adlBatches = data.batches || []; _adlRender(); _adlRenderBatchPanel(); // Don't call _adlUpdateBadge() here — it counts the truncated // 300-item local array. The WebSocket status push already // maintains the badge with the real server-side active count. } } catch (e) { console.error('Downloads page fetch error:', e); } // Refresh the quarantine panel every ~15 s (every 7 polls × 2 s) so new // quarantine entries created during a batch appear without a manual click. _adlFetchCount++; if (_adlFetchCount % 7 === 0) _verifLoadQuarantine(true); } function _adlUpdateBadge() { const activeCount = _adlData.filter(d => ['downloading', 'searching', 'queued', 'pending', 'post_processing'].includes(d.status)).length; _updateDlNavBadge(activeCount); } function _updateDlNavBadge(count) { const badge = document.getElementById('dl-nav-badge'); if (badge) { if (count > 0) { badge.textContent = count; badge.classList.remove('hidden'); } else { badge.classList.add('hidden'); } } const dlBtn = document.querySelector('.nav-button[data-page="active-downloads"]'); if (dlBtn) { dlBtn.classList.toggle('nav-downloads-active', count > 0); } } function _adlQualityBadge(dl) { // Show the real audio quality of a completed download (probed from the // file itself — FLAC bit depth, MP3 bitrate, …), so you can see at a // glance what was actually fetched. if (dl.status !== 'completed' || !dl.quality) return ''; return ` ${_adlEsc(dl.quality)}`; } function _adlVerifBadge(dl) { // Verification badge for completed downloads — how this file passed // verification (status comes from library_history / the live task): // verified = clean AcoustID pass; unverified = imported but not // hard-confirmed (cross-script/ambiguous/no fingerprint match); // force_imported = accepted as best candidate after the retry budget was // exhausted (version-mismatch fallback). if (dl.status !== 'completed') return ''; if (dl.verification_status === 'force_imported') { return ' '; } if (dl.verification_status === 'unverified') { return ' '; } if (dl.verification_status === 'verified') { return ' '; } if (dl.verification_status === 'human_verified') { return ' 🛡✔'; } return ''; } // ---- Verification review queue (the ⚠ Unverified/Quarantine filter) ---- function verifHistoryId(dl) { // Persistent history rows carry task_id 'history-'. if (dl.is_persistent_history && dl.task_id) { const m = String(dl.task_id).match(/^history-(\d+)$/); if (m) return m[1]; } // Still-live completed tasks carry the library_history id directly, so the // review actions (play/audit/approve/delete) work before the task becomes // a persistent-history row — otherwise the buttons "didn't always load". if (dl.history_id) return String(dl.history_id); return null; } function _verifTimeAgo(iso) { return (typeof formatHistoryTime === 'function' && iso) ? formatHistoryTime(iso) : ''; } function _verifReasonBadge(dl) { // Glanceable badge in the style of the library-history quarantine tab. if (dl.verification_status === 'force_imported') { return 'FORCE-IMPORTED'; } if (dl.verification_status === 'unverified') { return 'ACOUSTID UNCONFIRMED'; } return ''; } function _adlReviewActions(dl) { if (_adlFilter !== 'unverified') return ''; const hid = verifHistoryId(dl); if (!hid) return ''; const timeAgo = _verifTimeAgo(dl.created_at); return `
${_verifReasonBadge(dl)} ${timeAgo ? `${timeAgo}` : ''}
`; } async function verifPlay(hid) { // Plays the LOCAL file through the global media player (same machinery as // library playback) — full player UI with seek/stop instead of an // invisible Audio element that re-renders wiped. const dl = _adlData.find(d => verifHistoryId(d) === String(hid)); try { if (typeof setTrackInfo === 'function') { setTrackInfo({ title: (dl && dl.title) || 'Review track', artist: (dl && dl.artist) || '', album: (dl && dl.album) || '', is_library: true, image_url: (dl && dl.artwork) || null, }); } if (typeof showLoadingAnimation === 'function') showLoadingAnimation(); const r = await fetch(`/api/verification/${hid}/play`, { method: 'POST' }); const d = await r.json(); if (!d.success) throw new Error(d.error || 'Playback failed'); await startAudioPlayback(); } catch (e) { if (typeof hideLoadingAnimation === 'function') hideLoadingAnimation(); showToast && showToast('Playback failed: ' + e.message, 'error'); } } async function verifCompare(hid, btn) { // Same pipeline as the /search page play button — run server-side so the // local file's duration guides candidate ranking (avoids e.g. 10-hour // YouTube loops winning the match and timing out). if (btn) { btn.disabled = true; btn.textContent = '…'; } showToast && showToast('Searching stream for comparison…', 'info'); try { const res = await fetch(`/api/verification/${hid}/compare-stream`, { method: 'POST' }); const data = await res.json(); if (data.success && data.result && typeof startStream === 'function') { await startStream(data.result); } else { showToast && showToast(data.error || 'No stream candidate found for comparison', 'error'); } } catch (e) { showToast && showToast('Stream failed: ' + e.message, 'error'); } if (btn) { btn.disabled = false; btn.textContent = '⇆'; } } async function verifAudit(hid) { try { const r = await fetch(`/api/verification/${hid}/entry`); const d = await r.json(); if (d.success && d.entry && typeof openDownloadAuditModal === 'function') { openDownloadAuditModal(d.entry); } else { showToast && showToast(d.error || 'Audit data not available', 'error'); } } catch (e) { showToast && showToast('Audit load failed', 'error'); } } async function verifApprove(hid, btn) { if (btn) btn.disabled = true; try { const r = await fetch(`/api/verification/${hid}/approve`, { method: 'POST' }); const d = await r.json(); if (d.success) { showToast && showToast('Marked as human-verified 🛡✔', 'success'); _adlFetch(); } else showToast && showToast(d.error || 'Approve failed', 'error'); } catch (e) { showToast && showToast('Approve failed', 'error'); } if (btn) btn.disabled = false; } async function verifDelete(hid, btn) { if (!await showConfirmDialog({ title: 'Delete Unverified File', message: 'This permanently deletes the downloaded file from disk and removes the review entry. Cannot be undone.', confirmText: 'Delete', cancelText: 'Cancel', destructive: true, })) return; if (btn) btn.disabled = true; try { const r = await fetch(`/api/verification/${hid}/delete`, { method: 'POST' }); const d = await r.json(); if (d.success) { showToast && showToast('File deleted', 'success'); _adlFetch(); } else showToast && showToast(d.error || 'Delete failed', 'error'); } catch (e) { showToast && showToast('Delete failed', 'error'); } if (btn) btn.disabled = false; } // ---- Unverified review rows (Quarantine-style cards) ---- // The Unverified sub-view used to piggyback on the generic download row and // open a modal on click. These give it the same card design the Quarantine // sub-view got — row click expands an inline detail panel in place — minus the // grouping (each unverified import is its own track, nothing to group). let _verifUnvData = []; const _verifUnvOpenDetails = new Set(); function _verifUnvKey(dl) { return verifHistoryId(dl) || dl.task_id || ''; } function _verifUnvDetailRows(dl) { const reason = dl.verification_status === 'force_imported' ? 'Accepted as the best available candidate after the retry budget was exhausted (version-mismatch fallback). A library AcoustID scan reports these as informational.' : 'AcoustID could not hard-confirm this file (ambiguous / cross-script metadata / no fingerprint match). Imported, but not verified.'; const source = dl.batch_source || dl.batch_name || ''; return [ `
Why flagged: ${_adlEsc(reason)}
`, source ? `
Download source: ${_adlEsc(source)}
` : '', dl.quality ? `
Quality: ${_adlEsc(dl.quality)}
` : '', dl.file_path ? `
File: ${_adlEsc(dl.file_path)}
` : '', dl.created_at ? `
Downloaded: ${_adlEsc(dl.created_at)}
` : '', ].filter(Boolean).join(''); } function _verifUnverifiedRowHtml(dl, idx) { const hid = verifHistoryId(dl); const title = _adlEsc(dl.title || 'Unknown Track'); const meta = [_adlEsc(dl.artist || ''), _adlEsc(dl.album || '')].filter(Boolean).join(' · '); const source = _adlEsc(dl.batch_source || dl.batch_name || ''); const timeAgo = _verifTimeAgo(dl.created_at); const artHtml = dl.artwork ? `` : '
'; const detailsOpen = _verifUnvOpenDetails.has(_verifUnvKey(dl)); const actions = hid ? ` ` : ''; return `
${artHtml}
${title}
${meta ? `
${meta}
` : ''} ${source ? `
${source}
` : ''}
${_verifUnvDetailRows(dl)}
${_verifReasonBadge(dl)} ${dl.quality ? `${_adlEsc(dl.quality)}` : ''} ${timeAgo ? `${timeAgo}` : ''} ${actions}
`; } function _verifUnverifiedRows(items) { _verifUnvData = items; if (!items.length) return ''; return items.map((dl, idx) => _verifUnverifiedRowHtml(dl, idx)).join(''); } function verifUnvInspect(idx) { // Open-state lives in a Set keyed by the row's stable id (not the DOM) so it // survives the 2 s polling re-render — same pattern as verifQuarInspect. const dl = _verifUnvData[idx]; if (!dl) return; const key = _verifUnvKey(dl); if (_verifUnvOpenDetails.has(key)) _verifUnvOpenDetails.delete(key); else _verifUnvOpenDetails.add(key); const el = document.getElementById(`verif-unv-details-${idx}`); if (el) el.style.display = _verifUnvOpenDetails.has(key) ? '' : 'none'; } // ---- Quarantine sub-view inside the ⚠ filter ---- // The review queue covers BOTH kinds of suspect files: imported-but- // unconfirmed (unverified/force_imported) and not-imported-at-all // (quarantined). One place to listen, compare, approve or delete. let _verifSubView = 'unverified'; let _verifQuarEntries = []; let _verifQuarLoaded = false; let _verifQuarLoading = false; // Expanded 🔍 detail panels, keyed by quarantine entry id — survives the // polling re-render (which rebuilds the rows every few seconds). const _verifQuarOpenDetails = new Set(); const _verifQuarOpenGroups = new Set(); // group keys whose alt-members are expanded // null = not fetched yet (assume enabled). Without an AcoustID API key // nothing ever gets a verification status, so the review queue collapses // to quarantine-only. let _verifAcoustidEnabled = null; let _verifConfigLoading = false; async function _verifLoadConfig() { if (_verifAcoustidEnabled !== null || _verifConfigLoading) return; _verifConfigLoading = true; try { const r = await fetch('/api/verification/config'); const d = await r.json(); _verifAcoustidEnabled = !!(d && d.acoustid_enabled); // When require_verified is on, nothing ever lands in the library as // "unverified" — unconfirmed tracks go straight to quarantine instead. // Collapse the sub-view to quarantine-only just like the no-AcoustID case. if (_verifAcoustidEnabled && d && d.require_verified) _verifAcoustidEnabled = false; } catch (e) { _verifAcoustidEnabled = true; } _verifConfigLoading = false; if (_verifAcoustidEnabled === false) { _verifSubView = 'quarantine'; const pill = document.querySelector('.adl-pill[data-filter="unverified"]'); if (pill) { pill.textContent = '🛡 Quarantine'; pill.title = 'Files that failed import checks and were NOT imported. (AcoustID is not configured or require-verified is on, so there is no unverified review queue.)'; } if (_adlFilter === 'unverified') { _verifLoadQuarantine(true); _adlRender(); } } } function verifSetSubView(v) { if (_verifAcoustidEnabled === false) v = 'quarantine'; _verifSubView = v === 'quarantine' ? 'quarantine' : 'unverified'; if (_verifSubView === 'quarantine') _verifLoadQuarantine(true); _adlRender(); } async function _verifLoadQuarantine(force) { if (_verifQuarLoading || (_verifQuarLoaded && !force)) return; _verifQuarLoading = true; try { const r = await fetch('/api/quarantine/list'); const d = await r.json(); _verifQuarEntries = (d.success && Array.isArray(d.entries)) ? d.entries : []; } catch (e) { _verifQuarEntries = []; } _verifQuarLoaded = true; _verifQuarLoading = false; if (_adlFilter === 'unverified') _adlRender(); } const _VERIF_QUAR_TRIGGERS = { integrity: ['DURATION / INTEGRITY', 'verif-rb-int'], acoustid: ['ACOUSTID MISMATCH', 'verif-rb-force'], acoustid_unverified: ['ACOUSTID UNVERIFIED', 'verif-rb-unv'], bit_depth: ['BIT DEPTH FILTER', 'verif-rb-int'], }; // Streaming sources carry their service name in source_username; a Soulseek // download carries the uploader's peer name instead — collapse that to // 'soulseek' so the label matches the Completed view's download-source line. const _VERIF_QUAR_STREAMING_SOURCES = ['youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud', 'amazon', 'torrent', 'usenet']; function _verifQuarSourceLabel(q) { const u = String(q.source_username || '').toLowerCase(); if (_VERIF_QUAR_STREAMING_SOURCES.includes(u)) return _adlSourceLabel(u); return q.source_username ? _adlSourceLabel('soulseek') : ''; } function _verifQuarRowHtml(q, idx, extraAction = '') { const title = _adlEsc(q.expected_track || q.original_filename || q.filename || 'Unknown file'); const meta = [_adlEsc(q.expected_artist || ''), _adlEsc(q.original_filename || '')].filter(Boolean).join(' — '); const sourceLabel = _verifQuarSourceLabel(q); const [trigLabel, trigClass] = _VERIF_QUAR_TRIGGERS[q.trigger] || ['QUARANTINED', 'verif-rb-unv']; const timeAgo = _verifTimeAgo(q.timestamp); const approveBtn = q.has_full_context ? `` : ``; const details = [ q.reason ? `
Reason: ${_adlEsc(q.reason)}
` : '', q.source_username ? `
Source uploader: ${_adlEsc(q.source_username)}
` : '', q.source_filename ? `
Original Soulseek file: ${_adlEsc(q.source_filename)}
` : '', q.timestamp ? `
Quarantined: ${_adlEsc(q.timestamp)}
` : '', ].filter(Boolean).join(''); const detailsOpen = _verifQuarOpenDetails.has(q.id); const artHtml = q.thumb_url ? `` : '
'; return `
${artHtml}
${title}
${meta ? `
${meta}
` : ''} ${sourceLabel ? `
${sourceLabel}
` : ''}
${details || 'No further details in the sidecar.'}
${trigLabel} ${q.quality ? `${_adlEsc(q.quality)}` : ''} ${timeAgo ? `${timeAgo}` : ''} ${approveBtn}
${extraAction}
`; } function _verifQuarToggleGroup(btn) { const key = btn.dataset.groupKey; const open = !_verifQuarOpenGroups.has(key); if (open) _verifQuarOpenGroups.add(key); else _verifQuarOpenGroups.delete(key); const wrapper = btn.closest('.verif-quar-alt-wrapper'); if (wrapper) wrapper.querySelector('.verif-quar-alt-members')?.classList.toggle('vqg-open', open); btn.classList.toggle('open', open); btn.textContent = open ? `▴ ${btn.dataset.altCount} more` : `▾ ${btn.dataset.altCount} more`; } function _verifQuarRows() { if (!_verifQuarLoaded) return '
Loading quarantine…
'; if (!_verifQuarEntries.length) return ''; const idxById = new Map(_verifQuarEntries.map((q, i) => [q.id, i])); const groups = (typeof _groupQuarantineEntries === 'function') ? _groupQuarantineEntries(_verifQuarEntries) : _verifQuarEntries.map(q => ({ key: null, members: [q] })); let html = ''; for (const group of groups) { if (group.members.length === 1) { html += _verifQuarRowHtml(group.members[0], idxById.get(group.members[0].id)); } else { // First member shown as normal row; rest hidden under a toggle button. const first = group.members[0]; const firstIdx = idxById.get(first.id); const altCount = group.members.length - 1; const groupKey = group.key || first.id; const isOpen = _verifQuarOpenGroups.has(groupKey); const altBtn = ``; html += `
`; html += _verifQuarRowHtml(first, firstIdx, altBtn); html += `
`; for (let i = 1; i < group.members.length; i++) { html += _verifQuarRowHtml(group.members[i], idxById.get(group.members[i].id)); } html += `
`; } } return html; } function verifQuarInspect(idx) { // Open-state lives in a Set keyed by entry id (not the DOM) — the polling // re-render rebuilds the rows every few seconds and would collapse a // DOM-only toggle right after the click. const q = _verifQuarEntries[idx]; if (!q) return; if (_verifQuarOpenDetails.has(q.id)) _verifQuarOpenDetails.delete(q.id); else _verifQuarOpenDetails.add(q.id); const el = document.getElementById(`verif-quar-details-${idx}`); if (el) el.style.display = _verifQuarOpenDetails.has(q.id) ? '' : 'none'; } async function verifQuarAudit(idx) { // Same Audit Trail modal as the unverified rows — the backend synthesizes // a history-shaped entry from the quarantine sidecar (these files were // never imported, so no real history row exists). const q = _verifQuarEntries[idx]; if (!q) return; try { const r = await fetch(`/api/quarantine/${encodeURIComponent(q.id)}/entry`); const d = await r.json(); if (d.success && d.entry && typeof openDownloadAuditModal === 'function') { openDownloadAuditModal(d.entry); } else { showToast && showToast(d.error || 'Audit data not available', 'error'); } } catch (e) { showToast && showToast('Audit load failed', 'error'); } } async function verifQuarPlay(idx) { const q = _verifQuarEntries[idx]; if (!q) return; try { if (typeof setTrackInfo === 'function') { setTrackInfo({ title: `${q.expected_track || q.original_filename || 'Quarantined file'} (quarantined)`, artist: q.expected_artist || '', album: '', is_library: true, }); } if (typeof showLoadingAnimation === 'function') showLoadingAnimation(); const r = await fetch(`/api/quarantine/${encodeURIComponent(q.id)}/play`, { method: 'POST' }); const d = await r.json(); if (!d.success) throw new Error(d.error || 'Playback failed'); await startAudioPlayback(); } catch (e) { if (typeof hideLoadingAnimation === 'function') hideLoadingAnimation(); showToast && showToast('Playback failed: ' + e.message, 'error'); } } async function verifQuarCompare(idx, btn) { const q = _verifQuarEntries[idx]; if (!q) return; if (btn) { btn.disabled = true; btn.textContent = '…'; } showToast && showToast(`Searching stream for "${q.expected_track || ''}"…`, 'info'); try { const res = await fetch(`/api/quarantine/${encodeURIComponent(q.id)}/compare-stream`, { method: 'POST' }); const data = await res.json(); if (data.success && data.result && typeof startStream === 'function') { await startStream(data.result); } else { showToast && showToast(data.error || 'No stream candidate found for comparison', 'error'); } } catch (e) { showToast && showToast('Stream failed: ' + e.message, 'error'); } if (btn) { btn.disabled = false; btn.textContent = '⇆'; } } async function verifQuarApprove(idx, btn) { const q = _verifQuarEntries[idx]; if (!q) return; if (btn) btn.disabled = true; try { const r = await fetch(`/api/quarantine/${encodeURIComponent(q.id)}/approve`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ remove_siblings: true }), }); const d = await r.json(); if (d.success) { const extra = d.removed_siblings && d.removed_siblings.length ? ` (${d.removed_siblings.length} duplicate candidate${d.removed_siblings.length > 1 ? 's' : ''} removed)` : ''; showToast && showToast(`Approved — re-importing, will be marked human-verified 🛡✔${extra}`, 'success'); _verifLoadQuarantine(true); } else { showToast && showToast(d.error || 'Approve failed', 'error'); } } catch (e) { showToast && showToast('Approve failed', 'error'); } if (btn) btn.disabled = false; } async function verifQuarRecover(idx, btn) { const q = _verifQuarEntries[idx]; if (!q) return; if (btn) btn.disabled = true; try { const r = await fetch(`/api/quarantine/${encodeURIComponent(q.id)}/recover`, { method: 'POST' }); const d = await r.json(); if (d.success) { showToast && showToast('Moved to Staging — finish via the Import page', 'success'); _verifLoadQuarantine(true); } else { showToast && showToast(d.error || 'Recover failed', 'error'); } } catch (e) { showToast && showToast('Recover failed', 'error'); } if (btn) btn.disabled = false; } async function verifQuarDelete(idx, btn) { const q = _verifQuarEntries[idx]; if (!q) return; if (!await showConfirmDialog({ title: 'Delete Quarantined File', message: 'This permanently removes the file and its metadata sidecar. Cannot be undone.', confirmText: 'Delete', cancelText: 'Cancel', destructive: true, })) return; if (btn) btn.disabled = true; try { const r = await fetch(`/api/quarantine/${encodeURIComponent(q.id)}`, { method: 'DELETE' }); const d = await r.json(); if (d.success) { showToast && showToast('Quarantined file deleted', 'success'); _verifLoadQuarantine(true); } else showToast && showToast(d.error || 'Delete failed', 'error'); } catch (e) { showToast && showToast('Delete failed', 'error'); } if (btn) btn.disabled = false; } function _verifUnverifiedIds() { const done = ['completed', 'skipped', 'already_owned']; return _adlData .filter(d => done.includes(d.status) && (d.verification_status === 'unverified' || d.verification_status === 'force_imported')) .map(d => verifHistoryId(d)) .filter(Boolean); } async function verifApproveAll(btn) { const ids = _verifUnverifiedIds(); if (!ids.length) return; if (!await showConfirmDialog({ title: 'Approve Unverified Files', message: `Mark all ${ids.length} unverified entries as human-verified? The AcoustID scanner will skip them from now on.`, confirmText: 'Approve All', cancelText: 'Cancel', })) return; if (btn) btn.disabled = true; let ok = 0; for (const id of ids) { try { const r = await fetch(`/api/verification/${id}/approve`, { method: 'POST' }); const d = await r.json(); if (d.success) ok++; } catch (e) {} } showToast && showToast(`Approved ${ok}/${ids.length} entries 🛡✔`, ok === ids.length ? 'success' : 'error'); if (btn) btn.disabled = false; _adlFetch(); } async function verifDeleteAll(btn) { const ids = _verifUnverifiedIds(); if (!ids.length) return; if (!await showConfirmDialog({ title: 'Delete Unverified Files', message: `This permanently deletes all ${ids.length} unverified files from disk and removes their review entries. Cannot be undone.`, confirmText: 'Delete All', cancelText: 'Cancel', destructive: true, })) return; if (btn) btn.disabled = true; let ok = 0; for (const id of ids) { try { const r = await fetch(`/api/verification/${id}/delete`, { method: 'POST' }); const d = await r.json(); if (d.success) ok++; } catch (e) {} } showToast && showToast(`Deleted ${ok}/${ids.length} files`, ok === ids.length ? 'success' : 'error'); if (btn) btn.disabled = false; _adlFetch(); } async function verifCleanOrphans(btn) { // Removes review entries whose file is GONE (deleted / replaced / re-downloaded // elsewhere) — dead log rows that can never be healed. Server-side it does a // filesystem check and refuses if the whole library looks offline. Removes log // rows only, never a file. if (!await showConfirmDialog({ title: 'Clean orphaned entries', message: 'Remove review entries whose file no longer exists on disk (deleted, replaced, or re-downloaded elsewhere)? This removes only the stale log rows — it never deletes a file. It checks the filesystem first and refuses if your library looks offline.', confirmText: 'Clean up', cancelText: 'Cancel', })) return; if (btn) btn.disabled = true; try { const r = await fetch('/api/verification/clean-orphans', { method: 'POST' }); const d = await r.json(); if (d.success) { showToast && showToast(`Removed ${d.removed} orphaned entr${d.removed === 1 ? 'y' : 'ies'} (checked ${d.checked})`, 'success'); _adlFetch(); } else { showToast && showToast(d.error || 'Clean-up failed', 'error'); } } catch (e) { showToast && showToast('Clean-up failed', 'error'); } if (btn) btn.disabled = false; } async function verifQuarApproveAll(btn) { const entries = _verifQuarEntries.filter(q => q.has_full_context); if (!entries.length) { showToast && showToast('No one-click-approvable entries (legacy sidecars need Recover)', 'info'); return; } if (!await showConfirmDialog({ title: 'Approve Quarantined Files', message: `Approve and re-import all ${entries.length} quarantined files? They will be imported into the library marked human-verified.`, confirmText: 'Approve & Import All', cancelText: 'Cancel', })) return; if (btn) btn.disabled = true; let ok = 0; for (const q of entries) { try { const r = await fetch(`/api/quarantine/${encodeURIComponent(q.id)}/approve`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ remove_siblings: true }), }); const d = await r.json(); if (d.success) ok++; } catch (e) {} // Each approve spawns a server-side re-import thread — stagger them. await new Promise(res => setTimeout(res, 500)); } showToast && showToast(`Approved ${ok}/${entries.length} — re-importing as human-verified`, ok === entries.length ? 'success' : 'error'); if (btn) btn.disabled = false; _verifLoadQuarantine(true); } async function verifQuarClearAll(btn) { if (!_verifQuarEntries.length) return; if (!await showConfirmDialog({ title: 'Clear Quarantine', message: `This permanently deletes all ${_verifQuarEntries.length} quarantined files and their metadata sidecars. Cannot be undone.`, confirmText: 'Delete All', cancelText: 'Cancel', destructive: true, })) return; if (btn) btn.disabled = true; try { const r = await fetch('/api/quarantine/clear', { method: 'POST' }); const d = await r.json(); if (d.success) showToast && showToast('Quarantine cleared', 'success'); else showToast && showToast(d.error || 'Clear failed', 'error'); } catch (e) { showToast && showToast('Clear failed', 'error'); } if (btn) btn.disabled = false; _verifLoadQuarantine(true); } window.verifPlay = verifPlay; window.verifApprove = verifApprove; window.verifDelete = verifDelete; window.verifCompare = verifCompare; window.verifAudit = verifAudit; window.verifSetSubView = verifSetSubView; window.verifApproveAll = verifApproveAll; window.verifDeleteAll = verifDeleteAll; window.verifQuarPlay = verifQuarPlay; window.verifQuarCompare = verifQuarCompare; window.verifQuarInspect = verifQuarInspect; window.verifQuarAudit = verifQuarAudit; window.verifQuarApprove = verifQuarApprove; window.verifQuarRecover = verifQuarRecover; window.verifQuarDelete = verifQuarDelete; window.verifQuarApproveAll = verifQuarApproveAll; window.verifQuarClearAll = verifQuarClearAll; function _adlRender() { const list = document.getElementById('adl-list'); const empty = document.getElementById('adl-empty'); const countEl = document.getElementById('adl-count'); if (!list) return; // Apply filter const activeStatuses = ['downloading', 'searching', 'post_processing']; const queuedStatuses = ['queued']; const completedStatuses = ['completed', 'skipped', 'already_owned']; const failedStatuses = ['failed', 'not_found', 'cancelled']; let filtered = _adlData; // Batch filter: if a batch card is selected, narrow to that batch first if (_adlFilterBatchId) { filtered = filtered.filter(d => d.batch_id === _adlFilterBatchId); } if (_adlFilter === 'active') filtered = filtered.filter(d => activeStatuses.includes(d.status)); else if (_adlFilter === 'queued') filtered = filtered.filter(d => queuedStatuses.includes(d.status)); else if (_adlFilter === 'completed') filtered = filtered.filter(d => completedStatuses.includes(d.status)); else if (_adlFilter === 'unverified') filtered = filtered.filter(d => completedStatuses.includes(d.status) && (d.verification_status === 'unverified' || d.verification_status === 'force_imported')); // (review banner injected below when this filter is active) else if (_adlFilter === 'failed') filtered = filtered.filter(d => failedStatuses.includes(d.status)); // Clear-completed clears live completed tasks AND the persisted download-history // tail (including unverified review-queue rows), so count every completed/failed // row — otherwise the button vanishes after a restart when the list is all // persisted completed rows. const completedN = _adlData.filter(d => [...completedStatuses, ...failedStatuses].includes(d.status) ).length; if (countEl) { const activeN = _adlData.filter(d => activeStatuses.includes(d.status)).length; const queuedN = _adlData.filter(d => queuedStatuses.includes(d.status)).length; const total = _adlData.length; const parts = []; if (activeN > 0) parts.push(`${activeN} active`); if (queuedN > 0) parts.push(`${queuedN} queued`); parts.push(`${total} total`); countEl.textContent = parts.join(' / '); } // Show/hide clear button const clearBtn = document.getElementById('adl-clear-btn'); if (clearBtn) clearBtn.style.display = completedN > 0 ? '' : 'none'; // Show/hide cancel-all button — only visible when there's something to cancel const cancelAllBtn = document.getElementById('adl-cancel-all-btn'); if (cancelAllBtn) { const hasRunningWork = _adlData.some(d => [...activeStatuses, ...queuedStatuses].includes(d.status) ); cancelAllBtn.style.display = hasRunningWork ? '' : 'none'; } // Batch filter indicator banner let existingBanner = document.getElementById('adl-batch-filter-banner'); if (_adlFilterBatchId) { const batchInfo = _adlBatches.find(b => b.batch_id === _adlFilterBatchId); const batchName = batchInfo ? batchInfo.batch_name : 'Unknown batch'; const colorIdx = _getBatchColor(_adlFilterBatchId); const colorDot = colorIdx >= 0 ? `` : ''; if (!existingBanner) { existingBanner = document.createElement('div'); existingBanner.id = 'adl-batch-filter-banner'; existingBanner.className = 'adl-batch-filter-banner'; list.parentNode.insertBefore(existingBanner, list); } existingBanner.innerHTML = `${colorDot}Showing: ${_adlEsc(batchName)} `; existingBanner.style.display = ''; } else if (existingBanner) { existingBanner.style.display = 'none'; } // Review queue sub-view toggle: unverified imports ⇄ quarantined files. let verifBanner = document.getElementById('verif-subview-banner'); if (_adlFilter === 'unverified') { _verifLoadConfig(); // no-op once fetched if (!verifBanner) { verifBanner = document.createElement('div'); verifBanner.id = 'verif-subview-banner'; verifBanner.className = 'adl-batch-filter-banner'; list.parentNode.insertBefore(verifBanner, list); } const quarCount = _verifQuarLoaded ? ` (${_verifQuarEntries.length})` : ''; const bulkBtns = _verifSubView === 'quarantine' ? ` ` : ` `; // Without an AcoustID key nothing ever gets a verification status — // hide the pointless Unverified pill and show quarantine only. const unvPill = _verifAcoustidEnabled === false ? '' : ``; verifBanner.innerHTML = ` ${unvPill} ${bulkBtns}`; verifBanner.style.display = ''; if (!_verifQuarLoaded) _verifLoadQuarantine(false); // count for the pill } else if (verifBanner) { verifBanner.style.display = 'none'; } if (_adlFilter === 'unverified' && _verifSubView === 'quarantine') { const qhtml = _verifQuarRows(); const qEmptyEl = document.getElementById('adl-empty'); const qEmptyHtml = qEmptyEl ? qEmptyEl.outerHTML : ''; list.innerHTML = qEmptyHtml + qhtml; const qNewEmpty = document.getElementById('adl-empty'); if (qNewEmpty) qNewEmpty.style.display = qhtml ? 'none' : ''; return; } // Unverified review sub-view: render the Quarantine-style cards (inline // expandable details), not the generic download rows. if (_adlFilter === 'unverified' && _verifSubView === 'unverified') { const uhtml = _verifUnverifiedRows(filtered); const uEmptyEl = document.getElementById('adl-empty'); const uEmptyHtml = uEmptyEl ? uEmptyEl.outerHTML : ''; list.innerHTML = uEmptyHtml + uhtml; const uNewEmpty = document.getElementById('adl-empty'); if (uNewEmpty) uNewEmpty.style.display = uhtml ? 'none' : ''; return; } if (filtered.length === 0) { if (empty) empty.style.display = ''; // Clear any existing rows but keep the empty message list.querySelectorAll('.adl-row').forEach(r => r.remove()); return; } if (empty) empty.style.display = 'none'; // Group by status category for section headers const groups = { active: [], queued: [], completed: [], failed: [] }; for (const dl of filtered) { const cls = _adlStatusClass(dl.status); if (cls === 'active') groups.active.push(dl); else if (cls === 'queued') groups.queued.push(dl); else if (cls === 'completed') groups.completed.push(dl); else groups.failed.push(dl); } let html = ''; const sections = [ { key: 'active', label: 'Active', items: groups.active }, { key: 'queued', label: 'Queued', items: groups.queued }, { key: 'completed', label: 'Completed', items: groups.completed }, { key: 'failed', label: 'Failed', items: groups.failed }, ]; for (const section of sections) { if (section.items.length === 0) continue; // Only show section headers in "all" filter mode if (_adlFilter === 'all') { html += `
${section.label} (${section.items.length})
`; } for (const dl of section.items) { const statusClass = _adlStatusClass(dl.status); const statusLabel = _adlStatusLabel(dl.status); // (verification badge appended next to the label via _adlVerifBadge) const title = _adlEsc(dl.title || 'Unknown Track'); const artist = _adlEsc(dl.artist || ''); const album = _adlEsc(dl.album || ''); const batchName = _adlEsc(dl.batch_name || ''); const error = dl.error ? _adlEsc(dl.error) : ''; const meta = [artist, album].filter(Boolean).join(' \u00B7 '); const artHtml = dl.artwork ? `` : '
'; // Track position: "3 of 19" const posText = dl.batch_total > 1 ? `${(dl.track_index || 0) + 1} of ${dl.batch_total}` : ''; const colorIdx = _getBatchColor(dl.batch_id); const colorBar = colorIdx >= 0 ? `
` : ''; // Per-row cancel only makes sense for in-flight tasks. Terminal // states (completed/failed/cancelled) have nothing to cancel. const isCancellable = statusClass === 'active' || statusClass === 'queued'; const cancelBtnHtml = isCancellable && dl.playlist_id && dl.track_index !== undefined ? `` : ''; // In the Unverified review sub-view, make the whole row clickable to // open the audit/info modal (same as the 🔍 button) — mirrors the // Quarantine rows, which are row-clickable for their details. The // action buttons stopPropagation so they don't double-trigger. const _unvHid = _adlFilter === 'unverified' ? verifHistoryId(dl) : null; const reviewRowClick = _unvHid ? ` onclick="verifAudit('${_unvHid}')" style="cursor:pointer" title="Click to show download details (audit trail, embedded tags, lyrics)"` : ''; html += `
${colorBar} ${artHtml}
${title}
${meta ? `
${meta}
` : ''} ${batchName ? `
${batchName}${posText ? ' · Track ' + posText : ''}
` : ''} ${error ? `
${error}
` : ''}
${statusLabel}${_adlVerifBadge(dl)}${_adlQualityBadge(dl)}${dl.retry_info && (statusClass === 'active' || statusClass === 'queued') ? ` 🔁 ${_adlEsc(String(dl.retry_info))}${['acoustid','acoustid_unverified'].includes(dl.retry_trigger) ? ' 🛡' : ''}` : ''}
${_adlReviewActions(dl)} ${cancelBtnHtml}
`; } } // Preserve empty element, inject rows const emptyEl = document.getElementById('adl-empty'); const emptyHtml = emptyEl ? emptyEl.outerHTML : ''; list.innerHTML = emptyHtml + html; const newEmpty = document.getElementById('adl-empty'); if (newEmpty) newEmpty.style.display = filtered.length > 0 ? 'none' : ''; } function _adlStatusClass(status) { switch (status) { case 'downloading': case 'searching': case 'post_processing': return 'active'; case 'queued': case 'pending': return 'queued'; case 'completed': case 'skipped': case 'already_owned': return 'completed'; case 'failed': case 'not_found': return 'failed'; case 'cancelled': return 'cancelled'; default: return 'queued'; } } function _adlStatusLabel(status) { switch (status) { case 'downloading': return 'Downloading'; case 'searching': return 'Searching'; case 'post_processing': return 'Processing'; case 'queued': case 'pending': return 'Queued'; case 'completed': return 'Completed'; case 'skipped': return 'Skipped'; case 'already_owned': return 'Owned'; case 'failed': return 'Failed'; case 'not_found': return 'Not Found'; case 'cancelled': return 'Cancelled'; default: return status; } } function _adlEsc(str) { if (!str) return ''; return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function _adlBundleProgressPercent(bundle) { if (!bundle) return 0; const raw = bundle.progress_percent ?? bundle.progress ?? 0; let progress = Number(raw); if (!Number.isFinite(progress)) progress = 0; if (progress <= 1) progress *= 100; return Math.max(0, Math.min(100, Math.round(progress))); } function _adlFormatBytes(bytes) { const value = Number(bytes); if (!Number.isFinite(value) || value <= 0) return ''; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let size = value; let unit = 0; while (size >= 1024 && unit < units.length - 1) { size /= 1024; unit += 1; } const decimals = size >= 10 || unit === 0 ? 0 : 1; return `${size.toFixed(decimals)} ${units[unit]}`; } function _adlFormatSpeed(bytesPerSecond) { const formatted = _adlFormatBytes(bytesPerSecond); return formatted ? `${formatted}/s` : ''; } function _adlSourceLabel(source) { const labels = { torrent: 'Torrent', usenet: 'Usenet', soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', amazon: 'Amazon', lidarr: 'Lidarr', soundcloud: 'SoundCloud' }; const key = String(source || '').toLowerCase(); return labels[key] || (source ? String(source) : 'Release'); } function _adlBundleStateLabel(state) { const labels = { searching: 'searching for release', downloading: 'downloading release', staged: 'matching tracks', failed: 'release failed' }; const key = String(state || '').toLowerCase(); return labels[key] || (state ? String(state).replace(/_/g, ' ') : 'downloading release'); } function _adlBundleProgressText(bundle) { const pct = _adlBundleProgressPercent(bundle); const source = _adlSourceLabel(bundle && bundle.source); const state = _adlBundleStateLabel(bundle && bundle.state); const release = bundle && bundle.release ? ` - ${bundle.release}` : ''; const speed = _adlFormatSpeed(bundle && bundle.speed); const size = _adlFormatBytes(bundle && bundle.size); const detail = speed || size ? ` (${[speed, size].filter(Boolean).join(' of ')})` : ''; return `${source} ${state} ${pct}%${release}${detail}`; } async function adlClearCompleted() { // This now also deletes the persisted completed-download history, so confirm. if (typeof showConfirmDialog === 'function') { const ok = await showConfirmDialog({ title: 'Clear Completed', message: 'Remove ALL completed and failed downloads from the list and history? ' + 'This also clears unverified items from the verification queue. ' + 'Your files stay in the library — only the download-history rows are removed.', confirmText: 'Clear', destructive: true, }); if (!ok) return; } try { const resp = await fetch('/api/downloads/clear-completed', { method: 'POST' }); const data = await resp.json(); if (data.success) { const n = data.total_cleared != null ? data.total_cleared : data.cleared; if (typeof showToast === 'function') showToast(`Cleared ${n} downloads`, 'success'); _adlFetch(); } } catch (e) { console.error('Error clearing completed downloads:', e); } } // ---- Batch Context Panel ---- const _BATCH_FADE_SECONDS = 15; // Remove completed batches after this many seconds function _adlRenderBatchPanel() { const container = document.getElementById('adl-batch-active'); const headerTitle = document.querySelector('.adl-batch-panel-title'); if (!container) return; const now = Date.now(); // Filter out batches that completed more than FADE seconds ago const visibleBatches = _adlBatches.filter(batch => { const isTerminal = batch.phase === 'complete' || batch.phase === 'cancelled' || batch.phase === 'error'; if (!isTerminal) { delete _batchCompletedAt[batch.batch_id]; // Reset if it came back to life return true; } if (!_batchCompletedAt[batch.batch_id]) { _batchCompletedAt[batch.batch_id] = now; } const elapsed = (now - _batchCompletedAt[batch.batch_id]) / 1000; return elapsed < _BATCH_FADE_SECONDS; }); const activeBatches = visibleBatches.filter(b => b.phase !== 'complete' && b.phase !== 'cancelled' && b.phase !== 'error'); // Update header with count if (headerTitle) { headerTitle.textContent = activeBatches.length > 0 ? `Batches (${activeBatches.length})` : 'Batches'; } _adlRenderBatchSummary(activeBatches); if (visibleBatches.length === 0) { container.innerHTML = `
Nothing downloading
Batches show up here as they run.
`; return; } let html = ''; for (const batch of visibleBatches) { const colorIdx = _getBatchColor(batch.batch_id); const colorStyle = colorIdx >= 0 ? `border-left-color: rgba(var(--batch-color-${colorIdx}), 0.6)` : ''; const isExpanded = _adlExpandedBatches.has(batch.batch_id); const isFiltered = _adlFilterBatchId === batch.batch_id; const albumBundle = batch.album_bundle || null; const bundleProgress = _adlBundleProgressPercent(albumBundle); const total = batch.total || 1; const done = batch.completed + batch.failed; const pct = batch.phase === 'album_downloading' ? bundleProgress : Math.round((done / total) * 100); const hasFailed = batch.failed > 0; const isTerminal = batch.phase === 'complete' || batch.phase === 'cancelled' || batch.phase === 'error'; const isActive = (batch.phase === 'downloading' && batch.active > 0) || batch.phase === 'album_downloading'; // Fade progress for completing batches let fadeStyle = ''; if (isTerminal && _batchCompletedAt[batch.batch_id]) { const elapsed = (now - _batchCompletedAt[batch.batch_id]) / 1000; const fadeStart = _BATCH_FADE_SECONDS * 0.6; if (elapsed > fadeStart) { const fadeProgress = Math.min(1, (elapsed - fadeStart) / (_BATCH_FADE_SECONDS - fadeStart)); fadeStyle = `opacity: ${1 - fadeProgress};`; } } const sourceBadge = batch.source_page ? `${_adlEsc(batch.source_page)}` : ''; // Phase label with icon let phaseText = ''; let phaseIcon = ''; if (batch.phase === 'queued') { // Batch is in the executor queue waiting for a worker slot. // ``missing_download_executor`` has max_workers=3 by default, // so wishlist runs with >3 sub-batches park the rest at this // state until a worker frees up. Pre-fix this status rendered // as "Analyzing..." which misled users into thinking 26 // batches were all working when really only 3 were running. phaseText = 'Queued'; phaseIcon = ''; } else if (batch.phase === 'analysis') { phaseText = 'Analyzing...'; phaseIcon = ''; } else if (batch.phase === 'album_downloading') { phaseText = _adlBundleProgressText(albumBundle); phaseIcon = ''; } else if (batch.phase === 'downloading') { phaseText = `${batch.completed}/${total} tracks`; if (batch.active > 0) phaseIcon = ''; } else if (batch.phase === 'complete') { phaseText = `Done \u2014 ${batch.completed} tracks`; phaseIcon = '\u2713'; } else if (batch.phase === 'cancelled') { phaseText = 'Cancelled'; } else if (batch.phase === 'error') { phaseText = 'Error'; } else { phaseText = batch.phase; } // Get first track artwork for batch thumbnail, fallback to initial const batchTracks = _adlData.filter(d => d.batch_id === batch.batch_id); const artworkTrack = batchTracks.find(t => t.artwork); let thumbHtml; if (artworkTrack) { thumbHtml = ``; } else { const initial = (batch.batch_name || 'D')[0].toUpperCase(); const bgColor = colorIdx >= 0 ? `rgba(var(--batch-color-${colorIdx}), 0.15)` : 'rgba(255,255,255,0.05)'; const fgColor = colorIdx >= 0 ? `rgba(var(--batch-color-${colorIdx}), 0.7)` : 'rgba(255,255,255,0.4)'; thumbHtml = `
${initial}
`; } // Build expanded tracks list with per-track progress let tracksHtml = ''; if (isExpanded) { if (batchTracks.length > 0) { tracksHtml = batchTracks.map((t, i) => { const cls = _adlStatusClass(t.status); const progress = t.progress || 0; const idx = (t.track_index != null ? t.track_index + 1 : i + 1); // Right-aligned state: % / spinner / \u2713 / \u2717 / \u00B7 \u2014 color via row class. let stateHtml = ''; if (t.status === 'downloading' && progress > 0) { stateHtml = `${Math.round(progress)}%`; } else if (t.status === 'searching') { stateHtml = ``; } else if (t.status === 'post_processing') { stateHtml = `proc`; } else if (cls === 'completed') { stateHtml = `\u2713`; } else if (cls === 'failed') { stateHtml = `\u2717`; } else { stateHtml = `\u00B7`; } const isDownloading = (t.status === 'downloading' && progress > 0); const miniBar = isDownloading ? `
` : ''; const sub = t.artist ? `${_adlEsc(t.artist)}` : ''; return `
${idx} ${_adlEsc(t.title || 'Unknown')}${sub} ${stateHtml} ${miniBar}
`; }).join(''); } else { tracksHtml = batch.phase === 'album_downloading' ? '
Downloading one release first. Track matching starts after staging.
' : '
No tracks loaded
'; } } const cardClasses = ['adl-batch-card', `phase-${batch.phase}`]; if (isExpanded) cardClasses.push('expanded'); if (isActive) cardClasses.push('active-glow'); if (isFiltered) cardClasses.push('filtered'); const playlistId = _adlEsc(batch.playlist_id || ''); // Segmented progress: done (green) / failed (red) / active (accent) of // total; the remaining width is the dim bar background (queued). let segDone = 0, segFail = 0, segActive = 0; if (batch.phase === 'album_downloading') { segActive = bundleProgress; // one release downloading } else { segDone = Math.max(0, Math.min(100, (batch.completed / total) * 100)); segFail = Math.max(0, Math.min(100 - segDone, (batch.failed / total) * 100)); segActive = Math.max(0, Math.min(100 - segDone - segFail, ((batch.active || 0) / total) * 100)); } // "Now downloading" — the live track on active batches. const nowTrack = isActive ? (batchTracks.find(t => t.status === 'downloading') || batchTracks.find(t => t.status === 'searching')) : null; const nowHtml = (nowTrack && nowTrack.title) ? `
${_adlEsc(nowTrack.title)}
` : ''; // Stat chips + ETA line. const chips = []; if (batch.completed) chips.push(`✓ ${batch.completed}`); if (batch.failed) chips.push(`✗ ${batch.failed}`); if (batch.active) chips.push(`↓ ${batch.active}`); if (batch.queued) chips.push(`${batch.queued} queued`); const etaText = _adlBatchEta(batch); const statLine = (chips.length || etaText) ? `
${chips.join('')}
${etaText ? `${_adlEsc(etaText)}` : ''}
` : ''; html += `
${thumbHtml}
${phaseIcon}${phaseText}
${nowHtml}
${sourceBadge}
${!isTerminal ? `` : ''}
${statLine}
${tracksHtml}
`; } container.innerHTML = html; } function _adlToggleBatch(batchId) { if (_adlExpandedBatches.has(batchId)) { _adlExpandedBatches.delete(batchId); } else { _adlExpandedBatches.add(batchId); } _adlRenderBatchPanel(); } function _adlOpenBatchModal(batchId, playlistId, batchName) { // For wishlist batches, navigate to wishlist and show modal if (playlistId === 'wishlist') { const clientProcess = activeDownloadProcesses['wishlist']; if (clientProcess && clientProcess.modalElement && document.body.contains(clientProcess.modalElement)) { clientProcess.modalElement.style.display = 'flex'; if (typeof WishlistModalState !== 'undefined') WishlistModalState.setVisible(); } else { rehydrateModal({ playlist_id: playlistId, playlist_name: batchName, batch_id: batchId }, true); } return; } // For other batches, try to show existing modal or rehydrate for (const [pid, process] of Object.entries(activeDownloadProcesses)) { if (process.batchId === batchId && process.modalElement && document.body.contains(process.modalElement)) { process.modalElement.style.display = 'flex'; return; } } // Rehydrate from server rehydrateModal({ playlist_id: playlistId, playlist_name: batchName, batch_id: batchId }, true); } function _adlFilterByBatch(batchId) { if (_adlFilterBatchId === batchId) { _adlFilterBatchId = null; // Toggle off } else { _adlFilterBatchId = batchId; } _adlRender(); _adlRenderBatchPanel(); } async function adlCancelRow(btnEl, playlistId, trackIndex) { // Per-row cancel on the Downloads page. Uses the same atomic cancel // endpoint the modal cancel buttons use, so worker slots free properly. if (!playlistId || trackIndex === undefined || trackIndex === null) { showToast('Cannot cancel — missing task coordinates', 'error'); return; } // Lock the button so rapid clicks don't fire duplicate requests if (btnEl) { if (btnEl.dataset.cancelling === '1') return; btnEl.dataset.cancelling = '1'; btnEl.classList.add('adl-row-cancel-pending'); } try { const resp = await fetch('/api/downloads/cancel_task_v2', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ playlist_id: playlistId, track_index: trackIndex }) }); const data = await resp.json(); if (data.success) { const name = data.task_info && data.task_info.track_name ? data.task_info.track_name : 'Track'; showToast(`Cancelled "${name}"`, 'info'); _adlFetch(); } else { showToast(data.error || 'Cancel failed', 'error'); if (btnEl) { btnEl.dataset.cancelling = '0'; btnEl.classList.remove('adl-row-cancel-pending'); } } } catch (e) { console.error('ADL row cancel error:', e); showToast('Cancel request failed', 'error'); if (btnEl) { btnEl.dataset.cancelling = '0'; btnEl.classList.remove('adl-row-cancel-pending'); } } } async function _adlCancelBatch(batchId) { const batch = _adlBatches.find(b => b.batch_id === batchId); const batchName = batch ? batch.batch_name : 'this batch'; const confirmed = await showConfirmDialog({ title: 'Cancel Batch', message: `Cancel "${batchName}"? All active and queued downloads in this batch will be stopped.`, confirmText: 'Cancel Batch', destructive: true }); if (!confirmed) return; try { const resp = await fetch(`/api/playlists/${batchId}/cancel_batch`, { method: 'POST' }); const data = await resp.json(); if (data.success) { showToast(`Cancelled ${data.cancelled_tasks} downloads`, 'info'); _adlFetch(); } else { showToast(data.error || 'Failed to cancel batch', 'error'); } } catch (e) { showToast('Failed to cancel batch', 'error'); } } async function adlCancelAll() { // Cancel every batch with active/queued work — equivalent to clicking // "Cancel All" inside each running download modal. Uses the same // /api/playlists//cancel_batch endpoint the per-batch card // cancel uses, so worker slots free atomically. const runningBatches = _adlBatches.filter(b => (b.active || 0) > 0 || (b.queued || 0) > 0); if (runningBatches.length === 0) { showToast('No active batches to cancel', 'info'); return; } const totalTasks = runningBatches.reduce((sum, b) => sum + (b.active || 0) + (b.queued || 0), 0); const batchWord = runningBatches.length === 1 ? 'batch' : 'batches'; const taskWord = totalTasks === 1 ? 'task' : 'tasks'; const confirmed = await showConfirmDialog({ title: 'Cancel All Downloads', message: `Cancel ${totalTasks} ${taskWord} across ${runningBatches.length} ${batchWord}? Active and queued downloads will be stopped and added to the wishlist.`, confirmText: 'Cancel All', destructive: true }); if (!confirmed) return; const btn = document.getElementById('adl-cancel-all-btn'); if (btn) { btn.disabled = true; btn.classList.add('adl-cancel-all-pending'); } let cancelled = 0; let failed = 0; // Sequential so we don't hammer the backend — cancel_batch takes a lock // internally and parallel calls would mostly serialize anyway. for (const batch of runningBatches) { try { const resp = await fetch(`/api/playlists/${batch.batch_id}/cancel_batch`, { method: 'POST' }); const data = await resp.json(); if (data.success) { cancelled += (data.cancelled_tasks || 0); } else { failed += 1; console.warn(`cancel_batch failed for ${batch.batch_id}:`, data.error); } } catch (e) { failed += 1; console.warn(`cancel_batch exception for ${batch.batch_id}:`, e); } } if (btn) { btn.disabled = false; btn.classList.remove('adl-cancel-all-pending'); } if (cancelled > 0 && failed === 0) { showToast(`Cancelled ${cancelled} downloads`, 'success'); } else if (cancelled > 0 && failed > 0) { showToast(`Cancelled ${cancelled} downloads (${failed} batches failed)`, 'info'); } else { showToast('Failed to cancel any downloads', 'error'); } _adlFetch(); } // ---- Batch History ---- async function _adlFetchBatchHistory() { try { const resp = await fetch('/api/downloads/batch-history?days=7&limit=50'); const data = await resp.json(); if (data.success) { _adlBatchHistory = data.history || []; _adlRenderBatchHistory(); } } catch (e) { console.debug('Batch history fetch error:', e); } } function _adlRenderBatchHistory() { const section = document.getElementById('adl-batch-history-section'); const list = document.getElementById('adl-batch-history-list'); if (!section || !list) return; if (_adlBatchHistory.length === 0) { section.style.display = 'none'; return; } section.style.display = ''; list.innerHTML = _adlBatchHistory.map(h => { const name = _adlEsc(h.playlist_name || 'Unknown'); const downloaded = h.tracks_downloaded || 0; const failed = h.tracks_failed || 0; const total = h.total_tracks || 0; const statsParts = [`${downloaded}/${total}`]; if (failed > 0) statsParts.push(`${failed} failed`); let dateText = ''; if (h.completed_at) { try { const d = new Date(h.completed_at); const now = new Date(); const diffMs = now - d; const diffH = Math.floor(diffMs / 3600000); if (diffH < 1) dateText = 'just now'; else if (diffH < 24) dateText = `${diffH}h ago`; else dateText = `${Math.floor(diffH / 24)}d ago`; } catch (e) { dateText = ''; } } const sourceLabel = h.source_page ? `${_adlEsc(h.source_page)}` : ''; // Source type color dot const sourceColors = { wishlist: '168, 85, 247', sync: '59, 130, 246', album: '16, 185, 129' }; const dotColor = sourceColors[h.source_page] || '255, 255, 255'; const histDot = ``; return `
${histDot}
${name} ${sourceLabel}
${statsParts.join(' ')}
${dateText}
`; }).join(''); } function adlToggleBatchHistory() { const section = document.getElementById('adl-batch-history-section'); if (section) section.classList.toggle('expanded'); } function adlToggleBatchPanel() { const panel = document.getElementById('adl-batch-panel'); if (panel) panel.classList.toggle('collapsed'); } window.adlSetFilter = adlSetFilter; window.adlClearCompleted = adlClearCompleted; window._adlToggleBatch = _adlToggleBatch; window._adlOpenBatchModal = _adlOpenBatchModal; window._adlFilterByBatch = _adlFilterByBatch; window._adlCancelBatch = _adlCancelBatch; window.adlCancelRow = adlCancelRow; window.adlCancelAll = adlCancelAll; window.adlToggleBatchHistory = adlToggleBatchHistory; window.adlToggleBatchPanel = adlToggleBatchPanel;