// ==================================================================================
// 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 `${label} ${count} `;
}).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 ? `Discover ` : '';
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 += ``;
section.albums.forEach((album, i) => {
const year = album.year || '';
const typeLabel = album.album_type === 'single' ? 'Single' : album.album_type === 'ep' ? 'EP' : 'Album';
cardsHtml += `
${album.image_url ? `
` : '
♫
'}
${album.owned ? '
✓ ' : ''}
${_esc(album.title || 'Unknown')}
${year}${year ? ' · ' : ''}${typeLabel} · ${album.track_count || '?'} tracks
`;
});
});
overlay.innerHTML = `
Add to Wishlist
${artistSections.length} artist${artistSections.length !== 1 ? 's' : ''} · ${totalAlbums} releases
×
Albums
EPs
Singles
Select All
Deselect
${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 `
${_esc(pl.name)}
${pl.track_count} tracks
${isSynced ? 'Synced ' : ''}
`;
}
let html = '';
if (synced.length > 0) {
html += `
${synced.map((pl, i) => _renderPlCard(pl, i, true)).join('')}
`;
}
if (unsynced.length > 0) {
html += `
${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;
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) 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 = `
${extra > 0 ? `` : ''}
`;
}
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` : ''}`;
}
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 += `
`;
}
// ── 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 += `
`;
}
});
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 = `
`;
// 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 = '';
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} ` : ''}
Select
`;
}).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 ''; }
}
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') dlDisplay = '→ Wishlist ';
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)}
×
All (${total})
Matched (${matchedCount})
Unmatched (${unmatchedCount})
${downloadedCount > 0 ? `Downloaded (${downloadedCount}) ` : ''}
#
Track
Artist
Album
Match
Conf.
Status
${trackRowsHtml}
`;
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
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);
}
}
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 _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) return null;
const m = String(dl.task_id).match(/^history-(\d+)$/);
return m ? m[1] : 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;
}
// ---- 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();
// 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);
} 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, 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'],
bit_depth: ['BIT DEPTH FILTER', 'verif-rb-int'],
};
function _verifQuarRows() {
if (!_verifQuarLoaded) return '';
if (!_verifQuarEntries.length) return '';
let html = '';
_verifQuarEntries.forEach((q, idx) => {
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 [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
? ` `
: '
';
html += `
${artHtml}
${title}
${meta ? `
${meta}
` : ''}
${details || 'No further details in the sidecar.'}
${trigLabel}
${timeAgo ? `${timeAgo} ` : ''}
▶
⇆
🔍
${approveBtn}
🗑
`;
});
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' });
const d = await r.json();
if (d.success) {
showToast && showToast('Approved — re-importing, will be marked human-verified 🛡✔', '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 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' });
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));
const completedN = _adlData.filter(d =>
[...completedStatuses, ...failedStatuses].includes(d.status) && !d.is_persistent_history
).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)} Clear filter `;
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'
? `✔ Approve all
🗑 Clear all `
: `✔ Approve all
🗑 Delete all `;
// Without an AcoustID key nothing ever gets a verification status —
// hide the pointless Unverified pill and show quarantine only.
const unvPill = _verifAcoustidEnabled === false
? ''
: `⚠ Unverified (${filtered.length}) `;
verifBanner.innerHTML = `
${unvPill}
🛡 Quarantine${quarCount}
${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;
}
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 += ``;
}
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
? `
`
: '';
html += `
${colorBar}
${artHtml}
${title}
${meta ? `
${meta}
` : ''}
${batchName ? `
${batchName}${posText ? ' · Track ' + posText : ''}
` : ''}
${error ? `
${error}
` : ''}
${statusLabel}${_adlVerifBadge(dl)}${dl.retry_info && (statusClass === 'active' || statusClass === 'queued') ? ` 🔁 ${_adlEsc(String(dl.retry_info))} ` : ''}
${_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() {
try {
const resp = await fetch('/api/downloads/clear-completed', { method: 'POST' });
const data = await resp.json();
if (data.success) {
if (typeof showToast === 'function') showToast(`Cleared ${data.cleared} 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}
${_adlEsc(batch.batch_name || 'Download')}
${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;