|
|
// LIBRARY PAGE FUNCTIONALITY
|
|
|
// ===============================
|
|
|
|
|
|
// Library page state
|
|
|
const libraryPageState = {
|
|
|
isInitialized: false,
|
|
|
currentSearch: "",
|
|
|
currentLetter: "all",
|
|
|
currentPage: 1,
|
|
|
limit: 75,
|
|
|
debounceTimer: null,
|
|
|
watchlistFilter: "all",
|
|
|
sourceFilter: ""
|
|
|
};
|
|
|
|
|
|
function initializeLibraryPage() {
|
|
|
console.log("🔧 Initializing Library page...");
|
|
|
|
|
|
try {
|
|
|
// Initialize search functionality
|
|
|
initializeLibrarySearch();
|
|
|
|
|
|
// Initialize watchlist filter
|
|
|
initializeWatchlistFilter();
|
|
|
|
|
|
// Initialize metadata source filter
|
|
|
initializeSourceFilter();
|
|
|
|
|
|
// Initialize alphabet selector
|
|
|
initializeAlphabetSelector();
|
|
|
|
|
|
// Initialize pagination
|
|
|
initializeLibraryPagination();
|
|
|
|
|
|
// Load initial data
|
|
|
loadLibraryArtists();
|
|
|
|
|
|
// Show download bubbles if any exist
|
|
|
showLibraryDownloadsSection();
|
|
|
|
|
|
libraryPageState.isInitialized = true;
|
|
|
console.log("✅ Library page initialized successfully");
|
|
|
|
|
|
} catch (error) {
|
|
|
console.error("❌ Error initializing Library page:", error);
|
|
|
showToast("Failed to initialize Library page", "error");
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function initializeLibrarySearch() {
|
|
|
const searchInput = document.getElementById("library-search-input");
|
|
|
if (!searchInput) return;
|
|
|
|
|
|
searchInput.addEventListener("input", (e) => {
|
|
|
const query = e.target.value.trim();
|
|
|
|
|
|
// Clear existing debounce timer
|
|
|
if (libraryPageState.debounceTimer) {
|
|
|
clearTimeout(libraryPageState.debounceTimer);
|
|
|
}
|
|
|
|
|
|
// Debounce search requests
|
|
|
libraryPageState.debounceTimer = setTimeout(() => {
|
|
|
libraryPageState.currentSearch = query;
|
|
|
libraryPageState.currentPage = 1; // Reset to first page
|
|
|
loadLibraryArtists();
|
|
|
}, 300);
|
|
|
});
|
|
|
|
|
|
// Clear search on Escape key
|
|
|
searchInput.addEventListener("keydown", (e) => {
|
|
|
if (e.key === "Escape") {
|
|
|
searchInput.value = "";
|
|
|
libraryPageState.currentSearch = "";
|
|
|
libraryPageState.currentPage = 1;
|
|
|
loadLibraryArtists();
|
|
|
}
|
|
|
});
|
|
|
}
|
|
|
|
|
|
function initializeWatchlistFilter() {
|
|
|
const filterButtons = document.querySelectorAll(".watchlist-filter-btn");
|
|
|
const watchAllBtn = document.getElementById("library-watchlist-all-btn");
|
|
|
|
|
|
filterButtons.forEach(button => {
|
|
|
button.addEventListener("click", () => {
|
|
|
const filter = button.getAttribute("data-filter");
|
|
|
|
|
|
// Update active state
|
|
|
filterButtons.forEach(btn => btn.classList.remove("active"));
|
|
|
button.classList.add("active");
|
|
|
|
|
|
// Show/hide "Watch All Unwatched" button
|
|
|
if (watchAllBtn) {
|
|
|
if (filter === "unwatched") {
|
|
|
watchAllBtn.classList.remove("hidden");
|
|
|
} else {
|
|
|
watchAllBtn.classList.add("hidden");
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Update state and reload
|
|
|
libraryPageState.watchlistFilter = filter;
|
|
|
libraryPageState.currentPage = 1;
|
|
|
loadLibraryArtists();
|
|
|
});
|
|
|
});
|
|
|
}
|
|
|
|
|
|
function initializeSourceFilter() {
|
|
|
const select = document.getElementById('library-source-filter');
|
|
|
if (!select) return;
|
|
|
select.addEventListener('change', () => {
|
|
|
libraryPageState.sourceFilter = select.value;
|
|
|
libraryPageState.currentPage = 1;
|
|
|
loadLibraryArtists();
|
|
|
});
|
|
|
}
|
|
|
|
|
|
function initializeAlphabetSelector() {
|
|
|
const alphabetButtons = document.querySelectorAll(".alphabet-btn");
|
|
|
|
|
|
alphabetButtons.forEach(button => {
|
|
|
button.addEventListener("click", () => {
|
|
|
const letter = button.getAttribute("data-letter");
|
|
|
|
|
|
// Update active state
|
|
|
alphabetButtons.forEach(btn => btn.classList.remove("active"));
|
|
|
button.classList.add("active");
|
|
|
|
|
|
// Update state and load data
|
|
|
libraryPageState.currentLetter = letter;
|
|
|
libraryPageState.currentPage = 1; // Reset to first page
|
|
|
loadLibraryArtists();
|
|
|
});
|
|
|
});
|
|
|
}
|
|
|
|
|
|
function initializeLibraryPagination() {
|
|
|
const prevBtn = document.getElementById("prev-page-btn");
|
|
|
const nextBtn = document.getElementById("next-page-btn");
|
|
|
|
|
|
if (prevBtn) {
|
|
|
prevBtn.addEventListener("click", () => {
|
|
|
if (libraryPageState.currentPage > 1) {
|
|
|
libraryPageState.currentPage--;
|
|
|
loadLibraryArtists();
|
|
|
}
|
|
|
});
|
|
|
}
|
|
|
|
|
|
if (nextBtn) {
|
|
|
nextBtn.addEventListener("click", () => {
|
|
|
libraryPageState.currentPage++;
|
|
|
loadLibraryArtists();
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function loadLibraryArtists() {
|
|
|
try {
|
|
|
// Show loading state
|
|
|
showLibraryLoading(true);
|
|
|
|
|
|
// Build query parameters
|
|
|
const params = new URLSearchParams({
|
|
|
search: libraryPageState.currentSearch,
|
|
|
letter: libraryPageState.currentLetter,
|
|
|
page: libraryPageState.currentPage,
|
|
|
limit: libraryPageState.limit,
|
|
|
watchlist: libraryPageState.watchlistFilter
|
|
|
});
|
|
|
if (libraryPageState.sourceFilter) params.set('source_filter', libraryPageState.sourceFilter);
|
|
|
|
|
|
// Fetch artists from API
|
|
|
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");
|
|
|
}
|
|
|
|
|
|
// Update UI with artists
|
|
|
displayLibraryArtists(data.artists);
|
|
|
updateLibraryPagination(data.pagination);
|
|
|
updateLibraryStats(data.pagination.total_count);
|
|
|
|
|
|
// Hide loading state
|
|
|
showLibraryLoading(false);
|
|
|
|
|
|
// Show empty state if no artists
|
|
|
if (data.artists.length === 0) {
|
|
|
showLibraryEmpty(true);
|
|
|
} else {
|
|
|
showLibraryEmpty(false);
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
console.error("❌ Error loading library artists:", error);
|
|
|
showToast("Failed to load artists", "error");
|
|
|
showLibraryLoading(false);
|
|
|
showLibraryEmpty(true);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function displayLibraryArtists(artists) {
|
|
|
const grid = document.getElementById("library-artists-grid");
|
|
|
if (!grid) return;
|
|
|
|
|
|
// Build all cards as HTML string for single DOM write (much faster than createElement loop)
|
|
|
grid.innerHTML = artists.map((artist, i) => {
|
|
|
try { return buildLibraryArtistCardHTML(artist, i); }
|
|
|
catch (e) { console.error('Failed to render artist card:', artist.name, e); return ''; }
|
|
|
}).join('');
|
|
|
|
|
|
// Attach click handlers via event delegation (single listener vs 75+ individual)
|
|
|
grid.onclick = (e) => {
|
|
|
// Ignore clicks on badge icons (they open external links / toggle watchlist)
|
|
|
const badge = e.target.closest('.source-card-icon');
|
|
|
if (badge) {
|
|
|
e.stopPropagation();
|
|
|
const url = badge.dataset.url;
|
|
|
if (url) { window.open(url, '_blank'); return; }
|
|
|
// Watchlist toggle
|
|
|
if (badge.classList.contains('watch-card-icon') && badge.dataset.unwatched) {
|
|
|
const card = badge.closest('.library-artist-card');
|
|
|
if (card) {
|
|
|
const artistId = card.dataset.artistId;
|
|
|
const artistName = card.dataset.artistName;
|
|
|
const artist = artists.find(a => String(a.id) === artistId);
|
|
|
if (artist) toggleLibraryCardWatchlist(badge, artist);
|
|
|
}
|
|
|
}
|
|
|
return;
|
|
|
}
|
|
|
const card = e.target.closest('.library-artist-card');
|
|
|
if (card) {
|
|
|
navigateToArtistDetail(card.dataset.artistId, card.dataset.artistName);
|
|
|
}
|
|
|
};
|
|
|
}
|
|
|
|
|
|
function buildLibraryArtistCardHTML(artist, index) {
|
|
|
const _esc = (s) => (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
|
const delay = Math.min(index * 20, 600); // Cap at 600ms so last cards don't wait too long
|
|
|
|
|
|
// Build badge icons
|
|
|
const badges = [];
|
|
|
if (artist.spotify_artist_id) badges.push({ logo: SPOTIFY_LOGO_URL, fb: 'SP', title: 'Spotify', url: `https://open.spotify.com/artist/${artist.spotify_artist_id}` });
|
|
|
if (artist.musicbrainz_id) badges.push({ logo: MUSICBRAINZ_LOGO_URL, fb: 'MB', title: 'MusicBrainz', url: `https://musicbrainz.org/artist/${artist.musicbrainz_id}` });
|
|
|
if (artist.deezer_id) badges.push({ logo: DEEZER_LOGO_URL, fb: 'Dz', title: 'Deezer', url: `https://www.deezer.com/artist/${artist.deezer_id}` });
|
|
|
if (artist.audiodb_id) {
|
|
|
const slug = artist.name ? artist.name.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '') : '';
|
|
|
badges.push({ logo: typeof getAudioDBLogoURL === 'function' ? getAudioDBLogoURL() : '', fb: 'ADB', title: 'AudioDB', url: `https://www.theaudiodb.com/artist/${artist.audiodb_id}-${slug}` });
|
|
|
}
|
|
|
if (artist.itunes_artist_id) badges.push({ logo: ITUNES_LOGO_URL, fb: 'IT', title: 'Apple Music', url: `https://music.apple.com/artist/${artist.itunes_artist_id}` });
|
|
|
if (artist.lastfm_url) badges.push({ logo: LASTFM_LOGO_URL, fb: 'LFM', title: 'Last.fm', url: artist.lastfm_url });
|
|
|
if (artist.genius_url) badges.push({ logo: GENIUS_LOGO_URL, fb: 'GEN', title: 'Genius', url: artist.genius_url });
|
|
|
if (artist.tidal_id) badges.push({ logo: TIDAL_LOGO_URL, fb: 'TD', title: 'Tidal', url: `https://tidal.com/browse/artist/${artist.tidal_id}` });
|
|
|
if (artist.qobuz_id) badges.push({ logo: QOBUZ_LOGO_URL, fb: 'Qz', title: 'Qobuz', url: `https://www.qobuz.com/artist/${artist.qobuz_id}` });
|
|
|
if (artist.discogs_id) badges.push({ logo: DISCOGS_LOGO_URL, fb: 'DC', title: 'Discogs', url: `https://www.discogs.com/artist/${artist.discogs_id}` });
|
|
|
if (artist.soul_id && !String(artist.soul_id).startsWith('soul_unnamed_')) badges.push({ logo: '/static/trans2.png', fb: 'SS', title: `SoulID: ${artist.soul_id}`, url: null });
|
|
|
|
|
|
// Watchlist badge
|
|
|
const hasActiveSourceId = currentMusicSourceName === 'iTunes'
|
|
|
? (artist.itunes_artist_id || artist.spotify_artist_id)
|
|
|
: (artist.spotify_artist_id || artist.itunes_artist_id);
|
|
|
let watchBadgeHTML = '';
|
|
|
if (artist.is_watched) {
|
|
|
watchBadgeHTML = `<div class="watch-card-icon watched source-card-icon" title="On your watchlist"><span class="watch-icon-emoji">👁️</span><span class="watch-icon-label">Watching</span></div>`;
|
|
|
} else if (hasActiveSourceId) {
|
|
|
watchBadgeHTML = `<div class="watch-card-icon source-card-icon" data-unwatched="1" title="Add to Watchlist" style="opacity:0.4"><span class="watch-icon-emoji">👁️</span><span class="watch-icon-label">Watch</span></div>`;
|
|
|
}
|
|
|
|
|
|
const maxPerColumn = 6;
|
|
|
const needsOverflow = badges.length > maxPerColumn;
|
|
|
const badgeIcon = (b) => `<div class="source-card-icon" title="${_esc(b.title)}" ${b.url ? `data-url="${_esc(b.url)}"` : ''}>${b.logo ? `<img src="${_esc(b.logo)}" style="width:16px;height:auto;display:block" onerror="this.parentNode.textContent='${b.fb}'">` : `<span style="font-size:9px;font-weight:700">${b.fb}</span>`}</div>`;
|
|
|
|
|
|
let badgeContainerHTML = '';
|
|
|
if (badges.length > 0 || watchBadgeHTML) {
|
|
|
if (needsOverflow) {
|
|
|
badgeContainerHTML = `<div class="card-badge-container">
|
|
|
<div class="badge-overflow-column">${watchBadgeHTML}${badges.slice(maxPerColumn).map(badgeIcon).join('')}</div>
|
|
|
<div class="badge-primary-column">${badges.slice(0, maxPerColumn).map(badgeIcon).join('')}</div>
|
|
|
</div>`;
|
|
|
} else {
|
|
|
badgeContainerHTML = `<div class="card-badge-container">${badges.map(badgeIcon).join('')}${watchBadgeHTML}</div>`;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Image
|
|
|
const hasImage = artist.image_url && artist.image_url.trim() !== '';
|
|
|
const deezerFallback = artist.deezer_id ? `if(!this.dataset.triedDeezer){this.dataset.triedDeezer='true';this.src='https://api.deezer.com/artist/${artist.deezer_id}/image?size=big'}else{this.parentNode.innerHTML='<div class=\\'library-artist-image-fallback\\'>🎵</div>'}` : `this.parentNode.innerHTML='<div class=\\'library-artist-image-fallback\\'>🎵</div>'`;
|
|
|
const imageHTML = hasImage
|
|
|
? `<div class="library-artist-image"><img src="${_esc(artist.image_url)}" alt="${_esc(artist.name)}" loading="lazy" onerror="${deezerFallback}"></div>`
|
|
|
: `<div class="library-artist-image"><div class="library-artist-image-fallback">🎵</div></div>`;
|
|
|
|
|
|
// Track stats
|
|
|
const trackStat = artist.track_count > 0 ? `<span class="library-artist-stat">${artist.track_count} track${artist.track_count !== 1 ? 's' : ''}</span>` : '';
|
|
|
|
|
|
return `<div class="library-artist-card" data-artist-id="${_esc(String(artist.id))}" data-artist-name="${_esc(artist.name)}" style="position:relative;animation:cardFadeIn 0.35s cubic-bezier(0.4,0,0.2,1) ${delay}ms both">
|
|
|
${badgeContainerHTML}
|
|
|
${imageHTML}
|
|
|
<div class="library-artist-info">
|
|
|
<h3 class="library-artist-name" title="${_esc(artist.name)}">${_esc(artist.name)}</h3>
|
|
|
<div class="library-artist-stats">${trackStat}</div>
|
|
|
</div>
|
|
|
</div>`;
|
|
|
}
|
|
|
|
|
|
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 = `
|
|
|
<div class="watch-all-modal">
|
|
|
<div class="watch-all-header">
|
|
|
<div class="watch-all-header-content">
|
|
|
<div class="watch-all-header-icon">👁</div>
|
|
|
<div>
|
|
|
<h2 class="watch-all-title">Watch All Unwatched</h2>
|
|
|
<p class="watch-all-subtitle">Add unwatched artists with ${_esc(sourceName)} IDs to your watchlist</p>
|
|
|
</div>
|
|
|
</div>
|
|
|
<button class="watch-all-close" onclick="closeWatchAllUnwatchedModal()">×</button>
|
|
|
</div>
|
|
|
<div class="watch-all-body">
|
|
|
<div class="watch-all-loading-state">
|
|
|
<div class="watch-all-loading-spinner"></div>
|
|
|
<div class="watch-all-loading-text">Loading unwatched artists...</div>
|
|
|
<div class="watch-all-loading-count" id="watch-all-load-count"></div>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div class="watch-all-footer">
|
|
|
<button class="watch-all-btn watch-all-btn-cancel" onclick="closeWatchAllUnwatchedModal()">Cancel</button>
|
|
|
<button class="watch-all-btn watch-all-btn-primary" id="watch-all-confirm-btn" disabled>Watch All</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
`;
|
|
|
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 = `<div class="watch-all-empty-state"><div class="watch-all-empty-icon">⚠</div><div>Failed to load artists</div><a href="#" onclick="closeWatchAllUnwatchedModal(); openWatchAllUnwatchedModal(); return false;" class="watch-all-retry-link">Retry</a></div>`;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
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 = '<div class="watch-all-empty-state"><div class="watch-all-empty-icon">🎵</div><div>No unwatched artists found</div></div>';
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// Store data for search filtering
|
|
|
overlay._watchAllEligible = eligible;
|
|
|
overlay._watchAllIneligible = ineligible;
|
|
|
|
|
|
let html = '';
|
|
|
|
|
|
// Summary bar (sticky)
|
|
|
html += '<div class="watch-all-stats">';
|
|
|
html += `<div class="watch-all-stat-card eligible"><div class="watch-all-stat-value">${eligible.length}</div><div class="watch-all-stat-label">Ready to watch</div></div>`;
|
|
|
html += `<div class="watch-all-stat-card ineligible"><div class="watch-all-stat-value">${ineligible.length}</div><div class="watch-all-stat-label">No ${_esc(sourceName)} ID</div></div>`;
|
|
|
html += `<div class="watch-all-stat-card total"><div class="watch-all-stat-value">${eligible.length + ineligible.length}</div><div class="watch-all-stat-label">Total unwatched</div></div>`;
|
|
|
html += '</div>';
|
|
|
|
|
|
// Search filter
|
|
|
if (eligible.length > 10) {
|
|
|
html += '<div class="watch-all-search-wrap"><input type="text" class="watch-all-search" id="watch-all-search" placeholder="Search artists..." oninput="_filterWatchAllList(this.value)"></div>';
|
|
|
}
|
|
|
|
|
|
// Eligible grid
|
|
|
if (eligible.length > 0) {
|
|
|
html += '<div class="watch-all-section-label">Artists to be watched</div>';
|
|
|
html += '<div class="watch-all-grid" id="watch-all-eligible-grid">';
|
|
|
html += _buildWatchAllRows(eligible, false);
|
|
|
html += '</div>';
|
|
|
}
|
|
|
|
|
|
// Ineligible section
|
|
|
if (ineligible.length > 0) {
|
|
|
html += `<div class="watch-all-ineligible">
|
|
|
<div class="watch-all-ineligible-header" onclick="this.parentElement.classList.toggle('expanded')">
|
|
|
<div class="watch-all-ineligible-label">
|
|
|
<span class="watch-all-ineligible-icon">⚠</span>
|
|
|
<span>${ineligible.length} artist${ineligible.length !== 1 ? 's' : ''} without ${_esc(sourceName)} ID</span>
|
|
|
</div>
|
|
|
<span class="watch-all-chevron">▼</span>
|
|
|
</div>
|
|
|
<div class="watch-all-ineligible-body">
|
|
|
<div class="watch-all-ineligible-hint">These artists haven't been matched to ${_esc(sourceName)} yet. The background enrichment worker will match them over time.</div>
|
|
|
<div class="watch-all-grid" id="watch-all-ineligible-grid">${_buildWatchAllRows(ineligible, true)}</div>
|
|
|
</div>
|
|
|
</div>`;
|
|
|
}
|
|
|
|
|
|
if (eligible.length === 0) {
|
|
|
html += `<div class="watch-all-empty-state"><div class="watch-all-empty-icon">🔌</div><div>None of your unwatched artists have a ${_esc(sourceName)} ID yet</div><div class="watch-all-empty-hint">The background enrichment worker will match them over time.</div></div>`;
|
|
|
}
|
|
|
|
|
|
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
|
|
|
? `<img src="${_esc(a.image_url)}" alt="" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex'" loading="lazy"><div class="watch-all-cell-placeholder" style="display:none">🎵</div>`
|
|
|
: `<div class="watch-all-cell-placeholder">🎵</div>`;
|
|
|
html += `<div class="watch-all-cell${dimmed ? ' dimmed' : ''}" data-name="${_esc(a.name.toLowerCase())}">
|
|
|
<div class="watch-all-cell-img">${img}</div>
|
|
|
<div class="watch-all-cell-name" title="${_esc(a.name)}">${_esc(a.name)}</div>
|
|
|
<div class="watch-all-cell-meta">${a.track_count || 0} tracks</div>
|
|
|
</div>`;
|
|
|
}
|
|
|
return html;
|
|
|
}
|
|
|
|
|
|
function _filterWatchAllList(query) {
|
|
|
const q = query.toLowerCase().trim();
|
|
|
document.querySelectorAll('#watch-all-eligible-grid .watch-all-cell').forEach(cell => {
|
|
|
cell.style.display = !q || cell.dataset.name.includes(q) ? '' : 'none';
|
|
|
});
|
|
|
}
|
|
|
|
|
|
async function _confirmWatchAllUnwatched(overlay, expectedCount) {
|
|
|
const confirmBtn = overlay.querySelector('#watch-all-confirm-btn');
|
|
|
const cancelBtn = overlay.querySelector('.watch-all-btn-cancel');
|
|
|
if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.textContent = 'Adding...'; }
|
|
|
if (cancelBtn) cancelBtn.disabled = true;
|
|
|
|
|
|
try {
|
|
|
const response = await fetch('/api/library/watchlist-all-unwatched', {
|
|
|
method: 'POST',
|
|
|
headers: { 'Content-Type': 'application/json' }
|
|
|
});
|
|
|
const data = await response.json();
|
|
|
|
|
|
if (data.success) {
|
|
|
const body = overlay.querySelector('.watch-all-body');
|
|
|
body.innerHTML = `<div class="watch-all-results">
|
|
|
<div class="watch-all-results-icon">✓</div>
|
|
|
<div class="watch-all-results-title">Added ${data.added} artist${data.added !== 1 ? 's' : ''} to watchlist</div>
|
|
|
${data.skipped_already > 0 ? `<div class="watch-all-results-detail">${data.skipped_already} already watched</div>` : ''}
|
|
|
${data.skipped_no_id > 0 ? `<div class="watch-all-results-detail">${data.skipped_no_id} skipped (no external ID)</div>` : ''}
|
|
|
</div>`;
|
|
|
|
|
|
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/<id>/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
|
|
|
? `<img src="${logo}" alt="${fallback}" onerror="this.parentNode.textContent='${fallback}'">`
|
|
|
: `<span style="font-size:9px;font-weight:700;">${fallback}</span>`;
|
|
|
if (url) return `<a class="artist-hero-badge" title="${title}" href="${url}" target="_blank" rel="noopener noreferrer">${inner}</a>`;
|
|
|
return `<div class="artist-hero-badge" title="${title}">${inner}</div>`;
|
|
|
};
|
|
|
|
|
|
const adbSlug = artist.name ? artist.name.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '') : '';
|
|
|
const badges = [];
|
|
|
if (artist.spotify_artist_id) badges.push(_hb(SPOTIFY_LOGO_URL, 'SP', 'Spotify', `https://open.spotify.com/artist/${artist.spotify_artist_id}`));
|
|
|
if (artist.musicbrainz_id) badges.push(_hb(MUSICBRAINZ_LOGO_URL, 'MB', 'MusicBrainz', `https://musicbrainz.org/artist/${artist.musicbrainz_id}`));
|
|
|
if (artist.deezer_id) badges.push(_hb(DEEZER_LOGO_URL, 'Dz', 'Deezer', `https://www.deezer.com/artist/${artist.deezer_id}`));
|
|
|
if (artist.audiodb_id) badges.push(_hb(typeof getAudioDBLogoURL === 'function' ? getAudioDBLogoURL() : '', 'ADB', 'AudioDB', `https://www.theaudiodb.com/artist/${artist.audiodb_id}-${adbSlug}`));
|
|
|
if (artist.itunes_artist_id) badges.push(_hb(ITUNES_LOGO_URL, 'IT', 'Apple Music', `https://music.apple.com/artist/${artist.itunes_artist_id}`));
|
|
|
if (artist.lastfm_url) badges.push(_hb(LASTFM_LOGO_URL, 'LFM', 'Last.fm', artist.lastfm_url));
|
|
|
if (artist.genius_url) badges.push(_hb(GENIUS_LOGO_URL, 'GEN', 'Genius', artist.genius_url));
|
|
|
if (artist.tidal_id) badges.push(_hb(TIDAL_LOGO_URL, 'TD', 'Tidal', `https://tidal.com/browse/artist/${artist.tidal_id}`));
|
|
|
if (artist.qobuz_id) badges.push(_hb(QOBUZ_LOGO_URL, 'Qz', 'Qobuz', `https://www.qobuz.com/artist/${artist.qobuz_id}`));
|
|
|
if (artist.discogs_id) badges.push(_hb(DISCOGS_LOGO_URL, 'DC', 'Discogs', `https://www.discogs.com/artist/${artist.discogs_id}`));
|
|
|
if (artist.soul_id && !String(artist.soul_id).startsWith('soul_unnamed_')) badges.push(_hb('/static/trans2.png', 'SS', `SoulID: ${artist.soul_id}`, null));
|
|
|
|
|
|
badgesContainer.innerHTML = badges.join('');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function renderArtistEnrichmentCoverage(enrichment) {
|
|
|
const el = document.getElementById('artist-enrichment-coverage');
|
|
|
if (!el) return;
|
|
|
|
|
|
if (!enrichment || !enrichment.total_tracks) {
|
|
|
el.style.display = 'none';
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const services = [
|
|
|
{ name: 'Spotify', key: 'spotify', color: '#1db954' },
|
|
|
{ name: 'MusicBrainz', key: 'musicbrainz', color: '#ba55d3' },
|
|
|
{ name: 'Deezer', key: 'deezer', color: '#a238ff' },
|
|
|
{ name: 'Last.fm', key: 'lastfm', color: '#d51007' },
|
|
|
{ name: 'iTunes', key: 'itunes', color: '#fc3c44' },
|
|
|
{ name: 'AudioDB', key: 'audiodb', color: '#1a9fff' },
|
|
|
{ name: 'Discogs', key: 'discogs', color: '#D4A574' },
|
|
|
{ name: 'Genius', key: 'genius', color: '#ffff64' },
|
|
|
{ name: 'Tidal', key: 'tidal', color: '#00ffff' },
|
|
|
{ name: 'Qobuz', key: 'qobuz', color: '#4285f4' },
|
|
|
];
|
|
|
|
|
|
const r = 20, circ = 2 * Math.PI * r;
|
|
|
|
|
|
el.style.display = '';
|
|
|
el.innerHTML = `
|
|
|
<div class="artist-enrich-title">Enrichment Coverage</div>
|
|
|
<div class="artist-enrich-grid">
|
|
|
${services.map((s, i) => {
|
|
|
const pct = enrichment[s.key] || 0;
|
|
|
const offset = circ - (circ * pct / 100);
|
|
|
const delay = (i * 0.08).toFixed(2);
|
|
|
return `<div class="artist-enrich-circle">
|
|
|
<div class="artist-enrich-ring" style="--ring-color:${s.color}">
|
|
|
<svg viewBox="0 0 48 48">
|
|
|
<circle class="ring-bg" cx="24" cy="24" r="${r}"/>
|
|
|
<circle class="ring-fill" cx="24" cy="24" r="${r}"
|
|
|
stroke="${s.color}" stroke-dasharray="${circ.toFixed(1)}"
|
|
|
style="--ring-circ:${circ.toFixed(1)};--ring-offset:${offset.toFixed(1)};stroke-dashoffset:${offset.toFixed(1)};animation:ringFillIn 1s cubic-bezier(0.4,0,0.2,1) ${delay}s both"/>
|
|
|
</svg>
|
|
|
<span class="ring-pct" style="animation:ringPctFade 0.8s ease ${(parseFloat(delay) + 0.3).toFixed(2)}s both">${Math.round(pct)}</span>
|
|
|
</div>
|
|
|
<span class="artist-enrich-label">${s.name}</span>
|
|
|
</div>`;
|
|
|
}).join('')}
|
|
|
</div>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function populateArtistDetailPage(data) {
|
|
|
const artist = data.artist;
|
|
|
const discography = data.discography;
|
|
|
|
|
|
console.log(`🎨 Populating artist detail page for: ${artist.name}`);
|
|
|
console.log(`📀 Discography data:`, discography);
|
|
|
console.log(`📀 Albums:`, discography.albums);
|
|
|
console.log(`📀 EPs:`, discography.eps);
|
|
|
console.log(`📀 Singles:`, discography.singles);
|
|
|
|
|
|
// Tag the body so CSS can hide library-only UI for source artists (e.g.
|
|
|
// the Enhanced view toggle, the Status filter, completion bars). Set
|
|
|
// BEFORE rendering so any layout-dependent code sees the right state.
|
|
|
document.body.dataset.artistSource = (artist && artist.server_source) ? 'library' : 'source';
|
|
|
|
|
|
// Update hero section with image, name, and stats
|
|
|
updateArtistHeroSection(artist, discography);
|
|
|
|
|
|
// Update genres (if element exists)
|
|
|
updateArtistGenres(artist.genres);
|
|
|
|
|
|
// Update summary stats (if element exists)
|
|
|
updateArtistSummaryStats(discography);
|
|
|
|
|
|
// Populate discography sections
|
|
|
populateDiscographySections(discography);
|
|
|
|
|
|
// Initialize the watchlist button. Library artists that have been enriched
|
|
|
// get the canonical Spotify identity; source artists fall back to the id
|
|
|
// they came in with (Deezer/iTunes/Discogs/etc.).
|
|
|
const libraryWatchlistBtn = document.getElementById('library-artist-watchlist-btn');
|
|
|
if (libraryWatchlistBtn) {
|
|
|
const watchlistId = (data.spotify_artist && data.spotify_artist.spotify_artist_id)
|
|
|
|| artist.id;
|
|
|
const watchlistName = (data.spotify_artist && data.spotify_artist.spotify_artist_name)
|
|
|
|| artist.name;
|
|
|
if (watchlistId && watchlistName) {
|
|
|
initializeLibraryWatchlistButton(watchlistId, watchlistName);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Load Similar Artists section (works for both library + source artists via
|
|
|
// MusicMap name lookup). Fire-and-forget — the function handles its own
|
|
|
// loading state and errors.
|
|
|
if (artist && artist.name && typeof loadSimilarArtists === 'function') {
|
|
|
loadSimilarArtists(artist.name);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function updateArtistDetailImage(imageUrl, artistName) {
|
|
|
const imageElement = document.getElementById("artist-detail-image");
|
|
|
const fallbackElement = document.getElementById("artist-image-fallback");
|
|
|
|
|
|
if (imageUrl && imageUrl.trim() !== "") {
|
|
|
imageElement.src = imageUrl;
|
|
|
imageElement.alt = artistName;
|
|
|
imageElement.classList.remove("hidden");
|
|
|
fallbackElement.classList.add("hidden");
|
|
|
|
|
|
imageElement.onerror = () => {
|
|
|
console.log(`Failed to load artist image for ${artistName}: ${imageUrl}`);
|
|
|
// Replace with fallback on error
|
|
|
imageElement.classList.add("hidden");
|
|
|
fallbackElement.classList.remove("hidden");
|
|
|
};
|
|
|
|
|
|
imageElement.onload = () => {
|
|
|
console.log(`Successfully loaded artist image for ${artistName}: ${imageUrl}`);
|
|
|
};
|
|
|
} else {
|
|
|
console.log(`No image URL for ${artistName}: '${imageUrl}'`);
|
|
|
imageElement.classList.add("hidden");
|
|
|
fallbackElement.classList.remove("hidden");
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function updateArtistGenres(genres) {
|
|
|
const genresContainer = document.getElementById("artist-genres");
|
|
|
if (!genresContainer) return;
|
|
|
|
|
|
genresContainer.innerHTML = "";
|
|
|
|
|
|
// Clear any previous artist format tags (they arrive later via streaming)
|
|
|
const oldFormats = genresContainer.parentElement?.querySelector('.artist-formats');
|
|
|
if (oldFormats) oldFormats.remove();
|
|
|
|
|
|
if (genres && genres.length > 0) {
|
|
|
genres.forEach(genre => {
|
|
|
const genreTag = document.createElement("span");
|
|
|
genreTag.className = "genre-tag";
|
|
|
genreTag.textContent = genre;
|
|
|
genresContainer.appendChild(genreTag);
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function updateArtistSummaryStats(discography) {
|
|
|
const allReleases = [...discography.albums, ...discography.eps, ...discography.singles];
|
|
|
const hasChecking = allReleases.some(r => r.owned === null);
|
|
|
|
|
|
const ownedAlbums = discography.albums.filter(album => album.owned === true).length;
|
|
|
const missingAlbums = discography.albums.filter(album => album.owned === false).length;
|
|
|
const totalAlbums = discography.albums.length;
|
|
|
const completionPercentage = totalAlbums > 0 ? Math.round((ownedAlbums / totalAlbums) * 100) : 0;
|
|
|
|
|
|
// Update owned albums count
|
|
|
const ownedElement = document.getElementById("owned-albums-count");
|
|
|
if (ownedElement) {
|
|
|
ownedElement.textContent = hasChecking ? '...' : ownedAlbums;
|
|
|
}
|
|
|
|
|
|
// Update missing albums count
|
|
|
const missingElement = document.getElementById("missing-albums-count");
|
|
|
if (missingElement) {
|
|
|
missingElement.textContent = hasChecking ? '...' : missingAlbums;
|
|
|
}
|
|
|
|
|
|
// Update completion percentage
|
|
|
const completionElement = document.getElementById("completion-percentage");
|
|
|
if (completionElement) {
|
|
|
completionElement.textContent = hasChecking ? 'Checking...' : `${completionPercentage}%`;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function updateArtistHeaderStats(albumCount, trackCount) {
|
|
|
// This function is deprecated - now using updateArtistHeroSection
|
|
|
console.log("📊 Using new hero section instead of old header stats");
|
|
|
}
|
|
|
|
|
|
function updateArtistHeroSection(artist, discography) {
|
|
|
console.log("🖼️ Updating artist hero section");
|
|
|
|
|
|
// Blurred background image (inline-Artists hero treatment) — set whenever
|
|
|
// we have an image_url; falls back to clearing the bg if not.
|
|
|
const heroBg = document.getElementById("artist-detail-hero-bg");
|
|
|
if (heroBg) {
|
|
|
if (artist.image_url && artist.image_url.trim() !== "" && artist.image_url !== "null") {
|
|
|
heroBg.style.backgroundImage = `url('${artist.image_url}')`;
|
|
|
} else {
|
|
|
heroBg.style.backgroundImage = '';
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Update artist image with detailed debugging
|
|
|
const imageElement = document.getElementById("artist-detail-image");
|
|
|
const fallbackElement = document.getElementById("artist-detail-image-fallback");
|
|
|
|
|
|
console.log(`🖼️ Debug Artist image info:`);
|
|
|
console.log(` - URL: '${artist.image_url}'`);
|
|
|
console.log(` - Type: ${typeof artist.image_url}`);
|
|
|
console.log(` - Full artist object:`, artist);
|
|
|
console.log(` - Image element:`, imageElement);
|
|
|
console.log(` - Fallback element:`, fallbackElement);
|
|
|
|
|
|
if (artist.image_url && artist.image_url.trim() !== "" && artist.image_url !== "null") {
|
|
|
console.log(`✅ Setting image src to: ${artist.image_url}`);
|
|
|
imageElement.src = artist.image_url;
|
|
|
imageElement.alt = artist.name;
|
|
|
imageElement.style.display = "block";
|
|
|
if (fallbackElement) {
|
|
|
fallbackElement.style.display = "none";
|
|
|
}
|
|
|
|
|
|
imageElement.onload = () => {
|
|
|
console.log(`✅ Successfully loaded artist image: ${artist.image_url}`);
|
|
|
};
|
|
|
|
|
|
imageElement.onerror = () => {
|
|
|
console.error(`❌ Failed to load artist image: ${artist.image_url}`);
|
|
|
// Try Deezer fallback before emoji
|
|
|
if (artist.deezer_id && !imageElement.dataset.triedDeezer) {
|
|
|
imageElement.dataset.triedDeezer = 'true';
|
|
|
imageElement.src = `https://api.deezer.com/artist/${artist.deezer_id}/image?size=big`;
|
|
|
} else {
|
|
|
imageElement.style.display = "none";
|
|
|
if (fallbackElement) {
|
|
|
fallbackElement.style.display = "flex";
|
|
|
}
|
|
|
}
|
|
|
};
|
|
|
} else {
|
|
|
console.log(`🖼️ No valid image URL - showing fallback for ${artist.name}`);
|
|
|
imageElement.style.display = "none";
|
|
|
if (fallbackElement) {
|
|
|
fallbackElement.style.display = "flex";
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Update artist name
|
|
|
const nameElement = document.getElementById("artist-detail-name");
|
|
|
if (nameElement) {
|
|
|
nameElement.textContent = artist.name;
|
|
|
}
|
|
|
|
|
|
// Calculate and update stats for each category
|
|
|
updateCategoryStats('albums', discography.albums);
|
|
|
updateCategoryStats('eps', discography.eps);
|
|
|
updateCategoryStats('singles', discography.singles);
|
|
|
|
|
|
// Show Download Discography button(s) if there are any releases
|
|
|
const _totalReleases = (discography.albums?.length || 0) + (discography.eps?.length || 0) + (discography.singles?.length || 0);
|
|
|
const _discogWrap = document.getElementById('discog-download-wrap');
|
|
|
if (_discogWrap) _discogWrap.style.display = _totalReleases > 0 ? '' : 'none';
|
|
|
const _discogBtnArtists = document.getElementById('discog-download-btn-artists');
|
|
|
if (_discogBtnArtists) _discogBtnArtists.style.display = _totalReleases > 0 ? '' : 'none';
|
|
|
|
|
|
// Last.fm stats (listeners / playcount)
|
|
|
const _fmtNum = (n) => {
|
|
|
if (!n || n <= 0) return '0';
|
|
|
if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
|
|
|
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
|
|
|
return n.toLocaleString();
|
|
|
};
|
|
|
|
|
|
const listenersEl = document.getElementById('artist-hero-listeners');
|
|
|
if (listenersEl) {
|
|
|
if (artist.lastfm_listeners) {
|
|
|
listenersEl.querySelector('.hero-stat-value').textContent = _fmtNum(artist.lastfm_listeners);
|
|
|
listenersEl.style.display = '';
|
|
|
} else {
|
|
|
listenersEl.style.display = 'none';
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const playcountEl = document.getElementById('artist-hero-playcount');
|
|
|
if (playcountEl) {
|
|
|
if (artist.lastfm_playcount) {
|
|
|
playcountEl.querySelector('.hero-stat-value').textContent = _fmtNum(artist.lastfm_playcount);
|
|
|
playcountEl.style.display = '';
|
|
|
} else {
|
|
|
playcountEl.style.display = 'none';
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Last.fm bio
|
|
|
const bioEl = document.getElementById('artist-hero-bio');
|
|
|
if (bioEl) {
|
|
|
const bio = artist.lastfm_bio;
|
|
|
if (bio && bio.trim()) {
|
|
|
// Strip HTML tags and "Read more on Last.fm" links
|
|
|
let cleanBio = bio.replace(/<a\b[^>]*>.*?<\/a>/gi, '').replace(/<[^>]+>/g, '').trim();
|
|
|
if (cleanBio) {
|
|
|
bioEl.innerHTML = `<span class="bio-text">${cleanBio}</span>
|
|
|
<span class="artist-hero-bio-toggle" onclick="this.parentElement.classList.toggle('expanded');this.textContent=this.parentElement.classList.contains('expanded')?'Show less':'Read more'">Read more</span>`;
|
|
|
bioEl.style.display = '';
|
|
|
} else {
|
|
|
bioEl.style.display = 'none';
|
|
|
}
|
|
|
} else {
|
|
|
bioEl.style.display = 'none';
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Last.fm tags — merge with existing genres (deduplicate)
|
|
|
if (artist.lastfm_tags) {
|
|
|
try {
|
|
|
let lfmTags = typeof artist.lastfm_tags === 'string' ? JSON.parse(artist.lastfm_tags) : artist.lastfm_tags;
|
|
|
if (Array.isArray(lfmTags) && lfmTags.length > 0) {
|
|
|
const existingGenres = new Set((artist.genres || []).map(g => g.toLowerCase()));
|
|
|
const newTags = lfmTags.filter(t => !existingGenres.has(t.toLowerCase())).slice(0, 5);
|
|
|
if (newTags.length > 0) {
|
|
|
const genresContainer = document.getElementById('artist-genres');
|
|
|
if (genresContainer) {
|
|
|
newTags.forEach(tag => {
|
|
|
const el = document.createElement('span');
|
|
|
el.className = 'genre-tag';
|
|
|
el.textContent = tag;
|
|
|
el.style.opacity = '0.6';
|
|
|
genresContainer.appendChild(el);
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
} catch (e) {
|
|
|
console.debug('Failed to parse Last.fm tags:', e);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Lazy-load top tracks sidebar
|
|
|
if (artist.lastfm_url || artist.lastfm_listeners) {
|
|
|
_loadArtistTopTracks(artist.name);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function _loadArtistTopTracks(artistName) {
|
|
|
const sidebar = document.getElementById('artist-hero-sidebar');
|
|
|
const container = document.getElementById('hero-top-tracks');
|
|
|
if (!sidebar || !container) return;
|
|
|
|
|
|
try {
|
|
|
const resp = await fetch(`/api/artist/0/lastfm-top-tracks?name=${encodeURIComponent(artistName)}`);
|
|
|
const data = await resp.json();
|
|
|
if (!data.success || !data.tracks || data.tracks.length === 0) {
|
|
|
sidebar.style.display = 'none';
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const _fmtNum = (n) => {
|
|
|
if (!n || n <= 0) return '0';
|
|
|
if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
|
|
|
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
|
|
|
return n.toLocaleString();
|
|
|
};
|
|
|
|
|
|
const _escAttr = (s) => (s || '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
|
container.innerHTML = data.tracks.map((t, i) => `
|
|
|
<div class="hero-top-track">
|
|
|
<span class="hero-top-track-num">${i + 1}</span>
|
|
|
<button class="hero-top-track-play" data-track="${_escAttr(t.name)}" data-artist="${_escAttr(artistName)}" title="Play">▶</button>
|
|
|
<span class="hero-top-track-name" title="${_escAttr(t.name)}">${_escAttr(t.name)}</span>
|
|
|
<span class="hero-top-track-plays">${_fmtNum(t.playcount)}</span>
|
|
|
</div>
|
|
|
`).join('');
|
|
|
|
|
|
// Attach play handlers via delegation (avoids inline JS escaping issues)
|
|
|
container.onclick = (e) => {
|
|
|
const btn = e.target.closest('.hero-top-track-play');
|
|
|
if (btn) {
|
|
|
e.stopPropagation();
|
|
|
playStatsTrack(btn.dataset.track, btn.dataset.artist, '');
|
|
|
}
|
|
|
};
|
|
|
sidebar.style.display = '';
|
|
|
} catch (e) {
|
|
|
console.debug('Failed to load top tracks:', e);
|
|
|
sidebar.style.display = 'none';
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function updateCategoryStats(category, releases) {
|
|
|
const hasChecking = releases.some(r => r.owned === null);
|
|
|
const owned = releases.filter(r => r.owned === true).length;
|
|
|
const total = releases.length;
|
|
|
const completion = total > 0 ? Math.round((owned / total) * 100) : 100;
|
|
|
|
|
|
// Update stats text (compact: "3/12")
|
|
|
const statsElement = document.getElementById(`${category}-stats`);
|
|
|
if (statsElement) {
|
|
|
statsElement.textContent = hasChecking ? '...' : `${owned}/${total}`;
|
|
|
}
|
|
|
|
|
|
// Update completion bar
|
|
|
const fillElement = document.getElementById(`${category}-completion-fill`);
|
|
|
if (fillElement) {
|
|
|
if (hasChecking) {
|
|
|
fillElement.style.width = '100%';
|
|
|
fillElement.classList.add('checking');
|
|
|
} else {
|
|
|
fillElement.style.width = `${completion}%`;
|
|
|
fillElement.classList.remove('checking');
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function populateDiscographySections(discography) {
|
|
|
// Populate albums
|
|
|
populateReleaseSection('albums', discography.albums);
|
|
|
|
|
|
// Populate EPs
|
|
|
populateReleaseSection('eps', discography.eps);
|
|
|
|
|
|
// Populate singles
|
|
|
populateReleaseSection('singles', discography.singles);
|
|
|
|
|
|
// Apply any active filters after populating
|
|
|
applyDiscographyFilters();
|
|
|
}
|
|
|
|
|
|
function populateReleaseSection(sectionType, releases) {
|
|
|
const gridId = `${sectionType}-grid`;
|
|
|
const ownedCountId = `${sectionType}-owned-count`;
|
|
|
const missingCountId = `${sectionType}-missing-count`;
|
|
|
|
|
|
const grid = document.getElementById(gridId);
|
|
|
if (!grid) return;
|
|
|
|
|
|
// Clear existing content
|
|
|
grid.innerHTML = "";
|
|
|
|
|
|
const hasChecking = releases.some(r => r.owned === null);
|
|
|
const ownedCount = releases.filter(release => release.owned === true).length;
|
|
|
const missingCount = releases.filter(release => release.owned === false).length;
|
|
|
|
|
|
// Update section stats
|
|
|
const ownedElement = document.getElementById(ownedCountId);
|
|
|
const missingElement = document.getElementById(missingCountId);
|
|
|
|
|
|
if (ownedElement) {
|
|
|
ownedElement.textContent = hasChecking ? 'Checking...' : `${ownedCount} owned`;
|
|
|
}
|
|
|
|
|
|
if (missingElement) {
|
|
|
missingElement.textContent = hasChecking ? '' : `${missingCount} missing`;
|
|
|
}
|
|
|
|
|
|
// Create release cards
|
|
|
releases.forEach((release, index) => {
|
|
|
const card = createReleaseCard(release);
|
|
|
grid.appendChild(card);
|
|
|
});
|
|
|
|
|
|
// Trigger lazy background-image loading on the new cards
|
|
|
if (typeof observeLazyBackgrounds === 'function') {
|
|
|
observeLazyBackgrounds(grid);
|
|
|
}
|
|
|
|
|
|
console.log(`📀 Populated ${sectionType} section: ${ownedCount} owned, ${missingCount} missing`);
|
|
|
}
|
|
|
|
|
|
function createReleaseCard(release) {
|
|
|
const card = document.createElement("div");
|
|
|
const isChecking = release.owned === null;
|
|
|
// .release-card keeps existing filter/state CSS + JS queries working;
|
|
|
// .album-card adopts the big-photo visual treatment from the retired
|
|
|
// inline Artists page (full-bleed image, gradient overlay, info pinned).
|
|
|
let stateCls = '';
|
|
|
if (isChecking) stateCls = ' checking';
|
|
|
else if (release.owned === false) stateCls = ' missing';
|
|
|
card.className = `release-card album-card${stateCls}`;
|
|
|
|
|
|
const releaseId = release.id || "";
|
|
|
card.setAttribute("data-release-id", releaseId);
|
|
|
card.setAttribute("data-album-id", releaseId);
|
|
|
card.setAttribute("data-album-name", release.title || "");
|
|
|
card.setAttribute("data-album-type", release.album_type || "album");
|
|
|
// Store mutable reference so stream updates propagate to click handler
|
|
|
card._releaseData = release;
|
|
|
|
|
|
// Tag card for content-type filtering
|
|
|
const livePattern = /\b(live)\b|\(live[^)]*\)|\[live[^]]*\]/i;
|
|
|
const compilationPattern = /\b(greatest hits|best of|collection|anthology|essential)\b/i;
|
|
|
const featuredPattern = /\(?\bfeat\.?\s|\bft\.?\s|\bfeaturing\b/i;
|
|
|
const isLive = livePattern.test(release.title || '') || (release.album_type === 'compilation' && livePattern.test(release.title || ''));
|
|
|
const isCompilation = (release.album_type === 'compilation') || compilationPattern.test(release.title || '');
|
|
|
const isFeatured = featuredPattern.test(release.title || '');
|
|
|
card.setAttribute("data-is-live", isLive ? "true" : "false");
|
|
|
card.setAttribute("data-is-compilation", isCompilation ? "true" : "false");
|
|
|
card.setAttribute("data-is-featured", isFeatured ? "true" : "false");
|
|
|
|
|
|
// Background image — use data-bg-src for IntersectionObserver lazy loading
|
|
|
// (observeLazyBackgrounds is called by the caller after appending the grid).
|
|
|
const imageDiv = document.createElement("div");
|
|
|
imageDiv.className = "album-card-image";
|
|
|
if (release.image_url && release.image_url.trim() !== "") {
|
|
|
imageDiv.dataset.bgSrc = release.image_url;
|
|
|
}
|
|
|
card.appendChild(imageDiv);
|
|
|
|
|
|
// Completion overlay — top-right floating badge. For library artists this
|
|
|
// shows the ownership state; for source artists (no library data) the
|
|
|
// overlay is omitted entirely so the card just shows the artwork + title.
|
|
|
const isSourceContext = (document.body.dataset.artistSource === 'source');
|
|
|
if (!isSourceContext) {
|
|
|
const overlay = document.createElement("div");
|
|
|
let overlayCls = '';
|
|
|
let overlayLabel = '';
|
|
|
|
|
|
if (isChecking || release.track_completion === 'checking') {
|
|
|
overlayCls = 'checking';
|
|
|
overlayLabel = 'Checking...';
|
|
|
} else if (release.owned) {
|
|
|
const tc = release.track_completion;
|
|
|
if (tc && typeof tc === 'object') {
|
|
|
const ownedTracks = tc.owned_tracks || 0;
|
|
|
const totalTracks = tc.total_tracks || 0;
|
|
|
const missingTracks = tc.missing_tracks || 0;
|
|
|
if (missingTracks === 0) {
|
|
|
overlayCls = 'completed';
|
|
|
overlayLabel = '✓ Owned';
|
|
|
} else {
|
|
|
const pct = totalTracks > 0 ? Math.round((ownedTracks / totalTracks) * 100) : 0;
|
|
|
overlayCls = pct >= 75 ? 'nearly_complete' : 'partial';
|
|
|
overlayLabel = `${ownedTracks}/${totalTracks}`;
|
|
|
}
|
|
|
} else {
|
|
|
const pct = release.track_completion || 100;
|
|
|
if (pct === 100) {
|
|
|
overlayCls = 'completed';
|
|
|
overlayLabel = '✓ Owned';
|
|
|
} else {
|
|
|
overlayCls = pct >= 75 ? 'nearly_complete' : 'partial';
|
|
|
overlayLabel = `${pct}%`;
|
|
|
}
|
|
|
}
|
|
|
} else {
|
|
|
overlayCls = 'missing';
|
|
|
overlayLabel = 'Missing';
|
|
|
}
|
|
|
|
|
|
overlay.className = `completion-overlay ${overlayCls}`;
|
|
|
overlay.innerHTML = `<span class="completion-status">${overlayLabel}</span>`;
|
|
|
card.appendChild(overlay);
|
|
|
}
|
|
|
|
|
|
// Year — extract from release_date or fall back to year field
|
|
|
let yearText = "";
|
|
|
if (release.release_date) {
|
|
|
try {
|
|
|
const yearMatch = release.release_date.match(/^(\d{4})/);
|
|
|
if (yearMatch) {
|
|
|
const ry = parseInt(yearMatch[1]);
|
|
|
if (ry && ry > 1900 && ry <= new Date().getFullYear() + 1) yearText = ry.toString();
|
|
|
} else {
|
|
|
const ry = new Date(release.release_date).getFullYear();
|
|
|
if (ry && !isNaN(ry) && ry > 1900 && ry <= new Date().getFullYear() + 1) yearText = ry.toString();
|
|
|
}
|
|
|
} catch (e) { /* fall through */ }
|
|
|
}
|
|
|
if (!yearText && release.year) yearText = release.year.toString();
|
|
|
|
|
|
// Content (bottom-pinned over gradient)
|
|
|
const content = document.createElement("div");
|
|
|
content.className = "album-card-content";
|
|
|
const _esc = (s) => String(s || '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
|
content.innerHTML = `
|
|
|
<div class="album-card-name" title="${_esc(release.title)}">${_esc(release.title)}</div>
|
|
|
${yearText ? `<div class="album-card-year">${_esc(yearText)}</div>` : ''}
|
|
|
`;
|
|
|
card.appendChild(content);
|
|
|
|
|
|
// Add MusicBrainz icon LAST so it sits above the gradient overlay
|
|
|
if (release.musicbrainz_release_id) {
|
|
|
const mbIcon = document.createElement("div");
|
|
|
mbIcon.className = "mb-card-icon";
|
|
|
mbIcon.title = "View on MusicBrainz";
|
|
|
mbIcon.innerHTML = `<img src="${MUSICBRAINZ_LOGO_URL}" style="width: 20px; height: auto; display: block;">`;
|
|
|
mbIcon.onclick = (e) => {
|
|
|
e.stopPropagation();
|
|
|
window.open(`https://musicbrainz.org/release/${release.musicbrainz_release_id}`, '_blank');
|
|
|
};
|
|
|
card.appendChild(mbIcon);
|
|
|
}
|
|
|
|
|
|
// Add click handler for release card (uses card._releaseData for mutable reference)
|
|
|
card.addEventListener("click", async () => {
|
|
|
const rel = card._releaseData;
|
|
|
console.log(`Clicked on release: ${rel.title} (Owned: ${rel.owned})`);
|
|
|
|
|
|
// Still checking - ignore click
|
|
|
if (rel.owned === null) {
|
|
|
showToast(`Still checking ownership for ${rel.title}...`, "info");
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
showLoadingOverlay('Loading album...');
|
|
|
|
|
|
// For missing or incomplete releases, open wishlist modal
|
|
|
try {
|
|
|
// Convert release object to album format expected by our function
|
|
|
const albumData = {
|
|
|
id: rel.id,
|
|
|
name: rel.title,
|
|
|
image_url: rel.image_url,
|
|
|
release_date: rel.year ? `${rel.year}-01-01` : '',
|
|
|
album_type: rel.album_type || rel.type || 'album',
|
|
|
total_tracks: (rel.track_completion && typeof rel.track_completion === 'object')
|
|
|
? rel.track_completion.total_tracks : (rel.track_count || 1)
|
|
|
};
|
|
|
|
|
|
// Get current artist from artist detail page state
|
|
|
const currentArtist = artistDetailPageState.currentArtistName ? {
|
|
|
id: artistDetailPageState.currentArtistId,
|
|
|
name: artistDetailPageState.currentArtistName,
|
|
|
image_url: getArtistImageFromPage() || '', // Get artist image from page
|
|
|
source: artistDetailPageState.currentArtistSource || null
|
|
|
} : null;
|
|
|
|
|
|
if (!currentArtist) {
|
|
|
console.error('❌ No current artist found for release click');
|
|
|
showToast('Error: No artist information available', 'error');
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// Load tracks for the album (pass name/artist for Hydrabase support)
|
|
|
const _aat2 = new URLSearchParams({ name: albumData.name || '', artist: currentArtist.name || '' });
|
|
|
if (currentArtist.source) {
|
|
|
_aat2.set('source', currentArtist.source);
|
|
|
}
|
|
|
const response = await fetch(`/api/album/${albumData.id}/tracks?${_aat2}`);
|
|
|
if (!response.ok) {
|
|
|
throw new Error(`Failed to load album tracks: ${response.status}`);
|
|
|
}
|
|
|
|
|
|
const data = await response.json();
|
|
|
if (!data.success || !data.tracks || data.tracks.length === 0) {
|
|
|
throw new Error('No tracks found for this release');
|
|
|
}
|
|
|
|
|
|
// Use the actual album type from release data
|
|
|
const albumType = rel.album_type || rel.type || 'album';
|
|
|
|
|
|
// Open the Add to Wishlist modal immediately (no waiting for ownership check)
|
|
|
hideLoadingOverlay();
|
|
|
await openAddToWishlistModal(albumData, currentArtist, data.tracks, albumType);
|
|
|
|
|
|
// Always lazy-load track ownership + metadata (non-blocking)
|
|
|
lazyLoadTrackOwnership(currentArtist.name, data.tracks, card, albumData.name);
|
|
|
|
|
|
} catch (error) {
|
|
|
hideLoadingOverlay();
|
|
|
console.error('❌ Error handling release click:', error);
|
|
|
showToast(`Error opening wishlist modal: ${error.message}`, 'error');
|
|
|
}
|
|
|
});
|
|
|
|
|
|
return card;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Helper function to get artist image from the current artist detail page
|
|
|
*/
|
|
|
function getArtistImageFromPage() {
|
|
|
try {
|
|
|
// Try to get from artist detail image element
|
|
|
const artistDetailImage = document.getElementById('artist-detail-image');
|
|
|
if (artistDetailImage && artistDetailImage.src && artistDetailImage.src !== window.location.href) {
|
|
|
return artistDetailImage.src;
|
|
|
}
|
|
|
|
|
|
// Try to get from artist hero image
|
|
|
const artistImage = document.getElementById('artist-image');
|
|
|
if (artistImage) {
|
|
|
const bgImage = window.getComputedStyle(artistImage).backgroundImage;
|
|
|
if (bgImage && bgImage !== 'none') {
|
|
|
// Extract URL from CSS background-image
|
|
|
const urlMatch = bgImage.match(/url\(["']?(.*?)["']?\)/);
|
|
|
if (urlMatch && urlMatch[1]) {
|
|
|
return urlMatch[1];
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
} catch (error) {
|
|
|
console.warn('Error getting artist image from page:', error);
|
|
|
return null;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// ================================================================================================
|
|
|
// LIBRARY COMPLETION STREAMING - Two-phase lazy-load pattern
|
|
|
// ================================================================================================
|
|
|
|
|
|
async function checkLibraryCompletion(artistName, discography) {
|
|
|
// Abort any in-progress check
|
|
|
if (artistDetailPageState.completionController) {
|
|
|
artistDetailPageState.completionController.abort();
|
|
|
}
|
|
|
artistDetailPageState.completionController = new AbortController();
|
|
|
|
|
|
const payload = {
|
|
|
artist_name: artistName,
|
|
|
albums: discography.albums || [],
|
|
|
eps: discography.eps || [],
|
|
|
singles: discography.singles || [],
|
|
|
source: discography?.source || null
|
|
|
};
|
|
|
|
|
|
try {
|
|
|
const response = await fetch('/api/library/completion-stream', {
|
|
|
method: 'POST',
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
body: JSON.stringify(payload),
|
|
|
signal: artistDetailPageState.completionController.signal
|
|
|
});
|
|
|
|
|
|
if (!response.ok) {
|
|
|
console.error(`❌ Completion stream failed: ${response.status}`);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const reader = response.body.getReader();
|
|
|
const decoder = new TextDecoder();
|
|
|
let buffer = '';
|
|
|
let ownedCounts = { albums: 0, eps: 0, singles: 0 };
|
|
|
let totalCounts = { albums: 0, eps: 0, singles: 0 };
|
|
|
const artistFormatSet = new Set();
|
|
|
|
|
|
while (true) {
|
|
|
const { done, value } = await reader.read();
|
|
|
if (done) break;
|
|
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
const lines = buffer.split('\n');
|
|
|
buffer = lines.pop(); // Keep incomplete line in buffer
|
|
|
|
|
|
for (const line of lines) {
|
|
|
if (!line.startsWith('data: ')) continue;
|
|
|
try {
|
|
|
const eventData = JSON.parse(line.slice(6));
|
|
|
if (eventData.type === 'completion') {
|
|
|
updateLibraryReleaseCard(eventData);
|
|
|
totalCounts[eventData.category]++;
|
|
|
if (eventData.status !== 'missing' && eventData.status !== 'error') {
|
|
|
ownedCounts[eventData.category]++;
|
|
|
// Accumulate formats for artist-level summary
|
|
|
if (eventData.formats) {
|
|
|
eventData.formats.forEach(f => artistFormatSet.add(f));
|
|
|
}
|
|
|
}
|
|
|
// Update stats incrementally
|
|
|
updateCategoryStatsFromStream(
|
|
|
eventData.category,
|
|
|
ownedCounts[eventData.category],
|
|
|
totalCounts[eventData.category] - ownedCounts[eventData.category]
|
|
|
);
|
|
|
} else if (eventData.type === 'complete') {
|
|
|
console.log(`✅ Library completion stream done: ${eventData.processed_count} items`);
|
|
|
// Final stats recalculation
|
|
|
recalculateSummaryStats(artistFormatSet);
|
|
|
}
|
|
|
} catch (parseError) {
|
|
|
console.warn('Error parsing SSE event:', parseError, line);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
} catch (error) {
|
|
|
if (error.name === 'AbortError') {
|
|
|
console.log('🛑 Library completion stream aborted (navigation)');
|
|
|
} else {
|
|
|
console.error('❌ Error in library completion stream:', error);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function updateLibraryReleaseCard(data) {
|
|
|
const releaseId = data.id || "";
|
|
|
const card = document.querySelector(`[data-release-id="${releaseId}"]`);
|
|
|
if (!card) return;
|
|
|
|
|
|
const isOwned = data.status !== 'missing' && data.status !== 'error';
|
|
|
|
|
|
// Update card class
|
|
|
card.classList.remove('checking', 'missing');
|
|
|
if (!isOwned) {
|
|
|
card.classList.add('missing');
|
|
|
}
|
|
|
|
|
|
// Use real numbers — no rounding or overrides
|
|
|
const isComplete = data.owned_tracks >= data.expected_tracks && data.owned_tracks > 0;
|
|
|
const effectiveMissing = data.expected_tracks - data.owned_tracks;
|
|
|
|
|
|
// Update the mutable release data on the card
|
|
|
if (card._releaseData) {
|
|
|
card._releaseData.owned = isOwned;
|
|
|
if (isOwned && data.expected_tracks > 0) {
|
|
|
card._releaseData.track_completion = {
|
|
|
owned_tracks: data.owned_tracks,
|
|
|
total_tracks: isComplete ? data.owned_tracks : data.expected_tracks,
|
|
|
percentage: isComplete ? 100 : data.completion_percentage,
|
|
|
missing_tracks: effectiveMissing
|
|
|
};
|
|
|
} else if (isOwned) {
|
|
|
card._releaseData.track_completion = {
|
|
|
owned_tracks: data.owned_tracks,
|
|
|
total_tracks: data.owned_tracks,
|
|
|
percentage: 100,
|
|
|
missing_tracks: 0
|
|
|
};
|
|
|
} else {
|
|
|
card._releaseData.track_completion = 0;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Update the floating completion-overlay badge (new big-photo card markup).
|
|
|
const overlay = card.querySelector('.completion-overlay');
|
|
|
const overlayStatus = overlay && overlay.querySelector('.completion-status');
|
|
|
if (overlay && overlayStatus) {
|
|
|
overlay.classList.remove('checking', 'completed', 'nearly_complete', 'partial', 'missing', 'error');
|
|
|
let badgeCls = '';
|
|
|
let badgeText = '';
|
|
|
let badgeTitle = '';
|
|
|
if (isOwned) {
|
|
|
if (effectiveMissing <= 0) {
|
|
|
badgeCls = 'completed';
|
|
|
badgeText = '✓ Owned';
|
|
|
badgeTitle = `Complete (${data.owned_tracks} tracks)`;
|
|
|
} else {
|
|
|
const pct = data.completion_percentage || Math.round((data.owned_tracks / data.expected_tracks) * 100);
|
|
|
badgeCls = pct >= 75 ? 'nearly_complete' : 'partial';
|
|
|
badgeText = `${data.owned_tracks}/${data.expected_tracks}`;
|
|
|
badgeTitle = `Missing ${effectiveMissing} track${effectiveMissing !== 1 ? 's' : ''}`;
|
|
|
}
|
|
|
} else {
|
|
|
badgeCls = 'missing';
|
|
|
badgeText = 'Missing';
|
|
|
badgeTitle = data.expected_tracks > 0
|
|
|
? `${data.expected_tracks} track${data.expected_tracks !== 1 ? 's' : ''} not in library`
|
|
|
: 'Not in library';
|
|
|
}
|
|
|
overlay.classList.add(badgeCls);
|
|
|
overlayStatus.textContent = badgeText;
|
|
|
overlay.title = badgeTitle;
|
|
|
}
|
|
|
|
|
|
// Display format tags on owned releases
|
|
|
if (isOwned && data.formats && data.formats.length > 0) {
|
|
|
// Store formats on release data for modal use
|
|
|
if (card._releaseData) {
|
|
|
card._releaseData.formats = data.formats;
|
|
|
}
|
|
|
// Remove any existing format tags
|
|
|
const existingFormats = card.querySelector('.release-formats');
|
|
|
if (existingFormats) existingFormats.remove();
|
|
|
|
|
|
const formatsDiv = document.createElement('div');
|
|
|
formatsDiv.className = 'release-formats';
|
|
|
formatsDiv.innerHTML = data.formats.map(f => `<span class="release-format-tag">${f}</span>`).join('');
|
|
|
card.appendChild(formatsDiv);
|
|
|
}
|
|
|
|
|
|
// Re-apply filters so newly resolved cards respect active filters
|
|
|
applyDiscographyFilters();
|
|
|
}
|
|
|
|
|
|
function updateCategoryStatsFromStream(category, ownedCount, missingCount) {
|
|
|
const total = ownedCount + missingCount;
|
|
|
const completion = total > 0 ? Math.round((ownedCount / total) * 100) : 100;
|
|
|
|
|
|
const statsElement = document.getElementById(`${category}-stats`);
|
|
|
if (statsElement) {
|
|
|
statsElement.textContent = `${ownedCount}/${total}`;
|
|
|
}
|
|
|
|
|
|
const fillElement = document.getElementById(`${category}-completion-fill`);
|
|
|
if (fillElement) {
|
|
|
fillElement.classList.remove('checking');
|
|
|
fillElement.style.width = `${completion}%`;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function recalculateSummaryStats(artistFormatSet) {
|
|
|
const disc = artistDetailPageState.currentDiscography;
|
|
|
if (!disc) return;
|
|
|
|
|
|
// Recalculate from the live card data
|
|
|
const categories = ['albums', 'eps', 'singles'];
|
|
|
for (const cat of categories) {
|
|
|
const grid = document.getElementById(`${cat}-grid`);
|
|
|
if (!grid) continue;
|
|
|
let owned = 0, missing = 0;
|
|
|
grid.querySelectorAll('.release-card').forEach(card => {
|
|
|
if (card._releaseData) {
|
|
|
if (card._releaseData.owned === true) owned++;
|
|
|
else if (card._releaseData.owned === false) missing++;
|
|
|
}
|
|
|
});
|
|
|
updateCategoryStatsFromStream(cat, owned, missing);
|
|
|
}
|
|
|
|
|
|
// Update summary stats (albums only, matches original behavior)
|
|
|
const albumGrid = document.getElementById('albums-grid');
|
|
|
if (albumGrid) {
|
|
|
let ownedAlbums = 0, missingAlbums = 0;
|
|
|
albumGrid.querySelectorAll('.release-card').forEach(card => {
|
|
|
if (card._releaseData) {
|
|
|
if (card._releaseData.owned === true) ownedAlbums++;
|
|
|
else if (card._releaseData.owned === false) missingAlbums++;
|
|
|
}
|
|
|
});
|
|
|
const total = ownedAlbums + missingAlbums;
|
|
|
const pct = total > 0 ? Math.round((ownedAlbums / total) * 100) : 0;
|
|
|
|
|
|
const ownedEl = document.getElementById("owned-albums-count");
|
|
|
if (ownedEl) ownedEl.textContent = ownedAlbums;
|
|
|
const missingEl = document.getElementById("missing-albums-count");
|
|
|
if (missingEl) missingEl.textContent = missingAlbums;
|
|
|
const completionEl = document.getElementById("completion-percentage");
|
|
|
if (completionEl) completionEl.textContent = `${pct}%`;
|
|
|
}
|
|
|
|
|
|
// Display artist-level format summary
|
|
|
if (artistFormatSet && artistFormatSet.size > 0) {
|
|
|
const heroInfo = document.querySelector('.artist-hero-section .artist-info');
|
|
|
if (heroInfo) {
|
|
|
// Remove any existing artist format tag
|
|
|
const existing = heroInfo.querySelector('.artist-formats');
|
|
|
if (existing) existing.remove();
|
|
|
|
|
|
const formatsDiv = document.createElement('div');
|
|
|
formatsDiv.className = 'artist-formats';
|
|
|
formatsDiv.innerHTML = [...artistFormatSet].sort()
|
|
|
.map(f => `<span class="artist-format-tag">${f}</span>`)
|
|
|
.join('');
|
|
|
// Insert after genres container
|
|
|
const genresContainer = heroInfo.querySelector('.artist-genres-container');
|
|
|
if (genresContainer && genresContainer.nextSibling) {
|
|
|
heroInfo.insertBefore(formatsDiv, genresContainer.nextSibling);
|
|
|
} else {
|
|
|
heroInfo.appendChild(formatsDiv);
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// ===============================================
|
|
|
// Discography Filter Functions
|
|
|
// ===============================================
|
|
|
|
|
|
function initializeDiscographyFilters() {
|
|
|
const container = document.getElementById('discography-filters');
|
|
|
if (!container) return;
|
|
|
|
|
|
container.addEventListener('click', (e) => {
|
|
|
const btn = e.target.closest('.discography-filter-btn');
|
|
|
if (!btn) return;
|
|
|
|
|
|
const filterType = btn.dataset.filter;
|
|
|
const value = btn.dataset.value;
|
|
|
|
|
|
if (filterType === 'category') {
|
|
|
// Multi-toggle: toggle this category on/off
|
|
|
btn.classList.toggle('active');
|
|
|
discographyFilterState.categories[value] = btn.classList.contains('active');
|
|
|
} else if (filterType === 'content') {
|
|
|
// Multi-toggle: toggle this content type on/off
|
|
|
btn.classList.toggle('active');
|
|
|
discographyFilterState.content[value] = btn.classList.contains('active');
|
|
|
} else if (filterType === 'ownership') {
|
|
|
// Single-select: deactivate siblings, activate this one
|
|
|
container.querySelectorAll('[data-filter="ownership"]').forEach(b => b.classList.remove('active'));
|
|
|
btn.classList.add('active');
|
|
|
discographyFilterState.ownership = value;
|
|
|
}
|
|
|
|
|
|
applyDiscographyFilters();
|
|
|
});
|
|
|
}
|
|
|
|
|
|
function resetDiscographyFilters() {
|
|
|
discographyFilterState.categories = { albums: true, eps: true, singles: true };
|
|
|
discographyFilterState.content = { live: true, compilations: true, featured: true };
|
|
|
discographyFilterState.ownership = 'all';
|
|
|
|
|
|
// Reset button visual states
|
|
|
const container = document.getElementById('discography-filters');
|
|
|
if (!container) return;
|
|
|
container.querySelectorAll('.discography-filter-btn').forEach(btn => {
|
|
|
const filterType = btn.dataset.filter;
|
|
|
const value = btn.dataset.value;
|
|
|
if (filterType === 'ownership') {
|
|
|
btn.classList.toggle('active', value === 'all');
|
|
|
} else {
|
|
|
btn.classList.add('active');
|
|
|
}
|
|
|
});
|
|
|
}
|
|
|
|
|
|
function applyDiscographyFilters() {
|
|
|
const categories = ['albums', 'eps', 'singles'];
|
|
|
|
|
|
for (const cat of categories) {
|
|
|
const section = document.getElementById(`${cat}-section`);
|
|
|
if (!section) continue;
|
|
|
|
|
|
// Category toggle — hide entire section
|
|
|
if (!discographyFilterState.categories[cat]) {
|
|
|
section.style.display = 'none';
|
|
|
continue;
|
|
|
}
|
|
|
section.style.display = '';
|
|
|
|
|
|
// Filter individual cards within the section
|
|
|
const grid = document.getElementById(`${cat}-grid`);
|
|
|
if (!grid) continue;
|
|
|
|
|
|
let visibleOwned = 0;
|
|
|
let visibleMissing = 0;
|
|
|
let visibleCount = 0;
|
|
|
|
|
|
grid.querySelectorAll('.release-card').forEach(card => {
|
|
|
let hidden = false;
|
|
|
|
|
|
// Content filters
|
|
|
if (!discographyFilterState.content.live && card.getAttribute('data-is-live') === 'true') {
|
|
|
hidden = true;
|
|
|
}
|
|
|
if (!discographyFilterState.content.compilations && card.getAttribute('data-is-compilation') === 'true') {
|
|
|
hidden = true;
|
|
|
}
|
|
|
if (!discographyFilterState.content.featured && card.getAttribute('data-is-featured') === 'true') {
|
|
|
hidden = true;
|
|
|
}
|
|
|
|
|
|
// Ownership filter (only apply if card is not still checking)
|
|
|
if (!hidden && discographyFilterState.ownership !== 'all' && card._releaseData) {
|
|
|
const owned = card._releaseData.owned;
|
|
|
if (owned !== null) { // Don't hide cards still being checked
|
|
|
if (discographyFilterState.ownership === 'owned' && !owned) hidden = true;
|
|
|
if (discographyFilterState.ownership === 'missing' && owned) hidden = true;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
card.style.display = hidden ? 'none' : '';
|
|
|
|
|
|
// Count visible cards for stats
|
|
|
if (!hidden && card._releaseData) {
|
|
|
visibleCount++;
|
|
|
if (card._releaseData.owned === true) visibleOwned++;
|
|
|
else if (card._releaseData.owned === false) visibleMissing++;
|
|
|
}
|
|
|
});
|
|
|
|
|
|
// Update section stats to reflect filtered view
|
|
|
const ownedEl = document.getElementById(`${cat}-owned-count`);
|
|
|
const missingEl = document.getElementById(`${cat}-missing-count`);
|
|
|
if (ownedEl) ownedEl.textContent = `${visibleOwned} owned`;
|
|
|
if (missingEl) missingEl.textContent = `${visibleMissing} missing`;
|
|
|
|
|
|
// Hide section entirely if all cards are hidden
|
|
|
section.style.display = visibleCount === 0 ? 'none' : '';
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// ==================== Download Discography Modal ====================
|
|
|
|
|
|
async function openDiscographyModal() {
|
|
|
// Support both Artists search page and Library artist detail page
|
|
|
let artist = artistsPageState.selectedArtist;
|
|
|
let discography = artistsPageState.artistDiscography;
|
|
|
let completionCache = artistsPageState.cache.completionData;
|
|
|
|
|
|
// Fallback to Library page state if Artists page has no data for THIS artist
|
|
|
const libId = artistDetailPageState.currentArtistId;
|
|
|
const libName = artistDetailPageState.currentArtistName;
|
|
|
const isLibraryPage = libId && libName;
|
|
|
const artistsPageMatchesLibrary = artist && isLibraryPage && artist.name?.toLowerCase() === libName?.toLowerCase();
|
|
|
|
|
|
if (isLibraryPage && (!artist || !discography || !artistsPageMatchesLibrary)) {
|
|
|
// On library page — don't trust stale artistsPageState from a previous Artists page search
|
|
|
artist = { id: libId, name: libName, image_url: document.getElementById('artist-detail-image')?.src || '' };
|
|
|
discography = null;
|
|
|
|
|
|
let metadataArtistId = null;
|
|
|
try {
|
|
|
showToast('Loading discography...', 'info');
|
|
|
|
|
|
// Fetch the artist's metadata IDs from the DB (enhanced view may not be loaded)
|
|
|
let lookupId = libId;
|
|
|
try {
|
|
|
const idRes = await fetch(`/api/library/artist/${libId}/enhanced`);
|
|
|
const idData = await idRes.json();
|
|
|
if (idData.success && idData.artist) {
|
|
|
const a = idData.artist;
|
|
|
metadataArtistId = a.spotify_artist_id || a.itunes_artist_id || a.deezer_id || null;
|
|
|
lookupId = metadataArtistId || libId;
|
|
|
}
|
|
|
} catch (e) {
|
|
|
console.debug('[Discography] Could not fetch artist IDs, using DB id');
|
|
|
}
|
|
|
|
|
|
const res = await fetch(`/api/artist/${encodeURIComponent(lookupId)}/discography?artist_name=${encodeURIComponent(libName)}`);
|
|
|
const data = await res.json();
|
|
|
|
|
|
if (!data.error) {
|
|
|
discography = { albums: data.albums || [], singles: data.singles || [] };
|
|
|
if (discography.albums.length > 0 || discography.singles.length > 0) {
|
|
|
artistsPageState.artistDiscography = discography;
|
|
|
artistsPageState.sourceOverride = data.source || artistsPageState.sourceOverride || null;
|
|
|
// Use metadata source ID for the modal (needed for download API calls)
|
|
|
if (metadataArtistId) artist.id = metadataArtistId;
|
|
|
artist.source = data.source || null;
|
|
|
artistsPageState.selectedArtist = artist;
|
|
|
} else {
|
|
|
discography = null;
|
|
|
}
|
|
|
}
|
|
|
} catch (e) {
|
|
|
console.error('Failed to load discography:', e);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (!artist || !discography) {
|
|
|
showToast('No discography found. Try searching this artist from the Search page instead.', 'error');
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const completionData = (completionCache || {})[artist.id] || {};
|
|
|
const allReleases = [
|
|
|
...(discography.albums || []).map(a => ({ ...a, _type: 'album' })),
|
|
|
...(discography.eps || []).map(a => ({ ...a, _type: 'ep' })),
|
|
|
...(discography.singles || []).map(a => ({ ...a, _type: 'single' })),
|
|
|
];
|
|
|
|
|
|
// Build modal
|
|
|
const overlay = document.createElement('div');
|
|
|
overlay.className = 'discog-modal-overlay';
|
|
|
overlay.id = 'discog-modal-overlay';
|
|
|
|
|
|
const artistImg = artist.image_url || '';
|
|
|
|
|
|
overlay.innerHTML = `
|
|
|
<div class="discog-modal">
|
|
|
<div class="discog-modal-hero" ${artistImg ? `style="background-image:url('${artistImg}')"` : ''}>
|
|
|
<div class="discog-modal-hero-overlay"></div>
|
|
|
<div class="discog-modal-hero-content">
|
|
|
<h2 class="discog-modal-title">Download Discography</h2>
|
|
|
<p class="discog-modal-artist">${_esc(artist.name)}</p>
|
|
|
</div>
|
|
|
<button class="discog-modal-close" onclick="closeDiscographyModal()">×</button>
|
|
|
</div>
|
|
|
<div class="discog-filter-bar">
|
|
|
<div class="discog-filters">
|
|
|
<button class="discog-filter active" data-type="album" onclick="toggleDiscogFilter(this)">Albums</button>
|
|
|
<button class="discog-filter active" data-type="ep" onclick="toggleDiscogFilter(this)">EPs</button>
|
|
|
<button class="discog-filter active" data-type="single" onclick="toggleDiscogFilter(this)">Singles</button>
|
|
|
</div>
|
|
|
<div class="discog-select-actions">
|
|
|
<button class="discog-select-btn" onclick="discogSelectAll(true)">Select All</button>
|
|
|
<button class="discog-select-btn" onclick="discogSelectAll(false)">Deselect All</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div class="discog-grid" id="discog-grid">
|
|
|
${allReleases.map((r, i) => _renderDiscogCard(r, i, completionData)).join('')}
|
|
|
</div>
|
|
|
<div class="discog-progress" id="discog-progress" style="display:none;"></div>
|
|
|
<div class="discog-footer" id="discog-footer">
|
|
|
<div class="discog-footer-info" id="discog-footer-info"></div>
|
|
|
<div class="discog-footer-actions">
|
|
|
<button class="discog-cancel-btn" onclick="closeDiscographyModal()">Cancel</button>
|
|
|
<button class="discog-submit-btn" id="discog-submit-btn">
|
|
|
<span class="discog-submit-icon">⬇</span>
|
|
|
<span id="discog-submit-text">Add to Wishlist</span>
|
|
|
</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
`;
|
|
|
|
|
|
document.body.appendChild(overlay);
|
|
|
requestAnimationFrame(() => overlay.classList.add('visible'));
|
|
|
_updateDiscogFooterCount();
|
|
|
|
|
|
// Bind submit button (avoids onclick being intercepted by helper system)
|
|
|
document.getElementById('discog-submit-btn')?.addEventListener('click', (e) => {
|
|
|
e.stopPropagation();
|
|
|
startDiscographyDownload();
|
|
|
});
|
|
|
}
|
|
|
|
|
|
function _esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
|
|
|
|
|
function _renderDiscogCard(release, index, completionData) {
|
|
|
const comp = completionData?.albums?.find(c => c.id === release.id) || completionData?.singles?.find(c => c.id === release.id);
|
|
|
const status = comp?.status || 'unknown';
|
|
|
const isOwned = status === 'completed';
|
|
|
const isPartial = status === 'partial' || status === 'nearly_complete';
|
|
|
const year = release.release_date ? release.release_date.substring(0, 4) : '';
|
|
|
const tracks = release.total_tracks || 0;
|
|
|
const img = release.image_url || '';
|
|
|
const checked = !isOwned;
|
|
|
const statusClass = isOwned ? 'owned' : isPartial ? 'partial' : '';
|
|
|
const statusIcon = isOwned ? '✓' : isPartial ? '◐' : '';
|
|
|
|
|
|
const albumName = release.name || release.title || '';
|
|
|
return `
|
|
|
<label class="discog-card ${statusClass}" data-type="${release._type}" style="animation-delay:${index * 0.03}s">
|
|
|
<input type="checkbox" class="discog-card-cb" data-album-id="${release.id}" data-album-name="${_esc(albumName)}" data-tracks="${tracks}" ${checked ? 'checked' : ''} onchange="_updateDiscogFooterCount()">
|
|
|
<div class="discog-card-art">
|
|
|
${img ? `<img src="${img}" alt="" loading="lazy">` : '<div class="discog-card-art-placeholder">🎵</div>'}
|
|
|
${statusIcon ? `<span class="discog-card-status">${statusIcon}</span>` : ''}
|
|
|
</div>
|
|
|
<div class="discog-card-info">
|
|
|
<div class="discog-card-title">${_esc(albumName)}</div>
|
|
|
<div class="discog-card-meta">${year}${year && tracks ? ' · ' : ''}${tracks ? tracks + ' tracks' : ''}</div>
|
|
|
</div>
|
|
|
<div class="discog-card-check"></div>
|
|
|
</label>
|
|
|
`;
|
|
|
}
|
|
|
|
|
|
function toggleDiscogFilter(btn) {
|
|
|
btn.classList.toggle('active');
|
|
|
const type = btn.dataset.type;
|
|
|
document.querySelectorAll(`.discog-card[data-type="${type}"]`).forEach(card => {
|
|
|
card.style.display = btn.classList.contains('active') ? '' : 'none';
|
|
|
});
|
|
|
_updateDiscogFooterCount();
|
|
|
}
|
|
|
|
|
|
function discogSelectAll(select) {
|
|
|
document.querySelectorAll('.discog-card-cb').forEach(cb => {
|
|
|
if (cb.closest('.discog-card').style.display !== 'none') {
|
|
|
cb.checked = select;
|
|
|
}
|
|
|
});
|
|
|
_updateDiscogFooterCount();
|
|
|
}
|
|
|
|
|
|
function _updateDiscogFooterCount() {
|
|
|
const checked = document.querySelectorAll('.discog-card-cb:checked');
|
|
|
let releases = 0, tracks = 0;
|
|
|
checked.forEach(cb => {
|
|
|
if (cb.closest('.discog-card').style.display !== 'none') {
|
|
|
releases++;
|
|
|
tracks += parseInt(cb.dataset.tracks) || 0;
|
|
|
}
|
|
|
});
|
|
|
const info = document.getElementById('discog-footer-info');
|
|
|
const btn = document.getElementById('discog-submit-text');
|
|
|
if (info) info.textContent = `${releases} release${releases !== 1 ? 's' : ''} · ${tracks} tracks`;
|
|
|
if (btn) btn.textContent = releases > 0 ? `Add ${releases} to Wishlist` : 'Select releases';
|
|
|
const submitBtn = document.getElementById('discog-submit-btn');
|
|
|
if (submitBtn) submitBtn.disabled = releases === 0;
|
|
|
}
|
|
|
|
|
|
async function startDiscographyDownload() {
|
|
|
let artist = artistsPageState.selectedArtist;
|
|
|
// Fallback to library page state
|
|
|
if (!artist && artistDetailPageState.currentArtistId) {
|
|
|
artist = { id: artistDetailPageState.currentArtistId, name: artistDetailPageState.currentArtistName || 'Unknown' };
|
|
|
}
|
|
|
if (!artist || !artist.id) {
|
|
|
showToast('No artist data available', 'error');
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const checked = document.querySelectorAll('.discog-card-cb:checked');
|
|
|
const albumEntries = [];
|
|
|
checked.forEach(cb => {
|
|
|
if (cb.closest('.discog-card').style.display !== 'none') {
|
|
|
albumEntries.push({
|
|
|
id: cb.dataset.albumId,
|
|
|
name: cb.dataset.albumName || '',
|
|
|
tracks: parseInt(cb.dataset.tracks) || 0
|
|
|
});
|
|
|
}
|
|
|
});
|
|
|
// Sort by track count descending — process Deluxe/expanded editions first
|
|
|
// so their tracks get added before standard editions (which then get deduped)
|
|
|
albumEntries.sort((a, b) => b.tracks - a.tracks);
|
|
|
|
|
|
if (albumEntries.length === 0) return;
|
|
|
|
|
|
// Switch to progress view
|
|
|
const grid = document.getElementById('discog-grid');
|
|
|
const progress = document.getElementById('discog-progress');
|
|
|
const footer = document.getElementById('discog-footer');
|
|
|
const filterBar = document.querySelector('.discog-filter-bar');
|
|
|
|
|
|
if (grid) grid.style.display = 'none';
|
|
|
if (filterBar) filterBar.style.display = 'none';
|
|
|
if (progress) {
|
|
|
progress.style.display = '';
|
|
|
progress.innerHTML = '';
|
|
|
}
|
|
|
|
|
|
// Build progress items
|
|
|
const albumMap = {};
|
|
|
checked.forEach(cb => {
|
|
|
if (cb.closest('.discog-card').style.display !== 'none') {
|
|
|
const card = cb.closest('.discog-card');
|
|
|
const id = cb.dataset.albumId;
|
|
|
const title = card.querySelector('.discog-card-title')?.textContent || '';
|
|
|
const img = card.querySelector('.discog-card-art img')?.src || '';
|
|
|
albumMap[id] = { title, img };
|
|
|
|
|
|
const item = document.createElement('div');
|
|
|
item.className = 'discog-progress-item';
|
|
|
item.id = `discog-prog-${id}`;
|
|
|
item.innerHTML = `
|
|
|
<div class="discog-prog-art">${img ? `<img src="${img}">` : '🎵'}</div>
|
|
|
<div class="discog-prog-info">
|
|
|
<div class="discog-prog-title">${_esc(title)}</div>
|
|
|
<div class="discog-prog-status">Waiting...</div>
|
|
|
</div>
|
|
|
<div class="discog-prog-icon"><div class="discog-spinner"></div></div>
|
|
|
`;
|
|
|
progress.appendChild(item);
|
|
|
}
|
|
|
});
|
|
|
|
|
|
// Update footer
|
|
|
const submitBtn = document.getElementById('discog-submit-btn');
|
|
|
if (submitBtn) submitBtn.style.display = 'none';
|
|
|
if (footer) {
|
|
|
const info = document.getElementById('discog-footer-info');
|
|
|
if (info) info.textContent = 'Processing... this may take a moment';
|
|
|
}
|
|
|
|
|
|
// Mark all items as active
|
|
|
document.querySelectorAll('.discog-progress-item').forEach(item => item.classList.add('active'));
|
|
|
|
|
|
// Per-album metadata so the backend can resolve each album through its
|
|
|
// own source — fixes albums whose IDs come from a fallback/provider-specific
|
|
|
// source (e.g. Deezer-formatted IDs surfaced via Hydrabase).
|
|
|
const sourceForBatch = (artist.source || artistsPageState.sourceOverride || '').toString().toLowerCase() || null;
|
|
|
const albumsPayload = albumEntries.map(e => ({
|
|
|
id: e.id,
|
|
|
name: e.name,
|
|
|
artist_name: artist.name,
|
|
|
source: sourceForBatch,
|
|
|
}));
|
|
|
|
|
|
try {
|
|
|
const response = await fetch(`/api/artist/${artist.id}/download-discography`, {
|
|
|
method: 'POST',
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
body: JSON.stringify({
|
|
|
albums: albumsPayload,
|
|
|
artist_name: artist.name,
|
|
|
source: sourceForBatch,
|
|
|
})
|
|
|
});
|
|
|
|
|
|
const reader = response.body.getReader();
|
|
|
const decoder = new TextDecoder();
|
|
|
let buffer = '';
|
|
|
|
|
|
while (true) {
|
|
|
const { done, value } = await reader.read();
|
|
|
if (done) break;
|
|
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
const lines = buffer.split('\n');
|
|
|
buffer = lines.pop(); // Keep incomplete line in buffer
|
|
|
|
|
|
for (const line of lines) {
|
|
|
if (!line.trim()) continue;
|
|
|
try {
|
|
|
const data = JSON.parse(line);
|
|
|
|
|
|
if (data.status === 'complete') {
|
|
|
_handleDiscogProgress({ type: 'complete', total_added: data.total_added, total_skipped: data.total_skipped });
|
|
|
} else {
|
|
|
// Per-album update
|
|
|
const item = document.getElementById(`discog-prog-${data.album_id}`);
|
|
|
if (!item) continue;
|
|
|
|
|
|
const statusEl = item.querySelector('.discog-prog-status');
|
|
|
const iconEl = item.querySelector('.discog-prog-icon');
|
|
|
item.classList.remove('active');
|
|
|
|
|
|
if (data.status === 'done') {
|
|
|
const parts = [];
|
|
|
if (data.tracks_added > 0) parts.push(`${data.tracks_added} added`);
|
|
|
if (data.tracks_skipped > 0) parts.push(`${data.tracks_skipped} skipped`);
|
|
|
statusEl.textContent = parts.join(', ') || 'No new tracks';
|
|
|
iconEl.innerHTML = data.tracks_added > 0 ? '<span class="discog-check">✓</span>' : '<span class="discog-skip">—</span>';
|
|
|
item.classList.add(data.tracks_added > 0 ? 'done' : 'skipped');
|
|
|
} else if (data.status === 'error') {
|
|
|
statusEl.textContent = data.message || 'Error';
|
|
|
iconEl.innerHTML = '<span class="discog-error">✗</span>';
|
|
|
item.classList.add('error');
|
|
|
}
|
|
|
}
|
|
|
} catch (e) { /* skip malformed line */ }
|
|
|
}
|
|
|
}
|
|
|
} catch (err) {
|
|
|
showToast(`Discography download failed: ${err.message}`, 'error');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function _handleDiscogProgress(data) {
|
|
|
if (data.type === 'album') {
|
|
|
const item = document.getElementById(`discog-prog-${data.album_id}`);
|
|
|
if (!item) return;
|
|
|
|
|
|
const statusEl = item.querySelector('.discog-prog-status');
|
|
|
const iconEl = item.querySelector('.discog-prog-icon');
|
|
|
|
|
|
if (data.status === 'processing') {
|
|
|
statusEl.textContent = `Processing ${data.tracks_total} tracks...`;
|
|
|
item.classList.add('active');
|
|
|
} else if (data.status === 'done') {
|
|
|
const parts = [];
|
|
|
if (data.tracks_added > 0) parts.push(`${data.tracks_added} added`);
|
|
|
if (data.tracks_skipped > 0) parts.push(`${data.tracks_skipped} skipped`);
|
|
|
statusEl.textContent = parts.join(', ') || 'No new tracks';
|
|
|
iconEl.innerHTML = data.tracks_added > 0 ? '<span class="discog-check">✓</span>' : '<span class="discog-skip">—</span>';
|
|
|
item.classList.remove('active');
|
|
|
item.classList.add(data.tracks_added > 0 ? 'done' : 'skipped');
|
|
|
} else if (data.status === 'error') {
|
|
|
statusEl.textContent = data.message || 'Error';
|
|
|
iconEl.innerHTML = '<span class="discog-error">✗</span>';
|
|
|
item.classList.add('error');
|
|
|
}
|
|
|
} else if (data.type === 'complete') {
|
|
|
const info = document.getElementById('discog-footer-info');
|
|
|
if (info) info.textContent = `Done — ${data.total_added} tracks added, ${data.total_skipped} skipped`;
|
|
|
|
|
|
// Show "Process Wishlist" button
|
|
|
const footer = document.querySelector('.discog-footer-actions');
|
|
|
if (footer && data.total_added > 0) {
|
|
|
footer.innerHTML = `
|
|
|
<button class="discog-cancel-btn" onclick="closeDiscographyModal()">Close</button>
|
|
|
<button class="discog-submit-btn" onclick="closeDiscographyModal();fetch('/api/wishlist/process',{method:'POST'});showToast('Wishlist processing started','success')">
|
|
|
<span class="discog-submit-icon">🚀</span>
|
|
|
<span>Process Wishlist Now</span>
|
|
|
</button>
|
|
|
`;
|
|
|
} else if (footer) {
|
|
|
footer.innerHTML = '<button class="discog-cancel-btn" onclick="closeDiscographyModal()">Close</button>';
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function closeDiscographyModal() {
|
|
|
const overlay = document.getElementById('discog-modal-overlay');
|
|
|
if (overlay) {
|
|
|
overlay.classList.remove('visible');
|
|
|
setTimeout(() => overlay.remove(), 300);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// ==================== Enhanced Library Management View ====================
|
|
|
|
|
|
function isEnhancedAdmin() {
|
|
|
return currentProfile && currentProfile.is_admin;
|
|
|
}
|
|
|
|
|
|
function toggleEnhancedView(enabled) {
|
|
|
|
|
|
const standardSections = document.querySelector('.discography-sections');
|
|
|
const enhancedContainer = document.getElementById('enhanced-view-container');
|
|
|
const toggleBtns = document.querySelectorAll('.enhanced-view-toggle-btn');
|
|
|
|
|
|
if (!standardSections || !enhancedContainer) return;
|
|
|
|
|
|
artistDetailPageState.enhancedView = enabled;
|
|
|
|
|
|
// Update toggle button states
|
|
|
toggleBtns.forEach(btn => {
|
|
|
const view = btn.getAttribute('data-view');
|
|
|
btn.classList.toggle('active', (view === 'enhanced') === enabled);
|
|
|
});
|
|
|
|
|
|
// Hide/show standard filter groups (not relevant in enhanced view)
|
|
|
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 = enabled ? 'none' : '';
|
|
|
}
|
|
|
});
|
|
|
const dividers = document.querySelectorAll('#discography-filters .filter-divider');
|
|
|
dividers.forEach((d, i) => {
|
|
|
if (i < dividers.length - 1) d.style.display = enabled ? 'none' : '';
|
|
|
});
|
|
|
|
|
|
// Similar Artists is part of the standard view — hide it in Enhanced.
|
|
|
const similarSection = document.getElementById('ad-similar-artists-section');
|
|
|
if (similarSection) similarSection.style.display = enabled ? 'none' : '';
|
|
|
|
|
|
if (enabled) {
|
|
|
standardSections.classList.add('hidden');
|
|
|
enhancedContainer.classList.remove('hidden');
|
|
|
|
|
|
if (!artistDetailPageState.enhancedData) {
|
|
|
loadEnhancedViewData(artistDetailPageState.currentArtistId);
|
|
|
} else {
|
|
|
renderEnhancedView();
|
|
|
}
|
|
|
} else {
|
|
|
standardSections.classList.remove('hidden');
|
|
|
enhancedContainer.classList.add('hidden');
|
|
|
const bulkBar = document.getElementById('enhanced-bulk-bar');
|
|
|
if (bulkBar) bulkBar.classList.remove('visible');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
async function loadEnhancedViewData(artistId) {
|
|
|
const container = document.getElementById('enhanced-view-container');
|
|
|
if (!container) return;
|
|
|
|
|
|
container.innerHTML = '<div class="enhanced-loading">Loading library data...</div>';
|
|
|
|
|
|
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 = `<div class="enhanced-loading" style="color: #ff6b6b;">Failed to load: ${escapeHtml(error.message)}</div>`;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function renderEnhancedView() {
|
|
|
const container = document.getElementById('enhanced-view-container');
|
|
|
const data = artistDetailPageState.enhancedData;
|
|
|
if (!container || !data) return;
|
|
|
|
|
|
container.innerHTML = '';
|
|
|
|
|
|
// Artist metadata card (visual + editable)
|
|
|
container.appendChild(renderArtistMetaPanel(data.artist));
|
|
|
|
|
|
// Library stats summary bar
|
|
|
container.appendChild(renderEnhancedStatsBar(data));
|
|
|
|
|
|
// Group albums by type
|
|
|
const grouped = { album: [], ep: [], single: [] };
|
|
|
(data.albums || []).forEach(album => {
|
|
|
const type = (album.record_type || 'album').toLowerCase();
|
|
|
if (grouped[type]) grouped[type].push(album);
|
|
|
else grouped[type] = [album];
|
|
|
});
|
|
|
|
|
|
const sectionLabels = { album: 'Albums', ep: 'EPs', single: 'Singles' };
|
|
|
for (const [type, label] of Object.entries(sectionLabels)) {
|
|
|
const albums = grouped[type] || [];
|
|
|
if (albums.length === 0) continue;
|
|
|
container.appendChild(renderEnhancedSection(type, label, albums));
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function renderEnhancedStatsBar(data) {
|
|
|
const bar = document.createElement('div');
|
|
|
bar.className = 'enhanced-stats-bar';
|
|
|
|
|
|
const albums = data.albums || [];
|
|
|
const totalAlbums = albums.filter(a => (a.record_type || 'album') === 'album').length;
|
|
|
const totalEps = albums.filter(a => a.record_type === 'ep').length;
|
|
|
const totalSingles = albums.filter(a => a.record_type === 'single').length;
|
|
|
const totalTracks = albums.reduce((s, a) => s + (a.tracks ? a.tracks.length : 0), 0);
|
|
|
|
|
|
// Calculate total duration
|
|
|
let totalDurationMs = 0;
|
|
|
albums.forEach(a => (a.tracks || []).forEach(t => { totalDurationMs += (t.duration || 0); }));
|
|
|
const totalHours = Math.floor(totalDurationMs / 3600000);
|
|
|
const totalMins = Math.floor((totalDurationMs % 3600000) / 60000);
|
|
|
|
|
|
// Calculate format breakdown
|
|
|
const formatCounts = {};
|
|
|
albums.forEach(a => (a.tracks || []).forEach(t => {
|
|
|
const fmt = extractFormat(t.file_path);
|
|
|
if (fmt !== '-') formatCounts[fmt] = (formatCounts[fmt] || 0) + 1;
|
|
|
}));
|
|
|
|
|
|
const statsItems = [
|
|
|
{ value: totalAlbums, label: 'Albums', icon: '💿' },
|
|
|
{ value: totalEps, label: 'EPs', icon: '📀' },
|
|
|
{ value: totalSingles, label: 'Singles', icon: '♪' },
|
|
|
{ value: totalTracks, label: 'Tracks', icon: '🎵' },
|
|
|
{ value: totalHours > 0 ? `${totalHours}h ${totalMins}m` : `${totalMins}m`, label: 'Duration', icon: '⏲' },
|
|
|
];
|
|
|
|
|
|
let statsHtml = statsItems.map(s =>
|
|
|
`<div class="enhanced-stat-item">
|
|
|
<span class="enhanced-stat-value">${s.value}</span>
|
|
|
<span class="enhanced-stat-label">${s.label}</span>
|
|
|
</div>`
|
|
|
).join('');
|
|
|
|
|
|
// Format badges
|
|
|
const formatBadges = Object.entries(formatCounts)
|
|
|
.sort((a, b) => b[1] - a[1])
|
|
|
.map(([fmt, count]) => {
|
|
|
const cls = fmt === 'FLAC' ? 'flac' : (fmt === 'MP3' ? 'mp3' : 'other');
|
|
|
return `<span class="enhanced-format-badge ${cls}">${fmt} (${count})</span>`;
|
|
|
}).join('');
|
|
|
|
|
|
bar.innerHTML = `
|
|
|
<div class="enhanced-stats-items">${statsHtml}</div>
|
|
|
<div class="enhanced-stats-formats">${formatBadges}</div>
|
|
|
`;
|
|
|
|
|
|
return bar;
|
|
|
}
|
|
|
|
|
|
function renderArtistMetaPanel(artist) {
|
|
|
const panel = document.createElement('div');
|
|
|
panel.className = 'enhanced-artist-meta';
|
|
|
panel.id = 'enhanced-artist-meta';
|
|
|
|
|
|
// Build using DOM to avoid innerHTML escaping issues
|
|
|
const header = document.createElement('div');
|
|
|
header.className = 'enhanced-artist-meta-header';
|
|
|
|
|
|
// Left side: artist image + name display
|
|
|
const headerLeft = document.createElement('div');
|
|
|
headerLeft.className = 'enhanced-artist-meta-header-left';
|
|
|
|
|
|
if (artist.thumb_url) {
|
|
|
const img = document.createElement('img');
|
|
|
img.className = 'enhanced-artist-meta-image';
|
|
|
img.src = artist.thumb_url;
|
|
|
img.alt = artist.name || '';
|
|
|
img.onerror = function () { this.style.display = 'none'; };
|
|
|
headerLeft.appendChild(img);
|
|
|
}
|
|
|
|
|
|
const headerInfo = document.createElement('div');
|
|
|
headerInfo.className = 'enhanced-artist-meta-info';
|
|
|
const artistTitle = document.createElement('div');
|
|
|
artistTitle.className = 'enhanced-artist-meta-name';
|
|
|
artistTitle.textContent = artist.name || 'Unknown Artist';
|
|
|
headerInfo.appendChild(artistTitle);
|
|
|
|
|
|
// ID badges row (clickable links)
|
|
|
const idBadges = document.createElement('div');
|
|
|
idBadges.className = 'enhanced-artist-id-badges';
|
|
|
const idSources = [
|
|
|
{ key: 'spotify_artist_id', label: 'Spotify', svc: 'spotify' },
|
|
|
{ key: 'musicbrainz_id', label: 'MusicBrainz', svc: 'musicbrainz' },
|
|
|
{ key: 'deezer_id', label: 'Deezer', svc: 'deezer' },
|
|
|
{ key: 'audiodb_id', label: 'AudioDB', svc: 'audiodb' },
|
|
|
{ key: 'discogs_id', label: 'Discogs', svc: 'discogs' },
|
|
|
{ key: 'itunes_artist_id', label: 'iTunes', svc: 'itunes' },
|
|
|
{ key: 'lastfm_url', label: 'Last.fm', svc: 'lastfm' },
|
|
|
{ key: 'genius_url', label: 'Genius', svc: 'genius' },
|
|
|
{ key: 'tidal_id', label: 'Tidal', svc: 'tidal' },
|
|
|
{ key: 'qobuz_id', label: 'Qobuz', svc: 'qobuz' },
|
|
|
];
|
|
|
idSources.forEach(src => {
|
|
|
if (artist[src.key]) {
|
|
|
idBadges.appendChild(makeClickableBadge(src.svc, 'artist', artist[src.key], src.label));
|
|
|
}
|
|
|
});
|
|
|
headerInfo.appendChild(idBadges);
|
|
|
headerLeft.appendChild(headerInfo);
|
|
|
header.appendChild(headerLeft);
|
|
|
|
|
|
// Right side: admin actions
|
|
|
const headerRight = document.createElement('div');
|
|
|
headerRight.className = 'enhanced-artist-meta-actions';
|
|
|
|
|
|
// Live reorganize-queue status — sits first so the user sees what's
|
|
|
// happening before any of the action buttons.
|
|
|
mountReorganizeStatusPanel(headerRight, String(artist.id));
|
|
|
|
|
|
if (isEnhancedAdmin()) {
|
|
|
const editToggle = document.createElement('button');
|
|
|
editToggle.className = 'enhanced-meta-edit-toggle';
|
|
|
editToggle.textContent = 'Edit Metadata';
|
|
|
editToggle.onclick = () => {
|
|
|
const form = document.getElementById('enhanced-artist-meta-form');
|
|
|
if (form) {
|
|
|
const isVisible = !form.classList.contains('hidden');
|
|
|
form.classList.toggle('hidden');
|
|
|
editToggle.textContent = isVisible ? 'Edit Metadata' : 'Hide Editor';
|
|
|
editToggle.classList.toggle('active', !isVisible);
|
|
|
}
|
|
|
};
|
|
|
headerRight.appendChild(editToggle);
|
|
|
|
|
|
// Enrich dropdown button
|
|
|
const enrichWrap = document.createElement('div');
|
|
|
enrichWrap.className = 'enhanced-enrich-wrap';
|
|
|
const enrichBtn = document.createElement('button');
|
|
|
enrichBtn.className = 'enhanced-enrich-btn';
|
|
|
enrichBtn.textContent = 'Enrich ▾';
|
|
|
enrichBtn.onclick = (e) => {
|
|
|
e.stopPropagation();
|
|
|
enrichMenu.classList.toggle('visible');
|
|
|
};
|
|
|
enrichWrap.appendChild(enrichBtn);
|
|
|
|
|
|
const enrichMenu = document.createElement('div');
|
|
|
enrichMenu.className = 'enhanced-enrich-menu';
|
|
|
const services = [
|
|
|
{ id: 'spotify', label: 'Spotify', icon: '🟢' },
|
|
|
{ id: 'musicbrainz', label: 'MusicBrainz', icon: '🟠' },
|
|
|
{ id: 'deezer', label: 'Deezer', icon: '🟣' },
|
|
|
{ id: 'discogs', label: 'Discogs', icon: '🟤' },
|
|
|
{ id: 'audiodb', label: 'AudioDB', icon: '🔵' },
|
|
|
{ id: 'itunes', label: 'iTunes', icon: '🔴' },
|
|
|
{ id: 'lastfm', label: 'Last.fm', icon: '⚪' },
|
|
|
{ id: 'genius', label: 'Genius', icon: '🟡' },
|
|
|
{ id: 'tidal', label: 'Tidal', icon: '⬛' },
|
|
|
{ id: 'qobuz', label: 'Qobuz', icon: '🔷' },
|
|
|
];
|
|
|
services.forEach(svc => {
|
|
|
const item = document.createElement('div');
|
|
|
item.className = 'enhanced-enrich-menu-item';
|
|
|
item.textContent = `${svc.icon} ${svc.label}`;
|
|
|
item.onclick = (e) => {
|
|
|
e.stopPropagation();
|
|
|
enrichMenu.classList.remove('visible');
|
|
|
runEnrichment('artist', artist.id, svc.id, artist.name, '', artist.id);
|
|
|
};
|
|
|
enrichMenu.appendChild(item);
|
|
|
});
|
|
|
enrichWrap.appendChild(enrichMenu);
|
|
|
headerRight.appendChild(enrichWrap);
|
|
|
}
|
|
|
|
|
|
// Sync / Validate button
|
|
|
const syncBtn = document.createElement('button');
|
|
|
syncBtn.className = 'enhanced-sync-btn';
|
|
|
syncBtn.innerHTML = '🔄 Sync';
|
|
|
syncBtn.title = 'Validate files — removes stale entries for tracks no longer on disk';
|
|
|
syncBtn.onclick = async (e) => {
|
|
|
e.stopPropagation();
|
|
|
syncBtn.disabled = true;
|
|
|
syncBtn.textContent = 'Syncing...';
|
|
|
try {
|
|
|
const res = await fetch(`/api/library/artist/${artist.id}/sync`, { method: 'POST' });
|
|
|
const data = await res.json();
|
|
|
if (data.success) {
|
|
|
const parts = [];
|
|
|
if (data.new_albums > 0) parts.push(`+${data.new_albums} albums`);
|
|
|
if (data.new_tracks > 0) parts.push(`+${data.new_tracks} tracks`);
|
|
|
if (data.stale_removed > 0) parts.push(`${data.stale_removed} stale removed`);
|
|
|
if (data.empty_albums_removed > 0) parts.push(`${data.empty_albums_removed} empty albums cleaned`);
|
|
|
if (data.name_updated) parts.push('name updated');
|
|
|
if (parts.length === 0) parts.push('Already in sync');
|
|
|
showToast(`${data.artist_name}: ${parts.join(', ')}`, 'success');
|
|
|
// Refresh enhanced view if anything changed
|
|
|
if (data.stale_removed > 0 || data.empty_albums_removed > 0) {
|
|
|
loadEnhancedViewData(artist.id);
|
|
|
}
|
|
|
} else {
|
|
|
showToast(`Sync failed: ${data.error}`, 'error');
|
|
|
}
|
|
|
} catch (err) {
|
|
|
showToast(`Sync failed: ${err.message}`, 'error');
|
|
|
}
|
|
|
syncBtn.disabled = false;
|
|
|
syncBtn.innerHTML = '🔄 Sync';
|
|
|
};
|
|
|
headerRight.appendChild(syncBtn);
|
|
|
|
|
|
const reorgAllBtn = document.createElement('button');
|
|
|
reorgAllBtn.className = 'enhanced-sync-btn';
|
|
|
reorgAllBtn.innerHTML = '📁 Reorganize All';
|
|
|
reorgAllBtn.title = 'Reorganize all albums for this artist using your configured download template';
|
|
|
reorgAllBtn.onclick = () => _showReorganizeAllModal();
|
|
|
headerRight.appendChild(reorgAllBtn);
|
|
|
|
|
|
header.appendChild(headerRight);
|
|
|
|
|
|
panel.appendChild(header);
|
|
|
|
|
|
// Match status row (clickable to rematch)
|
|
|
const statusRow = document.createElement('div');
|
|
|
statusRow.className = 'enhanced-match-status-row';
|
|
|
const statusServices = [
|
|
|
{ key: 'spotify_match_status', label: 'Spotify', attempted: 'spotify_last_attempted', svc: 'spotify' },
|
|
|
{ key: 'musicbrainz_match_status', label: 'MusicBrainz', attempted: 'musicbrainz_last_attempted', svc: 'musicbrainz' },
|
|
|
{ key: 'deezer_match_status', label: 'Deezer', attempted: 'deezer_last_attempted', svc: 'deezer' },
|
|
|
{ key: 'audiodb_match_status', label: 'AudioDB', attempted: 'audiodb_last_attempted', svc: 'audiodb' },
|
|
|
{ key: 'discogs_match_status', label: 'Discogs', attempted: 'discogs_last_attempted', svc: 'discogs' },
|
|
|
{ key: 'itunes_match_status', label: 'iTunes', attempted: 'itunes_last_attempted', svc: 'itunes' },
|
|
|
{ key: 'lastfm_match_status', label: 'Last.fm', attempted: 'lastfm_last_attempted', svc: 'lastfm' },
|
|
|
{ key: 'genius_match_status', label: 'Genius', attempted: 'genius_last_attempted', svc: 'genius' },
|
|
|
{ key: 'tidal_match_status', label: 'Tidal', attempted: 'tidal_last_attempted', svc: 'tidal' },
|
|
|
{ key: 'qobuz_match_status', label: 'Qobuz', attempted: 'qobuz_last_attempted', svc: 'qobuz' },
|
|
|
];
|
|
|
statusServices.forEach(s => {
|
|
|
const status = artist[s.key];
|
|
|
const attempted = artist[s.attempted];
|
|
|
const chip = document.createElement('span');
|
|
|
chip.className = `enhanced-match-chip clickable ${status === 'matched' ? 'matched' : (status === 'not_found' ? 'not-found' : 'pending')}`;
|
|
|
chip.textContent = `${s.label}: ${status || 'pending'}`;
|
|
|
const tipParts = [];
|
|
|
if (attempted) tipParts.push(`Last: ${new Date(attempted).toLocaleString()}`);
|
|
|
tipParts.push('Click to rematch');
|
|
|
chip.title = tipParts.join(' · ');
|
|
|
chip.onclick = () => openManualMatchModal('artist', artist.id, s.svc, artist.name, artist.id);
|
|
|
statusRow.appendChild(chip);
|
|
|
});
|
|
|
panel.appendChild(statusRow);
|
|
|
|
|
|
// Collapsible edit form (hidden by default)
|
|
|
const form = document.createElement('div');
|
|
|
form.className = 'enhanced-artist-meta-form hidden';
|
|
|
form.id = 'enhanced-artist-meta-form';
|
|
|
|
|
|
const editableFields = [
|
|
|
{ key: 'name', label: 'Artist Name', type: 'text' },
|
|
|
{ key: 'genres', label: 'Genres (comma separated)', type: 'text', isArray: true },
|
|
|
{ key: 'label', label: 'Label', type: 'text' },
|
|
|
{ key: 'style', label: 'Style', type: 'text' },
|
|
|
{ key: 'mood', label: 'Mood', type: 'text' },
|
|
|
{ key: 'summary', label: 'Summary / Bio', type: 'textarea', wide: true },
|
|
|
];
|
|
|
|
|
|
const grid = document.createElement('div');
|
|
|
grid.className = 'enhanced-artist-meta-grid';
|
|
|
|
|
|
editableFields.forEach(f => {
|
|
|
const fieldDiv = document.createElement('div');
|
|
|
fieldDiv.className = 'enhanced-meta-field' + (f.wide ? ' wide' : '');
|
|
|
|
|
|
const label = document.createElement('label');
|
|
|
label.className = 'enhanced-meta-field-label';
|
|
|
label.textContent = f.label;
|
|
|
fieldDiv.appendChild(label);
|
|
|
|
|
|
const val = f.isArray
|
|
|
? (Array.isArray(artist[f.key]) ? artist[f.key].join(', ') : (artist[f.key] || ''))
|
|
|
: (artist[f.key] || '');
|
|
|
|
|
|
if (f.type === 'textarea') {
|
|
|
const ta = document.createElement('textarea');
|
|
|
ta.className = 'enhanced-meta-field-input';
|
|
|
ta.dataset.field = f.key;
|
|
|
ta.placeholder = f.label + '...';
|
|
|
ta.textContent = val;
|
|
|
fieldDiv.appendChild(ta);
|
|
|
} else {
|
|
|
const inp = document.createElement('input');
|
|
|
inp.type = 'text';
|
|
|
inp.className = 'enhanced-meta-field-input';
|
|
|
inp.dataset.field = f.key;
|
|
|
inp.value = val;
|
|
|
inp.placeholder = f.label + '...';
|
|
|
fieldDiv.appendChild(inp);
|
|
|
}
|
|
|
|
|
|
grid.appendChild(fieldDiv);
|
|
|
});
|
|
|
|
|
|
form.appendChild(grid);
|
|
|
|
|
|
// Save/revert buttons
|
|
|
const formActions = document.createElement('div');
|
|
|
formActions.className = 'enhanced-artist-form-actions';
|
|
|
const revertBtn = document.createElement('button');
|
|
|
revertBtn.className = 'enhanced-meta-cancel-btn';
|
|
|
revertBtn.textContent = 'Revert';
|
|
|
revertBtn.onclick = () => revertArtistMetadata();
|
|
|
const saveBtn = document.createElement('button');
|
|
|
saveBtn.className = 'enhanced-meta-save-btn';
|
|
|
saveBtn.textContent = 'Save Changes';
|
|
|
saveBtn.onclick = () => saveArtistMetadata();
|
|
|
formActions.appendChild(revertBtn);
|
|
|
formActions.appendChild(saveBtn);
|
|
|
form.appendChild(formActions);
|
|
|
|
|
|
panel.appendChild(form);
|
|
|
|
|
|
return panel;
|
|
|
}
|
|
|
|
|
|
function renderEnhancedSection(type, label, albums) {
|
|
|
const section = document.createElement('div');
|
|
|
section.className = 'enhanced-section';
|
|
|
|
|
|
const totalTracks = albums.reduce((sum, a) => sum + (a.tracks ? a.tracks.length : 0), 0);
|
|
|
|
|
|
const sectionHeader = document.createElement('div');
|
|
|
sectionHeader.className = 'enhanced-section-header';
|
|
|
sectionHeader.innerHTML = `
|
|
|
<span class="enhanced-section-title">${label}</span>
|
|
|
<span class="enhanced-section-count">${albums.length} release${albums.length !== 1 ? 's' : ''} · ${totalTracks} tracks</span>
|
|
|
`;
|
|
|
section.appendChild(sectionHeader);
|
|
|
|
|
|
const grid = document.createElement('div');
|
|
|
grid.className = 'enhanced-album-grid';
|
|
|
|
|
|
albums.forEach(album => {
|
|
|
const wrapper = document.createElement('div');
|
|
|
wrapper.className = 'enhanced-album-wrapper';
|
|
|
wrapper.id = `enhanced-album-wrapper-${album.id}`;
|
|
|
const isExpanded = artistDetailPageState.expandedAlbums.has(album.id);
|
|
|
if (isExpanded) wrapper.classList.add('expanded');
|
|
|
|
|
|
wrapper.appendChild(renderAlbumRow(album, type));
|
|
|
|
|
|
const tracksPanel = document.createElement('div');
|
|
|
tracksPanel.className = 'enhanced-tracks-panel';
|
|
|
tracksPanel.id = `enhanced-tracks-panel-${album.id}`;
|
|
|
if (isExpanded) tracksPanel.classList.add('visible');
|
|
|
const inner = document.createElement('div');
|
|
|
inner.className = 'enhanced-tracks-panel-inner';
|
|
|
if (isExpanded) {
|
|
|
inner.dataset.rendered = 'true';
|
|
|
inner.appendChild(renderExpandedAlbumHeader(album));
|
|
|
inner.appendChild(renderAlbumMetaRow(album));
|
|
|
inner.appendChild(renderTrackTable(album));
|
|
|
}
|
|
|
tracksPanel.appendChild(inner);
|
|
|
wrapper.appendChild(tracksPanel);
|
|
|
|
|
|
grid.appendChild(wrapper);
|
|
|
});
|
|
|
section.appendChild(grid);
|
|
|
|
|
|
return section;
|
|
|
}
|
|
|
|
|
|
function renderAlbumRow(album, type) {
|
|
|
const row = document.createElement('div');
|
|
|
row.className = 'enhanced-album-row';
|
|
|
row.id = `enhanced-album-row-${album.id}`;
|
|
|
|
|
|
if (artistDetailPageState.expandedAlbums.has(album.id)) row.classList.add('expanded');
|
|
|
|
|
|
const trackCount = album.tracks ? album.tracks.length : 0;
|
|
|
const typeClass = (type || 'album').toLowerCase();
|
|
|
|
|
|
// Total duration for this album
|
|
|
let albumDurMs = 0;
|
|
|
(album.tracks || []).forEach(t => { albumDurMs += (t.duration || 0); });
|
|
|
const albumDur = formatDurationMs(albumDurMs);
|
|
|
|
|
|
// Format breakdown for this album
|
|
|
const fmts = {};
|
|
|
(album.tracks || []).forEach(t => {
|
|
|
const f = extractFormat(t.file_path);
|
|
|
if (f !== '-') fmts[f] = (fmts[f] || 0) + 1;
|
|
|
});
|
|
|
const primaryFormat = Object.keys(fmts).sort((a, b) => fmts[b] - fmts[a])[0] || '';
|
|
|
|
|
|
// Build with DOM for safety
|
|
|
const expandIcon = document.createElement('span');
|
|
|
expandIcon.className = 'enhanced-album-expand-icon';
|
|
|
expandIcon.innerHTML = '▶';
|
|
|
row.appendChild(expandIcon);
|
|
|
|
|
|
// Album art - larger, prominent
|
|
|
const artWrap = document.createElement('div');
|
|
|
artWrap.className = 'enhanced-album-art-wrap';
|
|
|
if (album.thumb_url) {
|
|
|
const img = document.createElement('img');
|
|
|
img.className = 'enhanced-album-thumb';
|
|
|
img.src = album.thumb_url;
|
|
|
img.alt = '';
|
|
|
img.loading = 'lazy';
|
|
|
img.onerror = function () {
|
|
|
const fallback = document.createElement('div');
|
|
|
fallback.className = 'enhanced-album-thumb-fallback';
|
|
|
fallback.innerHTML = '🎵';
|
|
|
this.replaceWith(fallback);
|
|
|
};
|
|
|
artWrap.appendChild(img);
|
|
|
} else {
|
|
|
const fallback = document.createElement('div');
|
|
|
fallback.className = 'enhanced-album-thumb-fallback';
|
|
|
fallback.innerHTML = '🎵';
|
|
|
artWrap.appendChild(fallback);
|
|
|
}
|
|
|
row.appendChild(artWrap);
|
|
|
|
|
|
// Info block (title + meta line)
|
|
|
const infoBlock = document.createElement('div');
|
|
|
infoBlock.className = 'enhanced-album-info-block';
|
|
|
|
|
|
const titleEl = document.createElement('span');
|
|
|
titleEl.className = 'enhanced-album-title';
|
|
|
titleEl.textContent = album.title || 'Unknown';
|
|
|
titleEl.title = album.title || '';
|
|
|
infoBlock.appendChild(titleEl);
|
|
|
|
|
|
const metaLine = document.createElement('span');
|
|
|
metaLine.className = 'enhanced-album-meta-line';
|
|
|
const metaParts = [];
|
|
|
if (album.year) metaParts.push(String(album.year));
|
|
|
metaParts.push(`${trackCount} track${trackCount !== 1 ? 's' : ''}`);
|
|
|
if (albumDur !== '-') metaParts.push(albumDur);
|
|
|
if (album.label) metaParts.push(album.label);
|
|
|
metaLine.textContent = metaParts.join(' \u00B7 ');
|
|
|
infoBlock.appendChild(metaLine);
|
|
|
|
|
|
row.appendChild(infoBlock);
|
|
|
|
|
|
// Type badge
|
|
|
const badge = document.createElement('span');
|
|
|
badge.className = `enhanced-album-type-badge ${typeClass}`;
|
|
|
badge.textContent = type;
|
|
|
row.appendChild(badge);
|
|
|
|
|
|
// Format badge inline
|
|
|
if (primaryFormat) {
|
|
|
const fmtBadge = document.createElement('span');
|
|
|
const fmtClass = primaryFormat === 'FLAC' ? 'flac' : (primaryFormat === 'MP3' ? 'mp3' : 'other');
|
|
|
fmtBadge.className = `enhanced-format-badge ${fmtClass}`;
|
|
|
fmtBadge.textContent = primaryFormat;
|
|
|
row.appendChild(fmtBadge);
|
|
|
}
|
|
|
|
|
|
row.addEventListener('click', () => toggleAlbumExpand(album.id));
|
|
|
|
|
|
return row;
|
|
|
}
|
|
|
|
|
|
function toggleAlbumExpand(albumId) {
|
|
|
const row = document.getElementById(`enhanced-album-row-${albumId}`);
|
|
|
const panel = document.getElementById(`enhanced-tracks-panel-${albumId}`);
|
|
|
const wrapper = document.getElementById(`enhanced-album-wrapper-${albumId}`);
|
|
|
if (!row || !panel) return;
|
|
|
|
|
|
const isExpanded = artistDetailPageState.expandedAlbums.has(albumId);
|
|
|
|
|
|
if (isExpanded) {
|
|
|
artistDetailPageState.expandedAlbums.delete(albumId);
|
|
|
row.classList.remove('expanded');
|
|
|
panel.classList.remove('visible');
|
|
|
if (wrapper) wrapper.classList.remove('expanded');
|
|
|
} else {
|
|
|
artistDetailPageState.expandedAlbums.add(albumId);
|
|
|
row.classList.add('expanded');
|
|
|
panel.classList.add('visible');
|
|
|
if (wrapper) wrapper.classList.add('expanded');
|
|
|
|
|
|
// Lazy render
|
|
|
const inner = panel.querySelector('.enhanced-tracks-panel-inner');
|
|
|
if (inner && !inner.dataset.rendered) {
|
|
|
const album = findEnhancedAlbum(albumId);
|
|
|
if (album) {
|
|
|
inner.innerHTML = '';
|
|
|
inner.appendChild(renderExpandedAlbumHeader(album));
|
|
|
inner.appendChild(renderAlbumMetaRow(album));
|
|
|
inner.appendChild(renderTrackTable(album));
|
|
|
inner.dataset.rendered = 'true';
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function findEnhancedAlbum(albumId) {
|
|
|
// Use cached map for O(1) lookups instead of O(n) array scan
|
|
|
if (artistDetailPageState._albumMap) {
|
|
|
return artistDetailPageState._albumMap.get(String(albumId)) || null;
|
|
|
}
|
|
|
const data = artistDetailPageState.enhancedData;
|
|
|
if (!data || !data.albums) return null;
|
|
|
return data.albums.find(a => String(a.id) === String(albumId));
|
|
|
}
|
|
|
|
|
|
function _rebuildAlbumMap() {
|
|
|
const data = artistDetailPageState.enhancedData;
|
|
|
if (!data || !data.albums) { artistDetailPageState._albumMap = null; return; }
|
|
|
const map = new Map();
|
|
|
data.albums.forEach(a => map.set(String(a.id), a));
|
|
|
artistDetailPageState._albumMap = map;
|
|
|
}
|
|
|
|
|
|
function renderExpandedAlbumHeader(album) {
|
|
|
const header = document.createElement('div');
|
|
|
header.className = 'enhanced-expanded-header';
|
|
|
|
|
|
// Large album art
|
|
|
if (album.thumb_url) {
|
|
|
const img = document.createElement('img');
|
|
|
img.className = 'enhanced-expanded-art';
|
|
|
img.src = album.thumb_url;
|
|
|
img.alt = album.title || '';
|
|
|
img.onerror = function () { this.style.display = 'none'; };
|
|
|
header.appendChild(img);
|
|
|
}
|
|
|
|
|
|
const info = document.createElement('div');
|
|
|
info.className = 'enhanced-expanded-info';
|
|
|
|
|
|
const title = document.createElement('div');
|
|
|
title.className = 'enhanced-expanded-title';
|
|
|
title.textContent = album.title || 'Unknown';
|
|
|
info.appendChild(title);
|
|
|
|
|
|
const meta = document.createElement('div');
|
|
|
meta.className = 'enhanced-expanded-meta';
|
|
|
|
|
|
const details = [];
|
|
|
if (album.year) details.push(String(album.year));
|
|
|
const trackCount = album.tracks ? album.tracks.length : 0;
|
|
|
details.push(`${trackCount} track${trackCount !== 1 ? 's' : ''}`);
|
|
|
let durMs = 0;
|
|
|
(album.tracks || []).forEach(t => { durMs += (t.duration || 0); });
|
|
|
if (durMs > 0) details.push(formatDurationMs(durMs));
|
|
|
if (album.label) details.push(album.label);
|
|
|
if (album.record_type) details.push(album.record_type.toUpperCase());
|
|
|
|
|
|
meta.textContent = details.join(' \u00B7 ');
|
|
|
info.appendChild(meta);
|
|
|
|
|
|
// Genre tags
|
|
|
const genres = Array.isArray(album.genres) ? album.genres : [];
|
|
|
if (genres.length > 0) {
|
|
|
const genreRow = document.createElement('div');
|
|
|
genreRow.className = 'enhanced-expanded-genres';
|
|
|
genres.forEach(g => {
|
|
|
const tag = document.createElement('span');
|
|
|
tag.className = 'enhanced-genre-tag';
|
|
|
tag.textContent = g;
|
|
|
genreRow.appendChild(tag);
|
|
|
});
|
|
|
info.appendChild(genreRow);
|
|
|
}
|
|
|
|
|
|
// External ID badges (clickable links)
|
|
|
const ids = document.createElement('div');
|
|
|
ids.className = 'enhanced-expanded-ids';
|
|
|
const idFields = [
|
|
|
{ key: 'spotify_album_id', label: 'Spotify', svc: 'spotify' },
|
|
|
{ key: 'musicbrainz_release_id', label: 'MusicBrainz', svc: 'musicbrainz' },
|
|
|
{ key: 'deezer_id', label: 'Deezer', svc: 'deezer' },
|
|
|
{ key: 'audiodb_id', label: 'AudioDB', svc: 'audiodb' },
|
|
|
{ key: 'discogs_id', label: 'Discogs', svc: 'discogs' },
|
|
|
{ key: 'itunes_album_id', label: 'iTunes', svc: 'itunes' },
|
|
|
{ key: 'lastfm_url', label: 'Last.fm', svc: 'lastfm' },
|
|
|
];
|
|
|
idFields.forEach(f => {
|
|
|
if (album[f.key]) {
|
|
|
ids.appendChild(makeClickableBadge(f.svc, 'album', album[f.key], f.label));
|
|
|
}
|
|
|
});
|
|
|
if (ids.children.length > 0) info.appendChild(ids);
|
|
|
|
|
|
// Resolve artist name for enrichment calls
|
|
|
const artistName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : '';
|
|
|
|
|
|
// Match status chips (clickable to rematch)
|
|
|
const statusRow = document.createElement('div');
|
|
|
statusRow.className = 'enhanced-match-status-row compact';
|
|
|
const statusSvcs = [
|
|
|
{ key: 'spotify_match_status', label: 'Spotify', attempted: 'spotify_last_attempted', svc: 'spotify' },
|
|
|
{ key: 'musicbrainz_match_status', label: 'MB', attempted: 'musicbrainz_last_attempted', svc: 'musicbrainz' },
|
|
|
{ key: 'deezer_match_status', label: 'Deezer', attempted: 'deezer_last_attempted', svc: 'deezer' },
|
|
|
{ key: 'audiodb_match_status', label: 'AudioDB', attempted: 'audiodb_last_attempted', svc: 'audiodb' },
|
|
|
{ key: 'discogs_match_status', label: 'Discogs', attempted: 'discogs_last_attempted', svc: 'discogs' },
|
|
|
{ key: 'itunes_match_status', label: 'iTunes', attempted: 'itunes_last_attempted', svc: 'itunes' },
|
|
|
{ key: 'lastfm_match_status', label: 'Last.fm', attempted: 'lastfm_last_attempted', svc: 'lastfm' },
|
|
|
];
|
|
|
statusSvcs.forEach(s => {
|
|
|
const status = album[s.key];
|
|
|
const attempted = album[s.attempted];
|
|
|
const chip = document.createElement('span');
|
|
|
chip.className = `enhanced-match-chip clickable ${status === 'matched' ? 'matched' : (status === 'not_found' ? 'not-found' : 'pending')}`;
|
|
|
chip.textContent = `${s.label}: ${status || '—'}`;
|
|
|
const tipParts = [];
|
|
|
if (attempted) tipParts.push(`Last: ${new Date(attempted).toLocaleString()}`);
|
|
|
tipParts.push('Click to rematch');
|
|
|
chip.title = tipParts.join(' · ');
|
|
|
chip.onclick = (e) => {
|
|
|
e.stopPropagation();
|
|
|
const aId = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.id : '';
|
|
|
openManualMatchModal('album', album.id, s.svc, album.title || '', aId);
|
|
|
};
|
|
|
statusRow.appendChild(chip);
|
|
|
});
|
|
|
info.appendChild(statusRow);
|
|
|
|
|
|
// Action buttons row
|
|
|
const enrichRow = document.createElement('div');
|
|
|
enrichRow.className = 'enhanced-expanded-actions';
|
|
|
|
|
|
if (isEnhancedAdmin()) {
|
|
|
const albumEnrichWrap = document.createElement('div');
|
|
|
albumEnrichWrap.className = 'enhanced-enrich-wrap';
|
|
|
const albumEnrichBtn = document.createElement('button');
|
|
|
albumEnrichBtn.className = 'enhanced-enrich-btn small';
|
|
|
albumEnrichBtn.textContent = 'Enrich Album ▾';
|
|
|
albumEnrichBtn.onclick = (e) => { e.stopPropagation(); albumEnrichMenu.classList.toggle('visible'); };
|
|
|
albumEnrichWrap.appendChild(albumEnrichBtn);
|
|
|
const albumEnrichMenu = document.createElement('div');
|
|
|
albumEnrichMenu.className = 'enhanced-enrich-menu';
|
|
|
[
|
|
|
{ id: 'spotify', label: 'Spotify', icon: '🟢' },
|
|
|
{ id: 'musicbrainz', label: 'MusicBrainz', icon: '🟠' },
|
|
|
{ id: 'deezer', label: 'Deezer', icon: '🟣' },
|
|
|
{ id: 'discogs', label: 'Discogs', icon: '🟤' },
|
|
|
{ id: 'audiodb', label: 'AudioDB', icon: '🔵' },
|
|
|
{ id: 'itunes', label: 'iTunes', icon: '🔴' },
|
|
|
{ id: 'lastfm', label: 'Last.fm', icon: '⚪' },
|
|
|
{ id: 'genius', label: 'Genius', icon: '🟡' },
|
|
|
].forEach(svc => {
|
|
|
const item = document.createElement('div');
|
|
|
item.className = 'enhanced-enrich-menu-item';
|
|
|
item.textContent = `${svc.icon} ${svc.label}`;
|
|
|
item.onclick = (e) => {
|
|
|
e.stopPropagation();
|
|
|
albumEnrichMenu.classList.remove('visible');
|
|
|
const aId = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.id : '';
|
|
|
runEnrichment('album', album.id, svc.id, album.title || '', artistName, aId);
|
|
|
};
|
|
|
albumEnrichMenu.appendChild(item);
|
|
|
});
|
|
|
albumEnrichWrap.appendChild(albumEnrichMenu);
|
|
|
enrichRow.appendChild(albumEnrichWrap);
|
|
|
|
|
|
const writeTagsBtn = document.createElement('button');
|
|
|
writeTagsBtn.className = 'enhanced-write-tags-album-btn';
|
|
|
writeTagsBtn.innerHTML = '✎ Write All Tags';
|
|
|
writeTagsBtn.title = 'Write DB metadata to file tags for all tracks in this album';
|
|
|
writeTagsBtn.onclick = (e) => { e.stopPropagation(); writeAlbumTags(album.id); };
|
|
|
enrichRow.appendChild(writeTagsBtn);
|
|
|
|
|
|
const rgAlbumBtn = document.createElement('button');
|
|
|
rgAlbumBtn.className = 'enhanced-rg-album-btn';
|
|
|
rgAlbumBtn.innerHTML = '♫ ReplayGain';
|
|
|
rgAlbumBtn.title = 'Analyze ReplayGain for all tracks in this album (writes track + album gain)';
|
|
|
rgAlbumBtn.dataset.albumId = album.id;
|
|
|
rgAlbumBtn.onclick = (e) => { e.stopPropagation(); analyzeAlbumReplayGain(album.id, rgAlbumBtn); };
|
|
|
enrichRow.appendChild(rgAlbumBtn);
|
|
|
|
|
|
const reorganizeBtn = document.createElement('button');
|
|
|
reorganizeBtn.className = 'enhanced-reorganize-album-btn';
|
|
|
reorganizeBtn.innerHTML = '📁 Reorganize';
|
|
|
reorganizeBtn.title = 'Reorganize album files using your configured download template';
|
|
|
reorganizeBtn.dataset.albumId = String(album.id);
|
|
|
reorganizeBtn.onclick = (e) => { e.stopPropagation(); showReorganizeModal(album.id); };
|
|
|
enrichRow.appendChild(reorganizeBtn);
|
|
|
|
|
|
const redownloadBtn = document.createElement('button');
|
|
|
redownloadBtn.className = 'enhanced-redownload-album-btn';
|
|
|
redownloadBtn.innerHTML = '↻ Redownload';
|
|
|
redownloadBtn.title = 'Redownload this album (opens Download Missing modal with force-download)';
|
|
|
redownloadBtn.onclick = (e) => {
|
|
|
e.stopPropagation();
|
|
|
const aName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : '';
|
|
|
redownloadLibraryAlbum(album, aName, redownloadBtn);
|
|
|
};
|
|
|
enrichRow.appendChild(redownloadBtn);
|
|
|
|
|
|
const deleteAlbumBtn = document.createElement('button');
|
|
|
deleteAlbumBtn.className = 'enhanced-delete-album-btn';
|
|
|
deleteAlbumBtn.textContent = 'Delete Album';
|
|
|
deleteAlbumBtn.onclick = (e) => { e.stopPropagation(); deleteLibraryAlbum(album.id); };
|
|
|
enrichRow.appendChild(deleteAlbumBtn);
|
|
|
}
|
|
|
|
|
|
// Report Issue button (available to all users)
|
|
|
const reportBtn = document.createElement('button');
|
|
|
reportBtn.className = 'enhanced-report-issue-btn';
|
|
|
reportBtn.innerHTML = '⚑ Report Issue';
|
|
|
reportBtn.title = 'Report a problem with this album';
|
|
|
reportBtn.onclick = (e) => {
|
|
|
e.stopPropagation();
|
|
|
const aName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : '';
|
|
|
showReportIssueModal('album', album.id, album.title || '', aName);
|
|
|
};
|
|
|
enrichRow.appendChild(reportBtn);
|
|
|
|
|
|
info.appendChild(enrichRow);
|
|
|
|
|
|
header.appendChild(info);
|
|
|
return header;
|
|
|
}
|
|
|
|
|
|
function renderAlbumMetaRow(album) {
|
|
|
const row = document.createElement('div');
|
|
|
row.className = 'enhanced-album-meta-row';
|
|
|
row.id = `enhanced-album-meta-${album.id}`;
|
|
|
|
|
|
const fields = [
|
|
|
{ key: 'title', label: 'Title', value: album.title || '' },
|
|
|
{ key: 'year', label: 'Year', value: album.year || '', type: 'number' },
|
|
|
{ key: 'genres', label: 'Genres', value: Array.isArray(album.genres) ? album.genres.join(', ') : (album.genres || '') },
|
|
|
{ key: 'label', label: 'Label', value: album.label || '' },
|
|
|
{ key: 'style', label: 'Style', value: album.style || '' },
|
|
|
{ key: 'mood', label: 'Mood', value: album.mood || '' },
|
|
|
{ key: 'record_type', label: 'Type', value: album.record_type || 'album' },
|
|
|
{ key: 'explicit', label: 'Explicit', value: album.explicit ? '1' : '0' },
|
|
|
];
|
|
|
|
|
|
const admin = isEnhancedAdmin();
|
|
|
fields.forEach(f => {
|
|
|
const fieldDiv = document.createElement('div');
|
|
|
fieldDiv.className = 'enhanced-album-meta-field';
|
|
|
const label = document.createElement('label');
|
|
|
label.className = 'enhanced-album-meta-label';
|
|
|
label.textContent = f.label;
|
|
|
fieldDiv.appendChild(label);
|
|
|
if (admin) {
|
|
|
const input = document.createElement('input');
|
|
|
input.className = 'enhanced-album-meta-input';
|
|
|
input.type = f.type || 'text';
|
|
|
input.dataset.albumId = album.id;
|
|
|
input.dataset.field = f.key;
|
|
|
input.value = String(f.value);
|
|
|
input.addEventListener('click', e => e.stopPropagation());
|
|
|
fieldDiv.appendChild(input);
|
|
|
} else {
|
|
|
const span = document.createElement('span');
|
|
|
span.className = 'enhanced-album-meta-value';
|
|
|
span.textContent = String(f.value) || '—';
|
|
|
fieldDiv.appendChild(span);
|
|
|
}
|
|
|
row.appendChild(fieldDiv);
|
|
|
});
|
|
|
|
|
|
if (admin) {
|
|
|
const saveDiv = document.createElement('div');
|
|
|
saveDiv.className = 'enhanced-album-meta-field';
|
|
|
const spacer = document.createElement('label');
|
|
|
spacer.className = 'enhanced-album-meta-label';
|
|
|
spacer.innerHTML = ' ';
|
|
|
saveDiv.appendChild(spacer);
|
|
|
const saveBtn = document.createElement('button');
|
|
|
saveBtn.className = 'enhanced-album-save-btn';
|
|
|
saveBtn.textContent = 'Save Album';
|
|
|
saveBtn.onclick = (e) => { e.stopPropagation(); saveAlbumMetadata(album.id); };
|
|
|
saveDiv.appendChild(saveBtn);
|
|
|
row.appendChild(saveDiv);
|
|
|
}
|
|
|
|
|
|
return row;
|
|
|
}
|
|
|
|
|
|
function _buildTrackRow(track, album, admin) {
|
|
|
const tr = document.createElement('tr');
|
|
|
tr.dataset.trackId = track.id;
|
|
|
tr.dataset.albumId = album.id;
|
|
|
if (artistDetailPageState.selectedTracks.has(String(track.id))) tr.classList.add('selected');
|
|
|
|
|
|
// Checkbox (admin only)
|
|
|
if (admin) {
|
|
|
const cbTd = document.createElement('td');
|
|
|
const cb = document.createElement('input');
|
|
|
cb.type = 'checkbox';
|
|
|
cb.className = 'enhanced-track-checkbox';
|
|
|
cb.checked = artistDetailPageState.selectedTracks.has(String(track.id));
|
|
|
cbTd.appendChild(cb);
|
|
|
tr.appendChild(cbTd);
|
|
|
}
|
|
|
|
|
|
// Play button
|
|
|
const playTd = document.createElement('td');
|
|
|
playTd.className = 'col-play';
|
|
|
const playBtn = document.createElement('button');
|
|
|
playBtn.className = 'enhanced-play-btn';
|
|
|
playBtn.innerHTML = '▶';
|
|
|
playBtn.title = track.file_path ? 'Play track' : 'No file available';
|
|
|
if (!track.file_path) playBtn.disabled = true;
|
|
|
playTd.appendChild(playBtn);
|
|
|
tr.appendChild(playTd);
|
|
|
|
|
|
// Track number
|
|
|
const numTd = document.createElement('td');
|
|
|
numTd.className = 'col-num' + (admin ? ' editable' : '');
|
|
|
numTd.textContent = track.track_number || '-';
|
|
|
tr.appendChild(numTd);
|
|
|
|
|
|
// Disc number
|
|
|
const discTd = document.createElement('td');
|
|
|
discTd.className = 'col-disc';
|
|
|
discTd.textContent = track.disc_number || '-';
|
|
|
tr.appendChild(discTd);
|
|
|
|
|
|
// Title
|
|
|
const titleTd = document.createElement('td');
|
|
|
titleTd.className = 'col-title' + (admin ? ' editable' : '');
|
|
|
titleTd.textContent = track.title || 'Unknown';
|
|
|
tr.appendChild(titleTd);
|
|
|
|
|
|
// Duration
|
|
|
const durTd = document.createElement('td');
|
|
|
durTd.className = 'col-duration';
|
|
|
durTd.textContent = formatDurationMs(track.duration);
|
|
|
tr.appendChild(durTd);
|
|
|
|
|
|
// Format
|
|
|
const fmtTd = document.createElement('td');
|
|
|
fmtTd.className = 'col-format';
|
|
|
const format = extractFormat(track.file_path);
|
|
|
const fmtSpan = document.createElement('span');
|
|
|
const fmtClass = format === 'FLAC' ? 'flac' : (format === 'MP3' ? 'mp3' : 'other');
|
|
|
fmtSpan.className = `enhanced-format-badge ${fmtClass}`;
|
|
|
fmtSpan.textContent = format;
|
|
|
fmtTd.appendChild(fmtSpan);
|
|
|
tr.appendChild(fmtTd);
|
|
|
|
|
|
// Bitrate
|
|
|
const brTd = document.createElement('td');
|
|
|
brTd.className = 'col-bitrate';
|
|
|
const brSpan = document.createElement('span');
|
|
|
const brClass = (track.bitrate || 0) >= 320 ? 'high' : ((track.bitrate || 0) >= 192 ? 'medium' : 'low');
|
|
|
brSpan.className = `enhanced-bitrate ${brClass}`;
|
|
|
brSpan.textContent = track.bitrate ? track.bitrate + ' kbps' : '-';
|
|
|
brTd.appendChild(brSpan);
|
|
|
tr.appendChild(brTd);
|
|
|
|
|
|
// BPM
|
|
|
const bpmTd = document.createElement('td');
|
|
|
bpmTd.className = 'col-bpm' + (admin ? ' editable' : '');
|
|
|
bpmTd.textContent = track.bpm || '-';
|
|
|
tr.appendChild(bpmTd);
|
|
|
|
|
|
// File path
|
|
|
const pathTd = document.createElement('td');
|
|
|
pathTd.className = 'col-path';
|
|
|
const filePath = track.file_path || '-';
|
|
|
const fileName = filePath !== '-' ? filePath.split(/[\\/]/).pop() : '-';
|
|
|
pathTd.textContent = fileName;
|
|
|
pathTd.title = filePath;
|
|
|
tr.appendChild(pathTd);
|
|
|
|
|
|
// Match status chips
|
|
|
const matchTd = document.createElement('td');
|
|
|
matchTd.className = 'col-match';
|
|
|
const matchCell = document.createElement('div');
|
|
|
matchCell.className = 'enhanced-track-match-cell';
|
|
|
const trackServices = [
|
|
|
{ svc: 'spotify', col: 'spotify_track_id', label: 'SP' },
|
|
|
{ svc: 'musicbrainz', col: 'musicbrainz_recording_id', label: 'MB' },
|
|
|
{ svc: 'deezer', col: 'deezer_id', label: 'Dz' },
|
|
|
{ svc: 'audiodb', col: 'audiodb_id', label: 'ADB' },
|
|
|
{ svc: 'itunes', col: 'itunes_track_id', label: 'iT' },
|
|
|
{ svc: 'lastfm', col: 'lastfm_url', label: 'LFM' },
|
|
|
{ svc: 'genius', col: 'genius_id', label: 'Gen' },
|
|
|
];
|
|
|
trackServices.forEach(s => {
|
|
|
const hasId = !!track[s.col];
|
|
|
const chip = document.createElement('span');
|
|
|
chip.className = 'enhanced-track-match-chip' + (hasId ? ' matched' : ' not-found');
|
|
|
chip.textContent = s.label;
|
|
|
chip.title = hasId ? `${s.svc}: ${track[s.col]}` : `${s.svc}: no match`;
|
|
|
chip.dataset.service = s.svc;
|
|
|
matchCell.appendChild(chip);
|
|
|
});
|
|
|
matchTd.appendChild(matchCell);
|
|
|
tr.appendChild(matchTd);
|
|
|
|
|
|
// Add to Queue button
|
|
|
const queueTd = document.createElement('td');
|
|
|
queueTd.className = 'col-queue';
|
|
|
if (track.file_path) {
|
|
|
const queueBtn = document.createElement('button');
|
|
|
queueBtn.className = 'enhanced-queue-btn';
|
|
|
queueBtn.innerHTML = '+';
|
|
|
queueBtn.title = 'Add to queue';
|
|
|
queueTd.appendChild(queueBtn);
|
|
|
}
|
|
|
tr.appendChild(queueTd);
|
|
|
|
|
|
if (admin) {
|
|
|
// Write Tags button (admin only)
|
|
|
const tagTd = document.createElement('td');
|
|
|
tagTd.className = 'col-writetag';
|
|
|
if (track.file_path) {
|
|
|
const tagBtn = document.createElement('button');
|
|
|
tagBtn.className = 'enhanced-write-tag-btn';
|
|
|
tagBtn.innerHTML = '✎';
|
|
|
tagBtn.title = 'Write tags to file';
|
|
|
tagTd.appendChild(tagBtn);
|
|
|
|
|
|
const rgBtn = document.createElement('button');
|
|
|
rgBtn.className = 'enhanced-rg-btn';
|
|
|
rgBtn.textContent = 'RG';
|
|
|
rgBtn.title = 'Analyze & write ReplayGain (track gain)';
|
|
|
tagTd.appendChild(rgBtn);
|
|
|
}
|
|
|
tr.appendChild(tagTd);
|
|
|
|
|
|
// Track actions cell — source info, redownload, delete (admin only)
|
|
|
const actionsTd = document.createElement('td');
|
|
|
actionsTd.className = 'col-track-actions';
|
|
|
actionsTd.innerHTML = `
|
|
|
<div class="enhanced-track-actions-group">
|
|
|
<button class="enhanced-source-info-btn" title="View download source info">ℹ</button>
|
|
|
<button class="enhanced-redownload-btn" title="Redownload this track">↻</button>
|
|
|
<button class="enhanced-delete-btn" title="Delete track from library">✕</button>
|
|
|
</div>
|
|
|
`;
|
|
|
tr.appendChild(actionsTd);
|
|
|
} else {
|
|
|
// Report Issue button per track (non-admin)
|
|
|
const reportTd = document.createElement('td');
|
|
|
reportTd.className = 'col-report';
|
|
|
const reportBtn = document.createElement('button');
|
|
|
reportBtn.className = 'enhanced-track-report-btn';
|
|
|
reportBtn.innerHTML = '⚑';
|
|
|
reportBtn.title = 'Report issue with this track';
|
|
|
reportTd.appendChild(reportBtn);
|
|
|
tr.appendChild(reportTd);
|
|
|
}
|
|
|
|
|
|
// Mobile actions column (visible only on mobile via CSS)
|
|
|
const mobileTd = document.createElement('td');
|
|
|
mobileTd.className = 'col-mobile-actions';
|
|
|
const mobileBtn = document.createElement('button');
|
|
|
mobileBtn.className = 'enhanced-mobile-actions-btn';
|
|
|
mobileBtn.innerHTML = '⋯';
|
|
|
mobileBtn.title = 'Actions';
|
|
|
mobileTd.appendChild(mobileBtn);
|
|
|
tr.appendChild(mobileTd);
|
|
|
|
|
|
return tr;
|
|
|
}
|
|
|
|
|
|
function _getTrackDataFromRow(tr) {
|
|
|
const trackId = tr.dataset.trackId;
|
|
|
const albumId = tr.dataset.albumId;
|
|
|
const album = findEnhancedAlbum(albumId);
|
|
|
if (!album) return null;
|
|
|
const track = (album.tracks || []).find(t => String(t.id) === String(trackId));
|
|
|
return track ? { track, album, trackId, albumId } : null;
|
|
|
}
|
|
|
|
|
|
function _attachTableDelegation(table, album) {
|
|
|
// Single click handler for the entire table — replaces 12-16 per-row handlers
|
|
|
const admin = isEnhancedAdmin();
|
|
|
table.addEventListener('click', (e) => {
|
|
|
const target = e.target;
|
|
|
const tr = target.closest('tr[data-track-id]');
|
|
|
|
|
|
// Header checkbox (select all)
|
|
|
if (target.closest('thead') && target.classList.contains('enhanced-track-checkbox')) {
|
|
|
toggleSelectAllTracks(album.id, target.checked);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// Sort header click
|
|
|
const th = target.closest('th[data-sort-field]');
|
|
|
if (th) {
|
|
|
cancelInlineEdit();
|
|
|
const sortField = th.dataset.sortField;
|
|
|
const current = artistDetailPageState.enhancedTrackSort[album.id];
|
|
|
const ascending = current && current.field === sortField ? !current.ascending : true;
|
|
|
artistDetailPageState.enhancedTrackSort[album.id] = { field: sortField, ascending };
|
|
|
sortEnhancedTracks(album, sortField, ascending);
|
|
|
_rebuildTbody(table, album);
|
|
|
// Update header sort indicators
|
|
|
table.querySelectorAll('th[data-sort-field]').forEach(h => {
|
|
|
const sf = h.dataset.sortField;
|
|
|
const baseLabel = h.dataset.label || '';
|
|
|
const sort = artistDetailPageState.enhancedTrackSort[album.id];
|
|
|
h.textContent = sort && sort.field === sf ? baseLabel + (sort.ascending ? ' \u25B2' : ' \u25BC') : baseLabel;
|
|
|
});
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
if (!tr) return;
|
|
|
const info = _getTrackDataFromRow(tr);
|
|
|
if (!info) return;
|
|
|
const { track, trackId } = info;
|
|
|
|
|
|
// Checkbox
|
|
|
if (target.classList.contains('enhanced-track-checkbox')) {
|
|
|
toggleTrackSelection(String(trackId));
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// Play button
|
|
|
if (target.closest('.enhanced-play-btn')) {
|
|
|
e.stopPropagation();
|
|
|
if (track.file_path) {
|
|
|
const artistName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : '';
|
|
|
playLibraryTrack(track, album.title || '', artistName);
|
|
|
}
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// Inline editable cells (admin)
|
|
|
if (admin) {
|
|
|
const cell = target.closest('td.editable');
|
|
|
if (cell) {
|
|
|
e.stopPropagation();
|
|
|
if (cell.classList.contains('col-num')) {
|
|
|
startInlineEdit(cell, 'track', track.id, 'track_number', track.track_number || '');
|
|
|
} else if (cell.classList.contains('col-title')) {
|
|
|
startInlineEdit(cell, 'track', track.id, 'title', track.title || '');
|
|
|
} else if (cell.classList.contains('col-bpm')) {
|
|
|
startInlineEdit(cell, 'track', track.id, 'bpm', track.bpm || '');
|
|
|
}
|
|
|
return;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Match chip click (admin — open manual match modal)
|
|
|
if (admin) {
|
|
|
const chip = target.closest('.enhanced-track-match-chip');
|
|
|
if (chip) {
|
|
|
e.stopPropagation();
|
|
|
const svc = chip.dataset.service;
|
|
|
const aId = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.id : null;
|
|
|
openManualMatchModal('track', track.id, svc, track.title || '', aId);
|
|
|
return;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Queue button
|
|
|
if (target.closest('.enhanced-queue-btn')) {
|
|
|
e.stopPropagation();
|
|
|
if (track.file_path) {
|
|
|
const artistName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : '';
|
|
|
let albumArt = album.thumb_url || null;
|
|
|
if (!albumArt && artistDetailPageState.enhancedData) {
|
|
|
albumArt = artistDetailPageState.enhancedData.artist?.thumb_url;
|
|
|
}
|
|
|
addToQueue({
|
|
|
title: track.title || 'Unknown Track',
|
|
|
artist: artistName || 'Unknown Artist',
|
|
|
album: album.title || 'Unknown Album',
|
|
|
file_path: track.file_path,
|
|
|
filename: track.file_path,
|
|
|
is_library: true,
|
|
|
image_url: albumArt,
|
|
|
id: track.id,
|
|
|
artist_id: artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.id : null,
|
|
|
album_id: album.id,
|
|
|
bitrate: track.bitrate,
|
|
|
sample_rate: track.sample_rate
|
|
|
});
|
|
|
}
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// Write tags button (admin)
|
|
|
if (target.closest('.enhanced-write-tag-btn')) {
|
|
|
e.stopPropagation();
|
|
|
showTagPreview(track.id);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// ReplayGain analyze button (admin)
|
|
|
if (target.closest('.enhanced-rg-btn')) {
|
|
|
e.stopPropagation();
|
|
|
analyzeTrackReplayGain(track.id, target.closest('.enhanced-rg-btn'));
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// Source info button (admin)
|
|
|
if (target.closest('.enhanced-source-info-btn')) {
|
|
|
e.stopPropagation();
|
|
|
showTrackSourceInfo(track, target.closest('.enhanced-source-info-btn'));
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// Redownload button (admin)
|
|
|
if (target.closest('.enhanced-redownload-btn')) {
|
|
|
e.stopPropagation();
|
|
|
showTrackRedownloadModal(track, album);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// Delete button (admin)
|
|
|
if (target.closest('.enhanced-delete-btn')) {
|
|
|
e.stopPropagation();
|
|
|
deleteLibraryTrack(track.id, album.id);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// Report button (non-admin)
|
|
|
if (target.closest('.enhanced-track-report-btn')) {
|
|
|
e.stopPropagation();
|
|
|
const artistName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : '';
|
|
|
showReportIssueModal('track', track.id, track.title || 'Unknown', artistName, album.title || '');
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// Mobile actions button (⋯)
|
|
|
if (target.closest('.enhanced-mobile-actions-btn')) {
|
|
|
e.stopPropagation();
|
|
|
_showMobileTrackActions(track, album);
|
|
|
return;
|
|
|
}
|
|
|
});
|
|
|
}
|
|
|
|
|
|
function _showMobileTrackActions(track, album) {
|
|
|
// Remove any existing popover
|
|
|
document.querySelectorAll('.mobile-popover-overlay, .enhanced-mobile-actions-popover').forEach(el => el.remove());
|
|
|
|
|
|
const overlay = document.createElement('div');
|
|
|
overlay.className = 'mobile-popover-overlay';
|
|
|
|
|
|
const popover = document.createElement('div');
|
|
|
popover.className = 'enhanced-mobile-actions-popover';
|
|
|
|
|
|
const title = document.createElement('div');
|
|
|
title.className = 'popover-title';
|
|
|
title.textContent = track.title || 'Track';
|
|
|
popover.appendChild(title);
|
|
|
|
|
|
const admin = isEnhancedAdmin();
|
|
|
const artistName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : '';
|
|
|
const albumArt = album.thumb_url || (artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist?.thumb_url : null);
|
|
|
|
|
|
const actions = [];
|
|
|
if (track.file_path) {
|
|
|
actions.push({
|
|
|
icon: '▶', label: 'Play', action: () => {
|
|
|
playLibraryTrack({ id: track.id, title: track.title, file_path: track.file_path, bitrate: track.bitrate, artist_id: artistDetailPageState.enhancedData?.artist?.id, album_id: album.id }, album.title || '', artistName);
|
|
|
}
|
|
|
});
|
|
|
actions.push({
|
|
|
icon: '+', label: 'Add to Queue', action: () => {
|
|
|
addToQueue({ title: track.title || 'Unknown', artist: artistName, album: album.title || '', file_path: track.file_path, filename: track.file_path, is_library: true, image_url: albumArt, id: track.id, artist_id: artistDetailPageState.enhancedData?.artist?.id, album_id: album.id, bitrate: track.bitrate });
|
|
|
}
|
|
|
});
|
|
|
}
|
|
|
if (admin && track.file_path) {
|
|
|
actions.push({ icon: '✎', label: 'Write Tags', action: () => showTagPreview(track.id) });
|
|
|
}
|
|
|
if (admin) {
|
|
|
actions.push({ icon: 'ℹ', label: 'Source Info', action: () => showTrackSourceInfo(track, null) });
|
|
|
actions.push({ icon: '↻', label: 'Redownload Track', action: () => showTrackRedownloadModal(track, album) });
|
|
|
actions.push({ icon: '✕', label: 'Delete Track', cls: 'popover-delete', action: () => deleteLibraryTrack(track.id, album.id) });
|
|
|
}
|
|
|
|
|
|
actions.forEach(a => {
|
|
|
const btn = document.createElement('button');
|
|
|
if (a.cls) btn.className = a.cls;
|
|
|
btn.innerHTML = `<span class="popover-icon">${a.icon}</span>${a.label}`;
|
|
|
btn.addEventListener('click', () => { close(); a.action(); });
|
|
|
popover.appendChild(btn);
|
|
|
});
|
|
|
|
|
|
const cancelBtn = document.createElement('button');
|
|
|
cancelBtn.className = 'popover-cancel';
|
|
|
cancelBtn.textContent = 'Cancel';
|
|
|
cancelBtn.addEventListener('click', close);
|
|
|
popover.appendChild(cancelBtn);
|
|
|
|
|
|
function close() {
|
|
|
overlay.remove();
|
|
|
popover.remove();
|
|
|
}
|
|
|
overlay.addEventListener('click', close);
|
|
|
|
|
|
document.body.appendChild(overlay);
|
|
|
document.body.appendChild(popover);
|
|
|
}
|
|
|
|
|
|
function _rebuildTbody(table, album) {
|
|
|
// Replace only the tbody — keeps thead and event delegation intact
|
|
|
const admin = isEnhancedAdmin();
|
|
|
const oldTbody = table.querySelector('tbody');
|
|
|
const newTbody = document.createElement('tbody');
|
|
|
(album.tracks || []).forEach(track => {
|
|
|
newTbody.appendChild(_buildTrackRow(track, album, admin));
|
|
|
});
|
|
|
if (oldTbody) table.replaceChild(newTbody, oldTbody);
|
|
|
else table.appendChild(newTbody);
|
|
|
}
|
|
|
|
|
|
function renderTrackTable(album) {
|
|
|
const wrapper = document.createElement('div');
|
|
|
const tracks = album.tracks || [];
|
|
|
|
|
|
// Re-apply stored sort order if any
|
|
|
const activeSort = artistDetailPageState.enhancedTrackSort[album.id];
|
|
|
if (activeSort) {
|
|
|
sortEnhancedTracks(album, activeSort.field, activeSort.ascending);
|
|
|
}
|
|
|
|
|
|
if (tracks.length === 0) {
|
|
|
wrapper.innerHTML = '<div class="enhanced-no-tracks">No tracks in database</div>';
|
|
|
return wrapper;
|
|
|
}
|
|
|
|
|
|
const table = document.createElement('table');
|
|
|
table.className = 'enhanced-track-table';
|
|
|
table.dataset.albumId = album.id;
|
|
|
|
|
|
const admin = isEnhancedAdmin();
|
|
|
// Clear stale selections for non-admin to prevent ghost state
|
|
|
if (!admin) {
|
|
|
artistDetailPageState.selectedTracks.clear();
|
|
|
}
|
|
|
|
|
|
// Header
|
|
|
const thead = document.createElement('thead');
|
|
|
const headRow = document.createElement('tr');
|
|
|
if (admin) {
|
|
|
const selectAllTh = document.createElement('th');
|
|
|
const selectAllCb = document.createElement('input');
|
|
|
selectAllCb.type = 'checkbox';
|
|
|
selectAllCb.className = 'enhanced-track-checkbox';
|
|
|
selectAllTh.appendChild(selectAllCb);
|
|
|
headRow.appendChild(selectAllTh);
|
|
|
}
|
|
|
|
|
|
const columns = [
|
|
|
{ label: '', cls: 'col-play' },
|
|
|
{ label: '#', cls: 'col-num', sortField: 'track_number' },
|
|
|
{ label: 'Disc', cls: 'col-disc', sortField: 'disc_number' },
|
|
|
{ label: 'Title', cls: 'col-title', sortField: 'title' },
|
|
|
{ label: 'Duration', cls: 'col-duration', sortField: 'duration' },
|
|
|
{ label: 'Format', cls: 'col-format', sortField: 'format' },
|
|
|
{ label: 'Bitrate', cls: 'col-bitrate', sortField: 'bitrate' },
|
|
|
{ label: 'BPM', cls: 'col-bpm', sortField: 'bpm' },
|
|
|
{ label: 'File', cls: 'col-path' },
|
|
|
{ label: 'Match', cls: 'col-match' },
|
|
|
{ label: '', cls: 'col-queue' },
|
|
|
...(admin ? [
|
|
|
{ label: '', cls: 'col-writetag' },
|
|
|
{ label: '', cls: 'col-delete' },
|
|
|
] : [
|
|
|
{ label: '', cls: 'col-report' },
|
|
|
]),
|
|
|
{ label: '', cls: 'col-mobile-actions' },
|
|
|
];
|
|
|
const currentSort = artistDetailPageState.enhancedTrackSort[album.id];
|
|
|
columns.forEach(col => {
|
|
|
const th = document.createElement('th');
|
|
|
th.className = col.cls;
|
|
|
if (col.sortField) {
|
|
|
let headerText = col.label;
|
|
|
if (currentSort && currentSort.field === col.sortField) {
|
|
|
headerText += currentSort.ascending ? ' \u25B2' : ' \u25BC';
|
|
|
}
|
|
|
th.textContent = headerText;
|
|
|
th.style.cursor = 'pointer';
|
|
|
th.dataset.sortField = col.sortField;
|
|
|
th.dataset.label = col.label;
|
|
|
} else {
|
|
|
th.textContent = col.label;
|
|
|
}
|
|
|
headRow.appendChild(th);
|
|
|
});
|
|
|
thead.appendChild(headRow);
|
|
|
table.appendChild(thead);
|
|
|
|
|
|
// Body
|
|
|
const tbody = document.createElement('tbody');
|
|
|
tracks.forEach(track => {
|
|
|
tbody.appendChild(_buildTrackRow(track, album, admin));
|
|
|
});
|
|
|
table.appendChild(tbody);
|
|
|
|
|
|
// Single delegated event listener for the whole table
|
|
|
_attachTableDelegation(table, album);
|
|
|
|
|
|
wrapper.appendChild(table);
|
|
|
return wrapper;
|
|
|
}
|
|
|
|
|
|
function sortEnhancedTracks(album, field, ascending) {
|
|
|
const tracks = album.tracks || [];
|
|
|
tracks.sort((a, b) => {
|
|
|
let valA, valB;
|
|
|
if (field === 'format') {
|
|
|
valA = extractFormat(a.file_path);
|
|
|
valB = extractFormat(b.file_path);
|
|
|
} else {
|
|
|
valA = a[field];
|
|
|
valB = b[field];
|
|
|
}
|
|
|
if (valA == null) return 1;
|
|
|
if (valB == null) return -1;
|
|
|
if (['track_number', 'disc_number', 'bpm', 'bitrate', 'duration'].includes(field)) {
|
|
|
return ascending ? (Number(valA) - Number(valB)) : (Number(valB) - Number(valA));
|
|
|
}
|
|
|
valA = String(valA).toLowerCase();
|
|
|
valB = String(valB).toLowerCase();
|
|
|
return ascending ? valA.localeCompare(valB) : valB.localeCompare(valA);
|
|
|
});
|
|
|
}
|
|
|
|
|
|
async function deleteLibraryTrack(trackId, albumId) {
|
|
|
cancelInlineEdit();
|
|
|
|
|
|
// Smart delete dialog — three options
|
|
|
const choice = await _showSmartDeleteDialog();
|
|
|
if (!choice) return;
|
|
|
|
|
|
const params = new URLSearchParams();
|
|
|
if (choice === 'delete_file') params.set('delete_file', 'true');
|
|
|
|
|
|
try {
|
|
|
const response = await fetch(`/api/library/track/${trackId}?${params}`, { method: 'DELETE' });
|
|
|
const result = await response.json();
|
|
|
if (!result.success) throw new Error(result.error);
|
|
|
|
|
|
let msg = 'Track removed from library';
|
|
|
let toastType = 'success';
|
|
|
if (result.file_deleted) {
|
|
|
msg = 'Track deleted from library and disk';
|
|
|
} else if (result.file_error) {
|
|
|
msg = 'Track removed from library but file could not be deleted';
|
|
|
toastType = 'warning';
|
|
|
}
|
|
|
if (result.blacklisted) msg += ' (source blacklisted)';
|
|
|
showToast(msg, toastType);
|
|
|
if (result.file_error) {
|
|
|
showToast(result.file_error, 'error', 8000);
|
|
|
}
|
|
|
|
|
|
if (artistDetailPageState.enhancedData) {
|
|
|
const albums = artistDetailPageState.enhancedData.albums || [];
|
|
|
const album = albums.find(a => a.id === albumId);
|
|
|
if (album) {
|
|
|
album.tracks = (album.tracks || []).filter(t => t.id !== trackId);
|
|
|
}
|
|
|
}
|
|
|
artistDetailPageState.selectedTracks.delete(String(trackId));
|
|
|
renderEnhancedView();
|
|
|
} catch (error) {
|
|
|
showToast(`Delete failed: ${error.message}`, 'error');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function _showSmartDeleteDialog() {
|
|
|
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 = `
|
|
|
<div class="smart-delete-modal">
|
|
|
<div class="smart-delete-header">
|
|
|
<h3>Delete Track</h3>
|
|
|
<button class="smart-delete-close">×</button>
|
|
|
</div>
|
|
|
<p class="smart-delete-desc">How should this track be deleted?</p>
|
|
|
<div class="smart-delete-options">
|
|
|
<button class="smart-delete-option" data-choice="db_only">
|
|
|
<div class="smart-delete-option-icon">📋</div>
|
|
|
<div class="smart-delete-option-info">
|
|
|
<div class="smart-delete-option-title">Remove from Library</div>
|
|
|
<div class="smart-delete-option-desc">Remove the database entry only. File stays on disk.</div>
|
|
|
</div>
|
|
|
</button>
|
|
|
<button class="smart-delete-option destructive" data-choice="delete_file">
|
|
|
<div class="smart-delete-option-icon">🗑️</div>
|
|
|
<div class="smart-delete-option-info">
|
|
|
<div class="smart-delete-option-title">Delete File Too</div>
|
|
|
<div class="smart-delete-option-desc">Remove from library and delete the audio file from disk.</div>
|
|
|
</div>
|
|
|
</button>
|
|
|
<!-- Blacklisting is done from Source Info (ℹ) where real download source data is available -->
|
|
|
</div>
|
|
|
</div>
|
|
|
`;
|
|
|
|
|
|
overlay.querySelectorAll('.smart-delete-option').forEach(btn => {
|
|
|
btn.addEventListener('click', () => close(btn.dataset.choice));
|
|
|
});
|
|
|
overlay.querySelector('.smart-delete-close').addEventListener('click', () => close(null));
|
|
|
|
|
|
// Escape to close
|
|
|
const escHandler = e => { if (e.key === 'Escape') { document.removeEventListener('keydown', escHandler); close(null); } };
|
|
|
document.addEventListener('keydown', escHandler);
|
|
|
|
|
|
document.body.appendChild(overlay);
|
|
|
});
|
|
|
}
|
|
|
|
|
|
// ==================================================================================
|
|
|
// TRACK SOURCE INFO — View download provenance and blacklist sources
|
|
|
// ==================================================================================
|
|
|
|
|
|
async function showTrackSourceInfo(track, anchorEl) {
|
|
|
// Remove existing popover
|
|
|
const existing = document.getElementById('source-info-popover');
|
|
|
if (existing) existing.remove();
|
|
|
|
|
|
const popover = document.createElement('div');
|
|
|
popover.id = 'source-info-popover';
|
|
|
popover.className = 'source-info-popover';
|
|
|
popover.innerHTML = '<div class="source-info-loading"><div class="server-search-spinner"></div>Loading source info...</div>';
|
|
|
|
|
|
document.body.appendChild(popover);
|
|
|
|
|
|
// Position near the button or center on mobile
|
|
|
if (anchorEl) {
|
|
|
const rect = anchorEl.getBoundingClientRect();
|
|
|
const popW = 360;
|
|
|
let left = rect.left - popW - 8;
|
|
|
if (left < 10) left = rect.right + 8;
|
|
|
let top = rect.top - 20;
|
|
|
if (top + 300 > window.innerHeight) top = window.innerHeight - 310;
|
|
|
popover.style.left = `${left}px`;
|
|
|
popover.style.top = `${Math.max(10, top)}px`;
|
|
|
} else {
|
|
|
popover.style.left = '50%';
|
|
|
popover.style.top = '50%';
|
|
|
popover.style.transform = 'translate(-50%, -50%)';
|
|
|
}
|
|
|
|
|
|
requestAnimationFrame(() => popover.classList.add('visible'));
|
|
|
|
|
|
// Close on outside click
|
|
|
const closeHandler = e => {
|
|
|
if (!popover.contains(e.target) && e.target !== anchorEl) {
|
|
|
popover.remove();
|
|
|
document.removeEventListener('click', closeHandler);
|
|
|
}
|
|
|
};
|
|
|
setTimeout(() => document.addEventListener('click', closeHandler), 100);
|
|
|
|
|
|
// Escape to close
|
|
|
const escH = e => { if (e.key === 'Escape') { popover.remove(); document.removeEventListener('keydown', escH); document.removeEventListener('click', closeHandler); } };
|
|
|
document.addEventListener('keydown', escH);
|
|
|
|
|
|
try {
|
|
|
const res = await fetch(`/api/library/track/${track.id}/source-info`);
|
|
|
const data = await res.json();
|
|
|
|
|
|
if (!data.success || !data.downloads || data.downloads.length === 0) {
|
|
|
popover.innerHTML = `
|
|
|
<div class="source-info-header">
|
|
|
<span class="source-info-title">Source Info</span>
|
|
|
<button class="source-info-close" onclick="document.getElementById('source-info-popover')?.remove()">×</button>
|
|
|
</div>
|
|
|
<div class="source-info-empty">No download source data available for this track. Source tracking starts with new downloads.</div>
|
|
|
`;
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const serviceIcons = { soulseek: '🔍', youtube: '▶️', tidal: '🌊', qobuz: '🎵', hifi: '🎧', deezer: '💜' };
|
|
|
const serviceLabels = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer: 'Deezer' };
|
|
|
|
|
|
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 = `
|
|
|
<div class="source-info-header">
|
|
|
<span class="source-info-title">Source Info</span>
|
|
|
<button class="source-info-close" onclick="document.getElementById('source-info-popover')?.remove()">×</button>
|
|
|
</div>
|
|
|
<div class="source-info-body">
|
|
|
<div class="source-info-row">
|
|
|
<span class="source-info-label">Service</span>
|
|
|
<span class="source-info-value">${icon} ${label}</span>
|
|
|
</div>
|
|
|
${dl.source_service === 'soulseek' && dl.source_username ? `<div class="source-info-row">
|
|
|
<span class="source-info-label">User</span>
|
|
|
<span class="source-info-value source-info-mono">${_esc(dl.source_username)}</span>
|
|
|
</div>` : ''}
|
|
|
<div class="source-info-row">
|
|
|
<span class="source-info-label">Original File</span>
|
|
|
<span class="source-info-value source-info-mono source-info-ellipsis" title="${_esc(dl.source_filename || '')}">${_esc(displayFile)}</span>
|
|
|
</div>
|
|
|
${sizeStr ? `<div class="source-info-row">
|
|
|
<span class="source-info-label">Size</span>
|
|
|
<span class="source-info-value">${sizeStr}</span>
|
|
|
</div>` : ''}
|
|
|
${dl.audio_quality ? `<div class="source-info-row">
|
|
|
<span class="source-info-label">Quality</span>
|
|
|
<span class="source-info-value">${_esc(dl.audio_quality)}</span>
|
|
|
</div>` : ''}
|
|
|
${dl.bit_depth || dl.sample_rate || dl.bitrate ? `<div class="source-info-row">
|
|
|
<span class="source-info-label">Audio</span>
|
|
|
<span class="source-info-value">${[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(' · ')}</span>
|
|
|
</div>` : ''}
|
|
|
${dateStr ? `<div class="source-info-row">
|
|
|
<span class="source-info-label">Downloaded</span>
|
|
|
<span class="source-info-value">${dateStr}</span>
|
|
|
</div>` : ''}
|
|
|
${dl.status !== 'completed' ? `<div class="source-info-row">
|
|
|
<span class="source-info-label">Status</span>
|
|
|
<span class="source-info-value" style="color:#ef5350">${dl.status}</span>
|
|
|
</div>` : ''}
|
|
|
</div>
|
|
|
${dl.source_username && dl.source_filename ? `
|
|
|
<div class="source-info-actions">
|
|
|
<button class="source-info-blacklist-btn" id="source-info-blacklist-btn">⛔ Blacklist This Source</button>
|
|
|
</div>` : ''}
|
|
|
${data.downloads.length > 1 ? `<div class="source-info-history">${data.downloads.length} download records for this track</div>` : ''}
|
|
|
`;
|
|
|
|
|
|
// Blacklist button handler
|
|
|
const blBtn = document.getElementById('source-info-blacklist-btn');
|
|
|
if (blBtn) {
|
|
|
blBtn.addEventListener('click', async () => {
|
|
|
if (!await showConfirmDialog({ title: 'Blacklist Source', message: `Blacklist "${displayFile}" from ${dl.source_service === 'soulseek' ? dl.source_username : label}? This source will be skipped in future downloads.`, confirmText: 'Blacklist', destructive: true })) return;
|
|
|
|
|
|
try {
|
|
|
const db_res = await fetch('/api/library/blacklist', {
|
|
|
method: 'POST',
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
body: JSON.stringify({
|
|
|
track_title: dl.track_title || track.title,
|
|
|
track_artist: dl.track_artist || '',
|
|
|
blocked_filename: dl.source_filename,
|
|
|
blocked_username: dl.source_username,
|
|
|
reason: 'user_rejected'
|
|
|
})
|
|
|
});
|
|
|
const result = await db_res.json();
|
|
|
if (result.success) {
|
|
|
showToast('Source blacklisted', 'success');
|
|
|
blBtn.disabled = true;
|
|
|
blBtn.textContent = '⛔ Blacklisted';
|
|
|
} else {
|
|
|
showToast(result.error || 'Failed to blacklist', 'error');
|
|
|
}
|
|
|
} catch (e) {
|
|
|
showToast('Error: ' + e.message, 'error');
|
|
|
}
|
|
|
});
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
popover.innerHTML = `<div class="source-info-empty">Error loading source info: ${_esc(e.message)}</div>`;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
// ==================================================================================
|
|
|
// TRACK REDOWNLOAD MODAL — Multi-step: metadata selection → source selection → download
|
|
|
// ==================================================================================
|
|
|
|
|
|
async function showTrackRedownloadModal(track, album) {
|
|
|
const overlay = document.createElement('div');
|
|
|
overlay.id = 'redownload-overlay';
|
|
|
overlay.className = 'redownload-overlay';
|
|
|
overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };
|
|
|
|
|
|
const artistName = artistDetailPageState.enhancedData?.artist?.name || '';
|
|
|
const ext = (track.file_path || '').split('.').pop().toUpperCase();
|
|
|
const fmt = ['FLAC', 'MP3', 'OPUS', 'OGG', 'M4A', 'WAV'].includes(ext) ? ext : '';
|
|
|
|
|
|
overlay.innerHTML = `
|
|
|
<div class="redownload-modal">
|
|
|
<div class="redownload-header">
|
|
|
<div>
|
|
|
<h3>Redownload Track</h3>
|
|
|
<p class="redownload-header-sub">Find the correct version and download from your preferred source</p>
|
|
|
</div>
|
|
|
<button class="redownload-close" onclick="document.getElementById('redownload-overlay')?.remove()">×</button>
|
|
|
</div>
|
|
|
<div class="redownload-current" id="redownload-current">
|
|
|
<div class="redownload-current-art" id="redownload-current-art">
|
|
|
<div class="redownload-art-empty">🎵</div>
|
|
|
</div>
|
|
|
<div class="redownload-current-info">
|
|
|
<div class="redownload-current-title">${_esc(track.title)}</div>
|
|
|
<div class="redownload-current-meta">${_esc(artistName)} · ${_esc(album?.title || '')}</div>
|
|
|
</div>
|
|
|
<div class="redownload-current-badges">
|
|
|
${fmt ? `<span class="redownload-badge fmt">${fmt}</span>` : ''}
|
|
|
${track.bitrate ? `<span class="redownload-badge bitrate">${track.bitrate}k</span>` : ''}
|
|
|
</div>
|
|
|
</div>
|
|
|
<div class="redownload-steps">
|
|
|
<div class="redownload-step active" data-step="1"><span class="redownload-step-num">1</span> Choose Metadata</div>
|
|
|
<div class="redownload-step-line"></div>
|
|
|
<div class="redownload-step" data-step="2"><span class="redownload-step-num">2</span> Choose Source</div>
|
|
|
<div class="redownload-step-line"></div>
|
|
|
<div class="redownload-step" data-step="3"><span class="redownload-step-num">3</span> Downloading</div>
|
|
|
</div>
|
|
|
<div class="redownload-body" id="redownload-body">
|
|
|
<div class="redownload-loading">
|
|
|
<div class="server-search-spinner"></div>
|
|
|
Searching metadata sources...
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
`;
|
|
|
|
|
|
// Escape to close
|
|
|
const escH = e => { if (e.key === 'Escape') { document.removeEventListener('keydown', escH); overlay.remove(); } };
|
|
|
document.addEventListener('keydown', escH);
|
|
|
|
|
|
document.body.appendChild(overlay);
|
|
|
|
|
|
// Auto-search metadata
|
|
|
try {
|
|
|
const res = await fetch(`/api/library/track/${track.id}/redownload/search-metadata`, { method: 'POST' });
|
|
|
const data = await res.json();
|
|
|
if (!data.success) throw new Error(data.error);
|
|
|
|
|
|
// Set album art in header if available
|
|
|
const artEl = document.getElementById('redownload-current-art');
|
|
|
if (artEl && data.current_track?.thumb_url) {
|
|
|
artEl.innerHTML = `<img src="${data.current_track.thumb_url}" alt="">`;
|
|
|
}
|
|
|
|
|
|
_renderRedownloadStep1(overlay, track, data);
|
|
|
} catch (e) {
|
|
|
document.getElementById('redownload-body').innerHTML = `<div class="redownload-error">Error: ${_esc(e.message)}</div>`;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function _renderRedownloadStep1(overlay, track, data) {
|
|
|
const body = document.getElementById('redownload-body');
|
|
|
if (!body) return;
|
|
|
|
|
|
const sources = Object.keys(data.metadata_results);
|
|
|
if (sources.length === 0) {
|
|
|
body.innerHTML = '<div class="redownload-error">No metadata sources available. Check your Spotify/iTunes/Deezer connections.</div>';
|
|
|
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 = `<div class="redownload-col-empty">No results</div>`;
|
|
|
} else {
|
|
|
itemsHtml = results.slice(0, 8).map((r, i) => {
|
|
|
const pct = Math.round((r.match_score || 0) * 100);
|
|
|
const cls = pct >= 90 ? 'high' : pct >= 70 ? 'medium' : 'low';
|
|
|
const dur = r.duration_ms ? `${Math.floor(r.duration_ms / 60000)}:${String(Math.floor((r.duration_ms % 60000) / 1000)).padStart(2, '0')}` : '';
|
|
|
const checked = (source === bestSource && i === 0) ? 'checked' : '';
|
|
|
return `
|
|
|
<label class="redownload-result" data-source="${source}" data-index="${i}">
|
|
|
<input type="radio" name="metadata-choice" value="${source}|${i}" ${checked}>
|
|
|
<div class="redownload-result-art">${r.image_url ? `<img src="${r.image_url}" loading="lazy">` : '<div class="redownload-art-empty"></div>'}</div>
|
|
|
<div class="redownload-result-info">
|
|
|
<div class="redownload-result-title">${_esc(r.name)}${r.is_current_match ? ' <span class="redownload-current-badge">current</span>' : ''}</div>
|
|
|
<div class="redownload-result-meta">${_esc(r.artist)}${r.album ? ` · ${_esc(r.album)}` : ''}</div>
|
|
|
</div>
|
|
|
<div class="redownload-result-right">
|
|
|
<div class="redownload-result-score ${cls}">${pct}%</div>
|
|
|
${dur ? `<div class="redownload-result-dur">${dur}</div>` : ''}
|
|
|
</div>
|
|
|
</label>`;
|
|
|
}).join('');
|
|
|
}
|
|
|
|
|
|
return `
|
|
|
<div class="redownload-source-col">
|
|
|
<div class="redownload-col-header">
|
|
|
<span class="redownload-col-icon">${icon}</span>
|
|
|
<span class="redownload-col-label">${label}</span>
|
|
|
<span class="redownload-col-count">${results.length}</span>
|
|
|
</div>
|
|
|
<div class="redownload-col-results">${itemsHtml}</div>
|
|
|
</div>`;
|
|
|
}).join('');
|
|
|
|
|
|
body.innerHTML = `<div class="redownload-columns">${columnsHtml}</div>`;
|
|
|
|
|
|
// Add sticky footer for Step 1
|
|
|
const modal = overlay.querySelector('.redownload-modal');
|
|
|
const oldFooter = modal.querySelector('.redownload-sticky-footer');
|
|
|
if (oldFooter) oldFooter.remove();
|
|
|
const footer = document.createElement('div');
|
|
|
footer.className = 'redownload-sticky-footer';
|
|
|
footer.innerHTML = `
|
|
|
<div class="redownload-actions">
|
|
|
<button class="redownload-btn secondary" onclick="document.getElementById('redownload-overlay')?.remove()">Cancel</button>
|
|
|
<button class="redownload-btn primary" id="redownload-next-btn">Search Download Sources →</button>
|
|
|
</div>
|
|
|
`;
|
|
|
modal.appendChild(footer);
|
|
|
|
|
|
// Next button
|
|
|
document.getElementById('redownload-next-btn').addEventListener('click', async () => {
|
|
|
const checked = body.querySelector('input[name="metadata-choice"]:checked');
|
|
|
if (!checked) { showToast('Select a metadata source first', 'error'); return; }
|
|
|
const [source, idx] = checked.value.split('|');
|
|
|
selectedMeta = data.metadata_results[source][parseInt(idx)];
|
|
|
selectedMeta._source = source;
|
|
|
|
|
|
// Update step indicator
|
|
|
overlay.querySelectorAll('.redownload-step').forEach(s => s.classList.remove('active'));
|
|
|
overlay.querySelector('.redownload-step[data-step="2"]').classList.add('active');
|
|
|
|
|
|
// Stream results from all download sources — columns appear as each source responds
|
|
|
// Body gets the scrollable content, footer is sticky outside the scroll
|
|
|
body.innerHTML = `
|
|
|
<div class="rdl-src-columns" id="rdl-src-columns">
|
|
|
<div class="redownload-loading" id="rdl-src-loading"><div class="server-search-spinner"></div>Searching download sources...</div>
|
|
|
</div>
|
|
|
`;
|
|
|
// Add sticky footer outside the scrollable body
|
|
|
const existingFooter = overlay.querySelector('.redownload-sticky-footer');
|
|
|
if (existingFooter) existingFooter.remove();
|
|
|
const modal = overlay.querySelector('.redownload-modal');
|
|
|
const footer = document.createElement('div');
|
|
|
footer.className = 'redownload-sticky-footer';
|
|
|
footer.innerHTML = `
|
|
|
<label class="redownload-delete-old">
|
|
|
<input type="checkbox" id="redownload-delete-old-check" checked>
|
|
|
Delete old file after successful download
|
|
|
</label>
|
|
|
<div class="redownload-actions">
|
|
|
<button class="redownload-btn secondary" onclick="document.getElementById('redownload-overlay')?.remove()">Cancel</button>
|
|
|
<button class="redownload-btn primary" id="redownload-start-btn" disabled>Waiting for results...</button>
|
|
|
</div>
|
|
|
`;
|
|
|
modal.appendChild(footer);
|
|
|
|
|
|
// Wire up download button IMMEDIATELY (before streaming starts)
|
|
|
// so it works as soon as results appear
|
|
|
window._redownloadCandidates = [];
|
|
|
window._redownloadMetadata = selectedMeta;
|
|
|
document.getElementById('redownload-start-btn').addEventListener('click', async () => {
|
|
|
const checked = document.querySelector('input[name="source-choice"]:checked');
|
|
|
if (!checked) { showToast('Select a download source', 'error'); return; }
|
|
|
const cand = window._redownloadCandidates[parseInt(checked.value)];
|
|
|
if (!cand) { showToast('Invalid selection', 'error'); return; }
|
|
|
const deleteOld = document.getElementById('redownload-delete-old-check')?.checked ?? true;
|
|
|
|
|
|
overlay.querySelectorAll('.redownload-step').forEach(s => s.classList.remove('active'));
|
|
|
overlay.querySelector('.redownload-step[data-step="3"]').classList.add('active');
|
|
|
|
|
|
// Remove sticky footer for step 3
|
|
|
const ft = overlay.querySelector('.redownload-sticky-footer');
|
|
|
if (ft) ft.remove();
|
|
|
|
|
|
const body = document.getElementById('redownload-body');
|
|
|
body.innerHTML = `
|
|
|
<div class="redownload-progress">
|
|
|
<div class="redownload-progress-title">Downloading: ${_esc(cand.display_name)}</div>
|
|
|
<div class="redownload-progress-from">from ${_esc(cand.source_service === 'soulseek' ? cand.username : (cand.source_service || 'unknown'))}</div>
|
|
|
<div class="redownload-progress-bar-wrap"><div class="redownload-progress-bar" id="redownload-progress-bar"></div></div>
|
|
|
<div class="redownload-progress-status" id="redownload-progress-status">Starting download...</div>
|
|
|
</div>
|
|
|
`;
|
|
|
|
|
|
try {
|
|
|
const res = await fetch(`/api/library/track/${track.id}/redownload/start`, {
|
|
|
method: 'POST',
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
body: JSON.stringify({ metadata: window._redownloadMetadata, candidate: cand, delete_old_file: deleteOld })
|
|
|
});
|
|
|
const startData = await res.json();
|
|
|
if (!startData.success) throw new Error(startData.error);
|
|
|
_pollRedownloadProgress(startData.task_id, overlay);
|
|
|
} catch (e) {
|
|
|
body.innerHTML = `<div class="redownload-error">Download failed: ${_esc(e.message)}</div>`;
|
|
|
}
|
|
|
});
|
|
|
|
|
|
_streamRedownloadSources(overlay, track, selectedMeta);
|
|
|
});
|
|
|
}
|
|
|
|
|
|
async function _streamRedownloadSources(overlay, track, metadata) {
|
|
|
const columnsEl = document.getElementById('rdl-src-columns');
|
|
|
const loadingEl = document.getElementById('rdl-src-loading');
|
|
|
const startBtn = document.getElementById('redownload-start-btn');
|
|
|
if (!columnsEl) return;
|
|
|
|
|
|
const serviceIcons = { soulseek: '🔍', youtube: '▶️', tidal: '🌊', qobuz: '🎵', hifi: '🎧', deezer_dl: '💜', hybrid: '⚡' };
|
|
|
const serviceLabels = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', hybrid: 'Auto' };
|
|
|
|
|
|
let allCandidates = [];
|
|
|
let firstResult = true;
|
|
|
let bestGlobalIdx = -1;
|
|
|
|
|
|
try {
|
|
|
const res = await fetch(`/api/library/track/${track.id}/redownload/search-sources`, {
|
|
|
method: 'POST',
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
body: JSON.stringify({ metadata })
|
|
|
});
|
|
|
|
|
|
const reader = res.body.getReader();
|
|
|
const decoder = new TextDecoder();
|
|
|
let buffer = '';
|
|
|
|
|
|
while (true) {
|
|
|
const { done, value } = await reader.read();
|
|
|
if (done) break;
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
|
|
|
const lines = buffer.split('\n');
|
|
|
buffer = lines.pop(); // keep incomplete line
|
|
|
|
|
|
for (const line of lines) {
|
|
|
if (!line.trim()) continue;
|
|
|
try {
|
|
|
const data = JSON.parse(line);
|
|
|
if (data.done) continue;
|
|
|
|
|
|
const svc = data.source;
|
|
|
const candidates = data.candidates || [];
|
|
|
|
|
|
// Remove loading spinner on first result
|
|
|
if (firstResult && loadingEl) { loadingEl.remove(); firstResult = false; }
|
|
|
|
|
|
// Assign global indices
|
|
|
const startIdx = allCandidates.length;
|
|
|
candidates.forEach((c, i) => { c._globalIdx = startIdx + i; });
|
|
|
allCandidates.push(...candidates);
|
|
|
window._redownloadCandidates = allCandidates; // Keep global ref updated for button handler
|
|
|
|
|
|
// Find best overall candidate
|
|
|
bestGlobalIdx = -1;
|
|
|
let bestConf = 0;
|
|
|
allCandidates.forEach((c, i) => {
|
|
|
if (!c.blacklisted && c.confidence > bestConf) { bestConf = c.confidence; bestGlobalIdx = i; }
|
|
|
});
|
|
|
|
|
|
// Render column for this source
|
|
|
const icon = serviceIcons[svc] || '📦';
|
|
|
const label = serviceLabels[svc] || svc;
|
|
|
|
|
|
const itemsHtml = candidates.length === 0
|
|
|
? '<div class="rdl-src-col-empty">No results</div>'
|
|
|
: candidates.slice(0, 10).map(c => {
|
|
|
const confPct = Math.round((c.confidence || 0) * 100);
|
|
|
const confCls = confPct >= 90 ? 'high' : confPct >= 70 ? 'medium' : 'low';
|
|
|
const isRec = c._globalIdx === bestGlobalIdx;
|
|
|
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 `
|
|
|
<label class="rdl-src-item${blClass}${isRec ? ' recommended' : ''}">
|
|
|
${c.blacklisted ? '<div class="rdl-src-radio-placeholder"></div>' : `<input type="radio" name="source-choice" value="${c._globalIdx}" ${isRec ? 'checked' : ''}>`}
|
|
|
<div class="rdl-src-item-body">
|
|
|
<div class="rdl-src-item-top">
|
|
|
<div class="rdl-src-item-name" title="${_esc(c.filename)}">${_esc(c.display_name)}</div>
|
|
|
${isRec ? '<span class="rdl-src-recommended">Best</span>' : ''}
|
|
|
</div>
|
|
|
<div class="rdl-src-item-details">
|
|
|
${c.quality ? `<span class="rdl-src-fmt">${c.quality}</span>` : ''}
|
|
|
${c.bitrate ? `<span class="rdl-src-detail">${c.bitrate}k</span>` : ''}
|
|
|
<span class="rdl-src-detail">${c.size_display}</span>
|
|
|
${dur ? `<span class="rdl-src-detail">${dur}</span>` : ''}
|
|
|
${svc === 'soulseek' ? `<span class="rdl-src-detail rdl-src-user">${_esc(c.username)}</span>` : ''}
|
|
|
${svc === 'soulseek' && c.free_upload_slots != null ? `<span class="rdl-src-detail">${c.free_upload_slots} slots</span>` : ''}
|
|
|
</div>
|
|
|
<div class="rdl-src-conf-bar"><div class="rdl-src-conf-fill ${confCls}" style="width:${confPct}%"></div></div>
|
|
|
</div>
|
|
|
<div class="rdl-src-conf-pct ${confCls}">${confPct}%</div>
|
|
|
${c.blacklisted ? '<span class="rdl-src-bl">Blacklisted</span>' : ''}
|
|
|
</label>`;
|
|
|
}).join('');
|
|
|
|
|
|
const colEl = document.createElement('div');
|
|
|
colEl.className = 'rdl-src-col';
|
|
|
colEl.style.animation = 'fadeSlideUp 0.3s ease both';
|
|
|
colEl.innerHTML = `
|
|
|
<div class="rdl-src-col-header">
|
|
|
<span class="rdl-src-col-icon">${icon}</span>
|
|
|
<span class="rdl-src-col-label">${label}</span>
|
|
|
<span class="rdl-src-col-count">${candidates.length}</span>
|
|
|
</div>
|
|
|
<div class="rdl-src-col-body">${itemsHtml}</div>
|
|
|
`;
|
|
|
columnsEl.appendChild(colEl);
|
|
|
|
|
|
// Enable the download button
|
|
|
if (startBtn && allCandidates.some(c => !c.blacklisted)) {
|
|
|
startBtn.disabled = false;
|
|
|
startBtn.textContent = 'Download Selected';
|
|
|
}
|
|
|
|
|
|
} catch (e) { /* skip malformed lines */ }
|
|
|
}
|
|
|
}
|
|
|
} catch (e) {
|
|
|
if (loadingEl) loadingEl.innerHTML = `<div class="redownload-error">Error: ${_esc(e.message)}</div>`;
|
|
|
}
|
|
|
|
|
|
// If no results at all
|
|
|
if (allCandidates.length === 0 && loadingEl) {
|
|
|
loadingEl.innerHTML = '<div class="rdl-src-col-empty">No download sources found for this track.</div>';
|
|
|
}
|
|
|
|
|
|
// 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: '⚡' };
|
|
|
const serviceLabels = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', hybrid: 'Auto' };
|
|
|
|
|
|
// 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 `
|
|
|
<label class="rdl-src-item${blClass}${isRecommended ? ' recommended' : ''}" data-index="${c._origIdx}">
|
|
|
${c.blacklisted ? '<div class="rdl-src-radio-placeholder"></div>' : `<input type="radio" name="source-choice" value="${c._origIdx}" ${checked}>`}
|
|
|
<div class="rdl-src-item-body">
|
|
|
<div class="rdl-src-item-top">
|
|
|
<div class="rdl-src-item-name" title="${_esc(c.filename)}">${_esc(c.display_name)}</div>
|
|
|
${isRecommended ? '<span class="rdl-src-recommended">Best Match</span>' : ''}
|
|
|
</div>
|
|
|
<div class="rdl-src-item-details">
|
|
|
${c.quality ? `<span class="rdl-src-fmt">${c.quality}</span>` : ''}
|
|
|
${c.bitrate ? `<span class="rdl-src-detail">${c.bitrate}k</span>` : ''}
|
|
|
<span class="rdl-src-detail">${c.size_display}</span>
|
|
|
${dur ? `<span class="rdl-src-detail">${dur}</span>` : ''}
|
|
|
${svc === 'soulseek' ? `<span class="rdl-src-detail rdl-src-user">${_esc(c.username)}</span>` : ''}
|
|
|
${svc === 'soulseek' ? `<span class="rdl-src-detail">${c.free_upload_slots || 0} slots</span>` : ''}
|
|
|
</div>
|
|
|
<div class="rdl-src-conf-bar">
|
|
|
<div class="rdl-src-conf-fill ${confCls}" style="width:${confPct}%"></div>
|
|
|
</div>
|
|
|
</div>
|
|
|
<div class="rdl-src-conf-pct ${confCls}">${confPct}%</div>
|
|
|
${c.blacklisted ? '<span class="rdl-src-bl">Blacklisted</span>' : ''}
|
|
|
</label>`;
|
|
|
}).join('');
|
|
|
|
|
|
return `
|
|
|
<div class="rdl-src-col">
|
|
|
<div class="rdl-src-col-header">
|
|
|
<span class="rdl-src-col-icon">${icon}</span>
|
|
|
<span class="rdl-src-col-label">${label}</span>
|
|
|
<span class="rdl-src-col-count">${items.length}</span>
|
|
|
</div>
|
|
|
<div class="rdl-src-col-body">${itemsHtml}</div>
|
|
|
</div>`;
|
|
|
}).join('');
|
|
|
|
|
|
body.innerHTML = `
|
|
|
<div class="rdl-src-columns">${sourceColumnsHtml}</div>
|
|
|
<label class="redownload-delete-old">
|
|
|
<input type="checkbox" id="redownload-delete-old-check" checked>
|
|
|
Delete old file after successful download
|
|
|
</label>
|
|
|
<div class="redownload-actions">
|
|
|
<button class="redownload-btn secondary" onclick="document.getElementById('redownload-overlay')?.remove()">Cancel</button>
|
|
|
<button class="redownload-btn primary" id="redownload-start-btn">Download Selected</button>
|
|
|
</div>
|
|
|
`;
|
|
|
|
|
|
document.getElementById('redownload-start-btn').addEventListener('click', async () => {
|
|
|
const checked = body.querySelector('input[name="source-choice"]:checked');
|
|
|
if (!checked) { showToast('Select a download source', 'error'); return; }
|
|
|
const candidate = candidates[parseInt(checked.value)];
|
|
|
const deleteOld = document.getElementById('redownload-delete-old-check')?.checked ?? true;
|
|
|
|
|
|
// Update step indicator
|
|
|
overlay.querySelectorAll('.redownload-step').forEach(s => s.classList.remove('active'));
|
|
|
overlay.querySelector('.redownload-step[data-step="3"]').classList.add('active');
|
|
|
|
|
|
body.innerHTML = `
|
|
|
<div class="redownload-progress">
|
|
|
<div class="redownload-progress-title">Downloading: ${_esc(candidate.display_name)}</div>
|
|
|
<div class="redownload-progress-from">from ${_esc(candidate.username)}</div>
|
|
|
<div class="redownload-progress-bar-wrap"><div class="redownload-progress-bar" id="redownload-progress-bar"></div></div>
|
|
|
<div class="redownload-progress-status" id="redownload-progress-status">Starting download...</div>
|
|
|
</div>
|
|
|
`;
|
|
|
|
|
|
try {
|
|
|
const res = await fetch(`/api/library/track/${track.id}/redownload/start`, {
|
|
|
method: 'POST',
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
body: JSON.stringify({ metadata, candidate, delete_old_file: deleteOld })
|
|
|
});
|
|
|
const startData = await res.json();
|
|
|
if (!startData.success) throw new Error(startData.error);
|
|
|
|
|
|
// Poll for progress
|
|
|
_pollRedownloadProgress(startData.task_id, overlay);
|
|
|
} catch (e) {
|
|
|
body.innerHTML = `<div class="redownload-error">Download failed: ${_esc(e.message)}</div>`;
|
|
|
}
|
|
|
});
|
|
|
}
|
|
|
|
|
|
function _pollRedownloadProgress(taskId, overlay) {
|
|
|
let completed = false;
|
|
|
|
|
|
const poll = setInterval(async () => {
|
|
|
if (completed) return;
|
|
|
|
|
|
// Get fresh DOM references every tick (in case DOM was rebuilt)
|
|
|
const bar = document.getElementById('redownload-progress-bar');
|
|
|
const status = document.getElementById('redownload-progress-status');
|
|
|
|
|
|
try {
|
|
|
// Poll real download progress from /api/downloads/status
|
|
|
const dlRes = await fetch('/api/downloads/status');
|
|
|
const dlData = await dlRes.json();
|
|
|
const transfers = dlData.transfers || [];
|
|
|
|
|
|
// Find any active transfer
|
|
|
let bestTransfer = null;
|
|
|
for (const t of transfers) {
|
|
|
const st = (t.state || '').toLowerCase();
|
|
|
if (st.includes('inprogress') || st.includes('queued') || st.includes('initializing')) {
|
|
|
bestTransfer = t;
|
|
|
break;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (bestTransfer) {
|
|
|
const pct = bestTransfer.percentComplete || 0;
|
|
|
const transferred = bestTransfer.bytesTransferred || 0;
|
|
|
const total = bestTransfer.size || 0;
|
|
|
const transferredMB = (transferred / 1048576).toFixed(1);
|
|
|
const totalMB = (total / 1048576).toFixed(1);
|
|
|
|
|
|
if (bar) bar.style.width = `${Math.min(95, pct)}%`;
|
|
|
if (status) {
|
|
|
status.textContent = total > 0
|
|
|
? `Downloading... ${Math.round(pct)}% (${transferredMB} / ${totalMB} MB)`
|
|
|
: `Downloading... ${Math.round(pct)}%`;
|
|
|
}
|
|
|
} else {
|
|
|
// No active slskd transfer — streaming source or post-processing
|
|
|
if (bar) bar.style.width = '80%';
|
|
|
if (status) status.textContent = 'Processing...';
|
|
|
}
|
|
|
|
|
|
// Check for batch completion
|
|
|
const procRes = await fetch('/api/active-processes');
|
|
|
const procData = await procRes.json();
|
|
|
const procs = procData.active_processes || [];
|
|
|
const ourBatch = procs.find(p => p.batch_id && p.batch_id.includes('redownload_batch_'));
|
|
|
|
|
|
if (!ourBatch) {
|
|
|
completed = true;
|
|
|
clearInterval(poll);
|
|
|
if (bar) bar.style.width = '100%';
|
|
|
if (status) status.textContent = 'Complete! File replaced successfully.';
|
|
|
showToast('Track redownloaded successfully', 'success');
|
|
|
setTimeout(() => {
|
|
|
overlay.remove();
|
|
|
if (artistDetailPageState.enhancedData?.artist?.id) {
|
|
|
loadEnhancedViewData(artistDetailPageState.enhancedData.artist.id);
|
|
|
}
|
|
|
}, 2000);
|
|
|
}
|
|
|
} catch (e) { /* ignore poll errors */ }
|
|
|
}, 1500);
|
|
|
|
|
|
// Safety timeout — 5 minutes
|
|
|
setTimeout(() => {
|
|
|
if (!completed) {
|
|
|
clearInterval(poll);
|
|
|
const status = document.getElementById('redownload-progress-status');
|
|
|
if (status) status.textContent = 'Download may still be in progress. Check the dashboard.';
|
|
|
}
|
|
|
}, 300000);
|
|
|
}
|
|
|
|
|
|
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 = `
|
|
|
<div class="smart-delete-modal">
|
|
|
<div class="smart-delete-header">
|
|
|
<h3>Delete Album</h3>
|
|
|
<button class="smart-delete-close">×</button>
|
|
|
</div>
|
|
|
<p class="smart-delete-desc">How should this album be deleted?</p>
|
|
|
<div class="smart-delete-options">
|
|
|
<button class="smart-delete-option" data-choice="db_only">
|
|
|
<div class="smart-delete-option-icon">📋</div>
|
|
|
<div class="smart-delete-option-info">
|
|
|
<div class="smart-delete-option-title">Remove from Library</div>
|
|
|
<div class="smart-delete-option-desc">Remove the album and all tracks from the database. Files on disk are not affected.</div>
|
|
|
</div>
|
|
|
</button>
|
|
|
<button class="smart-delete-option destructive" data-choice="delete_files">
|
|
|
<div class="smart-delete-option-icon">🗑️</div>
|
|
|
<div class="smart-delete-option-info">
|
|
|
<div class="smart-delete-option-title">Delete Files Too</div>
|
|
|
<div class="smart-delete-option-desc">Remove from library and delete all audio files from disk. Empty album folder will be cleaned up.</div>
|
|
|
</div>
|
|
|
</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
`;
|
|
|
|
|
|
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}`,
|
|
|
},
|
|
|
};
|
|
|
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 = `
|
|
|
<div class="enhanced-bulk-modal-field">
|
|
|
<label>Track Number (leave blank to skip)</label>
|
|
|
<input type="number" id="bulk-edit-track-number" placeholder="Track number..." min="1">
|
|
|
</div>
|
|
|
<div class="enhanced-bulk-modal-field">
|
|
|
<label>BPM (leave blank to skip)</label>
|
|
|
<input type="number" id="bulk-edit-bpm" placeholder="BPM..." step="0.1">
|
|
|
</div>
|
|
|
<div class="enhanced-bulk-modal-field">
|
|
|
<label>Style (leave blank to skip)</label>
|
|
|
<input type="text" id="bulk-edit-style" placeholder="Style...">
|
|
|
</div>
|
|
|
<div class="enhanced-bulk-modal-field">
|
|
|
<label>Mood (leave blank to skip)</label>
|
|
|
<input type="text" id="bulk-edit-mood" placeholder="Mood...">
|
|
|
</div>
|
|
|
<div class="enhanced-bulk-modal-field">
|
|
|
<label>Explicit</label>
|
|
|
<select id="bulk-edit-explicit">
|
|
|
<option value="">-- No change --</option>
|
|
|
<option value="0">No</option>
|
|
|
<option value="1">Yes</option>
|
|
|
</select>
|
|
|
</div>
|
|
|
`;
|
|
|
|
|
|
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 = {};
|
|
|
|
|
|
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 ((value || '') !== (album[field] || '')) updates[field] = value || null;
|
|
|
}
|
|
|
});
|
|
|
|
|
|
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'
|
|
|
};
|
|
|
|
|
|
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 = `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 = '<div class="enhanced-match-results-hint">Press Search or Enter to find matches</div>';
|
|
|
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 = '<div class="enhanced-match-results-hint">Enter a search term</div>';
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
container.innerHTML = '<div class="enhanced-loading">Searching...</div>';
|
|
|
|
|
|
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 = '<div class="enhanced-match-results-hint">No results found. Try a different search.</div>';
|
|
|
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 = `<div class="enhanced-match-results-hint" style="color:#ff6b6b;">Error: ${escapeHtml(error.message)}</div>`;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
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');
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// ---- 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 = '<div class="tag-preview-loading">Loading tag comparison...</div>';
|
|
|
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 = `<div class="tag-preview-error">${escapeHtml(result.error)}</div>`;
|
|
|
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 = '<table class="tag-preview-table"><thead><tr>';
|
|
|
html += '<th>Field</th><th>Current File Tag</th><th></th><th>DB Value</th>';
|
|
|
html += '</tr></thead><tbody>';
|
|
|
|
|
|
diff.forEach(d => {
|
|
|
const rowClass = d.changed ? 'tag-diff-changed' : 'tag-diff-same';
|
|
|
const arrow = d.changed ? '<span class="tag-diff-arrow">→</span>' : '<span class="tag-diff-check">✓</span>';
|
|
|
html += `<tr class="${rowClass}">`;
|
|
|
html += `<td class="tag-field-name">${d.field}</td>`;
|
|
|
html += `<td class="tag-file-value">${escapeHtml(d.file_value) || '<span class="tag-empty">empty</span>'}</td>`;
|
|
|
html += `<td class="tag-diff-indicator">${arrow}</td>`;
|
|
|
html += `<td class="tag-db-value">${escapeHtml(d.db_value) || '<span class="tag-empty">empty</span>'}</td>`;
|
|
|
html += '</tr>';
|
|
|
});
|
|
|
|
|
|
html += '</tbody></table>';
|
|
|
|
|
|
if (!hasChanges) {
|
|
|
html += '<div class="tag-preview-no-changes">File tags already match DB metadata</div>';
|
|
|
}
|
|
|
|
|
|
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 = `<div class="tag-preview-error">Failed to load preview: ${escapeHtml(error.message)}</div>`;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
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 = '<div class="tag-preview-loading">Loading tag previews...</div>';
|
|
|
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 = `<div class="tag-preview-error">${escapeHtml(result.error)}</div>`;
|
|
|
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 = '<div class="batch-tag-summary">';
|
|
|
if (withChanges.length > 0) summaryHtml += `<span class="batch-tag-stat changed">${withChanges.length} with changes</span>`;
|
|
|
if (noChanges.length > 0) summaryHtml += `<span class="batch-tag-stat unchanged">${noChanges.length} unchanged</span>`;
|
|
|
if (errors.length > 0) summaryHtml += `<span class="batch-tag-stat errored">${errors.length} unavailable</span>`;
|
|
|
summaryHtml += '</div>';
|
|
|
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 += `<div class="batch-tag-track error">`;
|
|
|
html += `<div class="batch-tag-track-header">`;
|
|
|
html += `<span class="batch-tag-track-number">${track.track_number || '—'}</span>`;
|
|
|
html += `<span class="batch-tag-track-title">${escapeHtml(track.title)}</span>`;
|
|
|
html += `<span class="batch-tag-track-status error">${escapeHtml(track.error)}</span>`;
|
|
|
html += `</div></div>`;
|
|
|
});
|
|
|
|
|
|
// Unchanged tracks (collapsed)
|
|
|
if (noChanges.length > 0) {
|
|
|
html += `<div class="batch-tag-unchanged-group">`;
|
|
|
html += `<div class="batch-tag-unchanged-header" onclick="this.parentElement.classList.toggle('expanded')">`;
|
|
|
html += `<span>${noChanges.length} track${noChanges.length !== 1 ? 's' : ''} already up to date</span>`;
|
|
|
html += `<span class="batch-tag-chevron">▾</span>`;
|
|
|
html += `</div>`;
|
|
|
html += `<div class="batch-tag-unchanged-list">`;
|
|
|
noChanges.forEach(track => {
|
|
|
html += `<div class="batch-tag-track-row unchanged">`;
|
|
|
html += `<span class="batch-tag-track-number">${track.track_number || '—'}</span>`;
|
|
|
html += `<span class="batch-tag-track-title">${escapeHtml(track.title)}</span>`;
|
|
|
html += `<span class="batch-tag-track-status ok">✓ Tags match</span>`;
|
|
|
html += `</div>`;
|
|
|
});
|
|
|
html += `</div></div>`;
|
|
|
}
|
|
|
|
|
|
if (withChanges.length === 0 && errors.length === 0) {
|
|
|
html += '<div class="tag-preview-no-changes">All file tags already match DB metadata</div>';
|
|
|
}
|
|
|
|
|
|
body.innerHTML = html;
|
|
|
|
|
|
// Store state for write action
|
|
|
overlay._batchTrackIds = trackIds;
|
|
|
overlay._batchServerType = serverType;
|
|
|
writeBtn.disabled = withChanges.length === 0;
|
|
|
|
|
|
} catch (error) {
|
|
|
body.innerHTML = `<div class="tag-preview-error">Failed to load previews: ${escapeHtml(error.message)}</div>`;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function _renderBatchTrackDiff(track, expanded) {
|
|
|
let html = `<div class="batch-tag-track${expanded ? ' expanded' : ''}">`;
|
|
|
html += `<div class="batch-tag-track-header" onclick="this.parentElement.classList.toggle('expanded')">`;
|
|
|
html += `<span class="batch-tag-track-number">${track.track_number || '—'}</span>`;
|
|
|
html += `<span class="batch-tag-track-title">${escapeHtml(track.title)}</span>`;
|
|
|
html += `<span class="batch-tag-track-status changed">${track.changed_count} field${track.changed_count !== 1 ? 's' : ''} changed</span>`;
|
|
|
html += `<span class="batch-tag-chevron">▾</span>`;
|
|
|
html += `</div>`;
|
|
|
html += `<div class="batch-tag-track-diff">`;
|
|
|
html += '<table class="tag-preview-table"><thead><tr>';
|
|
|
html += '<th>Field</th><th>Current File</th><th></th><th>New Value</th>';
|
|
|
html += '</tr></thead><tbody>';
|
|
|
|
|
|
(track.diff || []).forEach(d => {
|
|
|
if (!d.changed) return; // Only show changed fields in batch view
|
|
|
html += `<tr class="tag-diff-changed">`;
|
|
|
html += `<td class="tag-field-name">${d.field}</td>`;
|
|
|
html += `<td class="tag-file-value">${escapeHtml(d.file_value) || '<span class="tag-empty">empty</span>'}</td>`;
|
|
|
html += `<td class="tag-diff-indicator"><span class="tag-diff-arrow">→</span></td>`;
|
|
|
html += `<td class="tag-db-value">${escapeHtml(d.db_value) || '<span class="tag-empty">empty</span>'}</td>`;
|
|
|
html += '</tr>';
|
|
|
});
|
|
|
|
|
|
html += '</tbody></table></div></div>';
|
|
|
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 = '<div class="reorganize-content">';
|
|
|
|
|
|
// Metadata source picker — populated from /reorganize/sources.
|
|
|
// Empty value = use configured primary (with fallback chain).
|
|
|
// Specific source = strict mode, that source only.
|
|
|
html += '<div class="reorganize-source-section">';
|
|
|
html += '<label class="reorganize-label">Metadata Source</label>';
|
|
|
html += '<div class="reorganize-template-hint">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.</div>';
|
|
|
html += '<select id="reorganize-source-select" class="reorganize-template-input">';
|
|
|
html += '<option value="">Use configured primary (auto)</option>';
|
|
|
html += '</select>';
|
|
|
html += '</div>';
|
|
|
|
|
|
// Preview area
|
|
|
html += '<div class="reorganize-preview-section">';
|
|
|
html += '<div class="reorganize-preview-header">';
|
|
|
html += '<label class="reorganize-label">Preview</label>';
|
|
|
html += '<button class="reorganize-preview-btn" onclick="loadReorganizePreview()">Generate Preview</button>';
|
|
|
html += '</div>';
|
|
|
html += '<div id="reorganize-preview-body" class="reorganize-preview-body">';
|
|
|
html += '<div class="reorganize-preview-hint">Click "Generate Preview" to see how files will be reorganized.</div>';
|
|
|
html += '</div></div>';
|
|
|
|
|
|
html += '</div>';
|
|
|
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 = '<div class="reorganize-preview-loading">Loading preview...</div>';
|
|
|
|
|
|
// 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 = `<div class="reorganize-preview-error">${escapeHtml(result.error || 'Preview failed')}</div>`;
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const tracks = result.tracks || [];
|
|
|
if (tracks.length === 0) {
|
|
|
previewBody.innerHTML = '<div class="reorganize-preview-hint">No tracks found.</div>';
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
let hasChanges = false;
|
|
|
let hasCollisions = false;
|
|
|
let html = '<table class="reorganize-preview-table"><thead><tr>';
|
|
|
html += '<th>#</th><th>Title</th><th>Current Path</th><th></th><th>New Path</th>';
|
|
|
html += '</tr></thead><tbody>';
|
|
|
|
|
|
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 ? `<em>${escapeHtml(t.reason || 'Not in selected source\'s tracklist')}</em>`
|
|
|
: missingPath ? `<em>${escapeHtml(t.reason || 'Couldn\'t compute destination path')}</em>`
|
|
|
: (escapeHtml(t.new_path) + (collision ? ' <em>(collision)</em>' : ''));
|
|
|
|
|
|
html += `<tr class="${rowClass}">`;
|
|
|
html += `<td>${t.track_number || ''}</td>`;
|
|
|
html += `<td>${escapeHtml(t.title)}</td>`;
|
|
|
html += `<td class="reorganize-path">${noFile ? '<em>File not found</em>' : escapeHtml(t.current_path)}</td>`;
|
|
|
html += `<td class="reorganize-arrow">${arrow}</td>`;
|
|
|
html += `<td class="reorganize-path">${newCell}</td>`;
|
|
|
html += '</tr>';
|
|
|
});
|
|
|
|
|
|
html += '</tbody></table>';
|
|
|
|
|
|
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 = `<div class="reorganize-preview-summary">`;
|
|
|
if (changedCount > 0) summary += `<span class="reorganize-stat changed">${changedCount} will move</span>`;
|
|
|
if (skippedCount > 0) summary += `<span class="reorganize-stat unchanged">${skippedCount} unchanged</span>`;
|
|
|
if (unmatchedCount > 0) summary += `<span class="reorganize-stat missing">${unmatchedCount} not in source — try a different source</span>`;
|
|
|
if (noPathCount > 0) summary += `<span class="reorganize-stat missing">${noPathCount} couldn't compute destination</span>`;
|
|
|
if (missingCount > 0) summary += `<span class="reorganize-stat missing">${missingCount} missing on disk</span>`;
|
|
|
if (collisionCount > 0) summary += `<span class="reorganize-stat collision">${collisionCount} collision${collisionCount !== 1 ? 's' : ''} — likely a source data issue</span>`;
|
|
|
summary += '</div>';
|
|
|
|
|
|
previewBody.innerHTML = summary + html;
|
|
|
|
|
|
canApply = hasChanges && !hasCollisions;
|
|
|
|
|
|
} catch (error) {
|
|
|
previewBody.innerHTML = `<div class="reorganize-preview-error">Error: ${escapeHtml(error.message)}</div>`;
|
|
|
} 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 = '<div class="reorganize-content">';
|
|
|
|
|
|
// 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 += '<div class="reorganize-source-section">';
|
|
|
html += '<label class="reorganize-label">Metadata Source (applies to all albums)</label>';
|
|
|
html += '<div class="reorganize-template-hint">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.</div>';
|
|
|
html += '<select id="reorganize-source-select" class="reorganize-template-input">';
|
|
|
html += '<option value="">Use configured primary (auto)</option>';
|
|
|
html += '</select>';
|
|
|
html += '</div>';
|
|
|
|
|
|
// Album list
|
|
|
html += '<div style="margin-top:14px;">';
|
|
|
html += `<label class="reorganize-label">${albums.length} album${albums.length !== 1 ? 's' : ''} will be reorganized:</label>`;
|
|
|
html += '<div style="max-height:200px;overflow-y:auto;margin-top:6px;border:1px solid rgba(255,255,255,0.08);border-radius:8px;padding:6px 10px;">';
|
|
|
albums.forEach((a, i) => {
|
|
|
const trackCount = a.tracks ? a.tracks.length : '?';
|
|
|
html += `<div style="padding:4px 0;font-size:0.88em;color:rgba(255,255,255,0.7);border-bottom:${i < albums.length - 1 ? '1px solid rgba(255,255,255,0.04)' : 'none'};">`;
|
|
|
html += `${escapeHtml(a.title)} <span style="color:rgba(255,255,255,0.3);">(${trackCount} tracks)</span>`;
|
|
|
html += '</div>';
|
|
|
});
|
|
|
html += '</div></div>';
|
|
|
|
|
|
html += '</div>';
|
|
|
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);
|
|
|
}
|
|
|
|
|
|
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 pick applies to every album in the batch.
|
|
|
const chosenSource = document.getElementById('reorganize-source-select')?.value || '';
|
|
|
|
|
|
try {
|
|
|
const resp = await fetch(`/api/library/artist/${artistId}/reorganize-all`, {
|
|
|
method: 'POST',
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
body: JSON.stringify({ source: chosenSource }),
|
|
|
});
|
|
|
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 = '<div class="reorg-panel-compact" onclick="toggleReorganizeStatusPanel()">';
|
|
|
html += '<div class="reorg-panel-compact-left">';
|
|
|
|
|
|
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 += `<span class="reorg-panel-spinner"></span>`;
|
|
|
html += `<span class="reorg-panel-active-text">Reorganizing <strong>${escapeHtml(albumLabel)}</strong>`;
|
|
|
if (total > 0) html += ` (${done}/${total} · ${pct}%)`;
|
|
|
html += `${trackBit}</span>`;
|
|
|
} else if (queued.length > 0) {
|
|
|
html += `<span class="reorg-panel-spinner"></span>`;
|
|
|
html += `<span class="reorg-panel-active-text">Reorganize queue starting…</span>`;
|
|
|
} 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 += `<span class="reorg-panel-recent-icon ${cls}"></span>`;
|
|
|
const parts = [];
|
|
|
if (done > 0) parts.push(`${done} reorganized`);
|
|
|
if (failed > 0) parts.push(`${failed} failed`);
|
|
|
html += `<span class="reorg-panel-active-text">${parts.join(', ') || 'Recent activity'}</span>`;
|
|
|
}
|
|
|
html += '</div>';
|
|
|
|
|
|
// Right: queue count badge + expand chevron.
|
|
|
html += '<div class="reorg-panel-compact-right">';
|
|
|
if (queued.length > 0) {
|
|
|
html += `<span class="reorg-panel-queue-badge" title="${queued.length} waiting in queue">+${queued.length} queued</span>`;
|
|
|
}
|
|
|
const chev = _reorgPanelExpanded ? '▾' : '▸';
|
|
|
html += `<span class="reorg-panel-chevron">${chev}</span>`;
|
|
|
html += '</div>';
|
|
|
html += '</div>';
|
|
|
|
|
|
if (_reorgPanelExpanded) {
|
|
|
html += '<div class="reorg-panel-expanded">';
|
|
|
|
|
|
// Active card
|
|
|
if (active) {
|
|
|
html += _reorgPanelRenderActiveCard(active);
|
|
|
}
|
|
|
|
|
|
// Queued list
|
|
|
if (queued.length > 0) {
|
|
|
html += '<div class="reorg-panel-section-header">';
|
|
|
html += `<span>Queued (${queued.length})</span>`;
|
|
|
html += `<button class="reorg-panel-clear-btn" onclick="clearReorganizeQueue(event)">Cancel All</button>`;
|
|
|
html += '</div>';
|
|
|
html += '<div class="reorg-panel-list">';
|
|
|
queued.forEach((item, idx) => {
|
|
|
html += _reorgPanelRenderQueuedRow(item, idx + 1);
|
|
|
});
|
|
|
html += '</div>';
|
|
|
}
|
|
|
|
|
|
// Recent
|
|
|
if (recentVisible.length > 0) {
|
|
|
html += `<div class="reorg-panel-section-header"><span>Recent</span></div>`;
|
|
|
html += '<div class="reorg-panel-list">';
|
|
|
recentVisible.slice(0, 6).forEach(item => {
|
|
|
html += _reorgPanelRenderRecentRow(item);
|
|
|
});
|
|
|
html += '</div>';
|
|
|
}
|
|
|
|
|
|
html += '</div>';
|
|
|
}
|
|
|
|
|
|
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 = '<div class="reorg-panel-active-card">';
|
|
|
h += `<div class="reorg-panel-active-title">${escapeHtml(active.album_title || 'Unknown album')}`;
|
|
|
if (crossArtist) {
|
|
|
h += ` <span class="reorg-panel-cross-artist">${escapeHtml(active.artist_name || 'other artist')}</span>`;
|
|
|
}
|
|
|
h += '</div>';
|
|
|
h += '<div class="reorg-panel-progress-track">';
|
|
|
h += `<div class="reorg-panel-progress-fill" style="width:${pct}%"></div>`;
|
|
|
h += '</div>';
|
|
|
h += '<div class="reorg-panel-active-meta">';
|
|
|
if (total > 0) {
|
|
|
h += `<span>${done}/${total}</span>`;
|
|
|
}
|
|
|
if (active.current_track) {
|
|
|
h += `<span class="reorg-panel-current-track">${escapeHtml(active.current_track)}</span>`;
|
|
|
}
|
|
|
h += '<span class="reorg-panel-counters">';
|
|
|
h += `<span class="ok">${active.moved || 0} moved</span>`;
|
|
|
if ((active.skipped || 0) > 0) h += `<span class="warn">${active.skipped} skipped</span>`;
|
|
|
if ((active.failed || 0) > 0) h += `<span class="fail">${active.failed} failed</span>`;
|
|
|
h += '</span>';
|
|
|
h += '</div>';
|
|
|
h += '</div>';
|
|
|
return h;
|
|
|
}
|
|
|
|
|
|
function _reorgPanelRenderQueuedRow(item, position) {
|
|
|
const crossArtist = _reorgPanelArtistId && item.artist_id && String(item.artist_id) !== _reorgPanelArtistId;
|
|
|
let h = '<div class="reorg-panel-row queued-row">';
|
|
|
h += `<span class="reorg-panel-row-pos">#${position}</span>`;
|
|
|
h += '<div class="reorg-panel-row-body">';
|
|
|
h += `<div class="reorg-panel-row-title">${escapeHtml(item.album_title || 'Unknown album')}</div>`;
|
|
|
if (crossArtist) {
|
|
|
h += `<div class="reorg-panel-row-sub">${escapeHtml(item.artist_name || 'other artist')}</div>`;
|
|
|
} else if (item.source) {
|
|
|
h += `<div class="reorg-panel-row-sub">via ${escapeHtml(item.source)}</div>`;
|
|
|
}
|
|
|
h += '</div>';
|
|
|
h += `<button class="reorg-panel-cancel-btn" title="Cancel" onclick="cancelReorganizeQueueItem('${item.queue_id}', event)">×</button>`;
|
|
|
h += '</div>';
|
|
|
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 = `<div class="reorg-panel-row recent-row ${cls}">`;
|
|
|
h += `<span class="reorg-panel-row-icon ${cls}"></span>`;
|
|
|
h += '<div class="reorg-panel-row-body">';
|
|
|
h += `<div class="reorg-panel-row-title">${escapeHtml(item.album_title || 'Unknown album')}</div>`;
|
|
|
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 += `<div class="reorg-panel-row-sub">${escapeHtml(sub)}</div>`;
|
|
|
h += '</div></div>';
|
|
|
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;
|
|
|
}
|
|
|
|
|
|
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 || ''
|
|
|
})
|
|
|
});
|
|
|
|
|
|
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);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// =================================
|