Loading library data...
';
try {
const response = await fetch(`/api/library/artist/${artistId}/enhanced`);
const data = await response.json();
if (!data.success) throw new Error(data.error || 'Failed to load enhanced data');
artistDetailPageState.enhancedData = data;
artistDetailPageState.expandedAlbums = new Set();
artistDetailPageState.selectedTracks = new Set();
artistDetailPageState.enhancedTrackSort = {};
artistDetailPageState.serverType = data.server_type || null;
_tagPreviewServerType = data.server_type || null;
_rebuildAlbumMap();
renderEnhancedView();
} catch (error) {
console.error('Error loading enhanced view data:', error);
container.innerHTML = `No download source data available for this track. Source tracking starts with new downloads.
`;
return;
}
const serviceIcons = { soulseek: 'π', youtube: 'βΆοΈ', tidal: 'π', qobuz: 'π΅', hifi: 'π§', deezer: 'π', lidarr: 'π¦', amazon: 'π', soundcloud: 'βοΈ', auto_import: 'π₯', staging: 'π₯', torrent: 'π§²', usenet: 'π°' };
const serviceLabels = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer: 'Deezer', lidarr: 'Lidarr', amazon: 'Amazon Music', soundcloud: 'SoundCloud', auto_import: 'Auto-Import', staging: 'Staging', torrent: 'Torrent', usenet: 'Usenet' };
const dl = data.downloads[0]; // Most recent download
const icon = serviceIcons[dl.source_service] || 'π¦';
const label = serviceLabels[dl.source_service] || dl.source_service;
const displayFile = dl.source_filename ? dl.source_filename.replace(/\\/g, '/').split('/').pop() : 'Unknown';
const sizeStr = dl.source_size ? `${(dl.source_size / 1048576).toFixed(1)} MB` : '';
const dateStr = dl.created_at ? timeAgo(dl.created_at) : '';
popover.innerHTML = `
Service
${icon} ${label}
${dl.source_service === 'soulseek' && dl.source_username ? `
User
${_esc(dl.source_username)}
` : ''}
Original File
${_esc(displayFile)}
${sizeStr ? `
Size
${sizeStr}
` : ''}
${dl.audio_quality ? `
Quality
${_esc(dl.audio_quality)}
` : ''}
${dl.bit_depth || dl.sample_rate || dl.bitrate ? `
Audio
${[dl.bit_depth ? `${dl.bit_depth}-bit` : '', dl.sample_rate ? `${(dl.sample_rate / 1000).toFixed(1)}kHz` : '', dl.bitrate ? `${Math.round(dl.bitrate / 1000)}kbps` : ''].filter(Boolean).join(' Β· ')}
` : ''}
${dateStr ? `
Downloaded
${dateStr}
` : ''}
${dl.status !== 'completed' ? `
Status
${dl.status}
` : ''}
${dl.source_username && dl.source_filename ? `
No metadata sources available. Check your Spotify/iTunes/Deezer connections.
';
return;
}
const bestSource = data.best_match?.source || sources[0];
const sourceIcons = { spotify: 'π’', itunes: 'π', deezer: 'π£', hydrabase: 'π·' };
const sourceLabels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase' };
// Build columns β one per source, side by side
const columnsHtml = sources.map(source => {
const results = data.metadata_results[source] || [];
const icon = sourceIcons[source] || 'π';
const label = sourceLabels[source] || source;
let itemsHtml;
if (results.length === 0) {
itemsHtml = `No download sources found for this track.
';
}
// Update the shared candidates array (button handler reads from window._redownloadCandidates)
window._redownloadCandidates = allCandidates;
}
/* _renderRedownloadStep2 removed β replaced by _streamRedownloadSources above */
if (false) {
const serviceIcons = { soulseek: 'π', youtube: 'βΆοΈ', tidal: 'π', qobuz: 'π΅', hifi: 'π§', deezer_dl: 'π', hybrid: 'β‘', lidarr: 'π¦', amazon: 'π', soundcloud: 'βοΈ', torrent: 'π§²', usenet: 'π°' };
const serviceLabels = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', hybrid: 'Auto', lidarr: 'Lidarr', amazon: 'Amazon Music', soundcloud: 'SoundCloud', torrent: 'Torrent', usenet: 'Usenet' };
// Group candidates by source service
const grouped = {};
candidates.forEach((c, i) => {
c._origIdx = i; // preserve original index for radio value
const svc = c.source_service || 'unknown';
if (!grouped[svc]) grouped[svc] = [];
grouped[svc].push(c);
});
// Build columns β one per source
const sourceColumnsHtml = Object.entries(grouped).map(([svc, items]) => {
const icon = serviceIcons[svc] || 'π¦';
const label = serviceLabels[svc] || svc;
const itemsHtml = items.slice(0, 10).map(c => {
const confPct = Math.round((c.confidence || 0) * 100);
const confCls = confPct >= 90 ? 'high' : confPct >= 70 ? 'medium' : 'low';
const isRecommended = c._origIdx === bestIdx && !c.blacklisted;
const checked = isRecommended ? 'checked' : '';
const blClass = c.blacklisted ? ' blacklisted' : '';
const dur = c.duration ? `${Math.floor(c.duration / 60000)}:${String(Math.floor((c.duration % 60000) / 1000)).padStart(2, '0')}` : '';
return `
/tracks endpoint the Enhanced view uses for its canonical tracklist β
// so a redownload is always the album the user is actually looking at.
if (canonical) {
const params = new URLSearchParams({ name: albumName, artist: artistName || '', source: canonical.source });
const r = await fetch(`/api/album/${encodeURIComponent(canonical.id)}/tracks?${params}`);
if (r.ok) {
const data = await r.json();
if (data && data.success && Array.isArray(data.tracks) && data.tracks.length) {
albumData = { ...data.album, tracks: data.tracks }; // normalize to {β¦, tracks:[]}
}
}
}
// 2) Fallback: the stored spotify/iTunes id, then a last-resort search.
if (!albumData) {
let response;
if (spotifyAlbumId) {
response = await fetchAlbumBySource('spotify', spotifyAlbumId);
} else if (itunesAlbumId) {
response = await fetchAlbumBySource('itunes', itunesAlbumId);
}
if (!response || !response.ok) {
const query = `${artistName || ''} ${albumName}`.trim();
const searchResp = await fetch('/api/enhanced-search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query })
});
if (!searchResp.ok) throw new Error('Album search failed');
const searchData = await searchResp.json();
const spotHit = searchData.spotify_albums?.[0];
const found = spotHit || searchData.itunes_albums?.[0];
if (!found || !found.id) {
showToast(`Could not find "${albumName}" by ${artistName || 'unknown'}`, 'warning');
return;
}
// Fetch from the MATCHING source endpoint β the old fallback always hit Spotify,
// which is wrong for an iTunes search hit.
response = await fetchAlbumBySource(spotHit ? 'spotify' : 'itunes', found.id, found.name, found.artist);
}
if (!response.ok) throw new Error(`Failed to load album: ${response.status}`);
albumData = await response.json();
}
if (!albumData || !albumData.tracks || albumData.tracks.length === 0) {
showToast(`No tracks found for "${albumName}"`, 'warning');
return;
}
const resolvedId = albumData.id || spotifyAlbumId || album.id;
const virtualPlaylistId = `library_redownload_${resolvedId}`;
const playlistName = `[${artistName || 'Unknown'}] ${albumData.name}`;
const enrichedTracks = albumData.tracks.map(track => ({
...track,
album: {
name: albumData.name,
id: albumData.id,
album_type: albumData.album_type || 'album',
images: albumData.images || [],
release_date: albumData.release_date,
total_tracks: albumData.total_tracks
}
}));
const enhancedArtist = artistDetailPageState.enhancedData?.artist;
const artistObject = {
id: artistDetailPageState.currentArtistId || `library_${artistName || album.id}`,
name: artistName || '',
image_url: enhancedArtist?.thumb_url || ''
};
const fullAlbumObject = {
name: albumData.name,
id: albumData.id,
album_type: albumData.album_type || 'album',
images: albumData.images || [],
image_url: albumData.images?.[0]?.url || null,
release_date: albumData.release_date,
total_tracks: albumData.total_tracks,
artists: albumData.artists || [{ name: artistName || '' }]
};
await openDownloadMissingModalForArtistAlbum(
virtualPlaylistId, playlistName, enrichedTracks, fullAlbumObject, artistObject, true
);
const albumType = fullAlbumObject.album_type || 'album';
registerArtistDownload(artistObject, fullAlbumObject, virtualPlaylistId, albumType);
} catch (error) {
console.error('Redownload album error:', error);
showToast(`Error: ${error.message}`, 'error');
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = origText; }
}
}
async function deleteLibraryAlbum(albumId) {
const choice = await _showAlbumDeleteDialog();
if (!choice) return;
const deleteFiles = choice === 'delete_files';
const params = deleteFiles ? '?delete_files=true' : '';
try {
const response = await fetch(`/api/library/album/${albumId}${params}`, { method: 'DELETE' });
const result = await response.json();
if (!result.success) throw new Error(result.error);
let msg = `Album removed from library (${result.tracks_deleted || 0} tracks)`;
let toastType = 'success';
if (deleteFiles) {
if (result.files_deleted > 0) {
msg = `Album deleted β ${result.files_deleted} files removed from disk`;
}
if (result.files_failed > 0) {
msg += ` (${result.files_failed} files could not be deleted)`;
toastType = 'warning';
}
}
showToast(msg, toastType);
if (artistDetailPageState.enhancedData) {
const album = (artistDetailPageState.enhancedData.albums || []).find(a => a.id === albumId);
if (album && album.tracks) {
album.tracks.forEach(t => artistDetailPageState.selectedTracks.delete(String(t.id)));
}
artistDetailPageState.enhancedData.albums = (artistDetailPageState.enhancedData.albums || []).filter(a => a.id !== albumId);
_rebuildAlbumMap();
}
artistDetailPageState.expandedAlbums.delete(albumId);
delete artistDetailPageState.enhancedTrackSort[albumId];
renderEnhancedView();
} catch (error) {
showToast(`Delete failed: ${error.message}`, 'error');
}
}
function _showAlbumDeleteDialog() {
return new Promise(resolve => {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:10000;display:flex;align-items:center;justify-content:center;';
const close = (val) => { overlay.remove(); resolve(val); };
overlay.onclick = e => { if (e.target === overlay) close(null); };
overlay.innerHTML = `
How should this album be deleted?
π
Remove from Library
Remove the album and all tracks from the database. Files on disk are not affected.
ποΈ
Delete Files Too
Remove from library and delete all audio files from disk. Empty album folder will be cleaned up.
`;
overlay.querySelectorAll('.smart-delete-option').forEach(btn => {
btn.addEventListener('click', () => close(btn.dataset.choice));
});
overlay.querySelector('.smart-delete-close').addEventListener('click', () => close(null));
const escHandler = e => { if (e.key === 'Escape') { document.removeEventListener('keydown', escHandler); close(null); } };
document.addEventListener('keydown', escHandler);
document.body.appendChild(overlay);
});
}
function extractFormat(filePath) {
if (!filePath) return '-';
const ext = filePath.split('.').pop().toLowerCase();
const formatMap = { mp3: 'MP3', flac: 'FLAC', m4a: 'AAC', ogg: 'OGG', opus: 'OPUS', wav: 'WAV', wma: 'WMA', aac: 'AAC' };
return formatMap[ext] || ext.toUpperCase();
}
function formatDurationMs(ms) {
if (!ms) return '-';
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
function getServiceUrl(service, entityType, id) {
if (!id) return null;
const urls = {
spotify: {
artist: `https://open.spotify.com/artist/${id}`,
album: `https://open.spotify.com/album/${id}`,
track: `https://open.spotify.com/track/${id}`,
},
musicbrainz: {
artist: `https://musicbrainz.org/artist/${id}`,
album: `https://musicbrainz.org/release/${id}`,
track: `https://musicbrainz.org/recording/${id}`,
},
deezer: {
artist: `https://www.deezer.com/artist/${id}`,
album: `https://www.deezer.com/album/${id}`,
track: `https://www.deezer.com/track/${id}`,
},
audiodb: {
artist: `https://www.theaudiodb.com/artist/${id}`,
album: `https://www.theaudiodb.com/album/${id}`,
track: `https://www.theaudiodb.com/track/${id}`,
},
itunes: {
artist: `https://music.apple.com/artist/${id}`,
album: `https://music.apple.com/album/${id}`,
track: `https://music.apple.com/song/${id}`,
},
lastfm: {
artist: id, // lastfm_url is already a full URL
album: id,
track: id,
},
genius: {
artist: id, // genius_url is already a full URL
track: id, // genius_url on tracks is already a full URL
},
tidal: {
artist: `https://tidal.com/browse/artist/${id}`,
album: `https://tidal.com/browse/album/${id}`,
track: `https://tidal.com/browse/track/${id}`,
},
qobuz: {
artist: `https://www.qobuz.com/artist/${id}`,
album: `https://www.qobuz.com/album/${id}`,
track: `https://www.qobuz.com/track/${id}`,
},
discogs: {
artist: `https://www.discogs.com/artist/${id}`,
album: `https://www.discogs.com/release/${id}`,
},
amazon: {
album: `https://music.amazon.com/albums/${id}`,
track: `https://music.amazon.com/tracks/${id}`,
},
};
return urls[service] && urls[service][entityType] || null;
}
function makeClickableBadge(service, entityType, id, label) {
const url = getServiceUrl(service, entityType, id);
if (url) {
const a = document.createElement('a');
a.className = `enhanced-id-badge ${service === 'musicbrainz' ? 'mb' : service}`;
a.href = url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.textContent = label;
a.title = `${label}: ${id} (click to open)`;
a.onclick = (e) => e.stopPropagation();
return a;
}
const span = document.createElement('span');
span.className = `enhanced-id-badge ${service === 'musicbrainz' ? 'mb' : service}`;
span.textContent = label;
span.title = `${label}: ${id}`;
return span;
}
// ---- Inline Editing ----
function startInlineEdit(cell, type, id, field, currentValue) {
if (cell.querySelector('.enhanced-inline-input')) return;
cancelInlineEdit();
const isNumeric = ['track_number', 'bpm'].includes(field);
const originalContent = cell.innerHTML;
cell.dataset.originalContent = originalContent;
const input = document.createElement('input');
input.type = isNumeric ? 'number' : 'text';
input.className = 'enhanced-inline-input' + (isNumeric ? ' num' : '');
input.value = currentValue || '';
if (field === 'bpm') input.step = '0.1';
if (field === 'track_number') { input.min = '1'; input.step = '1'; }
cell.innerHTML = '';
cell.appendChild(input);
input.focus();
input.select();
artistDetailPageState.editingCell = { cell, type, id, field, originalContent };
input.addEventListener('click', e => e.stopPropagation());
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
saveInlineEdit(type, id, field, input.value);
} else if (e.key === 'Escape') {
cancelInlineEdit();
}
e.stopPropagation();
});
input.addEventListener('blur', () => {
setTimeout(() => {
if (artistDetailPageState.editingCell && artistDetailPageState.editingCell.cell === cell) {
saveInlineEdit(type, id, field, input.value);
}
}, 150);
});
}
async function saveInlineEdit(type, id, field, newValue) {
const editInfo = artistDetailPageState.editingCell;
if (!editInfo) return;
artistDetailPageState.editingCell = null;
let parsedValue = newValue;
if (field === 'track_number') parsedValue = parseInt(newValue) || null;
else if (field === 'bpm') parsedValue = parseFloat(newValue) || null;
else if (field === 'explicit') parsedValue = parseInt(newValue) || 0;
const url = type === 'track' ? `/api/library/track/${id}` : `/api/library/album/${id}`;
try {
const response = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: parsedValue })
});
const result = await response.json();
if (!result.success) throw new Error(result.error);
const displayValue = parsedValue !== null && parsedValue !== '' ? String(parsedValue) : '-';
editInfo.cell.textContent = displayValue;
updateLocalEnhancedData(type, id, field, parsedValue);
showToast(`Updated ${field}`, 'success');
} catch (error) {
console.error('Failed to save inline edit:', error);
editInfo.cell.innerHTML = editInfo.originalContent;
showToast(`Failed to update: ${error.message}`, 'error');
}
}
function cancelInlineEdit() {
const editInfo = artistDetailPageState.editingCell;
if (!editInfo) return;
editInfo.cell.innerHTML = editInfo.originalContent;
artistDetailPageState.editingCell = null;
}
function updateLocalEnhancedData(type, id, field, value) {
const data = artistDetailPageState.enhancedData;
if (!data) return;
if (type === 'track') {
for (const album of data.albums) {
const track = (album.tracks || []).find(t => String(t.id) === String(id));
if (track) { track[field] = value; break; }
}
} else if (type === 'album') {
const album = data.albums.find(a => String(a.id) === String(id));
if (album) album[field] = value;
} else if (type === 'artist') {
data.artist[field] = value;
}
}
// ---- Track Selection & Bulk Operations ----
function toggleTrackSelection(trackId) {
trackId = String(trackId);
if (artistDetailPageState.selectedTracks.has(trackId)) {
artistDetailPageState.selectedTracks.delete(trackId);
} else {
artistDetailPageState.selectedTracks.add(trackId);
}
const row = document.querySelector(`tr[data-track-id="${trackId}"]`);
if (row) row.classList.toggle('selected', artistDetailPageState.selectedTracks.has(trackId));
updateBulkBar();
}
function toggleSelectAllTracks(albumId, checked) {
const album = findEnhancedAlbum(albumId);
if (!album || !album.tracks) return;
// Batch update state
album.tracks.forEach(track => {
const tid = String(track.id);
if (checked) artistDetailPageState.selectedTracks.add(tid);
else artistDetailPageState.selectedTracks.delete(tid);
});
// Scoped DOM query β only search within this album's panel, not entire document
const panel = document.getElementById(`enhanced-tracks-panel-${albumId}`);
if (panel) {
panel.querySelectorAll('tr[data-track-id]').forEach(row => {
row.classList.toggle('selected', checked);
const cb = row.querySelector('.enhanced-track-checkbox');
if (cb) cb.checked = checked;
});
}
updateBulkBar();
}
function clearTrackSelection() {
// Scoped batch clear β query the container once instead of per-track
const container = document.getElementById('enhanced-view-container');
if (container) {
container.querySelectorAll('tr[data-track-id].selected').forEach(row => {
row.classList.remove('selected');
const cb = row.querySelector('.enhanced-track-checkbox');
if (cb) cb.checked = false;
});
container.querySelectorAll('.enhanced-track-table thead .enhanced-track-checkbox').forEach(cb => cb.checked = false);
}
artistDetailPageState.selectedTracks.clear();
updateBulkBar();
}
function updateBulkBar() {
const bar = document.getElementById('enhanced-bulk-bar');
const count = document.getElementById('enhanced-bulk-count');
if (!bar || !count) return;
if (!isEnhancedAdmin()) {
bar.classList.remove('visible');
return;
}
const n = artistDetailPageState.selectedTracks.size;
count.textContent = n;
bar.classList.toggle('visible', n > 0);
}
function showBulkEditModal() {
const overlay = document.getElementById('enhanced-bulk-edit-overlay');
const body = document.getElementById('enhanced-bulk-modal-body');
const title = document.getElementById('enhanced-bulk-modal-title');
if (!overlay || !body) return;
const count = artistDetailPageState.selectedTracks.size;
title.textContent = `Batch Edit ${count} Track${count !== 1 ? 's' : ''}`;
body.innerHTML = `
Track Number (leave blank to skip)
BPM (leave blank to skip)
Style (leave blank to skip)
Mood (leave blank to skip)
Explicit
-- No change --
No
Yes
`;
overlay.classList.remove('hidden');
}
function closeBulkEditModal() {
const overlay = document.getElementById('enhanced-bulk-edit-overlay');
if (overlay) overlay.classList.add('hidden');
}
async function executeBulkEdit() {
const trackIds = Array.from(artistDetailPageState.selectedTracks);
if (trackIds.length === 0) return;
const updates = {};
const trackNum = document.getElementById('bulk-edit-track-number');
const bpm = document.getElementById('bulk-edit-bpm');
const style = document.getElementById('bulk-edit-style');
const mood = document.getElementById('bulk-edit-mood');
const explicit = document.getElementById('bulk-edit-explicit');
if (trackNum && trackNum.value !== '') updates.track_number = parseInt(trackNum.value);
if (bpm && bpm.value !== '') updates.bpm = parseFloat(bpm.value);
if (style && style.value !== '') updates.style = style.value;
if (mood && mood.value !== '') updates.mood = mood.value;
if (explicit && explicit.value !== '') updates.explicit = parseInt(explicit.value);
if (Object.keys(updates).length === 0) {
showToast('No changes to apply', 'error');
return;
}
try {
const response = await fetch('/api/library/tracks/batch', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ track_ids: trackIds, updates })
});
const result = await response.json();
if (!result.success) throw new Error(result.error);
showToast(`Updated ${result.updated_count} tracks`, 'success');
closeBulkEditModal();
for (const [field, val] of Object.entries(updates)) {
trackIds.forEach(tid => updateLocalEnhancedData('track', tid, field, val));
}
reRenderExpandedPanels();
clearTrackSelection();
} catch (error) {
console.error('Bulk edit failed:', error);
showToast(`Bulk edit failed: ${error.message}`, 'error');
}
}
// ---- Save Artist / Album Metadata ----
async function saveArtistMetadata() {
const form = document.getElementById('enhanced-artist-meta-form');
if (!form) return;
const inputs = form.querySelectorAll('.enhanced-meta-field-input');
const updates = {};
const original = artistDetailPageState.enhancedData.artist;
inputs.forEach(input => {
const field = input.dataset.field;
if (!field) return;
let value = (input.tagName === 'TEXTAREA' ? input.value : input.value).trim();
let origVal = original[field];
if (field === 'genres') {
const newGenres = value ? value.split(',').map(g => g.trim()).filter(Boolean) : [];
const origGenres = Array.isArray(origVal) ? origVal : [];
if (JSON.stringify(newGenres) !== JSON.stringify(origGenres)) updates[field] = newGenres;
} else {
if ((value || '') !== (origVal || '')) updates[field] = value || null;
}
});
if (Object.keys(updates).length === 0) {
showToast('No changes to save', 'error');
return;
}
try {
const response = await fetch(`/api/library/artist/${original.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
const result = await response.json();
if (!result.success) throw new Error(result.error);
for (const [field, value] of Object.entries(updates)) {
artistDetailPageState.enhancedData.artist[field] = value;
}
// Update the display name in the header
if (updates.name) {
const nameEl = document.querySelector('.enhanced-artist-meta-name');
if (nameEl) nameEl.textContent = updates.name;
}
showToast(`Artist metadata saved (${(result.updated_fields || []).join(', ')})`, 'success');
} catch (error) {
console.error('Failed to save artist metadata:', error);
showToast(`Failed to save: ${error.message}`, 'error');
}
}
function revertArtistMetadata() {
const data = artistDetailPageState.enhancedData;
if (!data) return;
const panel = document.getElementById('enhanced-artist-meta');
if (!panel) return;
const parent = panel.parentNode;
const newPanel = renderArtistMetaPanel(data.artist);
parent.replaceChild(newPanel, panel);
showToast('Reverted to saved values', 'success');
}
async function saveAlbumMetadata(albumId) {
const metaRow = document.getElementById(`enhanced-album-meta-${albumId}`);
if (!metaRow) return;
const album = findEnhancedAlbum(albumId);
if (!album) return;
const inputs = metaRow.querySelectorAll('.enhanced-album-meta-input');
const updates = {};
let invalidDate = false;
inputs.forEach(input => {
const field = input.dataset.field;
if (!field) return;
let value = input.value.trim();
if (field === 'genres') {
const newGenres = value ? value.split(',').map(g => g.trim()).filter(Boolean) : [];
const origGenres = Array.isArray(album.genres) ? album.genres : [];
if (JSON.stringify(newGenres) !== JSON.stringify(origGenres)) updates[field] = newGenres;
} else if (field === 'year' || field === 'explicit' || field === 'track_count') {
const numVal = value !== '' ? parseInt(value) : null;
if (numVal !== (album[field] || null)) updates[field] = numVal;
} else if (field === 'release_date') {
// Accept empty, YYYY, YYYY-MM or YYYY-MM-DD (#824 full release dates).
if (value && !/^\d{4}(-\d{2}(-\d{2})?)?$/.test(value)) { invalidDate = true; return; }
if ((value || '') !== (album.release_date || '')) updates[field] = value || null;
} else {
if ((value || '') !== (album[field] || '')) updates[field] = value || null;
}
});
if (invalidDate) {
showToast('Release Date must be YYYY-MM-DD (or just YYYY)', 'error');
return;
}
if (Object.keys(updates).length === 0) {
showToast('No album changes to save', 'error');
return;
}
try {
const response = await fetch(`/api/library/album/${albumId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
const result = await response.json();
if (!result.success) throw new Error(result.error);
for (const [field, value] of Object.entries(updates)) {
album[field] = value;
}
// Update album row display
const albumRow = document.getElementById(`enhanced-album-row-${albumId}`);
if (albumRow) {
if (updates.title) {
const titleEl = albumRow.querySelector('.enhanced-album-title');
if (titleEl) { titleEl.textContent = updates.title; titleEl.title = updates.title; }
}
if (updates.year !== undefined) {
const yearEl = albumRow.querySelector('.enhanced-album-year');
if (yearEl) yearEl.textContent = updates.year || '-';
}
}
showToast(`Album metadata saved (${(result.updated_fields || []).join(', ')})`, 'success');
} catch (error) {
console.error('Failed to save album metadata:', error);
showToast(`Failed to save: ${error.message}`, 'error');
}
}
function reRenderExpandedPanels() {
artistDetailPageState.expandedAlbums.forEach(albumId => {
const panel = document.getElementById(`enhanced-tracks-panel-${albumId}`);
if (!panel) return;
const inner = panel.querySelector('.enhanced-tracks-panel-inner');
if (!inner) return;
const album = findEnhancedAlbum(albumId);
if (album) {
inner.innerHTML = '';
inner.appendChild(renderExpandedAlbumHeader(album));
inner.appendChild(renderAlbumMetaRow(album));
inner.appendChild(renderTrackTable(album));
}
});
}
// ---- Manual Match Modal ----
function openManualMatchModal(entityType, entityId, service, defaultQuery, artistId) {
// Remove existing modal if any
const existing = document.getElementById('enhanced-manual-match-overlay');
if (existing) existing.remove();
const serviceLabels = {
spotify: 'Spotify', musicbrainz: 'MusicBrainz', deezer: 'Deezer',
audiodb: 'AudioDB', itunes: 'iTunes', lastfm: 'Last.fm', genius: 'Genius',
tidal: 'Tidal', qobuz: 'Qobuz', amazon: 'Amazon Music'
};
const overlay = document.createElement('div');
overlay.id = 'enhanced-manual-match-overlay';
overlay.className = 'modal-overlay';
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
const modal = document.createElement('div');
modal.className = 'enhanced-manual-match-modal';
// Header
const header = document.createElement('div');
header.className = 'enhanced-bulk-modal-header';
const title = document.createElement('h3');
title.textContent = `Match ${entityType} on ${serviceLabels[service] || service}`;
header.appendChild(title);
const closeBtn = document.createElement('button');
closeBtn.className = 'enhanced-bulk-modal-close';
closeBtn.innerHTML = '×';
closeBtn.onclick = () => overlay.remove();
header.appendChild(closeBtn);
modal.appendChild(header);
// Search bar
const searchRow = document.createElement('div');
searchRow.className = 'enhanced-match-search-row';
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.className = 'enhanced-match-search-input';
searchInput.placeholder = service === 'musicbrainz'
? `Search ${serviceLabels[service]}β¦ or paste a MusicBrainz ID/URL`
: `Search ${serviceLabels[service] || service}...`;
searchInput.value = defaultQuery;
searchRow.appendChild(searchInput);
const searchBtn = document.createElement('button');
searchBtn.className = 'enhanced-enrich-btn';
searchBtn.textContent = 'Search';
searchBtn.onclick = () => doManualMatchSearch(service, entityType, searchInput.value, resultsContainer, entityId, artistId);
searchRow.appendChild(searchBtn);
// Clear Match button β lets user revert a wrong match to not_found
const clearBtn = document.createElement('button');
clearBtn.className = 'enhanced-enrich-btn';
clearBtn.style.cssText = 'background:rgba(255,80,80,0.12);color:#ff6b6b;margin-left:6px';
clearBtn.textContent = 'Clear Match';
clearBtn.title = 'Remove the current match β reverts to Not Found';
clearBtn.onclick = async () => {
if (!confirm(`Clear ${serviceLabels[service] || service} match for this ${entityType}? It will revert to "Not Found".`)) return;
try {
const res = await fetch('/api/library/clear-match', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_type: entityType, entity_id: entityId, service, artist_id: artistId })
});
const data = await res.json();
if (data.success) {
showToast(`Cleared ${serviceLabels[service] || service} match`, 'success');
overlay.remove();
if (data.updated_data) {
artistDetailPageState.enhancedData = data.updated_data;
renderEnhancedArtistView(data.updated_data, true);
}
} else {
showToast(data.error || 'Failed to clear match', 'error');
}
} catch (e) {
showToast('Error clearing match', 'error');
}
};
searchRow.appendChild(clearBtn);
modal.appendChild(searchRow);
// Handle Enter key
searchInput.onkeydown = (e) => {
if (e.key === 'Enter') searchBtn.click();
};
// Results container
const resultsContainer = document.createElement('div');
resultsContainer.className = 'enhanced-match-results';
resultsContainer.innerHTML = 'Press Search or Enter to find matches
';
modal.appendChild(resultsContainer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
// Auto-search on open
searchInput.focus();
searchBtn.click();
}
async function doManualMatchSearch(service, entityType, query, container, entityId, artistId) {
if (!query.trim()) {
container.innerHTML = 'Enter a search term
';
return;
}
container.innerHTML = 'Searching...
';
try {
const response = await fetch('/api/library/search-service', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service, entity_type: entityType, query: query.trim() })
});
const data = await response.json();
if (!data.success) throw new Error(data.error);
const results = data.results || [];
container.innerHTML = '';
if (results.length === 0) {
container.innerHTML = 'No results found. Try a different search.
';
return;
}
results.forEach(result => {
const row = document.createElement('div');
row.className = 'enhanced-match-result-row';
if (result.image) {
const img = document.createElement('img');
img.className = 'enhanced-match-result-img';
img.src = result.image;
img.alt = '';
img.onerror = function () { this.style.display = 'none'; };
row.appendChild(img);
} else {
const placeholder = document.createElement('div');
placeholder.className = 'enhanced-match-result-img-placeholder';
placeholder.innerHTML = '🎵';
row.appendChild(placeholder);
}
const info = document.createElement('div');
info.className = 'enhanced-match-result-info';
const name = document.createElement('div');
name.className = 'enhanced-match-result-name';
name.textContent = result.name || 'Unknown';
info.appendChild(name);
if (result.extra) {
const extra = document.createElement('div');
extra.className = 'enhanced-match-result-extra';
extra.textContent = result.extra;
info.appendChild(extra);
}
const idLine = document.createElement('div');
idLine.className = 'enhanced-match-result-id';
const providerLabel = result.provider && result.provider !== service ? ` (${result.provider})` : '';
idLine.textContent = `ID: ${result.id}${providerLabel}`;
info.appendChild(idLine);
row.appendChild(info);
const matchBtn = document.createElement('button');
matchBtn.className = 'enhanced-meta-save-btn';
matchBtn.textContent = 'Match';
matchBtn.onclick = () => applyManualMatch(entityType, entityId, result.provider || service, result.id, artistId);
row.appendChild(matchBtn);
container.appendChild(row);
});
} catch (error) {
container.innerHTML = `Error: ${escapeHtml(error.message)}
`;
}
}
async function applyManualMatch(entityType, entityId, service, serviceId, artistId) {
try {
showToast(`Matching ${entityType} to ${service}...`, 'info');
const response = await fetch('/api/library/manual-match', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
entity_type: entityType,
entity_id: entityId,
service: service,
service_id: serviceId,
artist_id: artistId
})
});
const result = await response.json();
if (!result.success) throw new Error(result.error);
showToast(`Manually matched to ${service} ID: ${serviceId}`, 'success');
// Close modal
const overlay = document.getElementById('enhanced-manual-match-overlay');
if (overlay) overlay.remove();
// Update view with fresh data
if (result.updated_data && result.updated_data.success) {
artistDetailPageState.enhancedData = result.updated_data;
_rebuildAlbumMap();
renderEnhancedView();
} else if (artistDetailPageState.currentArtistId) {
await loadEnhancedViewData(artistDetailPageState.currentArtistId);
}
} catch (error) {
showToast(`Match failed: ${error.message}`, 'error');
}
}
async function wishlistEnhancedMissingTrack(track, album, downloadNow = false) {
if (!track._hasActionableContext) {
showToast('This missing track needs metadata context before it can be wishlisted or downloaded.', 'error');
return;
}
const artistName = artistDetailPageState.enhancedData?.artist?.name || artistDetailPageState.currentArtistName || '';
const artist = {
id: artistDetailPageState.enhancedData?.artist?.id || artistDetailPageState.currentArtistId || '',
name: artistName,
image_url: artistDetailPageState.enhancedData?.artist?.thumb_url || getArtistImageFromPage() || '',
};
const albumData = {
id: album.id,
name: album.title || 'Unknown Album',
title: album.title || 'Unknown Album',
image_url: album.thumb_url || '',
release_date: album.year ? `${album.year}-01-01` : '',
album_type: album.record_type || 'album',
total_tracks: Number(album.api_track_count || album.track_count || album.tracks?.length || 1),
};
const wishlistTrack = {
id: track.spotify_track_id || track.deezer_id || track.itunes_track_id || track.musicbrainz_recording_id || track.id,
name: track.title || `Track ${track.track_number || ''}`,
title: track.title || `Track ${track.track_number || ''}`,
artists: [{ name: artistName }],
duration_ms: track.duration || 0,
track_number: track.track_number || 1,
disc_number: track.disc_number || 1,
album: albumData,
};
if (typeof openAddToWishlistModal !== 'function') {
showToast('Wishlist modal is not available on this page', 'error');
return;
}
await openAddToWishlistModal(albumData, artist, [wishlistTrack], albumData.album_type, { [wishlistTrack.name]: false });
if (downloadNow && typeof handleWishlistDownloadNow === 'function') {
setTimeout(() => handleWishlistDownloadNow(), 150);
}
}
function openMissingTrackManageModal(track, album) {
if (track._missingExpected && !track._hasActionableContext) {
showToast('This missing track needs metadata context before it can be managed.', 'error');
return;
}
const existing = document.getElementById('enhanced-missing-manage-overlay');
if (existing) existing.remove();
const artistName = artistDetailPageState.enhancedData?.artist?.name || artistDetailPageState.currentArtistName || '';
const overlay = document.createElement('div');
overlay.id = 'enhanced-missing-manage-overlay';
overlay.className = 'modal-overlay';
let isImporting = false;
overlay.onclick = (e) => { if (e.target === overlay && !isImporting) overlay.remove(); };
const modal = document.createElement('div');
modal.className = 'confirm-modal enhanced-missing-manage-modal';
modal.innerHTML = `
Missing album slot
#${escapeHtml(String(track.track_number || '?'))} ${escapeHtml(track.title || 'Unknown Track')}
${escapeHtml(artistName)} · ${escapeHtml(album.title || '')}
+
Add to Library
Open the normal library-add flow with this exact track context.
OK
I Have This
Copy an existing file and process it into this album slot. The original stays untouched.
Cancel
`;
overlay.appendChild(modal);
document.body.appendChild(overlay);
const close = () => overlay.remove();
modal.querySelector('.confirm-modal-close').onclick = close;
modal.querySelector('[data-action="cancel"]').onclick = close;
modal.querySelectorAll('.enhanced-missing-option').forEach(button => {
button.onclick = async () => {
const action = button.dataset.action;
close();
if (action === 'library') {
await wishlistEnhancedMissingTrack(track, album, false);
} else if (action === 'have') {
openHaveMissingTrackModal(track, album);
}
};
});
}
function openHaveMissingTrackModal(track, album) {
if (track._missingExpected && !track._hasActionableContext) {
showToast('This missing track needs metadata context before it can be imported.', 'error');
return;
}
const existing = document.getElementById('enhanced-have-track-overlay');
if (existing) existing.remove();
const artistName = artistDetailPageState.enhancedData?.artist?.name || artistDetailPageState.currentArtistName || '';
const overlay = document.createElement('div');
overlay.id = 'enhanced-have-track-overlay';
overlay.className = 'modal-overlay';
let isImporting = false;
overlay.onclick = (e) => { if (e.target === overlay && !isImporting) overlay.remove(); };
const modal = document.createElement('div');
modal.className = 'enhanced-manual-match-modal enhanced-have-track-modal';
modal.innerHTML = `
Missing album slot
#${escapeHtml(String(track.track_number || '?'))} ${escapeHtml(track.title || 'Unknown Track')}
${escapeHtml(artistName)} · ${escapeHtml(album.title || '')}
Search
Searching your library...
Selected
The selected file stays in its current album/folder. SoulSync copies it, writes the missing track's tags, and places the copy in this album.
Preparing import...
0s
Waiting to start.
`;
overlay.appendChild(modal);
document.body.appendChild(overlay);
let selectedTrackId = null;
let selectedTrackSummary = '';
let importTimer = null;
const searchResultsById = new Map();
const searchInput = modal.querySelector('#enhanced-have-track-search');
const resultsEl = modal.querySelector('#enhanced-have-track-results');
const selectedEl = modal.querySelector('#enhanced-have-selected');
const selectedText = selectedEl.querySelector('strong');
const confirmBtn = modal.querySelector('#enhanced-have-confirm');
const cancelBtn = modal.querySelector('#enhanced-have-cancel');
const closeBtn = modal.querySelector('.enhanced-bulk-modal-close');
const searchBtn = modal.querySelector('#enhanced-have-track-search-btn');
const statusEl = modal.querySelector('#enhanced-have-import-status');
const statusTitle = statusEl.querySelector('.enhanced-have-import-title');
const statusDetail = statusEl.querySelector('.enhanced-have-import-detail');
const statusTime = statusEl.querySelector('.enhanced-have-import-time');
const close = () => {
if (isImporting) return;
if (importTimer) clearInterval(importTimer);
overlay.remove();
};
closeBtn.onclick = close;
cancelBtn.onclick = close;
searchBtn.onclick = () => runSearch();
searchInput.onkeydown = (e) => { if (e.key === 'Enter' && !isImporting) runSearch(); };
function selectResultRow(row) {
if (isImporting || !row) return;
const trackId = row.dataset.trackId;
const result = searchResultsById.get(String(trackId));
if (!trackId || !result) return;
resultsEl.querySelectorAll('.enhanced-have-result-row').forEach(r => {
r.classList.remove('selected');
r.setAttribute('aria-pressed', 'false');
});
row.classList.add('selected');
row.setAttribute('aria-pressed', 'true');
selectedTrackId = trackId;
selectedTrackSummary = `${result.title || 'Unknown'}${result.album_title ? ` from ${result.album_title}` : ''}`;
selectedText.textContent = selectedTrackSummary;
selectedEl.hidden = false;
confirmBtn.disabled = false;
}
resultsEl.addEventListener('click', (event) => {
const row = event.target.closest('.enhanced-have-result-row');
if (!row || !resultsEl.contains(row)) return;
event.preventDefault();
selectResultRow(row);
});
resultsEl.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
const row = event.target.closest('.enhanced-have-result-row');
if (!row) return;
event.preventDefault();
selectResultRow(row);
});
function setImportStatus(title, detail, tone = 'working') {
statusEl.hidden = false;
statusEl.classList.toggle('error', tone === 'error');
statusEl.classList.toggle('success', tone === 'success');
statusTitle.textContent = title;
statusDetail.textContent = detail;
}
function startImportTimer() {
const start = Date.now();
const stages = [
{ after: 0, text: 'Copying selected file into staging.' },
{ after: 4, text: 'Verifying audio and writing the missing track tags.' },
{ after: 10, text: 'Post-processing can take a moment for FLAC files, lyrics, ReplayGain, and metadata.' },
{ after: 20, text: 'Still working. Waiting for the backend to finish and return the refreshed library row.' },
];
if (importTimer) clearInterval(importTimer);
importTimer = setInterval(() => {
const elapsed = Math.floor((Date.now() - start) / 1000);
statusTime.textContent = `${elapsed}s`;
const stage = [...stages].reverse().find(item => elapsed >= item.after);
if (stage) statusDetail.textContent = stage.text;
}, 250);
}
async function runSearch() {
const query = searchInput.value.trim();
if (!query) {
resultsEl.innerHTML = 'Enter a title or artist to search.
';
return;
}
selectedTrackId = null;
selectedTrackSummary = '';
selectedEl.hidden = true;
confirmBtn.disabled = true;
resultsEl.innerHTML = 'Searching...
';
searchResultsById.clear();
try {
const res = await fetch(`/api/library/search-tracks?q=${encodeURIComponent(query)}&limit=12`);
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Search failed');
const tracks = data.tracks || [];
if (tracks.length === 0) {
resultsEl.innerHTML = 'No library tracks found. Try a different search.
';
return;
}
resultsEl.innerHTML = '';
tracks.forEach(result => {
if (!result.id) return;
searchResultsById.set(String(result.id), result);
const row = document.createElement('div');
row.className = 'enhanced-have-result-row';
row.dataset.trackId = String(result.id);
row.setAttribute('role', 'button');
row.setAttribute('tabindex', '0');
row.setAttribute('aria-pressed', 'false');
const fileName = result.file_path ? result.file_path.split(/[\\/]/).pop() : 'No file path';
row.innerHTML = `
${escapeHtml(result.title || 'Unknown')}
${escapeHtml(result.artist_name || '')}${result.album_title ? ` · ${escapeHtml(result.album_title)}` : ''}
${escapeHtml(fileName)}
${result.duration ? `${formatDurationMs(result.duration)} ` : ''}
${result.bitrate ? `${result.bitrate} kbps ` : ''}
`;
resultsEl.appendChild(row);
});
} catch (error) {
resultsEl.innerHTML = `Error: ${escapeHtml(error.message)}
`;
}
}
confirmBtn.onclick = async () => {
if (!selectedTrackId) return;
isImporting = true;
confirmBtn.disabled = true;
confirmBtn.textContent = 'Importing...';
cancelBtn.disabled = true;
closeBtn.disabled = true;
searchBtn.disabled = true;
searchInput.disabled = true;
resultsEl.querySelectorAll('.enhanced-have-result-row').forEach(row => {
row.setAttribute('aria-disabled', 'true');
row.classList.add('disabled');
});
setImportStatus(
'Importing selected file',
selectedTrackSummary ? `Using ${selectedTrackSummary}.` : 'Using the selected library track.'
);
startImportTimer();
try {
const sourceTrack = track._sourceTrack || track;
const expectedTrack = {
title: track.title || sourceTrack.title || sourceTrack.name || '',
name: track.title || sourceTrack.title || sourceTrack.name || '',
track_number: track.track_number || sourceTrack.track_number,
disc_number: track.disc_number || sourceTrack.disc_number || 1,
duration: track.duration || sourceTrack.duration || sourceTrack.duration_ms || 0,
duration_ms: track.duration || sourceTrack.duration_ms || sourceTrack.duration || 0,
source: track.source || sourceTrack.source || '',
track_id: track.track_id || sourceTrack.track_id || sourceTrack.id || '',
id: track.track_id || sourceTrack.track_id || sourceTrack.id || '',
album_id: track.album_id || sourceTrack.album_id || '',
spotify_track_id: track.spotify_track_id || sourceTrack.spotify_track_id || '',
deezer_id: track.deezer_id || sourceTrack.deezer_id || '',
itunes_track_id: track.itunes_track_id || sourceTrack.itunes_track_id || '',
musicbrainz_recording_id: track.musicbrainz_recording_id || sourceTrack.musicbrainz_recording_id || '',
artists: track.artists || sourceTrack.artists || [artistName],
};
const discs = (album.canonical_tracks || album.tracks || []).map(t => Number(t.disc_number || 1));
const res = await fetch(`/api/library/album/${album.id}/import-existing-track`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source_track_id: selectedTrackId,
expected_track: expectedTrack,
album_source_id: album.spotify_album_id || album.deezer_id || album.itunes_album_id || album.musicbrainz_release_id || album.discogs_id || album.tidal_id || album.qobuz_id || '',
total_discs: Math.max(1, ...discs),
}),
});
setImportStatus('Finalizing import', 'Backend finished. Refreshing the enhanced library view...');
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to import track');
if (importTimer) clearInterval(importTimer);
statusTime.textContent = statusTime.textContent || 'done';
setImportStatus('Import complete', 'The copied file is now being shown in this album.', 'success');
showToast('Track imported. Original file was left untouched.', 'success');
if (data.updated_data && data.updated_data.success) {
artistDetailPageState.enhancedData = data.updated_data;
_rebuildAlbumMap();
renderEnhancedView();
} else if (artistDetailPageState.currentArtistId) {
await loadEnhancedViewData(artistDetailPageState.currentArtistId);
}
setTimeout(() => overlay.remove(), 650);
} catch (error) {
if (importTimer) clearInterval(importTimer);
isImporting = false;
confirmBtn.disabled = false;
confirmBtn.textContent = 'Import Track';
cancelBtn.disabled = false;
closeBtn.disabled = false;
searchBtn.disabled = false;
searchInput.disabled = false;
resultsEl.querySelectorAll('.enhanced-have-result-row').forEach(row => {
row.setAttribute('aria-disabled', 'false');
row.classList.remove('disabled');
});
setImportStatus('Import failed', error.message, 'error');
showToast(`Import failed: ${error.message}`, 'error');
}
};
searchInput.focus();
runSearch();
}
// ---- Enrichment ----
let _enrichmentInFlight = false;
async function runEnrichment(entityType, entityId, service, name, artistName, artistId) {
if (_enrichmentInFlight) {
showToast('An enrichment is already in progress', 'error');
return;
}
_enrichmentInFlight = true;
// Add loading class to all match chips for this service
const chipPrefixes = {
'spotify': ['spotify', 'sp'],
'musicbrainz': ['musicbrainz', 'mb'],
'deezer': ['deezer', 'dz'],
'audiodb': ['audiodb', 'adb'],
'itunes': ['itunes', 'it'],
'lastfm': ['last.fm', 'lfm'],
'genius': ['genius', 'gen'],
};
const prefixes = chipPrefixes[service] || [service];
document.querySelectorAll('.enhanced-match-chip').forEach(chip => {
const chipText = chip.textContent.toLowerCase();
if (prefixes.some(p => chipText.startsWith(p))) {
chip.classList.add('loading');
}
});
showToast(`Enriching ${entityType} from ${service}...`, 'info');
try {
const response = await fetch('/api/library/enrich', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
entity_type: entityType,
entity_id: entityId,
service: service,
name: name,
artist_name: artistName,
artist_id: artistId
})
});
const result = await response.json();
if (response.status === 429) {
showToast(result.error || 'Another enrichment is in progress', 'error');
return;
}
if (!result.success) {
throw new Error(result.error || 'Enrichment failed');
}
// Show per-service results
const results = result.results || {};
const successes = Object.entries(results).filter(([, r]) => r.success).map(([s]) => s);
const failures = Object.entries(results).filter(([, r]) => !r.success).map(([s, r]) => `${s}: ${r.error}`);
if (successes.length > 0) {
showToast(`Enriched from: ${successes.join(', ')}`, 'success');
}
if (failures.length > 0) {
showToast(`Failed: ${failures.join('; ')}`, 'error');
}
// Update local data with fresh response and re-render (preserves expanded state)
if (result.updated_data && result.updated_data.success) {
artistDetailPageState.enhancedData = result.updated_data;
_rebuildAlbumMap();
renderEnhancedView();
} else if (artistDetailPageState.currentArtistId) {
await loadEnhancedViewData(artistDetailPageState.currentArtistId);
}
} catch (error) {
console.error('Enrichment error:', error);
showToast(`Enrichment error: ${error.message}`, 'error');
} finally {
_enrichmentInFlight = false;
document.querySelectorAll('.enhanced-match-chip.loading').forEach(c => c.classList.remove('loading'));
}
}
// Close enrich dropdowns when clicking outside (early bail when enhanced view isn't active)
document.addEventListener('click', (e) => {
if (!artistDetailPageState.enhancedView) return;
if (!e.target.closest('.enhanced-enrich-wrap')) {
document.querySelectorAll('.enhanced-enrich-menu.visible').forEach(m => m.classList.remove('visible'));
}
});
// ---- Write Tags to File ----
let _tagPreviewTrackId = null;
let _tagPreviewServerType = null;
async function showTagPreview(trackId) {
_tagPreviewTrackId = trackId;
_tagPreviewServerType = null;
const overlay = document.getElementById('tag-preview-overlay');
const body = document.getElementById('tag-preview-body');
const title = document.getElementById('tag-preview-title');
if (!overlay || !body) return;
title.textContent = 'Write Tags to File';
body.innerHTML = 'Loading tag comparison...
';
overlay.classList.remove('hidden');
// Hide sync checkbox until we know server type
const syncLabel = document.getElementById('tag-preview-sync-label');
if (syncLabel) syncLabel.classList.add('hidden');
try {
const response = await fetch(`/api/library/track/${trackId}/tag-preview`);
const result = await response.json();
if (!result.success) {
body.innerHTML = `${escapeHtml(result.error)}
`;
return;
}
const diff = result.diff || [];
const hasChanges = result.has_changes;
// Show server sync checkbox if a server is connected (not navidrome β it auto-detects)
_tagPreviewServerType = result.server_type || null;
if (syncLabel && _tagPreviewServerType && _tagPreviewServerType !== 'navidrome') {
const syncText = document.getElementById('tag-preview-sync-text');
if (syncText) syncText.textContent = `Sync to ${_tagPreviewServerType === 'plex' ? 'Plex' : 'Jellyfin'}`;
syncLabel.classList.remove('hidden');
}
let html = '';
html += 'Field Current File Tag DB Value ';
html += ' ';
diff.forEach(d => {
const rowClass = d.changed ? 'tag-diff-changed' : 'tag-diff-same';
const arrow = d.changed ? '→ ' : '✓ ';
html += ``;
html += `${d.field} `;
html += `${escapeHtml(d.file_value) || 'empty '} `;
html += `${arrow} `;
html += `${escapeHtml(d.db_value) || 'empty '} `;
html += ' ';
});
html += '
';
if (!hasChanges) {
html += 'File tags already match DB metadata
';
}
body.innerHTML = html;
const writeBtn = document.getElementById('tag-preview-write-btn');
if (writeBtn) {
writeBtn.disabled = !hasChanges && !document.getElementById('tag-preview-embed-cover')?.checked;
}
} catch (error) {
body.innerHTML = `Failed to load preview: ${escapeHtml(error.message)}
`;
}
}
function closeTagPreviewModal() {
const overlay = document.getElementById('tag-preview-overlay');
if (overlay) overlay.classList.add('hidden');
_tagPreviewTrackId = null;
}
async function executeWriteTags() {
if (!_tagPreviewTrackId) return;
const writeBtn = document.getElementById('tag-preview-write-btn');
if (writeBtn) {
writeBtn.disabled = true;
writeBtn.textContent = 'Writing...';
}
const embedCover = document.getElementById('tag-preview-embed-cover')?.checked ?? true;
const syncToServer = document.getElementById('tag-preview-sync-server')?.checked && _tagPreviewServerType && _tagPreviewServerType !== 'navidrome';
try {
const response = await fetch(`/api/library/track/${_tagPreviewTrackId}/write-tags`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ embed_cover: embedCover, sync_to_server: syncToServer })
});
const result = await response.json();
if (!result.success) throw new Error(result.error);
const fieldCount = (result.written_fields || []).length;
let msg = `Tags written successfully (${fieldCount} fields)`;
if (result.server_sync) {
const ss = result.server_sync;
if (ss.synced > 0) msg += ` β synced to ${_tagPreviewServerType === 'plex' ? 'Plex' : 'Jellyfin'}`;
else if (ss.failed > 0) msg += ` β server sync failed`;
}
showToast(msg, 'success');
closeTagPreviewModal();
} catch (error) {
showToast(`Failed to write tags: ${error.message}`, 'error');
} finally {
if (writeBtn) {
writeBtn.disabled = false;
writeBtn.textContent = 'Write Tags';
}
}
}
async function writeAlbumTags(albumId) {
const album = findEnhancedAlbum(albumId);
if (!album) return;
const tracks = (album.tracks || []).filter(t => t.file_path);
if (tracks.length === 0) {
showToast('No tracks with files in this album', 'error');
return;
}
await showBatchTagPreview(tracks.map(t => t.id), album.title);
}
async function batchWriteTagsSelected() {
const trackIds = Array.from(artistDetailPageState.selectedTracks);
if (trackIds.length === 0) return;
await showBatchTagPreview(trackIds, null);
}
async function showBatchTagPreview(trackIds, albumTitle) {
const overlay = document.getElementById('batch-tag-preview-overlay');
const body = document.getElementById('batch-tag-preview-body');
const titleEl = document.getElementById('batch-tag-preview-title');
const summary = document.getElementById('batch-tag-preview-summary');
const writeBtn = document.getElementById('batch-tag-preview-write-btn');
if (!overlay || !body) return;
titleEl.textContent = albumTitle ? `Write Tags β ${albumTitle}` : `Write Tags β ${trackIds.length} Tracks`;
body.innerHTML = 'Loading tag previews...
';
summary.innerHTML = '';
writeBtn.disabled = true;
overlay.classList.remove('hidden');
// Hide sync checkbox until we know server type
const syncLabel = document.getElementById('batch-tag-preview-sync-label');
if (syncLabel) syncLabel.classList.add('hidden');
try {
const response = await fetch('/api/library/tracks/tag-preview-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ track_ids: trackIds })
});
const result = await response.json();
if (!result.success) {
body.innerHTML = `${escapeHtml(result.error)}
`;
return;
}
const tracks = result.tracks || [];
const serverType = result.server_type || null;
// Show sync checkbox if server connected
if (syncLabel && serverType && serverType !== 'navidrome') {
const syncText = document.getElementById('batch-tag-preview-sync-text');
if (syncText) syncText.textContent = `Sync to ${serverType === 'plex' ? 'Plex' : 'Jellyfin'}`;
syncLabel.classList.remove('hidden');
}
// Categorize tracks
const withChanges = tracks.filter(t => t.has_changes);
const noChanges = tracks.filter(t => !t.error && !t.has_changes);
const errors = tracks.filter(t => t.error);
// Summary bar
let summaryHtml = '';
if (withChanges.length > 0) summaryHtml += `${withChanges.length} with changes `;
if (noChanges.length > 0) summaryHtml += `${noChanges.length} unchanged `;
if (errors.length > 0) summaryHtml += `${errors.length} unavailable `;
summaryHtml += '
';
summary.innerHTML = summaryHtml;
// Build track accordion
let html = '';
// Tracks with changes (expanded by default)
withChanges.forEach(track => {
html += _renderBatchTrackDiff(track, true);
});
// Errors
errors.forEach(track => {
html += ``;
html += `
`;
});
// Unchanged tracks (collapsed)
if (noChanges.length > 0) {
html += ``;
html += ``;
html += `
`;
noChanges.forEach(track => {
html += `
`;
html += `${track.track_number || 'β'} `;
html += `${escapeHtml(track.title)} `;
html += `β Tags match `;
html += `
`;
});
html += `
`;
}
if (withChanges.length === 0 && errors.length === 0) {
html += 'All file tags already match DB metadata
';
}
body.innerHTML = html;
// Store state for write action
overlay._batchTrackIds = trackIds;
overlay._batchServerType = serverType;
writeBtn.disabled = withChanges.length === 0;
} catch (error) {
body.innerHTML = `Failed to load previews: ${escapeHtml(error.message)}
`;
}
}
function _renderBatchTrackDiff(track, expanded) {
let html = ``;
html += ``;
html += `
`;
html += '
';
html += 'Field Current File New Value ';
html += ' ';
(track.diff || []).forEach(d => {
if (!d.changed) return; // Only show changed fields in batch view
html += ``;
html += `${d.field} `;
html += `${escapeHtml(d.file_value) || 'empty '} `;
html += `→ `;
html += `${escapeHtml(d.db_value) || 'empty '} `;
html += ' ';
});
html += '
';
return html;
}
function closeBatchTagPreviewModal() {
const overlay = document.getElementById('batch-tag-preview-overlay');
if (overlay) {
overlay.classList.add('hidden');
overlay._batchTrackIds = null;
overlay._batchServerType = null;
}
}
async function executeBatchWriteTags() {
const overlay = document.getElementById('batch-tag-preview-overlay');
const trackIds = overlay?._batchTrackIds;
if (!trackIds || trackIds.length === 0) return;
const writeBtn = document.getElementById('batch-tag-preview-write-btn');
if (writeBtn) {
writeBtn.disabled = true;
writeBtn.textContent = 'Writing...';
}
const embedCover = document.getElementById('batch-tag-preview-embed-cover')?.checked ?? true;
const serverType = overlay._batchServerType;
const syncToServer = document.getElementById('batch-tag-preview-sync-server')?.checked && serverType && serverType !== 'navidrome';
closeBatchTagPreviewModal();
await _startBatchWriteTags(trackIds, embedCover, syncToServer);
if (writeBtn) {
writeBtn.disabled = false;
writeBtn.textContent = 'Write Tags';
}
}
async function _startBatchWriteTags(trackIds, embedCover, syncToServer = false) {
try {
const response = await fetch('/api/library/tracks/write-tags-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ track_ids: trackIds, embed_cover: embedCover, sync_to_server: syncToServer })
});
const result = await response.json();
if (!result.success) throw new Error(result.error);
showToast(`Writing tags for ${trackIds.length} tracks...`, 'info');
_pollBatchWriteTagsStatus();
} catch (error) {
showToast(`Failed to start tag write: ${error.message}`, 'error');
}
}
let _batchWriteTagsPollTimer = null;
function _pollBatchWriteTagsStatus() {
if (_batchWriteTagsPollTimer) clearTimeout(_batchWriteTagsPollTimer);
async function poll() {
try {
const response = await fetch('/api/library/tracks/write-tags-batch/status');
const state = await response.json();
if (state.status === 'running') {
if (state.sync_phase === 'syncing') {
const serverName = state.sync_server === 'plex' ? 'Plex' : state.sync_server === 'jellyfin' ? 'Jellyfin' : state.sync_server;
showToast(`Syncing to ${serverName}...`, 'info');
} else {
const pct = state.total > 0 ? Math.round(state.processed / state.total * 100) : 0;
showToast(`Writing tags: ${state.processed}/${state.total} (${pct}%) β ${state.current_track}`, 'info');
}
_batchWriteTagsPollTimer = setTimeout(poll, 1000);
} else if (state.status === 'done') {
let msg = `Tags written: ${state.written} succeeded, ${state.failed} failed`;
if (state.sync_phase === 'done') {
const serverName = state.sync_server === 'plex' ? 'Plex' : state.sync_server === 'jellyfin' ? 'Jellyfin' : state.sync_server;
if (state.sync_synced > 0 && state.sync_failed === 0) {
msg += ` β synced to ${serverName}`;
} else if (state.sync_failed > 0) {
msg += ` β ${serverName} sync: ${state.sync_synced} synced, ${state.sync_failed} failed`;
}
}
// Surface the first error reason so users can diagnose (e.g. "File not found")
if (state.failed > 0 && state.errors && state.errors.length > 0) {
const firstErr = state.errors[0].error || 'Unknown error';
msg += ` (${firstErr})`;
}
showToast(msg, state.failed > 0 || state.sync_failed > 0 ? 'warning' : 'success');
_batchWriteTagsPollTimer = null;
}
} catch (error) {
console.error('Poll write-tags status failed:', error);
_batchWriteTagsPollTimer = null;
}
}
_batchWriteTagsPollTimer = setTimeout(poll, 800);
}
// ββ ReplayGain Analysis ββ
let _rgBatchPollTimer = null;
let _rgAlbumPollTimer = null;
/**
* Analyze a single track and write track-level ReplayGain tags.
* Synchronous on the server side (~1β3 s). Shows spinner on the button.
*/
async function analyzeTrackReplayGain(trackId, btn) {
if (btn) {
btn.disabled = true;
btn.textContent = 'β¦';
}
try {
const res = await fetch(`/api/library/track/${trackId}/analyze-replaygain`, { method: 'POST' });
const data = await res.json();
if (data.success) {
showToast(`ReplayGain written: ${data.track_gain} (${data.lufs} LUFS)`, 'success');
} else {
showToast(`ReplayGain failed: ${data.error}`, 'error');
}
} catch (err) {
showToast('ReplayGain analysis failed', 'error');
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = 'RG';
}
}
}
/**
* Analyze all tracks in an album and write track + album ReplayGain tags.
* Kicks off a background job; polls for progress.
*/
async function analyzeAlbumReplayGain(albumId, btn) {
if (btn) {
btn.disabled = true;
btn.innerHTML = '♫ Analyzingβ¦';
}
try {
const res = await fetch(`/api/library/album/${albumId}/analyze-replaygain`, { method: 'POST' });
const data = await res.json();
if (!data.success) {
showToast(`ReplayGain: ${data.error}`, 'error');
if (btn) { btn.disabled = false; btn.innerHTML = '♫ ReplayGain'; }
return;
}
showToast('Album ReplayGain analysis startedβ¦', 'info');
_pollAlbumRgStatus(albumId, btn);
} catch (err) {
showToast('Failed to start album ReplayGain analysis', 'error');
if (btn) { btn.disabled = false; btn.innerHTML = '♫ ReplayGain'; }
}
}
function _pollAlbumRgStatus(albumId, btn) {
if (_rgAlbumPollTimer) clearTimeout(_rgAlbumPollTimer);
async function poll() {
try {
const res = await fetch(`/api/library/album/${albumId}/analyze-replaygain/status`);
const state = await res.json();
if (state.status === 'running') {
const pct = state.total > 0 ? Math.round(state.processed / state.total * 100) : 0;
showToast(`ReplayGain: ${state.processed}/${state.total} tracks (${pct}%)`, 'info');
_rgAlbumPollTimer = setTimeout(poll, 1200);
} else if (state.status === 'done') {
const msg = `ReplayGain done: ${state.analyzed} analyzed, ${state.failed} failed`;
showToast(msg, state.failed > 0 ? 'warning' : 'success');
if (btn) { btn.disabled = false; btn.innerHTML = '♫ ReplayGain'; }
_rgAlbumPollTimer = null;
}
} catch (err) {
console.error('ReplayGain album poll failed:', err);
if (btn) { btn.disabled = false; btn.innerHTML = '♫ ReplayGain'; }
_rgAlbumPollTimer = null;
}
}
_rgAlbumPollTimer = setTimeout(poll, 1000);
}
/**
* Analyze selected tracks (track gain only β they may span albums).
*/
async function batchAnalyzeReplayGainSelected() {
const trackIds = Array.from(artistDetailPageState.selectedTracks);
if (trackIds.length === 0) return;
try {
const res = await fetch('/api/library/tracks/analyze-replaygain-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ track_ids: trackIds }),
});
const data = await res.json();
if (!data.success) {
showToast(`ReplayGain: ${data.error}`, 'error');
return;
}
showToast(`ReplayGain analysis started for ${trackIds.length} tracksβ¦`, 'info');
_pollBatchRgStatus();
} catch (err) {
showToast('Failed to start batch ReplayGain analysis', 'error');
}
}
function _pollBatchRgStatus() {
if (_rgBatchPollTimer) clearTimeout(_rgBatchPollTimer);
async function poll() {
try {
const res = await fetch('/api/library/tracks/analyze-replaygain-batch/status');
const state = await res.json();
if (state.status === 'running') {
const pct = state.total > 0 ? Math.round(state.processed / state.total * 100) : 0;
showToast(`ReplayGain: ${state.processed}/${state.total} (${pct}%) β ${state.current_track}`, 'info');
_rgBatchPollTimer = setTimeout(poll, 1000);
} else if (state.status === 'done') {
const msg = `ReplayGain done: ${state.analyzed} written, ${state.failed} failed`;
showToast(msg, state.failed > 0 ? 'warning' : 'success');
_rgBatchPollTimer = null;
}
} catch (err) {
console.error('ReplayGain batch poll failed:', err);
_rgBatchPollTimer = null;
}
}
_rgBatchPollTimer = setTimeout(poll, 800);
}
// ββ Reorganize Album Files ββ
//
// Click β enqueue β close modal. The reorganize queue worker (server-
// side) processes items FIFO. The Reorganize Status panel mounted at
// the top of the artist's enhanced-actions section is what surfaces
// live progress β buttons no longer wait or lock.
let _reorganizeAlbumId = null;
async function showReorganizeModal(albumId) {
// Short-circuit if this album is already queued or running β opening
// the modal would be misleading (the apply click would just dedupe).
const queuedState = _reorganizeStateForAlbum(albumId);
if (queuedState) {
const label = queuedState === 'running' ? 'Reorganize already running for this album' : 'Album already queued for reorganize';
showToast(label, 'info');
if (typeof refreshReorganizeStatusPanel === 'function') {
refreshReorganizeStatusPanel();
}
return;
}
_reorganizeAlbumId = albumId;
const overlay = document.getElementById('reorganize-overlay');
const body = document.getElementById('reorganize-modal-body');
const title = document.getElementById('reorganize-modal-title');
const applyBtn = document.getElementById('reorganize-apply-btn');
if (!overlay || !body) return;
// Find album data from enhanced view state
let albumData = null;
let artistName = '';
if (artistDetailPageState.enhancedData) {
artistName = artistDetailPageState.enhancedData.artist.name || '';
const allAlbums = artistDetailPageState.enhancedData.albums || [];
albumData = allAlbums.find(a => String(a.id) === String(albumId));
}
title.textContent = `Reorganize: ${albumData ? albumData.title : 'Album'}`;
if (applyBtn) {
applyBtn.disabled = true;
applyBtn.textContent = 'Apply';
applyBtn.onclick = () => executeReorganize();
}
let html = '';
// Metadata MODE picker β API call (default) vs read embedded tags.
// Tag-mode (#592) trusts the user's enriched library and issues
// zero API calls.
html += '
';
html += '
Metadata Mode ';
html += '
"API" queries your metadata source for the canonical tracklist. "Embedded tags" reads each file\'s own tags as the source of truth β useful for well-tagged libraries and avoids API calls.
';
html += '
';
html += 'API metadata (default) ';
html += 'Embedded file tags ';
html += ' ';
html += '
';
// Metadata source picker β populated from /reorganize/sources.
// Empty value = use configured primary (with fallback chain).
// Specific source = strict mode, that source only.
// Hidden when mode = 'tags' since the source picker is irrelevant
// (tags are read straight off the file).
html += '
';
html += '
Metadata Source ';
html += '
Pick which source to read the album\'s tracklist from. Defaults to your configured primary. Reorganize uses your global download template, same as fresh downloads.
';
html += '
';
html += 'Use configured primary (auto) ';
html += ' ';
html += '
';
// Action: full pipeline vs rename-only (#875).
html += '
';
html += '
Action ';
html += '
"Full reorganize" re-tags and re-checks every track through the import pipeline β thorough, but slow and it re-touches every file. "Rename only" just moves files to your current naming scheme: no re-tagging, no quality/AcoustID checks, and only files whose name actually changes are touched. Tip: renaming can reset play counts / date-added on your media server.
';
html += '
';
html += 'Full reorganize (default) ';
html += 'Rename only (skip post-processing) ';
html += ' ';
html += '
';
// Preview area
html += '
';
html += '';
html += '
';
html += '
Click "Generate Preview" to see how files will be reorganized.
';
html += '
';
html += '
';
body.innerHTML = html;
overlay.classList.remove('hidden');
// Populate source picker after the modal mounts
setTimeout(() => _populateReorganizeSources(_reorganizeAlbumId), 50);
// Apply user's saved default mode if any
try {
const savedMode = localStorage.getItem('soulsync-reorganize-mode') || 'api';
const sel = document.getElementById('reorganize-mode-select');
if (sel) sel.value = savedMode;
_onReorganizeModeChange();
} catch (e) { /* localStorage unavailable, ignore */ }
}
function _onReorganizeModeChange() {
const mode = document.getElementById('reorganize-mode-select')?.value || 'api';
const srcSection = document.getElementById('reorganize-source-section');
if (srcSection) srcSection.style.display = (mode === 'tags') ? 'none' : '';
try { localStorage.setItem('soulsync-reorganize-mode', mode); } catch (e) {}
}
async function _populateReorganizeSources(albumId) {
const select = document.getElementById('reorganize-source-select');
if (!select || !albumId) return;
try {
const resp = await fetch(`/api/library/album/${albumId}/reorganize/sources`);
if (!resp.ok) return;
const data = await resp.json();
const sources = data.sources || [];
// Keep the "auto" default option, append concrete sources beneath it.
sources.forEach(s => {
const opt = document.createElement('option');
opt.value = s.source;
opt.textContent = s.label || s.source;
select.appendChild(opt);
});
if (sources.length === 0) {
const opt = document.createElement('option');
opt.disabled = true;
opt.textContent = 'No sources available β run enrichment first';
select.appendChild(opt);
}
} catch (err) {
console.error('Failed to load reorganize sources:', err);
}
}
function closeReorganizeModal() {
const overlay = document.getElementById('reorganize-overlay');
if (overlay) overlay.classList.add('hidden');
_reorganizeAlbumId = null;
}
async function loadReorganizePreview() {
const previewBody = document.getElementById('reorganize-preview-body');
const applyBtn = document.getElementById('reorganize-apply-btn');
if (!previewBody || !_reorganizeAlbumId) return;
if (applyBtn) applyBtn.disabled = true;
previewBody.innerHTML = 'Loading preview...
';
// Final apply-button state: only enable when the preview actually
// produced movable tracks AND no collisions blocked it. Any error
// path or empty result keeps it disabled. We compute it as we go and
// commit it in finally so an early return / throw can't leave the
// button stuck disabled forever.
let canApply = false;
try {
const chosenSource = document.getElementById('reorganize-source-select')?.value || '';
const chosenMode = document.getElementById('reorganize-mode-select')?.value || 'api';
const response = await fetch(`/api/library/album/${_reorganizeAlbumId}/reorganize/preview`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: chosenSource, mode: chosenMode })
});
const result = await response.json();
if (!result.success) {
previewBody.innerHTML = `${escapeHtml(result.error || 'Preview failed')}
`;
return;
}
const tracks = result.tracks || [];
if (tracks.length === 0) {
previewBody.innerHTML = 'No tracks found.
';
return;
}
let hasChanges = false;
let hasCollisions = false;
let html = '';
html += '# Title Current Path New Path ';
html += ' ';
tracks.forEach(t => {
const unchanged = t.unchanged;
const noFile = !t.file_exists;
const collision = t.collision;
const unmatched = (t.matched === false);
const missingPath = !unmatched && !noFile && !t.new_path; // matched but path-build failed
if (!unchanged && t.file_exists && !unmatched && !missingPath) hasChanges = true;
if (collision) hasCollisions = true;
let rowClass;
if (collision) rowClass = 'reorganize-row-collision';
else if (noFile || unmatched || missingPath) rowClass = 'reorganize-row-missing';
else if (unchanged) rowClass = 'reorganize-row-unchanged';
else rowClass = 'reorganize-row-changed';
const arrow = collision ? '!!'
: unchanged ? '='
: (noFile || unmatched || missingPath) ? 'β'
: 'β';
const newCell = noFile ? ''
: unmatched ? `${escapeHtml(t.reason || 'Not in selected source\'s tracklist')} `
: missingPath ? `${escapeHtml(t.reason || 'Couldn\'t compute destination path')} `
: (escapeHtml(t.new_path) + (collision ? ' (collision) ' : ''));
html += ``;
html += `${t.track_number || ''} `;
html += `${escapeHtml(t.title)} `;
html += `${noFile ? 'File not found ' : escapeHtml(t.current_path)} `;
html += `${arrow} `;
html += `${newCell} `;
html += ' ';
});
html += '
';
const changedCount = tracks.filter(t => !t.unchanged && t.file_exists && !t.collision && t.matched !== false && t.new_path).length;
const skippedCount = tracks.filter(t => t.unchanged).length;
const missingCount = tracks.filter(t => !t.file_exists).length;
const collisionCount = tracks.filter(t => t.collision).length;
const unmatchedCount = tracks.filter(t => t.file_exists && t.matched === false).length;
const noPathCount = tracks.filter(t => t.file_exists && t.matched !== false && !t.new_path && !t.collision).length;
let summary = ``;
if (changedCount > 0) summary += `${changedCount} will move `;
if (skippedCount > 0) summary += `${skippedCount} unchanged `;
if (unmatchedCount > 0) summary += `${unmatchedCount} not in source β try a different source `;
if (noPathCount > 0) summary += `${noPathCount} couldn't compute destination `;
if (missingCount > 0) summary += `${missingCount} missing on disk `;
if (collisionCount > 0) summary += `${collisionCount} collision${collisionCount !== 1 ? 's' : ''} β likely a source data issue `;
summary += '
';
previewBody.innerHTML = summary + html;
canApply = hasChanges && !hasCollisions;
} catch (error) {
previewBody.innerHTML = `Error: ${escapeHtml(error.message)}
`;
} finally {
if (applyBtn) applyBtn.disabled = !canApply;
}
}
async function executeReorganize() {
if (!_reorganizeAlbumId) return;
const applyBtn = document.getElementById('reorganize-apply-btn');
if (applyBtn) {
applyBtn.disabled = true;
applyBtn.textContent = 'Queueing...';
}
const albumTitle = document.getElementById('reorganize-modal-title')?.textContent
?.replace(/^Reorganize:\s*/, '') || 'album';
try {
const chosenSource = document.getElementById('reorganize-source-select')?.value || '';
const chosenMode = document.getElementById('reorganize-mode-select')?.value || 'api';
const renameOnly = document.getElementById('reorganize-action-select')?.value === 'rename';
const response = await fetch(`/api/library/album/${_reorganizeAlbumId}/reorganize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: chosenSource, mode: chosenMode, rename_only: renameOnly })
});
const result = await response.json();
if (!result.success) throw new Error(result.error);
closeReorganizeModal();
if (result.queued) {
const posLabel = result.position && result.position > 1 ? ` (#${result.position} in queue)` : '';
showToast(`Queued: ${albumTitle}${posLabel}`, 'info');
} else if (result.reason === 'already_queued') {
showToast(`Already queued: ${albumTitle}`, 'info');
} else {
showToast('Reorganize queued', 'info');
}
// Wake the status panel so the user sees the new item land
// immediately rather than waiting for the next poll tick.
if (typeof refreshReorganizeStatusPanel === 'function') {
refreshReorganizeStatusPanel();
}
} catch (error) {
showToast(`Reorganize failed: ${error.message}`, 'error');
if (applyBtn) {
applyBtn.disabled = false;
applyBtn.textContent = 'Apply';
}
}
}
// kettui PR #377 review: distinguish 'completed' from non-completed
// outcomes so zero-failure skips (no_source_id, no_album, no_tracks,
// setup_failed, error) don't get a green checkmark.
function _classifyReorganizeOutcome(state) {
const status = state.result_status;
if (status && status !== 'completed') return 'warning';
if (state.failed && state.failed > 0) return 'warning';
return 'success';
}
function _formatReorganizeResultMessage(state) {
const status = state.result_status;
if (status === 'no_source_id') {
return 'Reorganize skipped β album has no metadata source ID. Run enrichment first.';
}
if (status === 'no_album') {
return 'Reorganize skipped β album not found in DB.';
}
if (status === 'no_tracks') {
return 'Reorganize skipped β album has no tracks.';
}
if (status === 'setup_failed') {
return 'Reorganize failed β couldn\'t create staging directory.';
}
if (status === 'error') {
return 'Reorganize failed β see server logs for details.';
}
let msg = `Reorganized: ${state.moved || 0} moved`;
if (state.skipped > 0) msg += `, ${state.skipped} skipped`;
if (state.failed > 0) msg += `, ${state.failed} failed`;
if (state.failed > 0 && state.errors && state.errors.length > 0) {
msg += ` (${state.errors[0].error})`;
}
return msg;
}
// ββ Reorganize All Albums for Artist ββ
async function _showReorganizeAllModal() {
if (!artistDetailPageState.enhancedData) {
showToast('No album data loaded', 'error');
return;
}
const albums = artistDetailPageState.enhancedData.albums || [];
const artistName = artistDetailPageState.enhancedData.artist.name || 'Artist';
if (albums.length === 0) {
showToast('No albums to reorganize', 'error');
return;
}
const overlay = document.getElementById('reorganize-overlay');
const body = document.getElementById('reorganize-modal-body');
const title = document.getElementById('reorganize-modal-title');
const applyBtn = document.getElementById('reorganize-apply-btn');
if (!overlay || !body) return;
title.textContent = `Reorganize All Albums β ${artistName}`;
let html = '';
// Mode picker β applies to ALL albums.
html += '
';
html += '
Metadata Mode ';
html += '
"API" queries your metadata source for the canonical tracklist. "Embedded tags" reads each file\'s own tags as the source of truth β useful for well-tagged libraries and avoids API calls.
';
html += '
';
html += 'API metadata (default) ';
html += 'Embedded file tags ';
html += ' ';
html += '
';
// Source picker β applies to ALL albums in this run. Hidden when
// mode = 'tags'. Albums without an ID for the chosen source will
// be skipped at the backend with a clear status. Auto = use
// configured primary with fallback chain.
html += '
';
html += '
Metadata Source (applies to all albums) ';
html += '
Pick which source to read tracklists from. Albums without an ID for that source will be skipped. Reorganize uses your global download template, same as fresh downloads.
';
html += '
';
html += 'Use configured primary (auto) ';
html += ' ';
html += '
';
// Album list
html += '
';
html += `
${albums.length} album${albums.length !== 1 ? 's' : ''} will be reorganized: `;
html += '
';
albums.forEach((a, i) => {
const trackCount = a.tracks ? a.tracks.length : '?';
html += `
`;
html += `${escapeHtml(a.title)} (${trackCount} tracks) `;
html += '
';
});
html += '
';
html += '
';
body.innerHTML = html;
// Wire apply button for bulk mode
if (applyBtn) {
applyBtn.disabled = false;
applyBtn.textContent = 'Reorganize All';
applyBtn.onclick = () => _executeReorganizeAll();
}
overlay.classList.remove('hidden');
// Populate the source dropdown from the global authed-sources endpoint
setTimeout(async () => {
const select = document.getElementById('reorganize-source-select');
if (!select) return;
try {
const resp = await fetch('/api/library/reorganize/sources');
if (!resp.ok) return;
const data = await resp.json();
(data.sources || []).forEach(s => {
const opt = document.createElement('option');
opt.value = s.source;
opt.textContent = s.label || s.source;
select.appendChild(opt);
});
} catch (err) {
console.error('Failed to load reorganize sources:', err);
}
}, 50);
// Apply user's saved default mode if any
try {
const savedMode = localStorage.getItem('soulsync-reorganize-mode') || 'api';
const sel = document.getElementById('reorganize-mode-select');
if (sel) sel.value = savedMode;
_onReorganizeModeChange();
} catch (e) { /* localStorage unavailable, ignore */ }
}
async function _executeReorganizeAll() {
const albums = artistDetailPageState.enhancedData?.albums || [];
const total = albums.length;
const artistName = artistDetailPageState.enhancedData?.artist?.name || 'this artist';
const artistId = artistDetailPageState.currentArtistId;
if (!artistId) return;
const confirmed = await showConfirmDialog({
title: 'Reorganize All Albums',
message: `This will queue ${total} album${total !== 1 ? 's' : ''} for ${artistName} using your configured download template. Files will be moved and renamed. This cannot be undone.`,
confirmText: 'Queue All',
destructive: false,
});
if (!confirmed) return;
const applyBtn = document.getElementById('reorganize-apply-btn');
if (applyBtn) { applyBtn.disabled = true; applyBtn.textContent = 'Queueing...'; }
const overlay = document.getElementById('reorganize-overlay');
if (overlay) overlay.classList.add('hidden');
// One source + mode pick applies to every album in the batch.
const chosenSource = document.getElementById('reorganize-source-select')?.value || '';
const chosenMode = document.getElementById('reorganize-mode-select')?.value || 'api';
try {
const resp = await fetch(`/api/library/artist/${artistId}/reorganize-all`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: chosenSource, mode: chosenMode }),
});
const result = await resp.json();
if (!result.success) throw new Error(result.error || 'Queue request failed');
const enqueued = result.enqueued || 0;
const already = result.already_queued || 0;
if (enqueued > 0 && already > 0) {
showToast(`Queued ${enqueued} album${enqueued !== 1 ? 's' : ''}; ${already} already in queue`, 'info');
} else if (enqueued > 0) {
showToast(`Queued ${enqueued} album${enqueued !== 1 ? 's' : ''} for ${artistName}`, 'info');
} else if (already > 0) {
showToast(`All ${already} album${already !== 1 ? 's' : ''} already in queue`, 'info');
} else {
showToast('No albums to queue', 'warning');
}
if (typeof refreshReorganizeStatusPanel === 'function') {
refreshReorganizeStatusPanel();
}
} catch (err) {
showToast(`Reorganize-all failed: ${err.message}`, 'error');
} finally {
if (applyBtn) { applyBtn.disabled = false; applyBtn.textContent = 'Reorganize All'; }
}
}
// ββ Reorganize Status Panel ββ
//
// Lives at the start of `.enhanced-artist-meta-actions`. Polls the
// queue snapshot endpoint and renders an at-a-glance summary plus an
// expandable card list. Only visible when there's something to show
// (active item, queued items, or recent completions).
//
// Cross-artist hint: items belonging to a different artist than the
// page's current one are flagged so the user understands progress they
// see refers to a separate batch.
let _reorgPanelEl = null;
let _reorgPanelArtistId = null;
let _reorgPanelExpanded = false;
let _reorgPanelTimer = null;
let _reorgPanelLastSnapshot = null;
let _reorgPanelInflight = false;
const _REORG_PANEL_FAST_MS = 1500;
const _REORG_PANEL_SLOW_MS = 8000;
function mountReorganizeStatusPanel(container, artistId) {
if (!container) return;
// Tear down any panel left over from a previous artist view.
_stopReorganizeStatusPolling();
const panel = document.createElement('div');
panel.className = 'reorganize-status-panel hidden';
panel.id = 'reorganize-status-panel';
container.insertBefore(panel, container.firstChild);
_reorgPanelEl = panel;
_reorgPanelArtistId = artistId || null;
_reorgPanelExpanded = false;
_reorgPanelLastSnapshot = null;
// Defer the initial refresh: the caller (renderArtistMetaPanel) is
// still building the header in memory, so neither this panel nor
// its ancestor headerRight has been attached to document.body yet.
// refreshReorganizeStatusPanel guards on document.body.contains,
// so a synchronous call here would bail and kill polling forever.
// setTimeout 0 lets the call stack unwind so the parent appendChild
// runs before we check connectivity.
setTimeout(() => {
if (!_reorgPanelEl || !document.body.contains(_reorgPanelEl)) return;
refreshReorganizeStatusPanel();
}, 0);
}
function _stopReorganizeStatusPolling() {
if (_reorgPanelTimer) {
clearTimeout(_reorgPanelTimer);
_reorgPanelTimer = null;
}
_reorgPanelEl = null;
_reorgPanelLastSnapshot = null;
}
function _scheduleReorganizeStatusPoll(delayMs) {
if (_reorgPanelTimer) clearTimeout(_reorgPanelTimer);
_reorgPanelTimer = setTimeout(() => {
_reorgPanelTimer = null;
refreshReorganizeStatusPanel();
}, delayMs);
}
async function refreshReorganizeStatusPanel() {
// The panel may have been unmounted (user navigated away from
// enhanced view); detect by checking it's still in the document.
if (!_reorgPanelEl || !document.body.contains(_reorgPanelEl)) {
_stopReorganizeStatusPolling();
return;
}
if (_reorgPanelInflight) return;
_reorgPanelInflight = true;
let snapshot = null;
try {
const resp = await fetch('/api/library/reorganize/queue');
if (resp.ok) {
const data = await resp.json();
if (data.success !== false) snapshot = data;
} else {
console.warn('Reorganize queue snapshot HTTP', resp.status);
}
} catch (err) {
// Network blip β keep showing the last snapshot, retry slowly.
console.warn('Reorganize queue snapshot failed:', err);
} finally {
_reorgPanelInflight = false;
}
if (snapshot) _reorgPanelLastSnapshot = snapshot;
_renderReorganizeStatusPanel(_reorgPanelLastSnapshot);
// Reschedule. Fast cadence while there's actually work in flight,
// slow when the queue is empty so we're not hammering the endpoint.
if (_reorgPanelEl && document.body.contains(_reorgPanelEl)) {
const active = _reorgPanelLastSnapshot?.active;
const queued = _reorgPanelLastSnapshot?.queued?.length || 0;
const next = (active || queued > 0) ? _REORG_PANEL_FAST_MS : _REORG_PANEL_SLOW_MS;
_scheduleReorganizeStatusPoll(next);
}
}
function _renderReorganizeStatusPanel(snapshot) {
const panel = _reorgPanelEl;
if (!panel) return;
if (!snapshot) {
panel.classList.add('hidden');
return;
}
const active = snapshot.active;
const queued = snapshot.queued || [];
const recent = snapshot.recent || [];
// Show if anything is active/queued, OR a recent completion landed
// within the last 20 seconds (so the user sees the result).
const cutoffSec = (Date.now() / 1000) - 20;
const recentVisible = recent.filter(r => (r.finished_at || 0) >= cutoffSec);
if (!active && queued.length === 0 && recentVisible.length === 0) {
panel.classList.add('hidden');
panel.innerHTML = '';
_paintQueuedAlbumButtons(snapshot);
return;
}
panel.classList.remove('hidden');
// Compact summary (always visible). Click to toggle expand.
let html = '';
html += '
';
if (active) {
const total = active.progress_total || 0;
const done = active.progress_processed || 0;
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
const trackBit = active.current_track ? ` β ${escapeHtml(active.current_track)}` : '';
const albumLabel = _reorgPanelDisplayLabel(active);
html += ` `;
html += `Reorganizing ${escapeHtml(albumLabel)} `;
if (total > 0) html += ` (${done}/${total} Β· ${pct}%)`;
html += `${trackBit} `;
} else if (queued.length > 0) {
html += ` `;
html += `Reorganize queue starting⦠`;
} else {
// Only recent items remain β give a quick wrap-up summary.
const failed = recentVisible.filter(r => r.status === 'failed').length;
const done = recentVisible.filter(r => r.status === 'done').length;
const cls = failed > 0 ? 'recent-warn' : 'recent-ok';
html += ` `;
const parts = [];
if (done > 0) parts.push(`${done} reorganized`);
if (failed > 0) parts.push(`${failed} failed`);
html += `${parts.join(', ') || 'Recent activity'} `;
}
html += '
';
// Right: queue count badge + expand chevron.
html += '
';
if (queued.length > 0) {
html += `+${queued.length} queued `;
}
const chev = _reorgPanelExpanded ? 'βΎ' : 'βΈ';
html += `${chev} `;
html += '
';
html += '
';
if (_reorgPanelExpanded) {
html += '';
// Active card
if (active) {
html += _reorgPanelRenderActiveCard(active);
}
// Queued list
if (queued.length > 0) {
html += '';
html += '
';
queued.forEach((item, idx) => {
html += _reorgPanelRenderQueuedRow(item, idx + 1);
});
html += '
';
}
// Recent
if (recentVisible.length > 0) {
html += ``;
html += '
';
recentVisible.slice(0, 6).forEach(item => {
html += _reorgPanelRenderRecentRow(item);
});
html += '
';
}
html += '
';
}
panel.innerHTML = html;
// Mark per-album reorganize buttons so users see at-a-glance which
// albums are already in the queue without opening the modal.
_paintQueuedAlbumButtons(snapshot);
// If the active item just transitioned to a recent done/failed
// entry, refresh the enhanced view so the new on-disk paths show.
_maybeReloadEnhancedAfterCompletion(snapshot);
}
function _reorganizeStateForAlbum(albumId) {
const snap = _reorgPanelLastSnapshot;
if (!snap) return null;
const id = String(albumId);
if (snap.active && String(snap.active.album_id) === id) return 'running';
if ((snap.queued || []).some(q => String(q.album_id) === id)) return 'queued';
return null;
}
function _paintQueuedAlbumButtons(snapshot) {
const queuedIds = new Set();
const runningIds = new Set();
if (snapshot?.active) runningIds.add(String(snapshot.active.album_id));
(snapshot?.queued || []).forEach(q => queuedIds.add(String(q.album_id)));
document.querySelectorAll('.enhanced-reorganize-album-btn[data-album-id]').forEach(btn => {
const id = btn.dataset.albumId;
if (runningIds.has(id)) {
btn.classList.add('reorg-state-running');
btn.classList.remove('reorg-state-queued');
btn.title = 'Reorganize already running for this album';
} else if (queuedIds.has(id)) {
btn.classList.add('reorg-state-queued');
btn.classList.remove('reorg-state-running');
btn.title = 'Album already queued for reorganize';
} else {
btn.classList.remove('reorg-state-queued', 'reorg-state-running');
btn.title = 'Reorganize album files using your configured download template';
}
});
}
function _reorgPanelDisplayLabel(item) {
if (!item) return '';
if (_reorgPanelArtistId && item.artist_id && String(item.artist_id) !== _reorgPanelArtistId) {
return `${item.album_title || 'Unknown album'} (${item.artist_name || 'other artist'})`;
}
return item.album_title || 'Unknown album';
}
function _reorgPanelRenderActiveCard(active) {
const total = active.progress_total || 0;
const done = active.progress_processed || 0;
const pct = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0;
const crossArtist = _reorgPanelArtistId && active.artist_id && String(active.artist_id) !== _reorgPanelArtistId;
let h = '';
h += `
${escapeHtml(active.album_title || 'Unknown album')}`;
if (crossArtist) {
h += ` ${escapeHtml(active.artist_name || 'other artist')} `;
}
h += '
';
h += '
';
h += '
';
if (total > 0) {
h += `${done}/${total} `;
}
if (active.current_track) {
h += `${escapeHtml(active.current_track)} `;
}
h += '';
h += `${active.moved || 0} moved `;
if ((active.skipped || 0) > 0) h += `${active.skipped} skipped `;
if ((active.failed || 0) > 0) h += `${active.failed} failed `;
h += ' ';
h += '
';
h += '
';
return h;
}
function _reorgPanelRenderQueuedRow(item, position) {
const crossArtist = _reorgPanelArtistId && item.artist_id && String(item.artist_id) !== _reorgPanelArtistId;
let h = '';
h += `
#${position} `;
h += '
';
h += `
${escapeHtml(item.album_title || 'Unknown album')}
`;
if (crossArtist) {
h += `
${escapeHtml(item.artist_name || 'other artist')}
`;
} else if (item.source) {
h += `
via ${escapeHtml(item.source)}
`;
}
h += '
';
h += `
Γ `;
h += '
';
return h;
}
function _reorgPanelRenderRecentRow(item) {
const crossArtist = _reorgPanelArtistId && item.artist_id && String(item.artist_id) !== _reorgPanelArtistId;
const tone = _classifyReorganizeOutcome({
result_status: item.result_status,
failed: item.failed,
});
const cls = item.status === 'cancelled' ? 'cancelled' : tone;
let h = ``;
h += `
`;
h += '
';
h += `
${escapeHtml(item.album_title || 'Unknown album')}
`;
let sub;
if (item.status === 'cancelled') {
sub = 'Cancelled';
} else {
sub = _formatReorganizeResultMessage({
result_status: item.result_status,
moved: item.moved,
skipped: item.skipped,
failed: item.failed,
errors: item.error ? [{ error: item.error }] : [],
});
}
if (crossArtist) sub = `${escapeHtml(item.artist_name || 'other artist')} β ${sub}`;
h += `
${escapeHtml(sub)}
`;
h += '
';
return h;
}
function toggleReorganizeStatusPanel() {
_reorgPanelExpanded = !_reorgPanelExpanded;
_renderReorganizeStatusPanel(_reorgPanelLastSnapshot);
}
async function cancelReorganizeQueueItem(queueId, event) {
if (event) event.stopPropagation();
if (!queueId) return;
try {
const resp = await fetch(`/api/library/reorganize/queue/${encodeURIComponent(queueId)}/cancel`, {
method: 'POST',
});
const data = await resp.json();
if (data.cancelled) {
showToast('Cancelled queued item', 'info');
} else if (data.reason === 'running_cant_cancel') {
showToast('Already running β too late to cancel', 'warning');
} else {
showToast('Could not cancel item', 'warning');
}
} catch (err) {
showToast(`Cancel failed: ${err.message}`, 'error');
}
refreshReorganizeStatusPanel();
}
async function clearReorganizeQueue(event) {
if (event) event.stopPropagation();
const queued = _reorgPanelLastSnapshot?.queued?.length || 0;
if (queued === 0) return;
const confirmed = await showConfirmDialog({
title: 'Cancel All Queued',
message: `Cancel ${queued} queued reorganize${queued !== 1 ? 's' : ''}? The currently-running item will continue.`,
confirmText: 'Cancel All',
destructive: true,
});
if (!confirmed) return;
try {
const resp = await fetch('/api/library/reorganize/queue/clear', { method: 'POST' });
const data = await resp.json();
if (data.success) {
showToast(`Cancelled ${data.cancelled} queued item${data.cancelled !== 1 ? 's' : ''}`, 'info');
}
} catch (err) {
showToast(`Clear failed: ${err.message}`, 'error');
}
refreshReorganizeStatusPanel();
}
let _reorgPanelLastActiveId = null;
let _reorgPanelPendingReload = false;
let _reorgPanelReloadTimer = null;
function _maybeReloadEnhancedAfterCompletion(snapshot) {
// When an item completes for the artist on screen, the moved file
// paths need to be re-rendered in the enhanced view. Two failure
// modes to avoid:
// 1. Reloading mid-batch β a 20-album "Reorganize All" would
// otherwise fire 20 sequential /api/library/artist/X/enhanced
// calls + 20 full re-renders, hammering the server.
// 2. Never reloading β if we wait for queue idle but more items
// keep arriving, the user never sees the freshly-moved paths.
//
// Strategy: mark a reload as pending whenever a completion lands
// for our artist. Defer the reload until the queue is fully idle
// for that artist (no active item, nothing queued) β that's the
// natural "batch finished" boundary. Use a 1.5s timer reset on
// every snapshot so we don't fire while the worker is still
// between items.
const active = snapshot?.active;
const recent = snapshot?.recent || [];
const queued = snapshot?.queued || [];
// Detect a fresh completion (recent-top is a new queue_id we
// hadn't seen as 'active' before) for our artist.
if (active) {
_reorgPanelLastActiveId = active.queue_id;
} else if (_reorgPanelLastActiveId && recent.length > 0) {
const recentTop = recent[0];
if (recentTop.queue_id === _reorgPanelLastActiveId) {
const finishedRecently = (recentTop.finished_at || 0) >= ((Date.now() / 1000) - 10);
const sameArtist = _reorgPanelArtistId &&
recentTop.artist_id && String(recentTop.artist_id) === _reorgPanelArtistId;
if (finishedRecently && sameArtist) {
_reorgPanelPendingReload = true;
}
_reorgPanelLastActiveId = null;
}
}
if (!_reorgPanelPendingReload) return;
// Hold the reload until the queue is fully idle for our artist.
const stillBusyForOurArtist = active &&
_reorgPanelArtistId &&
active.artist_id && String(active.artist_id) === _reorgPanelArtistId;
const queuedForOurArtist = queued.some(q =>
_reorgPanelArtistId && q.artist_id && String(q.artist_id) === _reorgPanelArtistId
);
if (stillBusyForOurArtist || queuedForOurArtist) {
// More work coming for this artist β keep the pending flag,
// don't reload yet. Cancel any already-armed timer.
if (_reorgPanelReloadTimer) {
clearTimeout(_reorgPanelReloadTimer);
_reorgPanelReloadTimer = null;
}
return;
}
// Queue is idle for our artist. Arm a debounced reload β the
// 1.5s gap absorbs the brief window between worker items so a
// back-to-back batch doesn't trigger mid-flight.
if (_reorgPanelReloadTimer) clearTimeout(_reorgPanelReloadTimer);
_reorgPanelReloadTimer = setTimeout(() => {
_reorgPanelReloadTimer = null;
_reorgPanelPendingReload = false;
if (artistDetailPageState.currentArtistId && artistDetailPageState.enhancedView) {
loadEnhancedViewData(artistDetailPageState.currentArtistId);
}
}, 1500);
}
async function playLibraryTrack(track, albumTitle, artistName) {
if (!track.file_path) {
showToast('No file available for this track', 'error');
return;
}
// Library tracks have authoritative metadata in the SoulSync DB β
// any title / artist / album the caller passes in is downstream of
// whatever modal triggered playback and may carry noise like the
// ``||`` filename prefix from a Prowlarr result.
// When the caller has a track.id, fetch the canonical row from
// resolve-track and overwrite the caller-supplied fields with the
// DB values. Falls back silently to the caller-supplied values on
// any error so we never lose the play action over a metadata fetch.
if (track.id && (track.title || track.name) && (artistName || track.artist_name)) {
try {
const _dbResp = await fetch('/api/stats/resolve-track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: track.title || track.name,
artist: artistName || track.artist_name || '',
}),
});
const _dbData = await _dbResp.json();
if (_dbData && _dbData.success && _dbData.track) {
const _row = _dbData.track;
track = {
...track,
id: _row.id ?? track.id,
title: _row.title || track.title,
file_path: _row.file_path || track.file_path,
bitrate: _row.bitrate ?? track.bitrate,
artist_id: _row.artist_id ?? track.artist_id,
album_id: _row.album_id ?? track.album_id,
_stats_image: _row.image_url || _row.album_thumb_url || track._stats_image || null,
};
if (_row.album_title) albumTitle = _row.album_title;
if (_row.artist_name) artistName = _row.artist_name;
}
} catch (_dbErr) {
console.debug('library track DB refresh skipped:', _dbErr);
}
}
try {
// Stop any current playback first
if (audioPlayer && !audioPlayer.paused) {
audioPlayer.pause();
}
// Get album art from enhanced data if available
let albumArt = null;
if (artistDetailPageState.enhancedData) {
const albums = artistDetailPageState.enhancedData.albums || [];
for (const a of albums) {
if ((a.tracks || []).some(t => t.id === track.id)) {
albumArt = a.thumb_url;
break;
}
}
if (!albumArt) albumArt = artistDetailPageState.enhancedData.artist?.thumb_url;
}
if (!albumArt && track._stats_image) albumArt = track._stats_image;
// Set track info in the media player UI
setTrackInfo({
title: track.title || 'Unknown Track',
artist: artistName || 'Unknown Artist',
album: albumTitle || 'Unknown Album',
filename: track.file_path,
is_library: true,
image_url: albumArt,
id: track.id,
artist_id: track.artist_id,
album_id: track.album_id,
bitrate: track.bitrate,
sample_rate: track.sample_rate
});
// Show loading state
showLoadingAnimation();
const loadingText = document.querySelector('.loading-text');
if (loadingText) {
loadingText.textContent = 'Loading library track...';
}
// POST to library play endpoint
const response = await fetch('/api/library/play', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
file_path: track.file_path,
title: track.title || '',
artist: artistName || '',
album: albumTitle || '',
// Server song id so playback can stream via the media server
// when the file isn't on SoulSync's disk (#809).
track_id: track.id || null
})
});
const result = await response.json();
if (!result.success) {
// File not on disk β fall back to streaming from configured source
console.warn('Library file not found, falling back to stream source');
hideLoadingAnimation();
const streamRes = await fetch('/api/enhanced-search/stream-track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
track_name: track.title || '',
artist_name: artistName || '',
album_name: albumTitle || '',
})
});
const streamData = await streamRes.json();
if (streamData.success && streamData.result) {
streamData.result.artist = artistName;
streamData.result.title = track.title;
streamData.result.album = albumTitle;
streamData.result.image_url = track._stats_image || null;
startStream(streamData.result);
return;
}
throw new Error(result.error || 'Failed to start library playback');
}
// Re-apply repeat-one loop property
if (audioPlayer) audioPlayer.loop = (npRepeatMode === 'one');
// Stream state is already "ready" β start audio playback directly
await startAudioPlayback();
} catch (error) {
console.error('Library playback error:', error);
showToast(`Playback error: ${error.message}`, 'error');
hideLoadingAnimation();
clearTrack();
}
}
// ==================== End Enhanced Library Management View ====================
// UI state management functions
function showArtistDetailLoading(show) {
const loadingElement = document.getElementById("artist-detail-loading");
if (loadingElement) {
if (show) {
loadingElement.classList.remove("hidden");
} else {
loadingElement.classList.add("hidden");
}
}
}
function showArtistDetailError(show, message = "") {
const errorElement = document.getElementById("artist-detail-error");
const errorMessageElement = document.getElementById("artist-detail-error-message");
if (errorElement) {
if (show) {
errorElement.classList.remove("hidden");
if (errorMessageElement && message) {
errorMessageElement.textContent = message;
}
} else {
errorElement.classList.add("hidden");
}
}
}
function showArtistDetailMain(show) {
const mainElement = document.getElementById("artist-detail-main");
if (mainElement) {
if (show) {
mainElement.classList.remove("hidden");
} else {
mainElement.classList.add("hidden");
}
}
}
function showArtistDetailHero(show) {
const heroElement = document.getElementById("artist-hero-section");
if (heroElement) {
if (show) {
heroElement.classList.remove("hidden");
} else {
heroElement.classList.add("hidden");
}
}
}
/**
* Initialize the library page watchlist button
*/
async function initializeLibraryWatchlistButton(artistId, artistName) {
const button = document.getElementById('library-artist-watchlist-btn');
if (!button) return;
console.log(`π§ Initializing library watchlist button for: ${artistName} (${artistId})`);
// Reset button state
button.disabled = false;
button.classList.remove('watching');
// Set up click handler
button.onclick = (e) => toggleLibraryWatchlist(e, artistId, artistName);
// Check and update current status
await updateLibraryWatchlistButtonStatus(artistId);
}
/**
* Toggle watchlist status for library page
*/
async function toggleLibraryWatchlist(event, artistId, artistName) {
event.preventDefault();
const button = document.getElementById('library-artist-watchlist-btn');
const icon = button.querySelector('.watchlist-icon');
const text = button.querySelector('.watchlist-text');
// Show loading state
const originalText = text.textContent;
text.textContent = 'Loading...';
button.disabled = true;
try {
// Check current status
const checkResponse = await fetch('/api/watchlist/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_id: artistId })
});
const checkData = await checkResponse.json();
if (!checkData.success) {
throw new Error(checkData.error || 'Failed to check watchlist status');
}
const isWatching = checkData.is_watching;
// Toggle watchlist status
const endpoint = isWatching ? '/api/watchlist/remove' : '/api/watchlist/add';
const payload = isWatching ?
{ artist_id: artistId } :
{ artist_id: artistId, artist_name: artistName };
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to update watchlist');
}
// Update button state based on new status
if (isWatching) {
// Was watching, now removed
icon.textContent = 'ποΈ';
text.textContent = 'Add to Watchlist';
button.classList.remove('watching');
console.log(`β Removed ${artistName} from watchlist`);
} else {
// Was not watching, now added
icon.textContent = 'ποΈ';
text.textContent = 'Watching...';
button.classList.add('watching');
console.log(`β
Added ${artistName} to watchlist`);
}
// Update dashboard watchlist count if function exists
if (typeof updateWatchlistCount === 'function') {
updateWatchlistCount();
}
showToast(data.message, 'success');
} catch (error) {
console.error('Error toggling library watchlist:', error);
// Restore button state
text.textContent = originalText;
showToast(`Error: ${error.message}`, 'error');
} finally {
button.disabled = false;
}
}
/**
* Update library watchlist button status based on current state
*/
async function updateLibraryWatchlistButtonStatus(artistId) {
const button = document.getElementById('library-artist-watchlist-btn');
if (!button) return;
try {
const response = await fetch('/api/watchlist/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_id: artistId })
});
const data = await response.json();
if (data.success) {
const icon = button.querySelector('.watchlist-icon');
const text = button.querySelector('.watchlist-text');
if (data.is_watching) {
icon.textContent = 'ποΈ';
text.textContent = 'Watching...';
button.classList.add('watching');
} else {
icon.textContent = 'ποΈ';
text.textContent = 'Add to Watchlist';
button.classList.remove('watching');
}
}
} catch (error) {
console.warn('Failed to check library watchlist status:', error);
}
}
// ββ Manual Library Match ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
let _mlmOverlay = null;
let _mlmSelectedSource = null;
let _mlmSelectedLibrary = null;
let _mlmSourceTimer = null;
let _mlmLibraryTimer = null;
function openManualLibraryMatchTool(prefill) {
if (_mlmOverlay) _mlmOverlay.remove();
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.id = 'mlm-overlay';
overlay.onclick = (e) => { if (e.target === overlay) _mlmClose(); };
overlay.innerHTML = `
`;
document.body.appendChild(overlay);
_mlmOverlay = overlay;
_mlmSelectedSource = null;
_mlmSelectedLibrary = null;
_mlmUpdateSaveBtn();
_mlmLoadMatches();
if (prefill) {
const src = document.getElementById('mlm-source-search');
if (src) { src.value = prefill; _mlmSourceSearch(prefill); }
}
}
function _mlmClose() {
if (_mlmOverlay) { _mlmOverlay.remove(); _mlmOverlay = null; }
_mlmSelectedSource = null;
_mlmSelectedLibrary = null;
}
function _mlmSourceDebounce(q) {
clearTimeout(_mlmSourceTimer);
_mlmSourceTimer = setTimeout(() => _mlmSourceSearch(q), 300);
}
function _mlmLibraryDebounce(q) {
clearTimeout(_mlmLibraryTimer);
_mlmLibraryTimer = setTimeout(() => _mlmLibrarySearch(q), 300);
}
async function _mlmSourceSearch(q) {
const el = document.getElementById('mlm-source-results');
if (!el) return;
if (!q.trim()) { el.innerHTML = 'Type to search
'; return; }
el.innerHTML = 'Searching…
';
try {
const res = await fetch(`/api/manual-library-matches/source-search?q=${encodeURIComponent(q)}&limit=15`);
const data = await res.json();
_mlmRenderSourceResults(data.tracks || []);
} catch (e) { el.innerHTML = 'Search failed
'; }
}
async function _mlmLibrarySearch(q) {
const el = document.getElementById('mlm-library-results');
if (!el) return;
if (!q.trim()) { el.innerHTML = 'Type to search
'; return; }
el.innerHTML = 'Searching…
';
try {
const res = await fetch(`/api/manual-library-matches/library-search?q=${encodeURIComponent(q)}&limit=15`);
const data = await res.json();
_mlmRenderLibraryResults(data.tracks || []);
} catch (e) { el.innerHTML = 'Search failed
'; }
}
function _mlmEsc(str) {
return String(str || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
}
function _mlmRenderSourceResults(tracks) {
const el = document.getElementById('mlm-source-results');
if (!el) return;
if (!tracks.length) { el.innerHTML = 'No results
'; return; }
el.innerHTML = tracks.map((t, i) => {
const sel = _mlmSelectedSource && _mlmSelectedSource.source_track_id === t.source_track_id ? 'mlm-row-selected' : '';
return `
${_mlmEsc(t.title || 'β')}
${_mlmEsc(t.artist || '')}${t.album ? ' Β· ' + _mlmEsc(t.album) : ''}
${_mlmEsc(t.context || t.source || '')}
`;
}).join('');
el._mlmTracks = tracks;
}
function _mlmRenderLibraryResults(tracks) {
const el = document.getElementById('mlm-library-results');
if (!el) return;
if (!tracks.length) { el.innerHTML = 'No results
'; return; }
el.innerHTML = tracks.map((t, i) => {
const sel = _mlmSelectedLibrary && _mlmSelectedLibrary.id === t.id ? 'mlm-row-selected' : '';
const path = t.file_path ? t.file_path.split(/[/\\]/).pop() : '';
return `
${_mlmEsc(t.title || 'β')}
${_mlmEsc(t.artist_name || '')}${t.album_title ? ' Β· ' + _mlmEsc(t.album_title) : ''}
${_mlmEsc(path)}${t.bitrate ? ' Β· ' + t.bitrate + 'kbps' : ''}
`;
}).join('');
el._mlmTracks = tracks;
}
function _mlmSelectSource(idx) {
const el = document.getElementById('mlm-source-results');
if (!el || !el._mlmTracks) return;
_mlmSelectedSource = el._mlmTracks[idx];
el.querySelectorAll('.mlm-result-row').forEach((r, i) => r.classList.toggle('mlm-row-selected', i === idx));
_mlmUpdateSaveBtn();
}
function _mlmSelectLibrary(idx) {
const el = document.getElementById('mlm-library-results');
if (!el || !el._mlmTracks) return;
_mlmSelectedLibrary = el._mlmTracks[idx];
el.querySelectorAll('.mlm-result-row').forEach((r, i) => r.classList.toggle('mlm-row-selected', i === idx));
_mlmUpdateSaveBtn();
}
function _mlmUpdateSaveBtn() {
const btn = document.getElementById('mlm-save-btn');
if (btn) btn.disabled = !(_mlmSelectedSource && _mlmSelectedLibrary);
}
async function _mlmSaveMatch() {
if (!_mlmSelectedSource || !_mlmSelectedLibrary) return;
const status = document.getElementById('mlm-status');
if (status) status.textContent = 'Savingβ¦';
try {
const body = {
source: _mlmSelectedSource.source,
source_track_id: _mlmSelectedSource.source_track_id,
library_track_id: _mlmSelectedLibrary.id,
source_title: _mlmSelectedSource.title || '',
source_artist: _mlmSelectedSource.artist || '',
source_album: _mlmSelectedSource.album || '',
source_context_json: '',
server_source: '',
};
const res = await fetch('/api/manual-library-matches', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (data.success) {
if (status) status.textContent = 'Saved!';
_mlmSelectedSource = null;
_mlmSelectedLibrary = null;
_mlmUpdateSaveBtn();
await _mlmLoadMatches();
setTimeout(() => { if (status) status.textContent = ''; }, 2000);
} else {
if (status) status.textContent = 'Error: ' + (data.error || 'unknown');
}
} catch (e) {
if (status) status.textContent = 'Network error';
}
}
async function _mlmLoadMatches() {
const el = document.getElementById('mlm-matches-list');
if (!el) return;
try {
const res = await fetch('/api/manual-library-matches');
const data = await res.json();
const matches = data.matches || [];
const countEl = document.getElementById('mlm-match-count');
if (countEl) countEl.textContent = matches.length;
if (!matches.length) {
el.innerHTML = 'No matches saved yet
';
return;
}
el.innerHTML = `
Source Track Library Track Source
${matches.map(m => `
${_mlmEsc(m.source_title || m.source_track_id)}
${_mlmEsc(m.source_artist || '')}
${_mlmEsc(m.library_title || String(m.library_track_id))}
${_mlmEsc(m.library_artist || '')}
${_mlmEsc(m.source)}
✕
`).join('')}
`;
} catch (e) {
el.innerHTML = 'Failed to load matches
';
}
}
async function _mlmDeleteMatch(id) {
try {
await fetch(`/api/manual-library-matches/${id}`, { method: 'DELETE' });
await _mlmLoadMatches();
} catch (e) {
if (typeof showToast === 'function') showToast('Failed to remove match', 'error');
}
}
// =================================
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Artist "DB Record" inspector β everything the database knows about an artist.
// A small glowing button at the bottom-right of the hero opens a programmer-style
// modal: a copyable field table + syntax-highlighted raw JSON, with copy-all and
// save-as-JSON. Library artists only (source artists have no DB row).
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
let _artistRecordData = null; // last-fetched { artist_id, counts, record }
function setupArtistRecordButton(artist) {
const hero = document.getElementById('artist-hero-section');
if (!hero) return;
let btn = document.getElementById('artist-db-record-btn');
const isLibrary = !!(artist && artist.id && document.body.dataset.artistSource === 'library');
if (!isLibrary) { if (btn) btn.style.display = 'none'; return; }
if (!btn) {
btn = document.createElement('button');
btn.id = 'artist-db-record-btn';
btn.className = 'artist-db-record-btn';
btn.type = 'button';
btn.title = 'Inspect everything the database knows about this artist';
btn.innerHTML =
'' +
' ' +
' ' +
' ' +
' DB Record ';
hero.appendChild(btn);
}
btn.style.display = '';
btn.onclick = () => openArtistRecordModal(artist.id, artist.name || 'Artist');
}
async function openArtistRecordModal(artistId, artistName) {
// Clean any prior instance
const existing = document.getElementById('artist-record-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.id = 'artist-record-overlay';
overlay.className = 'arec-overlay';
overlay.innerHTML =
'' +
'' +
'
' +
'
' +
'
Loading recordβ¦
' +
'
' +
'' +
'
';
document.body.appendChild(overlay);
requestAnimationFrame(() => overlay.classList.add('visible'));
const close = () => {
overlay.classList.remove('visible');
document.removeEventListener('keydown', onKey);
setTimeout(() => overlay.remove(), 220);
};
const onKey = (e) => { if (e.key === 'Escape') close(); };
document.addEventListener('keydown', onKey);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
overlay.querySelector('#arec-close').onclick = close;
// Fetch the record
let payload;
try {
const res = await fetch(`/api/artist/${encodeURIComponent(artistId)}/record`);
payload = await res.json();
if (!payload || !payload.success) throw new Error((payload && payload.error) || 'Request failed');
} catch (err) {
document.getElementById('arec-body').innerHTML =
'Could not load record: ' + _arecEsc(err.message || String(err)) + '
';
return;
}
_artistRecordData = payload;
const record = payload.record || {};
const counts = payload.counts || {};
// Footer stat line
const fieldCount = Object.keys(record).length;
const matched = Object.entries(record).filter(([k, v]) => /match_status$/.test(k) && v === 'matched').length;
document.getElementById('arec-footer').innerHTML =
'' + fieldCount + ' fields ' +
'' + (counts.albums != null ? counts.albums : 'β') + ' albums ' +
'' + (counts.tracks != null ? counts.tracks : 'β') + ' tracks ' +
'' + matched + ' sources matched ' +
'id ' + _arecEsc(String(payload.artist_id)) + ' ';
_arecRenderFields(record);
// Toolbar wiring
overlay.querySelectorAll('.arec-tab').forEach(tab => {
tab.onclick = () => {
overlay.querySelectorAll('.arec-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
const filterEl = document.getElementById('arec-filter');
if (tab.dataset.tab === 'json') { _arecRenderJson(record); filterEl.style.visibility = 'hidden'; }
else { _arecRenderFields(record); filterEl.style.visibility = ''; _arecApplyFilter(filterEl.value); }
};
});
document.getElementById('arec-filter').addEventListener('input', (e) => _arecApplyFilter(e.target.value));
document.getElementById('arec-copy').onclick = () =>
_arecCopy(JSON.stringify(record, null, 2), 'Full record copied as JSON');
document.getElementById('arec-download').onclick = () => _arecDownload(record, artistName);
}
function _arecRenderFields(record) {
const body = document.getElementById('arec-body');
if (!body) return;
const rows = Object.entries(record).map(([key, val]) => {
const isEmpty = val === null || val === undefined || val === '';
let display, copyVal;
if (isEmpty) { display = 'null '; copyVal = ''; }
else if (typeof val === 'object') {
copyVal = JSON.stringify(val);
display = '' + _arecEsc(JSON.stringify(val)) + ' ';
} else {
copyVal = String(val);
display = _arecEsc(String(val));
}
return '' +
'' + _arecEsc(key) + ' ' +
'' + display + ' ' +
'β§ ' +
'
';
}).join('');
body.innerHTML = '' + rows + '
';
body.querySelectorAll('.arec-rowcopy').forEach(b => {
b.onclick = () => _arecCopy(b.getAttribute('data-copy'), 'Value copied');
});
}
function _arecRenderJson(record) {
const body = document.getElementById('arec-body');
if (!body) return;
body.innerHTML = '' + _jsonSyntaxHighlight(record) + ' ';
}
function _arecApplyFilter(q) {
q = (q || '').trim().toLowerCase();
document.querySelectorAll('#arec-body .arec-row').forEach(row => {
row.style.display = (!q || row.dataset.field.includes(q)) ? '' : 'none';
});
}
function _jsonSyntaxHighlight(obj) {
let json = JSON.stringify(obj, null, 2);
json = json.replace(/&/g, '&').replace(//g, '>');
return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false)\b|\bnull\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, (m) => {
let cls = 'tok-num';
if (/^"/.test(m)) cls = /:$/.test(m) ? 'tok-key' : 'tok-str';
else if (/true|false/.test(m)) cls = 'tok-bool';
else if (/null/.test(m)) cls = 'tok-null';
return '' + m + ' ';
});
}
function _arecCopy(text, label) {
text = text == null ? '' : String(text);
const done = () => (typeof showToast === 'function') && showToast(label || 'Copied', 'success');
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(done).catch(() => _arecCopyFallback(text, done));
} else { _arecCopyFallback(text, done); }
}
function _arecCopyFallback(text, done) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch (e) { /* ignore */ }
document.body.removeChild(ta);
done();
}
function _arecDownload(record, artistName) {
const safe = String(artistName || 'artist').replace(/[^a-z0-9._-]+/gi, '_').slice(0, 60) || 'artist';
const blob = new Blob([JSON.stringify(record, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = safe + '_db_record.json';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
if (typeof showToast === 'function') showToast('Saved ' + a.download, 'success');
}
function _arecEsc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&').replace(//g, '>');
}
function _arecEscAttr(s) {
return _arecEsc(s).replace(/"/g, '"');
}
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Watchlist export β bulk export the watchlist roster to JSON / CSV / text, with
// optional external discography links. Reuses the DB-record modal aesthetic +
// helpers (_jsonSyntaxHighlight / _arecCopy / _arecEsc). #export-request
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
async function openArtistExportModal(initialScope) {
// One export modal for both rosters β pick Watchlist or Library inside.
let scope = initialScope || 'watchlist';
const epOf = (s) => s === 'library' ? '/api/library/artists/export' : '/api/watchlist/export';
const fileOf = (s) => s === 'library' ? 'library_artists' : 'watchlist';
const existing = document.getElementById('wl-export-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.id = 'wl-export-overlay';
overlay.className = 'arec-overlay';
overlay.innerHTML =
'' +
'' +
'
' +
'
' +
'' +
'
';
document.body.appendChild(overlay);
requestAnimationFrame(() => overlay.classList.add('visible'));
let fmt = 'json', links = false, contents = false, content = '';
const applyScopeUI = () => {
// "library counts" only applies to the library roster.
document.getElementById('wlx-contents-wrap').style.display = (scope === 'library') ? '' : 'none';
if (scope !== 'library') {
contents = false;
const cb = document.getElementById('wlx-contents');
if (cb) cb.checked = false;
}
};
applyScopeUI();
const close = () => {
overlay.classList.remove('visible');
document.removeEventListener('keydown', onKey);
setTimeout(() => overlay.remove(), 220);
};
const onKey = (e) => { if (e.key === 'Escape') close(); };
document.addEventListener('keydown', onKey);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
overlay.querySelector('#wlx-close').onclick = close;
const refresh = async () => {
const body = document.getElementById('wlx-body');
body.innerHTML = 'Building exportβ¦
';
try {
const res = await fetch(epOf(scope) + '?format=' + fmt + '&links=' + (links ? '1' : '0')
+ (scope === 'library' && contents ? '&contents=1' : ''));
content = await res.text();
const count = res.headers.get('X-Export-Count') || '?';
document.getElementById('wlx-footer').innerHTML =
'' + count + ' ' + (scope === 'library' ? 'library' : 'watchlist') + ' artists ' +
'' + fmt.toUpperCase() + ' ';
if (fmt === 'json') {
let parsed; try { parsed = JSON.parse(content || '[]'); } catch (e) { parsed = []; }
body.innerHTML = '' + _jsonSyntaxHighlight(parsed) + ' ';
} else {
body.innerHTML = '' + _arecEsc(content || '(empty)') + ' ';
}
} catch (err) {
body.innerHTML = 'Export failed: ' + _arecEsc(err.message || String(err)) + '
';
}
};
overlay.querySelectorAll('#wlx-scope .arec-tab').forEach(t => {
t.onclick = () => {
if (t.dataset.scope === scope) return;
overlay.querySelectorAll('#wlx-scope .arec-tab').forEach(x => x.classList.remove('active'));
t.classList.add('active');
scope = t.dataset.scope;
applyScopeUI();
refresh();
};
});
overlay.querySelectorAll('#wlx-format .arec-tab').forEach(t => {
t.onclick = () => {
overlay.querySelectorAll('#wlx-format .arec-tab').forEach(x => x.classList.remove('active'));
t.classList.add('active');
fmt = t.dataset.fmt;
refresh();
};
});
document.getElementById('wlx-links').addEventListener('change', (e) => { links = e.target.checked; refresh(); });
document.getElementById('wlx-contents').addEventListener('change', (e) => { contents = e.target.checked; refresh(); });
document.getElementById('wlx-copy').onclick = () => _arecCopy(content, 'Export copied');
document.getElementById('wlx-download').onclick = () => {
const ext = fmt;
const mime = fmt === 'json' ? 'application/json' : (fmt === 'csv' ? 'text/csv' : 'text/plain');
const blob = new Blob([content || ''], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = fileOf(scope) + '_export.' + ext;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
if (typeof showToast === 'function') showToast('Saved ' + fileOf(scope) + '_export.' + ext, 'success');
};
refresh();
}
// ==================== Re-identify Track Modal (#889) ====================
// Lets an admin re-file an already-imported track under a different release
// (single / EP / album). Searches any configured metadata source (tabs, default
// active), and on confirm stages the file + writes a single-use hint the
// auto-import worker consumes (see core/imports/rematch_*.py).
const reidState = { trackId: null, source: null, sources: [], rows: [], selected: null };
function openReidentifyModal(trackId, title, artist, albumTitle, imageUrl) {
reidState.trackId = trackId;
reidState.source = null;
reidState.rows = [];
reidState.selected = null;
const overlay = document.getElementById('reid-modal-overlay');
if (!overlay) return;
// Hero
document.getElementById('reid-hero-title').textContent = title || 'Track';
const sub = document.getElementById('reid-hero-sub');
sub.textContent = (artist || '') + (albumTitle ? ` Β· currently in β${albumTitle}β` : '');
const art = document.getElementById('reid-hero-art');
const bg = document.getElementById('reid-hero-bg');
if (imageUrl) {
art.style.backgroundImage = `url('${imageUrl}')`;
art.classList.remove('empty');
bg.style.backgroundImage = `url('${imageUrl}')`;
} else {
art.style.backgroundImage = '';
art.classList.add('empty');
bg.style.backgroundImage = '';
}
document.getElementById('reid-search-input').value = `${title || ''} ${artist || ''}`.trim();
document.getElementById('reid-replace').checked = true;
_reidUpdateConfirm();
_reidRenderState('idle');
overlay.classList.remove('hidden');
_reidLoadTabs();
}
function closeReidentifyModal() {
const overlay = document.getElementById('reid-modal-overlay');
if (overlay) overlay.classList.add('hidden');
}
async function _reidLoadTabs() {
const tabsEl = document.getElementById('reid-tabs');
tabsEl.innerHTML = '';
try {
const resp = await fetch('/api/reidentify/sources');
const data = await resp.json();
reidState.sources = (data && data.sources) || [];
} catch (_) {
reidState.sources = [];
}
if (!reidState.sources.length) {
tabsEl.innerHTML = 'No metadata sources available ';
_reidRenderState('empty', 'No configured metadata source to search.');
return;
}
const active = reidState.sources.find(s => s.active) || reidState.sources[0];
reidState.source = active.source;
reidState.sources.forEach(s => {
const tab = document.createElement('div');
tab.className = 'reid-tab' + (s.source === reidState.source ? ' active' : '');
tab.textContent = s.label || s.source;
tab.onclick = () => _reidSelectTab(s.source);
tabsEl.appendChild(tab);
});
runReidentifySearch(); // auto-search the active source on open
}
function _reidSelectTab(source) {
if (source === reidState.source) return;
reidState.source = source;
document.querySelectorAll('#reid-tabs .reid-tab').forEach(t => {
t.classList.toggle('active', t.textContent ===
(reidState.sources.find(s => s.source === source) || {}).label);
});
runReidentifySearch();
}
async function runReidentifySearch() {
const query = (document.getElementById('reid-search-input').value || '').trim();
if (!query || !reidState.source) return;
reidState.selected = null;
_reidUpdateConfirm();
_reidRenderState('loading');
try {
const url = `/api/reidentify/search?source=${encodeURIComponent(reidState.source)}&q=${encodeURIComponent(query)}`;
const resp = await fetch(url);
const data = await resp.json();
reidState.rows = (data && data.results) || [];
_reidRenderResults();
} catch (e) {
_reidRenderState('empty', 'Search failed. Try another source.');
}
}
function _reidRenderResults() {
const el = document.getElementById('reid-results');
if (!reidState.rows.length) {
_reidRenderState('empty', 'No releases found. Try refining the search or another source tab.');
return;
}
// ISRC-bearing rows first (provably the same recording), then the rest.
const ranked = reidState.rows
.map((r, i) => ({ r, i }))
.sort((a, b) => (b.r.isrc ? 1 : 0) - (a.r.isrc ? 1 : 0));
el.innerHTML = '';
ranked.forEach(({ r }, n) => {
const badge = (r.album_type || 'album').toLowerCase();
const bits = [];
if (r.year) bits.push(r.year);
if (r.total_tracks) bits.push(`${r.total_tracks} track${r.total_tracks === 1 ? '' : 's'}`);
const row = document.createElement('div');
row.className = 'reid-result';
row.style.animationDelay = `${Math.min(n * 0.03, 0.3)}s`;
row.onclick = () => _reidSelectResult(r, row);
row.innerHTML = `
${r.image_url ? '' : 'βͺ '}
${escapeHtml(r.track_title || '')}
${escapeHtml(r.album_name || 'Unknown release')}${r.artist_name ? ' Β· ' + escapeHtml(r.artist_name) : ''}
${escapeHtml(badge)}
${bits.length ? `${escapeHtml(bits.join(' Β· '))} ` : ''}
`;
el.appendChild(row);
});
}
function _reidSelectResult(r, rowEl) {
reidState.selected = r;
document.querySelectorAll('#reid-results .reid-result').forEach(x => x.classList.remove('selected'));
rowEl.classList.add('selected');
_reidUpdateConfirm();
}
function _reidUpdateConfirm() {
const btn = document.getElementById('reid-confirm-btn');
if (btn) btn.disabled = !reidState.selected;
}
function _reidRenderState(kind, msg) {
const el = document.getElementById('reid-results');
if (!el) return;
if (kind === 'loading') {
el.innerHTML = ''
+ '
';
} else if (kind === 'empty') {
el.innerHTML = `π
${escapeHtml(msg || 'No results.')}
`;
} else { // idle
el.innerHTML = 'πΏ
'
+ '
Pick the release this track should be filed under β the same song may appear on a single, an EP, and an album.
';
}
}
async function confirmReidentify() {
if (!reidState.selected || !reidState.trackId) return;
const btn = document.getElementById('reid-confirm-btn');
const replace = document.getElementById('reid-replace').checked;
btn.disabled = true;
const prev = btn.textContent;
btn.textContent = 'Stagingβ¦';
try {
const resp = await fetch('/api/reidentify/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
library_track_id: reidState.trackId,
source: reidState.selected.source,
track_id: reidState.selected.track_id,
replace: replace,
}),
});
const data = await resp.json();
if (!resp.ok || !data.success) throw new Error(data.error || 'Re-identify failed');
showToast(`Re-filing under β${data.album_name || 'the chosen release'}β β it'll update after the next import pass.`, 'success');
closeReidentifyModal();
} catch (e) {
showToast(e.message || 'Re-identify failed', 'error');
btn.disabled = false;
btn.textContent = prev;
}
}