`;
}
function updateLibraryPagination(pagination) {
const prevBtn = document.getElementById("prev-page-btn");
const nextBtn = document.getElementById("next-page-btn");
const pageInfo = document.getElementById("page-info");
const paginationContainer = document.getElementById("library-pagination");
if (!paginationContainer) return;
// Update button states
if (prevBtn) {
prevBtn.disabled = !pagination.has_prev;
}
if (nextBtn) {
nextBtn.disabled = !pagination.has_next;
}
// Update page info
if (pageInfo) {
pageInfo.textContent = `Page ${pagination.page} of ${pagination.total_pages}`;
}
// Show/hide pagination based on total pages
if (pagination.total_pages > 1) {
paginationContainer.classList.remove("hidden");
} else {
paginationContainer.classList.add("hidden");
}
}
function updateLibraryStats(totalCount) {
const countElement = document.getElementById("library-artist-count");
if (countElement) {
countElement.textContent = totalCount;
}
}
function showLibraryLoading(show) {
const loadingElement = document.getElementById("library-loading");
if (loadingElement) {
if (show) {
loadingElement.classList.remove("hidden");
} else {
loadingElement.classList.add("hidden");
}
}
}
function showLibraryEmpty(show) {
const emptyElement = document.getElementById("library-empty");
if (!emptyElement) return;
if (!show) {
emptyElement.classList.add("hidden");
return;
}
// When a search query is active and returned zero library hits, swap the
// generic "no artists" copy for a CTA that hands the query off to /search
// so the user can look the artist up across metadata sources without
// retyping.
const query = (libraryPageState.currentSearch || '').trim();
const iconEl = document.getElementById('library-empty-icon');
const titleEl = document.getElementById('library-empty-title');
const subtitleEl = document.getElementById('library-empty-subtitle');
const ctaEl = document.getElementById('library-empty-search-cta');
const ctaQueryEl = document.getElementById('library-empty-search-cta-query');
if (query) {
if (iconEl) iconEl.textContent = 'π';
if (titleEl) titleEl.textContent = `"${query}" isn't in your library`;
if (subtitleEl) subtitleEl.textContent = 'They might be available on a connected metadata source.';
if (ctaQueryEl) ctaQueryEl.textContent = `"${query}"`;
if (ctaEl) {
ctaEl.classList.remove('hidden');
// Rebind cleanly β onclick avoids duplicate listeners across renders.
ctaEl.onclick = () => _handoffLibrarySearchToEnhancedSearch(query);
}
} else {
if (iconEl) iconEl.textContent = 'π΅';
if (titleEl) titleEl.textContent = 'No artists found';
if (subtitleEl) subtitleEl.textContent = 'Try adjusting your search or filters';
if (ctaEl) {
ctaEl.classList.add('hidden');
ctaEl.onclick = null;
}
}
emptyElement.classList.remove("hidden");
}
// Navigate to /search and pre-fill the enhanced search input with the query
// the user had typed into the library search. Uses the same hand-off pattern
// the global widget uses for Soulseek β navigate, then dispatch an `input`
// event so the Search page's existing debounced search kicks in.
function _handoffLibrarySearchToEnhancedSearch(query) {
if (typeof navigateToPage !== 'function') return;
navigateToPage('search');
setTimeout(() => {
const input = document.getElementById('enhanced-search-input');
if (input && query) {
input.value = query;
input.dispatchEvent(new Event('input', { bubbles: true }));
}
}, 300);
}
async function openWatchAllUnwatchedModal() {
if (document.getElementById('watch-all-modal-overlay')) return;
const sourceIdField = currentMusicSourceName === 'iTunes' ? 'itunes_artist_id'
: currentMusicSourceName === 'Deezer' ? 'deezer_id' : 'spotify_artist_id';
const sourceName = currentMusicSourceName || 'Spotify';
const overlay = document.createElement('div');
overlay.id = 'watch-all-modal-overlay';
overlay.className = 'modal-overlay';
overlay.onclick = (e) => { if (e.target === overlay) closeWatchAllUnwatchedModal(); };
overlay.innerHTML = `
👁
Watch All Unwatched
Add unwatched artists with ${_esc(sourceName)} IDs to your watchlist
Loading unwatched artists...
`;
document.body.appendChild(overlay);
// Fetch all unwatched artists paginated (SQLite variable limit safe)
try {
const eligible = [];
const ineligible = [];
let page = 1;
const pageSize = 400;
const countEl = document.getElementById('watch-all-load-count');
while (true) {
if (!document.getElementById('watch-all-modal-overlay')) return;
if (countEl) countEl.textContent = `${eligible.length + ineligible.length} artists loaded...`;
const params = new URLSearchParams({ search: '', letter: 'all', page, limit: pageSize, watchlist: 'unwatched' });
const response = await fetch(`/api/library/artists?${params}`);
const data = await response.json();
if (!data.success) throw new Error(data.error || 'Failed to load artists');
for (const a of (data.artists || [])) {
if (a[sourceIdField]) eligible.push(a);
else ineligible.push(a);
}
if (!data.pagination.has_next) break;
page++;
}
_renderWatchAllModalContent(overlay, eligible, ineligible, sourceName);
} catch (error) {
console.error('Error loading unwatched artists:', error);
const body = overlay.querySelector('.watch-all-body');
if (body) body.innerHTML = `
`;
}
}
function _renderWatchAllModalContent(overlay, eligible, ineligible, sourceName) {
const body = overlay.querySelector('.watch-all-body');
const confirmBtn = overlay.querySelector('#watch-all-confirm-btn');
if (eligible.length === 0 && ineligible.length === 0) {
body.innerHTML = '
🎵
No unwatched artists found
';
return;
}
// Store data for search filtering
overlay._watchAllEligible = eligible;
overlay._watchAllIneligible = ineligible;
let html = '';
// Summary bar (sticky)
html += '
';
html += `
${eligible.length}
Ready to watch
`;
html += `
${ineligible.length}
No ${_esc(sourceName)} ID
`;
html += `
${eligible.length + ineligible.length}
Total unwatched
`;
html += '
';
// Search filter
if (eligible.length > 10) {
html += '';
}
// Eligible grid
if (eligible.length > 0) {
html += '
Artists to be watched
';
html += '
';
html += _buildWatchAllRows(eligible, false);
html += '
';
}
// Ineligible section
if (ineligible.length > 0) {
html += `
⚠${ineligible.length} artist${ineligible.length !== 1 ? 's' : ''} without ${_esc(sourceName)} ID
▼
These artists haven't been matched to ${_esc(sourceName)} yet. The background enrichment worker will match them over time.
${_buildWatchAllRows(ineligible, true)}
`;
}
if (eligible.length === 0) {
html += `
🔌
None of your unwatched artists have a ${_esc(sourceName)} ID yet
The background enrichment worker will match them over time.
`;
}
body.innerHTML = html;
if (eligible.length > 0 && confirmBtn) {
confirmBtn.textContent = `Watch All (${eligible.length})`;
confirmBtn.disabled = false;
confirmBtn.onclick = () => _confirmWatchAllUnwatched(overlay, eligible.length);
}
}
function _buildWatchAllRows(artists, dimmed) {
let html = '';
for (const a of artists) {
const img = a.image_url
? `
`;
if (confirmBtn) confirmBtn.style.display = 'none';
if (cancelBtn) { cancelBtn.disabled = false; cancelBtn.textContent = 'Close'; }
overlay.dataset.needsRefresh = 'true';
} else {
throw new Error(data.error || 'Failed to add artists');
}
} catch (error) {
console.error('Error in watch all:', error);
if (confirmBtn) { confirmBtn.disabled = false; confirmBtn.textContent = `Watch All (${expectedCount})`; }
if (cancelBtn) cancelBtn.disabled = false;
showToast('Failed to add artists to watchlist', 'error');
}
}
function closeWatchAllUnwatchedModal() {
const overlay = document.getElementById('watch-all-modal-overlay');
if (!overlay) return;
const needsRefresh = overlay.dataset.needsRefresh === 'true';
overlay.remove();
if (needsRefresh) loadLibraryArtists();
}
async function toggleLibraryCardWatchlist(btn, artist) {
if (btn.disabled) return;
btn.disabled = true;
// Support both badge-style (.watch-icon-label) and button-style (.watchlist-text)
const label = btn.querySelector('.watch-icon-label') || btn.querySelector('.watchlist-text');
const isWatching = btn.classList.contains('watched') || btn.classList.contains('watching');
if (label) label.textContent = '...';
try {
// Use the ID matching the active metadata source
const artistId = currentMusicSourceName === 'iTunes'
? (artist.itunes_artist_id || artist.spotify_artist_id)
: (artist.spotify_artist_id || artist.itunes_artist_id);
if (!artistId) throw new Error('No iTunes or Spotify ID available for this artist');
if (isWatching) {
const response = await fetch('/api/watchlist/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_id: artistId })
});
const data = await response.json();
if (!data.success) throw new Error(data.error);
btn.classList.remove('watched', 'watching');
btn.style.opacity = '0.4';
btn.title = 'Add to Watchlist';
if (label) label.textContent = 'Watch';
showToast(`Removed ${artist.name} from watchlist`, 'success');
} else {
const response = await fetch('/api/watchlist/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_id: artistId, artist_name: artist.name })
});
const data = await response.json();
if (!data.success) throw new Error(data.error);
btn.classList.add('watched');
btn.style.opacity = '';
btn.title = 'Remove from Watchlist';
if (label) label.textContent = 'Watching';
showToast(`Added ${artist.name} to watchlist`, 'success');
}
if (typeof updateWatchlistCount === 'function') {
updateWatchlistCount();
}
} catch (error) {
console.error('Error toggling library card watchlist:', error);
if (label) label.textContent = isWatching ? 'Watching' : 'Watch';
showToast(`Error: ${error.message}`, 'error');
} finally {
btn.disabled = false;
}
}
// ===============================================
// Artist Detail Page Functions
// ===============================================
// Artist detail page state
let artistDetailPageState = {
isInitialized: false,
currentArtistId: null,
currentArtistName: null,
currentArtistSource: null,
// Stack of origins captured by navigateToArtistDetail for the back button.
// Each entry is either {type:'page', pageId} or {type:'artist', id, name, source}
// so chained navigation (Search β A β similar B β similar C) walks back one
// step at a time instead of jumping straight to Search.
originStack: [],
enhancedView: false,
enhancedData: null,
expandedAlbums: new Set(),
selectedTracks: new Set(),
editingCell: null,
enhancedTrackSort: {}
};
// Discography filter state
let discographyFilterState = {
categories: { albums: true, eps: true, singles: true },
content: { live: true, compilations: true, featured: true },
ownership: 'all' // 'all', 'owned', 'missing'
};
// Friendly labels for the dynamic "β Back to X" button on the artist-detail page.
// Page id (the value of currentPage) -> button label.
const _ARTIST_DETAIL_BACK_LABELS = {
library: 'Back to Library',
search: 'Back to Search',
discover: 'Back to Discover',
watchlist: 'Back to Watchlist',
wishlist: 'Back to Wishlist',
stats: 'Back to Stats',
'playlist-explorer': 'Back to Explorer',
automations: 'Back to Automations',
dashboard: 'Back to Dashboard',
sync: 'Back to Sync',
'active-downloads': 'Back to Downloads',
};
function navigateToArtistDetail(artistId, artistName, sourceOverride = null, options = {}) {
console.log(`π΅ Navigating to artist detail: ${artistName} (ID: ${artistId}${sourceOverride ? `, source: ${sourceOverride}` : ''})`);
// Capture the current location on the origin stack BEFORE navigateToPage
// flips currentPage. The back button walks this stack one step at a time,
// so a chain like Search β A β similar B β similar C steps back through
// C β B β A β Search instead of jumping straight home. `skipOriginPush`
// lets the back button re-enter a prior artist without re-pushing.
if (!options.skipOriginPush) {
// Fresh entry (from a non-artist page) starts a new chain; any stale
// entries from a prior artist-detail visit are dropped.
if (currentPage !== 'artist-detail') {
artistDetailPageState.originStack = [];
}
let entry;
if (currentPage === 'artist-detail' && artistDetailPageState.currentArtistId) {
entry = {
type: 'artist',
id: artistDetailPageState.currentArtistId,
name: artistDetailPageState.currentArtistName,
source: artistDetailPageState.currentArtistSource,
};
} else {
const pageId = (typeof currentPage === 'string' && currentPage && currentPage !== 'artist-detail')
? currentPage : 'library';
entry = { type: 'page', pageId };
}
// Avoid pushing a duplicate top entry on repeated clicks of the same target.
const top = artistDetailPageState.originStack[artistDetailPageState.originStack.length - 1];
const isDuplicate = top && top.type === entry.type && (
(entry.type === 'page' && top.pageId === entry.pageId) ||
(entry.type === 'artist' && String(top.id) === String(entry.id))
);
if (!isDuplicate) {
artistDetailPageState.originStack.push(entry);
}
}
// Abort any in-progress completion stream
if (artistDetailPageState.completionController) {
artistDetailPageState.completionController.abort();
artistDetailPageState.completionController = null;
}
// Cancel any active inline edit and close manual match modal before resetting state
cancelInlineEdit();
const existingMatchOverlay = document.getElementById('enhanced-manual-match-overlay');
if (existingMatchOverlay) existingMatchOverlay.remove();
// Store current artist info and reset enhanced view state
artistDetailPageState.currentArtistId = artistId;
artistDetailPageState.currentArtistName = artistName;
artistDetailPageState.currentArtistSource = sourceOverride || null;
artistDetailPageState.enhancedData = null;
artistDetailPageState.expandedAlbums = new Set();
artistDetailPageState.selectedTracks = new Set();
artistDetailPageState.enhancedTrackSort = {};
artistDetailPageState.enhancedView = false;
// Reset enhanced view toggle to standard
const toggleBtns = document.querySelectorAll('.enhanced-view-toggle-btn');
toggleBtns.forEach(btn => {
btn.classList.toggle('active', btn.getAttribute('data-view') === 'standard');
});
const enhancedContainer = document.getElementById('enhanced-view-container');
if (enhancedContainer) enhancedContainer.classList.add('hidden');
const standardSections = document.querySelector('.discography-sections');
if (standardSections) standardSections.classList.remove('hidden');
// Restore standard view filter groups
const filterGroups = document.querySelectorAll('#discography-filters .filter-group');
filterGroups.forEach(group => {
const label = group.querySelector('.filter-label');
if (label && label.textContent !== 'View') group.style.display = '';
});
const dividers = document.querySelectorAll('#discography-filters .filter-divider');
dividers.forEach(d => d.style.display = '');
// Hide bulk bar
const bulkBar = document.getElementById('enhanced-bulk-bar');
if (bulkBar) bulkBar.classList.remove('visible');
// Navigate to artist detail page
navigateToPage('artist-detail');
// Update back-button label to reflect where the next pop will land.
_updateArtistDetailBackButtonLabel();
// Initialize if needed and load data
if (!artistDetailPageState.isInitialized) {
initializeArtistDetailPage();
}
// Load artist data
loadArtistDetailData(artistId, artistName);
}
function _updateArtistDetailBackButtonLabel() {
const backBtnLabel = document.querySelector('#artist-detail-back-btn span');
if (!backBtnLabel) return;
const stack = artistDetailPageState.originStack || [];
const top = stack[stack.length - 1];
if (!top) {
backBtnLabel.textContent = `β ${_ARTIST_DETAIL_BACK_LABELS.library}`;
} else if (top.type === 'artist') {
backBtnLabel.textContent = `β Back to ${top.name}`;
} else {
const friendly = _ARTIST_DETAIL_BACK_LABELS[top.pageId] || _ARTIST_DETAIL_BACK_LABELS.library;
backBtnLabel.textContent = `β ${friendly}`;
}
}
function initializeArtistDetailPage() {
console.log("π§ Initializing Artist Detail page...");
// Initialize back button β pops the origin stack one step at a time so a
// chain like Search β A β B β C walks back through C β B β A β Search
// instead of jumping straight to the original entry page.
const backBtn = document.getElementById("artist-detail-back-btn");
if (backBtn) {
backBtn.addEventListener("click", () => {
// Abort any in-progress completion stream regardless of destination
if (artistDetailPageState.completionController) {
artistDetailPageState.completionController.abort();
artistDetailPageState.completionController = null;
}
const stack = artistDetailPageState.originStack || [];
if (stack.length > 0) {
const target = stack.pop();
if (target.type === 'artist') {
// Re-enter a prior artist in the chain without re-pushing,
// so the stack keeps shrinking as the user steps back.
navigateToArtistDetail(target.id, target.name, target.source, { skipOriginPush: true });
return;
}
// target.type === 'page' β fully exit the artist-detail chain
artistDetailPageState.currentArtistId = null;
artistDetailPageState.currentArtistName = null;
artistDetailPageState.originStack = [];
navigateToPage(target.pageId);
return;
}
// No history β default to library
artistDetailPageState.currentArtistId = null;
artistDetailPageState.currentArtistName = null;
artistDetailPageState.originStack = [];
navigateToPage('library');
});
}
// Initialize retry button
const retryBtn = document.getElementById("artist-detail-retry-btn");
if (retryBtn) {
retryBtn.addEventListener("click", () => {
if (artistDetailPageState.currentArtistId && artistDetailPageState.currentArtistName) {
loadArtistDetailData(artistDetailPageState.currentArtistId, artistDetailPageState.currentArtistName);
}
});
}
// Initialize discography filter buttons
initializeDiscographyFilters();
artistDetailPageState.isInitialized = true;
console.log("β Artist Detail page initialized successfully");
}
async function loadArtistDetailData(artistId, artistName) {
console.log(`π Loading artist detail data for: ${artistName} (ID: ${artistId})`);
// Reset discography filters to defaults
resetDiscographyFilters();
// Show loading state and hide all content
showArtistDetailLoading(true);
showArtistDetailError(false);
showArtistDetailMain(false);
showArtistDetailHero(false);
// Don't update header until data loads to avoid showing stale data
try {
// Call API to get artist discography data. If this artist came from a
// metadata source (not the library), pass source + name so the backend
// can synthesize a response from that source instead of 404ing on the
// local DB lookup.
const params = new URLSearchParams();
if (artistDetailPageState.currentArtistSource) {
params.set('source', artistDetailPageState.currentArtistSource);
}
if (artistName) {
params.set('name', artistName);
}
const qs = params.toString();
const response = await fetch(
`/api/artist-detail/${encodeURIComponent(artistId)}${qs ? '?' + qs : ''}`
);
if (!response.ok) {
throw new Error(`Failed to load artist data: ${response.statusText}`);
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to load artist data');
}
console.log(`β Loaded artist detail data:`, data);
// Hide loading and show all content
showArtistDetailLoading(false);
showArtistDetailMain(true);
showArtistDetailHero(true);
console.log(`π¨ Main content visibility:`, document.getElementById('artist-detail-main'));
console.log(`π¨ Albums section:`, document.getElementById('albums-section'));
// Populate the page with data (which updates the hero section and sets textContent)
populateArtistDetailPage(data);
// Library upgrade β if the backend resolved this source-artist click to
// an existing library record (e.g. clicking a Deezer result for an
// artist already in your Plex), data.artist.id is the library PK.
// Update currentArtistId so subsequent library-only API calls (Enhanced
// view, completion checks, server sync) hit the right id. Also flip
// the body source flag from 'source' back to 'library' so the
// library-only UI re-shows.
if (data.artist && data.artist.id && String(data.artist.id) !== String(artistDetailPageState.currentArtistId)) {
console.log(`π Library upgrade: ${artistDetailPageState.currentArtistId} β ${data.artist.id}`);
artistDetailPageState.currentArtistId = data.artist.id;
}
// Keep the resolved metadata source for album-track lookups.
artistDetailPageState.currentArtistSource = data.discography?.source || data.artist?.source || null;
// Update header with artist name and MusicBrainz link LAST to avoid overwrite
updateArtistDetailPageHeaderWithData(data.artist);
// Render per-artist enrichment coverage
renderArtistEnrichmentCoverage(data.enrichment_coverage);
// Start streaming ownership checks if we have Spotify discography with checking state
if (data.discography && data.discography.albums) {
const hasChecking = [...(data.discography.albums || []), ...(data.discography.eps || []), ...(data.discography.singles || [])]
.some(r => r.owned === null);
if (hasChecking) {
// Store discography for stream updates
artistDetailPageState.currentDiscography = data.discography;
checkLibraryCompletion(data.artist.name, data.discography);
}
}
// Check if artist has tracks eligible for quality enhancement.
// Use currentArtistId (not the closure arg) because the library-upgrade
// branch above may have rewritten it from the source ID to the library PK,
// and /api/library/artist//quality-analysis only works on library PKs.
checkArtistEnhanceEligibility(artistDetailPageState.currentArtistId);
} catch (error) {
console.error(`β Error loading artist detail data:`, error);
// Show error state (keep hero section hidden)
showArtistDetailLoading(false);
showArtistDetailError(true, error.message);
showArtistDetailHero(false);
showToast(`Failed to load artist details: ${error.message}`, "error");
}
}
function updateArtistDetailPageHeader(artistName) {
// Update header title
const headerTitle = document.getElementById("artist-detail-name");
if (headerTitle) {
headerTitle.textContent = artistName;
}
// Update main artist name
const mainTitle = document.getElementById("artist-info-name");
if (mainTitle) {
mainTitle.textContent = artistName;
}
}
function updateArtistDetailPageHeaderWithData(artist) {
// Update name
const mainTitle = document.getElementById("artist-detail-name");
if (mainTitle) {
mainTitle.textContent = artist.name;
// Remove any old source links that were appended to the h1
mainTitle.querySelectorAll('.source-link-btn').forEach(el => el.remove());
}
// Render badges in dedicated container
const badgesContainer = document.getElementById("artist-hero-badges");
if (badgesContainer) {
const _hb = (logo, fallback, title, url) => {
const inner = logo
? ``
: `${fallback}`;
if (url) return `${inner}`;
return `
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 += `
`;
html += `${track.track_number || 'β'}`;
html += `${escapeHtml(track.title)}`;
html += `${escapeHtml(track.error)}`;
html += `
`;
});
// Unchanged tracks (collapsed)
if (noChanges.length > 0) {
html += `
`;
html += `
`;
html += `${noChanges.length} track${noChanges.length !== 1 ? 's' : ''} already up to date`;
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 += `${track.track_number || 'β'}`;
html += `${escapeHtml(track.title)}`;
html += `${track.changed_count} field${track.changed_count !== 1 ? 's' : ''} changed`;
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 source picker β populated from /reorganize/sources.
// Empty value = use configured primary (with fallback chain).
// Specific source = strict mode, that source only.
html += '
';
html += '';
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 += '
';
// Preview area
html += '
';
html += '
';
html += '';
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);
}
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 response = await fetch(`/api/library/album/${_reorganizeAlbumId}/reorganize/preview`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: chosenSource })
});
const result = await response.json();
if (!result.success) {
previewBody.innerHTML = `
`;
} 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 response = await fetch(`/api/library/album/${_reorganizeAlbumId}/reorganize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: chosenSource })
});
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 = '
';
// Source picker β applies to ALL albums in this run. 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 += '';
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.
';
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 (${queued.length})`;
html += ``;
html += '
';
html += '
';
queued.forEach((item, idx) => {
html += _reorgPanelRenderQueuedRow(item, idx + 1);
});
html += '
';
}
// Recent
if (recentVisible.length > 0) {
html += `
Recent
`;
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 += ``;
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 = '