You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SoulSync/webui/static/pages-extra.js

2975 lines
134 KiB

// ==================================================================================
// 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 = `<span class="explorer-discovering-live">Discovering... ${Math.round(data.progress)}%</span>`;
}
}
}
});
}
}
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 = '<div class="explorer-picker-empty">No mirrored playlists found. Sync a playlist first.</div>';
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 `<button class="explorer-picker-tab ${isActive ? 'active' : ''}" data-source="${src}" onclick="explorerSwitchPickerTab('${src}')">${label} <span class="explorer-picker-tab-count">${count}</span></button>`;
}).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 = '<div class="explorer-picker-card-badge downloaded" title="Most tracks in library">&#10003;</div>';
} else if (wasExplored) {
statusBadge = '<div class="explorer-picker-card-badge explored" title="Already explored">&#10003;</div>';
} else if (wishlisted > 0) {
statusBadge = '<div class="explorer-picker-card-badge wishlisted" title="Tracks wishlisted">&#9829;</div>';
} else if (isFullyDiscovered) {
statusBadge = '<div class="explorer-picker-card-badge ready" title="Ready to explore">&#9733;</div>';
} else if (!isReady) {
statusBadge = `<div class="explorer-picker-card-badge needs-discovery" title="Needs discovery (${pct}%)">${pct}%</div>`;
}
// Meta line with status indicators
let metaHTML;
const statusParts = [];
if (inLibrary > 0) statusParts.push(`<span class="explorer-picker-in-library">${inLibrary} in library</span>`);
if (wishlisted > 0) statusParts.push(`<span class="explorer-picker-wishlisted">${wishlisted} wishlisted</span>`);
if (isFullyDiscovered) {
metaHTML = `${total} tracks &middot; <span class="explorer-picker-discovered">Fully discovered</span>`;
} else if (isReady) {
metaHTML = `${total} tracks &middot; ${pct}% discovered`;
} else {
metaHTML = `${total} tracks &middot; <span class="explorer-picker-not-ready">${pct}% discovered</span>`;
}
if (statusParts.length > 0) {
metaHTML += `<br>${statusParts.join(' &middot; ')}`;
}
// Discover button for undiscovered playlists (replaces redirect to Sync)
const discoverBtn = !isReady ? `<button class="explorer-picker-discover-btn" onclick="event.stopPropagation(); explorerStartDiscovery(${p.id})" title="Start discovery">Discover</button>` : '';
return `
<div class="explorer-picker-card ${isActive ? 'active' : ''} ${!isReady ? 'not-ready' : ''} ${wasExplored ? 'explored' : ''}"
data-id="${p.id}"
onclick="${isReady ? `explorerSelectPlaylist(${p.id}, this)` : ''}">
<div class="explorer-picker-card-art">
${img ? `<img src="${img}" alt="" loading="lazy">` : '<div class="explorer-picker-card-art-placeholder">&#9835;</div>'}
</div>
<div class="explorer-picker-card-info">
<div class="explorer-picker-card-name-row">
<div class="explorer-picker-card-name">${p.name || 'Untitled'}</div>
${statusBadge}
</div>
<div class="explorer-picker-card-meta">${metaHTML}</div>
${discoverBtn ? `<div class="explorer-picker-card-actions">${discoverBtn}</div>` : ''}
</div>
</div>
`;
}).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 = '<svg class="explorer-svg" id="explorer-svg"></svg>';
_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 = '<div class="explorer-picker-card-badge explored" title="Already explored">&#10003;</div>';
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 = `
<div class="explorer-tier explorer-tier-root">
<div class="explorer-node explorer-node-root" id="explorer-root">
<div class="explorer-node-glow"></div>
${meta.playlist_image
? `<img class="explorer-node-img" src="${meta.playlist_image}" alt="">`
: '<div class="explorer-node-img-placeholder">&#9835;</div>'
}
<div class="explorer-node-label">
<div class="explorer-node-label-sub">SOURCE</div>
<div class="explorer-node-label-main">${meta.playlist_name}</div>
<div class="explorer-node-label-meta">${meta.total_tracks} tracks &middot; ${meta.total_artists} artists</div>
</div>
</div>
</div>
<div class="explorer-artist-tiers" id="explorer-artist-tiers"></div>
`;
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 = `
<div class="explorer-branch" id="explorer-branch-${safeKey}" style="--enter-delay: ${(index % 5) * 0.1}s">
<div class="explorer-node explorer-node-artist ${hasError ? 'error' : ''}"
id="explorer-node-${safeKey}" data-key="${safeKey}"
onclick="${hasError || albumCount === 0 ? '' : `explorerToggleArtist('${safeKey}')`}">
${artist.image_url
? `<img class="explorer-node-img" src="${artist.image_url}" alt="" loading="lazy">`
: ''
}
<div class="explorer-node-label">
<div class="explorer-node-label-main">${artist.name || 'Unknown'}</div>
<div class="explorer-node-label-meta">${hasError ? 'Not found' : albumCount + ' album' + (albumCount !== 1 ? 's' : '')}</div>
</div>
${!hasError && albumCount > 0 ? '<div class="explorer-node-expand-hint">&#9662;</div>' : ''}
${hasError ? '<div class="explorer-node-error-ring"></div>' : ''}
</div>
<div class="explorer-children" id="explorer-children-${safeKey}"></div>
</div>
`;
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 `
<div class="explorer-branch" style="--enter-delay: ${i * 0.06}s">
<div class="explorer-node explorer-node-album ${selected ? 'selected' : ''} ${owned ? 'owned' : ''} ${inPlaylist ? 'in-playlist' : ''}"
data-id="${id}" data-key="${id}"
onclick="explorerToggleAlbum('${id}'); event.stopPropagation();"
title="${(album.title || '').replace(/"/g, '&quot;')}\n${album.year || ''} · ${typeLabel} · ${album.track_count || '?'} tracks${owned ? '\n✓ Already in library' : ''}${inPlaylist ? '\n♫ Track from this playlist' : ''}\nClick to select · Double-click for tracklist">
${album.image_url
? `<img class="explorer-node-img" src="${album.image_url}" alt="" loading="lazy">`
: ''
}
<div class="explorer-node-label">
<div class="explorer-node-label-main">${album.title || 'Unknown'}</div>
<div class="explorer-node-label-meta">${album.year || ''} &middot; ${album.track_count || '?'} tracks</div>
</div>
<div class="explorer-node-select ${selected ? 'active' : ''}">
<svg viewBox="0 0 20 20"><polyline points="4 10 8 14 16 6" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
${owned ? '<div class="explorer-node-badge-float owned">Owned</div>' : ''}
${inPlaylist ? '<div class="explorer-node-badge-float playlist">♫</div>' : ''}
</div>
<div class="explorer-children" id="explorer-tracks-${id}"></div>
</div>
`;
}).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) => `
<div class="explorer-branch" style="--enter-delay: ${i * 0.03}s">
<div class="explorer-node explorer-node-track">
<div class="explorer-node-label">
<div class="explorer-node-label-main">${t.track_number}. ${t.name}</div>
<div class="explorer-node-label-meta">${_formatDuration(t.duration_ms)}</div>
</div>
</div>
</div>
`).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 += `<div class="discog-section-header">${_esc(section.name)}</div>`;
section.albums.forEach((album, i) => {
const year = album.year || '';
const typeLabel = album.album_type === 'single' ? 'Single' : album.album_type === 'ep' ? 'EP' : 'Album';
cardsHtml += `
<label class="discog-card ${album.owned ? 'owned' : ''}" data-type="${album.album_type || 'album'}" data-artist-id="${section.artistId}" style="animation-delay:${i * 0.03}s">
<input type="checkbox" class="discog-card-cb" data-album-id="${album.spotify_id}" data-tracks="${album.track_count || 0}" ${!album.owned ? 'checked' : ''} onchange="_explorerWishlistUpdateCount()">
<div class="discog-card-art">
${album.image_url ? `<img src="${album.image_url}" alt="" loading="lazy">` : '<div class="discog-card-art-placeholder">&#9835;</div>'}
${album.owned ? '<span class="discog-card-status">&#10003;</span>' : ''}
</div>
<div class="discog-card-info">
<div class="discog-card-title">${_esc(album.title || 'Unknown')}</div>
<div class="discog-card-meta">${year}${year ? ' · ' : ''}${typeLabel} · ${album.track_count || '?'} tracks</div>
</div>
<div class="discog-card-check"></div>
</label>
`;
});
});
overlay.innerHTML = `
<div class="discog-modal">
<div class="discog-modal-hero" style="background-image:url('${artistSections[0]?.image || ''}')">
<div class="discog-modal-hero-overlay"></div>
<div class="discog-modal-hero-content">
<h2 class="discog-modal-title">Add to Wishlist</h2>
<p class="discog-modal-artist">${artistSections.length} artist${artistSections.length !== 1 ? 's' : ''} · ${totalAlbums} releases</p>
</div>
<button class="discog-modal-close" onclick="document.getElementById('explorer-wishlist-overlay')?.remove()">&times;</button>
</div>
<div class="discog-filter-bar">
<div class="discog-filters">
<button class="discog-filter active" data-type="album" onclick="_explorerWishlistToggleFilter(this)">Albums</button>
<button class="discog-filter active" data-type="ep" onclick="_explorerWishlistToggleFilter(this)">EPs</button>
<button class="discog-filter active" data-type="single" onclick="_explorerWishlistToggleFilter(this)">Singles</button>
</div>
<div class="discog-select-actions">
<button class="discog-select-btn" onclick="document.querySelectorAll('#explorer-wishlist-overlay .discog-card-cb').forEach(c=>{c.checked=true}); _explorerWishlistUpdateCount()">Select All</button>
<button class="discog-select-btn" onclick="document.querySelectorAll('#explorer-wishlist-overlay .discog-card-cb').forEach(c=>{c.checked=false}); _explorerWishlistUpdateCount()">Deselect</button>
</div>
</div>
<div class="discog-grid" id="explorer-wishlist-grid">${cardsHtml}</div>
<div class="discog-progress" id="explorer-wishlist-progress" style="display:none;"></div>
<div class="discog-footer" id="explorer-wishlist-footer">
<div class="discog-footer-info" id="explorer-wishlist-info"></div>
<div class="discog-footer-actions">
<button class="discog-cancel-btn" onclick="document.getElementById('explorer-wishlist-overlay')?.remove()">Cancel</button>
<button class="discog-submit-btn" id="explorer-wishlist-submit">
<span class="discog-submit-icon">&#11015;</span>
<span id="explorer-wishlist-submit-text">Add to Wishlist</span>
</button>
</div>
</div>
</div>
`;
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 = `
<div class="discog-prog-art">${album.img ? `<img src="${album.img}">` : '&#9835;'}</div>
<div class="discog-prog-info">
<div class="discog-prog-title">${_esc(album.title)}</div>
<div class="discog-prog-status">Waiting...</div>
</div>
<div class="discog-prog-icon"><div class="discog-spinner"></div></div>
`;
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 = '<span style="color:#4CAF50">&#10003;</span>';
item.classList.remove('active');
item.classList.add('done');
} else if (result.status === 'error') {
if (statusEl) statusEl.textContent = result.message || 'Error';
if (iconEl) iconEl.innerHTML = '<span style="color:#ff4757">&#10007;</span>';
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 = `
<linearGradient id="explorer-grad-root" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="rgba(${accentRgb}, 0.25)"/>
<stop offset="100%" stop-color="rgba(${accentRgb}, 0.06)"/>
</linearGradient>
<linearGradient id="explorer-grad-album" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="rgba(${accentRgb}, 0.15)"/>
<stop offset="100%" stop-color="rgba(${accentRgb}, 0.04)"/>
</linearGradient>
`;
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)
document.addEventListener('wheel', (e) => {
const viewport = document.getElementById('explorer-viewport');
if (!viewport || !viewport.contains(e.target)) return;
// Check if we're on the explorer page
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 = `<div class="server-pl-grid">${Array.from({ length: 6 }, (_, i) => `
<div class="server-pl-card server-pl-skeleton" style="animation-delay: ${i * 0.06}s">
<div class="server-pl-card-top">
<div class="skeleton-box" style="width:44px;height:44px;border-radius:12px"></div>
<div class="skeleton-box" style="width:28px;height:28px;border-radius:8px"></div>
</div>
<div class="server-pl-card-body">
<div class="skeleton-box" style="width:${60 + Math.random() * 30}%;height:14px;border-radius:4px;margin-bottom:8px"></div>
<div class="skeleton-box" style="width:40%;height:11px;border-radius:4px"></div>
</div>
<div class="server-pl-card-footer" style="border-top:1px solid rgba(255,255,255,0.05);padding-top:12px">
<div class="skeleton-box" style="width:60px;height:10px;border-radius:3px"></div>
</div>
</div>`).join('')}</div>`;
}
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 = `<div class="playlist-placeholder">${data.error || 'Could not load server playlists'}</div>`;
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 = '<div class="playlist-placeholder">No playlists found on your media server.</div>';
return;
}
// Server type icon SVG
const serverIcons = {
plex: '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M11.643 0H4.68l7.679 12L4.68 24h6.963L19.32 12z"/></svg>',
jellyfin: '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M12 2C8.5 2 6 5.1 6 9c0 2.4 1.2 5.5 3.3 8.7.7 1 1.5 2 2.2 2.9.2.3.4.3.5.4.1 0 .3-.1.5-.4.7-.9 1.5-1.9 2.2-2.9C16.8 14.5 18 11.4 18 9c0-3.9-2.5-7-6-7z"/></svg>',
navidrome: '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>'
};
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 `
<div class="${cardClass}" onclick="openServerPlaylistEditor('${pl.id}', '${safeName}')" style="animation-delay: ${i * 0.04}s; --card-hue: ${hue}">
<div class="server-pl-card-glow"></div>
<div class="server-pl-card-top">
<div class="server-pl-card-icon-wrap">
<div class="server-pl-card-bars">
<span></span><span></span><span></span><span></span>
</div>
</div>
<div class="server-pl-card-badge">${sIcon}</div>
</div>
<div class="server-pl-card-body">
<div class="server-pl-card-name">${_esc(pl.name)}</div>
<div class="server-pl-card-meta">
<span class="server-pl-track-count">${pl.track_count}</span> tracks
${isSynced ? '<span class="server-pl-synced-badge">Synced</span>' : ''}
</div>
</div>
<div class="server-pl-card-footer">
<span class="server-pl-card-action">${action}</span>
<span class="server-pl-card-arrow">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</span>
</div>
</div>`;
}
let html = '';
if (synced.length > 0) {
html += `<div class="server-pl-section">
<div class="server-pl-section-header">
<span class="server-pl-section-icon">&#128279;</span>
<span class="server-pl-section-title">Synced Playlists</span>
<span class="server-pl-section-count">${synced.length}</span>
</div>
<div class="server-pl-grid">${synced.map((pl, i) => _renderPlCard(pl, i, true)).join('')}</div>
</div>`;
}
if (unsynced.length > 0) {
html += `<div class="server-pl-section server-pl-section-unsynced">
<div class="server-pl-section-header">
<span class="server-pl-section-icon">&#127925;</span>
<span class="server-pl-section-title">Other Server Playlists</span>
<span class="server-pl-section-count">${unsynced.length}</span>
</div>
<div class="server-pl-grid">${unsynced.map((pl, i) => _renderPlCard(pl, i + synced.length, false)).join('')}</div>
</div>`;
}
container.innerHTML = html;
} catch (e) {
if (container) container.innerHTML = `<div class="playlist-placeholder">Error: ${e.message}</div>`;
} 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 `
<div class="server-disambig-card" onclick="selectDisambigPlaylist('${playlistId}', '${_esc(playlistName).replace(/'/g, "\\'")}', ${p.id})" style="animation-delay: ${i * 0.06}s">
<div class="server-disambig-icon">${icon}</div>
<div class="server-disambig-info">
<div class="server-disambig-name">${_esc(p.name)}</div>
<div class="server-disambig-details">
<span class="source-badge">${_esc(p.source)}</span>
<span>${p.track_count || 0} tracks</span>
${p.owner ? `<span>by ${_esc(p.owner)}</span>` : ''}
<span>Mirrored ${ago}</span>
</div>
</div>
<div class="server-disambig-arrow">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</div>
</div>`;
}).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 = '<div style="text-align:center;padding:30px;color:rgba(255,255,255,0.2);font-size:12px">Loading...</div>';
if (serverScroll) serverScroll.innerHTML = '<div style="text-align:center;padding:30px;color:rgba(255,255,255,0.2);font-size:12px">Loading...</div>';
// 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 = `
<div class="server-editor-stat"><div class="server-editor-stat-num matched">${matched}</div><div class="server-editor-stat-label">Matched</div></div>
<div class="server-editor-stat"><div class="server-editor-stat-num missing">${missing}</div><div class="server-editor-stat-label">Missing</div></div>
${extra > 0 ? `<div class="server-editor-stat"><div class="server-editor-stat-num extra">${extra}</div><div class="server-editor-stat-label">Extra</div></div>` : ''}
`;
}
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 += `
<div class="server-track-item ${status}" data-pair-id="${pairId}" data-index="${i}" data-status="${status}"
onclick="_compareTrackClick('source', ${i})">
<div class="server-track-num">${src.position != null ? src.position : i + 1}</div>
<div class="server-track-art">
${src.image_url ? `<img src="${src.image_url}" alt="" loading="lazy">` : '<div class="server-track-art-empty"></div>'}
</div>
<div class="server-track-info">
<div class="server-track-title">${_esc(src.name)}</div>
<div class="server-track-artist">${_esc(src.artist || '')}</div>
</div>
<div class="server-track-duration">${dur}</div>
<div class="server-track-status-dot"></div>
</div>`;
} else {
// Extra track — no source
sourceHTML += `
<div class="server-track-item extra-gap" data-pair-id="${pairId}" data-index="${i}" data-status="${status}">
<div class="server-track-empty-slot extra">
<span class="empty-slot-label">No source track</span>
</div>
</div>`;
}
// ── 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 = `<span class="server-track-conf ${cls}" title="Title similarity">${pct}%</span>`;
}
serverHTML += `
<div class="server-track-item ${status}" data-pair-id="${pairId}" data-index="${i}" data-status="${status}"
onclick="_compareTrackClick('server', ${i})">
<div class="server-track-num">${i + 1}</div>
<div class="server-track-art">
${svr.thumb ? `<img src="${svr.thumb}" alt="" loading="lazy">` : '<div class="server-track-art-empty"></div>'}
</div>
<div class="server-track-info">
<div class="server-track-title">${_esc(svr.title)}</div>
<div class="server-track-artist">${_esc(svr.artist || '')}</div>
</div>
${confBadge}
<div class="server-track-duration">${dur}</div>
<div class="server-track-actions">
${status === 'matched' ? `<button class="server-track-swap-btn" onclick="event.stopPropagation(); serverSearchReplace(${i}, 'replace')" title="Swap for different version">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M7 16V4m0 0L3 8m4-4l4 4M17 8v12m0 0l4-4m-4 4l-4-4"/></svg>
</button>` : ''}
<button class="server-track-remove-btn" onclick="event.stopPropagation(); _serverRemoveTrack(${i}, '${svr.id}')" title="Remove from playlist">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
<div class="server-track-status-dot"></div>
</div>`;
} else {
// Missing on server — clickable empty slot
const hint = src ? `${src.artist || ''}${src.name}` : '';
serverHTML += `
<div class="server-track-item empty-slot-wrap" data-pair-id="${pairId}" data-index="${i}" data-status="${status}"
onclick="serverSearchReplace(${i}, 'add')">
<div class="server-track-empty-slot missing">
<div class="empty-slot-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
</div>
<span class="empty-slot-label">Find &amp; add</span>
<span class="empty-slot-hint">${_esc(hint)}</span>
</div>
</div>`;
}
});
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);
}
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 || '';
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 = `
<div class="server-search-popover" id="server-search-popover">
<div class="server-search-header">
<div>
<div class="server-search-title">${mode === 'replace' ? 'Swap Track' : 'Add Track to Server'}</div>
${contextName ? `<div class="server-search-context">
<span class="server-search-context-label">Source:</span>
<span class="server-search-context-artist">${_esc(contextArtist)}</span>
<span class="server-search-context-sep">—</span>
<span class="server-search-context-name">${_esc(contextName)}</span>
</div>` : ''}
</div>
<button class="server-search-close" onclick="document.getElementById('server-search-overlay')?.remove()">&times;</button>
</div>
<div class="server-search-input-wrap">
<div class="server-search-input-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
</div>
<input type="text" class="server-search-input" id="server-search-input" value="${_esc(searchQuery)}" placeholder="Search by track name, artist, or album..." onkeydown="if(event.key==='Enter') _serverSearchExecute()">
</div>
<div class="server-search-results-header" id="server-search-results-header"></div>
<div class="server-search-results" id="server-search-results">
<div class="server-search-hint">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" style="margin-bottom:6px;opacity:0.4"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
<br>Searching...
</div>
</div>
</div>
`;
// 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 = '<div class="server-search-hint">Type a search query</div>';
if (resultsHeader) resultsHeader.textContent = '';
return;
}
results.innerHTML = '<div class="server-search-hint"><div class="server-search-spinner"></div>Searching library...</div>';
if (resultsHeader) resultsHeader.textContent = '';
try {
const response = await fetch(`/api/library/search-tracks?q=${encodeURIComponent(query)}&limit=20`);
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
results.innerHTML = `<div class="server-search-hint">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" style="margin-bottom:6px;opacity:0.3"><path d="M9.172 14.828a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<br>No results found<br><span style="font-size:10px;opacity:0.5">Try different keywords or a shorter query</span>
</div>`;
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 `
<div class="server-search-result" onclick="_serverSelectTrack(${trackIndex}, '${mode}', '${t.id}', this)" style="animation-delay:${i * 0.03}s">
<div class="server-search-result-art">
${t.album_thumb_url ? `<img src="${t.album_thumb_url}" alt="" loading="lazy">` : '<div class="server-search-result-art-empty"></div>'}
</div>
<div class="server-search-result-info">
<div class="server-search-result-title">${_esc(t.title)}</div>
<div class="server-search-result-meta">${_esc(t.artist_name)}${t.album_title ? ` · ${_esc(t.album_title)}` : ''}</div>
</div>
<div class="server-search-result-details">
${format ? `<span class="server-search-format">${format}</span>` : ''}
${bitrateStr ? `<span class="server-search-bitrate">${bitrateStr}</span>` : ''}
${dur ? `<span class="server-search-dur">${dur}</span>` : ''}
</div>
<button class="server-search-select-btn">Select</button>
</div>
`;
}).join('');
} catch (e) {
results.innerHTML = `<div class="server-search-hint">Error: ${e.message}</div>`;
}
}
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 || '',
})
});
}
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() {
const container = document.getElementById('sync-history-cards');
if (!container) return;
try {
const response = await fetch('/api/sync/history?limit=10');
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 = '<div class="sync-history-empty">No syncs yet</div>';
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 `
<div class="sync-history-card ${healthClass}" onclick="openSyncDetailModal(${entry.id})" style="animation-delay: ${cardIndex * 0.05}s">
<button class="sync-card-delete" onclick="event.stopPropagation(); deleteSyncHistoryCard(${entry.id}, this)" title="Remove">&times;</button>
<div class="sync-card-thumb">
${entry.thumb_url ? `<img src="${entry.thumb_url}" alt="" loading="lazy">` : '<div class="sync-card-thumb-placeholder">&#9835;</div>'}
</div>
<div class="sync-card-info">
<div class="sync-card-name">${typeof _esc === 'function' ? _esc(name) : name}</div>
<div class="sync-card-meta">
<span class="sync-card-source">${sourceLabel}</span>
<span class="sync-card-time">${timeStr}</span>
</div>
</div>
<div class="sync-card-stats">
<div class="sync-card-pct">${pct}%</div>
<div class="sync-card-bar">
<div class="sync-card-bar-fill" style="width: ${pct}%"></div>
</div>
<div class="sync-card-counts">${found}/${total} matched${downloaded > 0 ? ` · ${downloaded}` : ''}${failed > 0 ? ` · ${failed}` : ''}</div>
</div>
</div>
`;
}).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 = '<span class="sync-dl-wishlist">→ Wishlist</span>';
return `
<tr class="sync-detail-row ${statusClass}">
<td class="sync-detail-num">${i + 1}</td>
<td class="sync-detail-art">
${t.image_url ? `<img src="${t.image_url}" alt="" loading="lazy">` : '<div class="sync-detail-art-empty"></div>'}
</td>
<td class="sync-detail-track">${_esc(t.name || '')}</td>
<td class="sync-detail-artist">${_esc(t.artist || '')}</td>
<td class="sync-detail-album">${_esc(t.album || '')}</td>
<td class="sync-detail-status">${statusIcon}</td>
<td class="sync-detail-conf"><span class="conf-badge ${confClass}">${confPct}%</span></td>
<td class="sync-detail-dl">${dlDisplay}</td>
</tr>
`;
}).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 = `
<tr><td colspan="8" class="sync-detail-notice">
<div class="sync-detail-notice-text">Per-track match data not available for this sync.<br>Re-sync this playlist to see detailed match results.</div>
</td></tr>
` + 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 `
<tr class="sync-detail-row no-data">
<td class="sync-detail-num">${i + 1}</td>
<td class="sync-detail-art"></td>
<td class="sync-detail-track">${esc(t.name || '')}</td>
<td class="sync-detail-artist">${esc(artistName)}</td>
<td class="sync-detail-album">${esc(albumName)}</td>
<td class="sync-detail-status" colspan="3"></td>
</tr>
`;
}).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 = `
<div class="discog-modal">
<div class="discog-modal-hero" ${entry.thumb_url ? `style="background-image:url('${entry.thumb_url}')"` : ''}>
<div class="discog-modal-hero-overlay"></div>
<div class="discog-modal-hero-content">
<h2 class="discog-modal-title">Sync Details</h2>
<p class="discog-modal-artist">${_esc(name)}</p>
</div>
<button class="discog-modal-close" onclick="document.getElementById('sync-detail-overlay')?.remove()">&times;</button>
</div>
<div class="discog-filter-bar">
<div class="discog-filters">
<button class="discog-filter active" data-filter="all" onclick="_syncDetailFilter(this, 'all')">All (${total})</button>
<button class="discog-filter" data-filter="matched" onclick="_syncDetailFilter(this, 'matched')">Matched (${matchedCount})</button>
<button class="discog-filter" data-filter="unmatched" onclick="_syncDetailFilter(this, 'unmatched')">Unmatched (${unmatchedCount})</button>
${downloadedCount > 0 ? `<button class="discog-filter" data-filter="downloaded" onclick="_syncDetailFilter(this, 'downloaded')">Downloaded (${downloadedCount})</button>` : ''}
</div>
</div>
<div class="sync-detail-table-wrap">
<table class="sync-detail-table">
<thead>
<tr>
<th>#</th>
<th></th>
<th>Track</th>
<th>Artist</th>
<th>Album</th>
<th>Match</th>
<th>Conf.</th>
<th>Status</th>
</tr>
</thead>
<tbody id="sync-detail-tbody">
${trackRowsHtml}
</tbody>
</table>
</div>
<div class="discog-footer">
<div class="discog-footer-info">${found} matched · ${downloaded} downloaded · ${total} total</div>
<div class="discog-footer-actions">
<button class="discog-cancel-btn" onclick="document.getElementById('sync-detail-overlay')?.remove()">Close</button>
</div>
</div>
</div>
`;
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];
}
function loadActiveDownloadsPage() {
_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 _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 === 'failed') filtered = filtered.filter(d => failedStatuses.includes(d.status));
const completedN = _adlData.filter(d => [...completedStatuses, ...failedStatuses].includes(d.status)).length;
if (countEl) {
const activeN = _adlData.filter(d => activeStatuses.includes(d.status)).length;
const queuedN = _adlData.filter(d => queuedStatuses.includes(d.status)).length;
const total = _adlData.length;
const parts = [];
if (activeN > 0) parts.push(`${activeN} active`);
if (queuedN > 0) parts.push(`${queuedN} queued`);
parts.push(`${total} total`);
countEl.textContent = parts.join(' / ');
}
// Show/hide clear button
const clearBtn = document.getElementById('adl-clear-btn');
if (clearBtn) clearBtn.style.display = completedN > 0 ? '' : 'none';
// Show/hide cancel-all button — only visible when there's something to cancel
const cancelAllBtn = document.getElementById('adl-cancel-all-btn');
if (cancelAllBtn) {
const hasRunningWork = _adlData.some(d =>
[...activeStatuses, ...queuedStatuses].includes(d.status)
);
cancelAllBtn.style.display = hasRunningWork ? '' : 'none';
}
// Batch filter indicator banner
let existingBanner = document.getElementById('adl-batch-filter-banner');
if (_adlFilterBatchId) {
const batchInfo = _adlBatches.find(b => b.batch_id === _adlFilterBatchId);
const batchName = batchInfo ? batchInfo.batch_name : 'Unknown batch';
const colorIdx = _getBatchColor(_adlFilterBatchId);
const colorDot = colorIdx >= 0 ? `<span class="adl-filter-banner-dot" style="background:rgba(var(--batch-color-${colorIdx}),0.7)"></span>` : '';
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: <strong>${_adlEsc(batchName)}</strong> <button class="adl-filter-banner-clear" onclick="_adlFilterByBatch('${_adlFilterBatchId}')">Clear filter</button>`;
existingBanner.style.display = '';
} else if (existingBanner) {
existingBanner.style.display = 'none';
}
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 += `<div class="adl-section-header">${section.label} (${section.items.length})</div>`;
}
for (const dl of section.items) {
const statusClass = _adlStatusClass(dl.status);
const statusLabel = _adlStatusLabel(dl.status);
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
? `<img class="adl-row-art" src="${_adlEsc(dl.artwork)}" alt="" onerror="this.style.display='none'">`
: '<div class="adl-row-art adl-row-art-empty"></div>';
// 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
? `<div class="adl-row-batch-color" style="background:rgba(var(--batch-color-${colorIdx}),0.6)"></div>`
: '';
// 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
? `<button class="adl-row-cancel" onclick="event.stopPropagation(); adlCancelRow(this, '${_adlEsc(dl.playlist_id)}', ${dl.track_index})" title="Cancel this download" aria-label="Cancel download">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>`
: '';
html += `<div class="adl-row adl-row-${statusClass}" data-task-id="${dl.task_id}" data-batch-id="${dl.batch_id || ''}">
${colorBar}
${artHtml}
<div class="adl-row-info">
<div class="adl-row-title">${title}</div>
${meta ? `<div class="adl-row-meta">${meta}</div>` : ''}
${batchName ? `<div class="adl-row-batch">${batchName}${posText ? ' &middot; Track ' + posText : ''}</div>` : ''}
${error ? `<div class="adl-row-error">${error}</div>` : ''}
</div>
<div class="adl-row-status ${statusClass}">
<span class="adl-status-dot ${statusClass}"></span>
${statusLabel}
</div>
${cancelBtnHtml}
</div>`;
}
}
// 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 '<span class="adl-spinner"></span>Downloading';
case 'searching': return '<span class="adl-spinner"></span>Searching';
case 'post_processing': return '<span class="adl-spinner"></span>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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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;
});
// Update header with count
if (headerTitle) {
const activeCount = visibleBatches.filter(b => b.phase !== 'complete' && b.phase !== 'cancelled' && b.phase !== 'error').length;
headerTitle.textContent = activeCount > 0 ? `Batches (${activeCount})` : 'Batches';
}
if (visibleBatches.length === 0) {
container.innerHTML = `<div class="adl-batch-empty">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" style="opacity:0.25;margin-bottom:6px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<div>No active batches</div>
<div style="font-size:0.7rem;margin-top:2px;opacity:0.5">Start a download from Search, Sync, or Wishlist</div>
</div>`;
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
? `<span class="adl-batch-card-source">${_adlEsc(batch.source_page)}</span>`
: '';
// Phase label with icon
let phaseText = '';
let phaseIcon = '';
if (batch.phase === 'analysis') {
phaseText = 'Analyzing...';
phaseIcon = '<span class="adl-spinner" style="margin-right:4px"></span>';
} else if (batch.phase === 'album_downloading') {
phaseText = _adlBundleProgressText(albumBundle);
phaseIcon = '<span class="adl-spinner" style="margin-right:4px"></span>';
} else if (batch.phase === 'downloading') {
phaseText = `${batch.completed}/${total} tracks`;
if (batch.active > 0) phaseIcon = '<span class="adl-spinner" style="margin-right:4px"></span>';
} else if (batch.phase === 'complete') {
phaseText = `Done \u2014 ${batch.completed} tracks`;
phaseIcon = '<span style="color:#22c55e;margin-right:4px">\u2713</span>';
} 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 = `<img class="adl-batch-card-thumb" src="${_adlEsc(artworkTrack.artwork)}" alt="" onerror="this.outerHTML='<div class=\\'adl-batch-card-thumb adl-batch-card-thumb-fallback\\'>${_adlEsc((batch.batch_name || 'D')[0])}</div>'">`;
} 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 = `<div class="adl-batch-card-thumb adl-batch-card-thumb-fallback" style="background:${bgColor};color:${fgColor}">${initial}</div>`;
}
// Build expanded tracks list with per-track progress
let tracksHtml = '';
if (isExpanded) {
if (batchTracks.length > 0) {
tracksHtml = batchTracks.map(t => {
const cls = _adlStatusClass(t.status);
const progress = t.progress || 0;
// Status indicator with detail
let statusHtml = '';
if (t.status === 'downloading' && progress > 0) {
statusHtml = `<span class="adl-batch-track-status active">${Math.round(progress)}%</span>`;
} else if (t.status === 'searching') {
statusHtml = `<span class="adl-batch-track-status active"><span class="adl-spinner" style="width:8px;height:8px"></span></span>`;
} else if (t.status === 'post_processing') {
statusHtml = `<span class="adl-batch-track-status active" title="Processing">proc</span>`;
} else if (cls === 'completed') {
statusHtml = `<span class="adl-batch-track-status completed">\u2713</span>`;
} else if (cls === 'failed') {
statusHtml = `<span class="adl-batch-track-status failed">\u2717</span>`;
} else {
statusHtml = `<span class="adl-batch-track-status queued">\u00B7</span>`;
}
// Mini progress bar for downloading tracks
const miniBar = t.status === 'downloading' && progress > 0
? `<div class="adl-batch-track-progress"><div class="adl-batch-track-progress-fill" style="width:${progress}%"></div></div>`
: '';
return `<div class="adl-batch-track-row">
<span class="adl-batch-track-title">${_adlEsc(t.title || 'Unknown')}</span>
${statusHtml}
${miniBar}
</div>`;
}).join('');
} else {
tracksHtml = batch.phase === 'album_downloading'
? '<div class="adl-batch-release-note">Downloading one release first. Track matching starts after staging.</div>'
: '<div style="font-size:0.7rem;color:rgba(255,255,255,0.3);padding:4px 0">No tracks loaded</div>';
}
}
const cardClasses = ['adl-batch-card'];
if (isExpanded) cardClasses.push('expanded');
if (isActive) cardClasses.push('active-glow');
if (isFiltered) cardClasses.push('filtered');
const playlistId = _adlEsc(batch.playlist_id || '');
html += `<div class="${cardClasses.join(' ')}" style="${colorStyle}${fadeStyle}" data-batch-id="${batch.batch_id}" onclick="_adlToggleBatch('${batch.batch_id}')">
<div class="adl-batch-card-top">
${thumbHtml}
<div class="adl-batch-card-info">
<div class="adl-batch-card-name adl-batch-card-link" onclick="event.stopPropagation(); _adlOpenBatchModal('${batch.batch_id}', '${playlistId}', '${_adlEsc(batch.batch_name || 'Download')}')" title="Open download modal">${_adlEsc(batch.batch_name || 'Download')}</div>
<div class="adl-batch-card-meta">${phaseIcon}${phaseText}</div>
</div>
${sourceBadge}
<div class="adl-batch-card-actions">
<button class="adl-batch-card-filter ${isFiltered ? 'active' : ''}" onclick="event.stopPropagation(); _adlFilterByBatch('${batch.batch_id}')" title="${isFiltered ? 'Show all downloads' : 'Filter to this batch'}">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
</button>
${!isTerminal ? `<button class="adl-batch-card-cancel" onclick="event.stopPropagation(); _adlCancelBatch('${batch.batch_id}')" title="Cancel batch">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>` : ''}
</div>
</div>
<div class="adl-batch-progress">
<div class="adl-batch-progress-fill${hasFailed ? ' has-failed' : ''}" style="width:${pct}%"></div>
</div>
<div class="adl-batch-tracks">${tracksHtml}</div>
</div>`;
}
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/<batch_id>/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(`<span style="color:#ef4444">${failed} failed</span>`);
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 ? `<span class="adl-batch-card-source" style="font-size:0.6rem;padding:0 4px">${_adlEsc(h.source_page)}</span>` : '';
// 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 = `<span class="adl-batch-history-dot" style="background:rgba(${dotColor}, 0.6)"></span>`;
return `<div class="adl-batch-history-item">
${histDot}
<div class="adl-batch-history-name">${name} ${sourceLabel}</div>
<div class="adl-batch-history-stats">${statsParts.join(' ')}</div>
<div class="adl-batch-history-date">${dateText}</div>
</div>`;
}).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;