Extracts the single 77,957-line script.js into focused modules: core.js (874) - Global state, confirm dialog, websocket, constants init.js (2358) - Initialization, personal settings, navigation media-player.js (2398) - Media player, audio, visualizer, radio settings.js (3657) - Settings page, quality profiles, API keys, auth search.js (1542) - Search functionality, page data loading sync-spotify.js (2538) - Spotify sync, YouTube backend, hero section downloads.js (6398) - Wing It, batched polling, cancel, notifications wishlist-tools.js (7234) - Wishlist, matched downloads, tools, retag sync-services.js (9076) - Tidal, Deezer, Beatport, YouTube, ListenBrainz sync artists.js (4610) - Artists page, artist downloads api-monitor.js (3798) - API rate monitor gauges library.js (6652) - Library, artist detail, enhanced management beatport-ui.js (3902) - Beatport sliders, genre browser discover.js (8920) - Discover page and all sub-sections enrichment.js (3551) - All enrichment workers, library repair stats-automations.js (7575) - Stats, automations, issues, import pages-extra.js (2874) - Playlist explorer, server playlists, active downloads Load order: core.js first (globals), init.js last (DOMContentLoaded). All other modules define functions and load in any order. No functional changes - pure extraction along existing section boundaries.
1543 lines
64 KiB
JavaScript
1543 lines
64 KiB
JavaScript
// SEARCH FUNCTIONALITY
|
|
// ===============================
|
|
|
|
function initializeSearch() {
|
|
// --- FIX: Corrected the element IDs to match the HTML ---
|
|
const searchInput = document.getElementById('downloads-search-input');
|
|
const searchButton = document.getElementById('downloads-search-btn');
|
|
|
|
// Add this line to get the cancel button
|
|
const cancelButton = document.getElementById('downloads-cancel-btn');
|
|
|
|
if (searchButton && searchInput) {
|
|
searchButton.addEventListener('click', performDownloadsSearch);
|
|
searchInput.addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter') performDownloadsSearch();
|
|
});
|
|
}
|
|
|
|
// Add this event listener for the cancel button
|
|
if (cancelButton) {
|
|
cancelButton.addEventListener('click', () => {
|
|
if (searchAbortController) {
|
|
searchAbortController.abort(); // This cancels the fetch request
|
|
console.log("Search cancelled by user.");
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// ===============================
|
|
// SEARCH MODE TOGGLE
|
|
// ===============================
|
|
|
|
let searchModeToggleInitialized = false;
|
|
|
|
function initializeSearchModeToggle() {
|
|
// Only initialize once to prevent duplicate event listeners
|
|
if (searchModeToggleInitialized) {
|
|
console.log('Search mode toggle already initialized, skipping...');
|
|
return;
|
|
}
|
|
|
|
const toggleContainer = document.querySelector('.search-mode-toggle');
|
|
const modeBtns = document.querySelectorAll('.search-mode-btn');
|
|
const basicSection = document.getElementById('basic-search-section');
|
|
const enhancedSection = document.getElementById('enhanced-search-section');
|
|
|
|
if (!toggleContainer || !modeBtns.length || !basicSection || !enhancedSection) {
|
|
console.warn('Search mode toggle elements not found');
|
|
return;
|
|
}
|
|
|
|
searchModeToggleInitialized = true;
|
|
console.log('✅ Initializing search mode toggle (first time only)');
|
|
|
|
modeBtns.forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const mode = btn.dataset.mode;
|
|
|
|
// Update button active states
|
|
modeBtns.forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
|
|
// Update toggle slider position
|
|
toggleContainer.setAttribute('data-active', mode);
|
|
|
|
// Toggle sections
|
|
if (mode === 'basic') {
|
|
basicSection.classList.add('active');
|
|
enhancedSection.classList.remove('active');
|
|
console.log('Switched to basic search mode');
|
|
} else {
|
|
basicSection.classList.remove('active');
|
|
enhancedSection.classList.add('active');
|
|
console.log('Switched to enhanced search mode');
|
|
}
|
|
});
|
|
});
|
|
|
|
// Initialize enhanced search
|
|
const enhancedInput = document.getElementById('enhanced-search-input');
|
|
const enhancedSearchBtn = document.getElementById('enhanced-search-btn');
|
|
const enhancedCancelBtn = document.getElementById('enhanced-cancel-btn');
|
|
const enhancedDropdown = document.getElementById('enhanced-dropdown');
|
|
const loadingState = document.getElementById('enhanced-loading');
|
|
const emptyState = document.getElementById('enhanced-empty');
|
|
const resultsContainer = document.getElementById('enhanced-results-container');
|
|
|
|
let debounceTimer = null;
|
|
let abortController = null;
|
|
|
|
// Multi-source search state
|
|
let _enhancedSearchData = null; // Full response with all sources
|
|
let _activeSearchSource = null; // Currently displayed source tab
|
|
let _altSourceController = null; // AbortController for alternate source fetches
|
|
|
|
const SOURCE_LABELS = {
|
|
spotify: { text: 'Spotify', tabClass: 'enh-tab-spotify', badgeClass: 'enh-badge-spotify' },
|
|
itunes: { text: 'Apple Music', tabClass: 'enh-tab-itunes', badgeClass: 'enh-badge-itunes' },
|
|
deezer: { text: 'Deezer', tabClass: 'enh-tab-deezer', badgeClass: 'enh-badge-deezer' },
|
|
discogs: { text: 'Discogs', tabClass: 'enh-tab-discogs', badgeClass: 'enh-badge-discogs' },
|
|
hydrabase: { text: 'Hydrabase', tabClass: 'enh-tab-hydrabase', badgeClass: 'enh-badge-hydrabase' },
|
|
youtube_videos: { text: 'Music Videos', tabClass: 'enh-tab-youtube', badgeClass: 'enh-badge-youtube' },
|
|
musicbrainz: { text: 'MusicBrainz', tabClass: 'enh-tab-musicbrainz', badgeClass: 'enh-badge-musicbrainz' },
|
|
};
|
|
|
|
// Live search with debouncing
|
|
if (enhancedInput) {
|
|
enhancedInput.addEventListener('input', (e) => {
|
|
const query = e.target.value.trim();
|
|
|
|
// Show/hide cancel button
|
|
if (enhancedCancelBtn) {
|
|
enhancedCancelBtn.classList.toggle('hidden', query.length === 0);
|
|
}
|
|
|
|
// Clear debounce timer
|
|
clearTimeout(debounceTimer);
|
|
|
|
// Hide dropdown if query too short
|
|
if (query.length < 2) {
|
|
hideDropdown();
|
|
return;
|
|
}
|
|
|
|
// Debounce search
|
|
debounceTimer = setTimeout(() => {
|
|
performEnhancedSearch(query);
|
|
}, 300);
|
|
});
|
|
|
|
enhancedInput.addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter') {
|
|
const query = e.target.value.trim();
|
|
if (query.length >= 2) {
|
|
clearTimeout(debounceTimer);
|
|
performEnhancedSearch(query);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
if (enhancedSearchBtn) {
|
|
enhancedSearchBtn.addEventListener('click', (e) => {
|
|
// Prevent click from bubbling to document (which would close the dropdown)
|
|
e.stopPropagation();
|
|
|
|
// Get fresh references (in case we navigated away and back)
|
|
const dropdown = document.getElementById('enhanced-dropdown');
|
|
const results = document.getElementById('enhanced-results-container');
|
|
|
|
if (!dropdown) return;
|
|
|
|
// Toggle the dropdown visibility to show/hide previous search results
|
|
if (dropdown.classList.contains('hidden')) {
|
|
// Check if there are results to show by looking for actual content
|
|
const hasResults = results &&
|
|
!results.classList.contains('hidden') &&
|
|
results.children.length > 0;
|
|
|
|
if (hasResults) {
|
|
showDropdown();
|
|
} else {
|
|
showToast('No previous results to show. Type to search!', 'info');
|
|
}
|
|
} else {
|
|
hideDropdown();
|
|
}
|
|
});
|
|
}
|
|
|
|
if (enhancedCancelBtn) {
|
|
enhancedCancelBtn.addEventListener('click', () => {
|
|
enhancedInput.value = '';
|
|
enhancedCancelBtn.classList.add('hidden');
|
|
hideDropdown();
|
|
});
|
|
}
|
|
|
|
// Close button inside dropdown (mobile)
|
|
const dropdownCloseBtn = document.getElementById('enhanced-dropdown-close');
|
|
if (dropdownCloseBtn) {
|
|
dropdownCloseBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
hideDropdown();
|
|
});
|
|
}
|
|
|
|
// Close dropdown when clicking outside
|
|
document.addEventListener('click', (e) => {
|
|
const dropdown = document.getElementById('enhanced-dropdown');
|
|
if (dropdown && !dropdown.classList.contains('hidden')) {
|
|
const isClickInside = e.target.closest('.enhanced-search-input-wrapper');
|
|
if (!isClickInside) {
|
|
hideDropdown();
|
|
}
|
|
}
|
|
});
|
|
|
|
async function performEnhancedSearch(query) {
|
|
console.log('Enhanced search:', query);
|
|
const searchId = Date.now() + Math.random();
|
|
|
|
// Show loading state with correct source name
|
|
showDropdown();
|
|
const loadingText = document.getElementById('enhanced-loading-text');
|
|
if (loadingText) {
|
|
loadingText.textContent = `Searching across ${currentMusicSourceName} and your library...`;
|
|
}
|
|
loadingState.classList.remove('hidden');
|
|
emptyState.classList.add('hidden');
|
|
resultsContainer.classList.add('hidden');
|
|
|
|
// Abort previous requests (primary + alternates)
|
|
if (abortController) {
|
|
abortController.abort();
|
|
}
|
|
if (_altSourceController) {
|
|
_altSourceController.abort();
|
|
}
|
|
abortController = new AbortController();
|
|
_altSourceController = new AbortController();
|
|
|
|
// Initialize multi-source state early so alternate fetches can write to it
|
|
_enhancedSearchData = { db_artists: [], primary_source: null, sources: {}, searchId, query };
|
|
|
|
try {
|
|
const response = await fetch('/api/enhanced-search', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ query }),
|
|
signal: abortController.signal
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Search failed');
|
|
|
|
const data = await response.json();
|
|
console.log('Enhanced results:', data);
|
|
|
|
// Store multi-source state
|
|
const primarySource = data.primary_source || data.metadata_source || 'deezer';
|
|
_activeSearchSource = primarySource;
|
|
_enhancedSearchData = _enhancedSearchData || {};
|
|
_enhancedSearchData.db_artists = data.db_artists;
|
|
_enhancedSearchData.primary_source = primarySource;
|
|
if (!_enhancedSearchData.sources) _enhancedSearchData.sources = {};
|
|
_enhancedSearchData.sources[primarySource] = {
|
|
artists: data.spotify_artists || [],
|
|
albums: data.spotify_albums || [],
|
|
tracks: data.spotify_tracks || [],
|
|
available: true,
|
|
};
|
|
|
|
// Calculate total from primary source
|
|
const total = (data.db_artists?.length || 0) +
|
|
(data.spotify_artists?.length || 0) +
|
|
(data.spotify_albums?.length || 0) +
|
|
(data.spotify_tracks?.length || 0);
|
|
|
|
// Hide loading
|
|
loadingState.classList.add('hidden');
|
|
|
|
if (total === 0) {
|
|
emptyState.classList.remove('hidden');
|
|
} else {
|
|
renderSourceTabs(_enhancedSearchData);
|
|
renderDropdownResults(data);
|
|
resultsContainer.classList.remove('hidden');
|
|
}
|
|
|
|
// Alternate sources now start after the primary response has landed.
|
|
// This avoids speculative fan-out for short or aborted searches.
|
|
_queueAlternateSourceFetches(data.alternate_sources || [], query, searchId);
|
|
|
|
} catch (error) {
|
|
if (error.name !== 'AbortError') {
|
|
console.error('Enhanced search error:', error);
|
|
loadingState.classList.add('hidden');
|
|
emptyState.classList.remove('hidden');
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderDropdownResults(data) {
|
|
// Music Videos tab — don't render regular sections
|
|
if (_activeSearchSource === 'youtube_videos') return;
|
|
|
|
// Determine source badge from active tab (not just primary)
|
|
const displaySource = _activeSearchSource || data.metadata_source || 'spotify';
|
|
const sourceInfo = SOURCE_LABELS[displaySource] || SOURCE_LABELS.spotify;
|
|
const sourceBadge = { text: sourceInfo.text, class: sourceInfo.badgeClass };
|
|
|
|
// Render DB Artists
|
|
renderCompactSection(
|
|
'enh-db-artists-section',
|
|
'enh-db-artists-list',
|
|
'enh-db-artists-count',
|
|
data.db_artists || [],
|
|
(artist) => ({
|
|
image: artist.image_url,
|
|
placeholder: '📚',
|
|
name: artist.name,
|
|
meta: 'In Your Library',
|
|
badge: { text: 'Library', class: 'enh-badge-library' },
|
|
onClick: () => {
|
|
console.log(`🎵 Opening library artist detail: ${artist.name} (ID: ${artist.id})`);
|
|
hideDropdown();
|
|
navigateToArtistDetail(artist.id, artist.name);
|
|
}
|
|
})
|
|
);
|
|
|
|
// Render Artists (source-aware badge)
|
|
renderCompactSection(
|
|
'enh-spotify-artists-section',
|
|
'enh-spotify-artists-list',
|
|
'enh-spotify-artists-count',
|
|
data.spotify_artists || [],
|
|
(artist) => ({
|
|
image: artist.image_url,
|
|
placeholder: '🎤',
|
|
name: artist.name,
|
|
meta: 'Artist',
|
|
badge: sourceBadge,
|
|
onClick: async () => {
|
|
const sourceOverride = _activeSearchSource;
|
|
console.log(`🎵 Opening artist detail: ${artist.name} (ID: ${artist.id}, source: ${sourceOverride})`);
|
|
hideDropdown();
|
|
|
|
// Navigate to Artists page
|
|
navigateToPage('artists');
|
|
|
|
// Small delay to let the page load
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
// Load the artist details with source context
|
|
await selectArtistForDetail(artist, {
|
|
source: sourceOverride,
|
|
plugin: artist.external_urls?.hydrabase_plugin,
|
|
});
|
|
}
|
|
})
|
|
);
|
|
|
|
// Split albums from singles/EPs (albums is the catch-all for unknown types)
|
|
const allAlbums = data.spotify_albums || [];
|
|
const singlesAndEPs = allAlbums.filter(a => a.album_type === 'single' || a.album_type === 'ep');
|
|
const albums = allAlbums.filter(a => a.album_type !== 'single' && a.album_type !== 'ep');
|
|
|
|
// Render Albums
|
|
renderCompactSection(
|
|
'enh-albums-section',
|
|
'enh-albums-list',
|
|
'enh-albums-count',
|
|
albums,
|
|
(album) => ({
|
|
image: album.image_url,
|
|
placeholder: '💿',
|
|
name: album.name,
|
|
meta: `${album.artist} • ${album.release_date ? album.release_date.substring(0, 4) : 'N/A'}`,
|
|
onClick: () => handleEnhancedSearchAlbumClick(album)
|
|
})
|
|
);
|
|
|
|
// Render Singles & EPs
|
|
renderCompactSection(
|
|
'enh-singles-section',
|
|
'enh-singles-list',
|
|
'enh-singles-count',
|
|
singlesAndEPs,
|
|
(album) => ({
|
|
image: album.image_url,
|
|
placeholder: '🎶',
|
|
name: album.name,
|
|
meta: `${album.artist} • ${album.release_date ? album.release_date.substring(0, 4) : 'N/A'}`,
|
|
onClick: () => handleEnhancedSearchAlbumClick(album)
|
|
})
|
|
);
|
|
|
|
// Render Tracks
|
|
renderCompactSection(
|
|
'enh-tracks-section',
|
|
'enh-tracks-list',
|
|
'enh-tracks-count',
|
|
data.spotify_tracks || [],
|
|
(track) => {
|
|
const duration = formatDuration(track.duration_ms);
|
|
return {
|
|
image: track.image_url,
|
|
placeholder: '🎵',
|
|
name: track.name,
|
|
meta: `${track.artist} • ${track.album}`,
|
|
duration: duration,
|
|
onClick: () => handleEnhancedSearchTrackClick(track),
|
|
onPlay: () => streamEnhancedSearchTrack(track)
|
|
};
|
|
}
|
|
);
|
|
|
|
// Lazy load artist images that are missing
|
|
lazyLoadEnhancedSearchArtistImages();
|
|
|
|
// Async library ownership check — doesn't block rendering
|
|
_checkSearchResultsLibraryOwnership(data);
|
|
}
|
|
|
|
async function _checkSearchResultsLibraryOwnership(data) {
|
|
try {
|
|
const allAlbums = data.spotify_albums || [];
|
|
const allTracks = data.spotify_tracks || [];
|
|
if (!allAlbums.length && !allTracks.length) return;
|
|
|
|
const resp = await fetch('/api/enhanced-search/library-check', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
albums: allAlbums.map(a => ({ name: a.name, artist: a.artist })),
|
|
tracks: allTracks.map(t => ({ name: t.name, artist: t.artist })),
|
|
}),
|
|
});
|
|
const result = await resp.json();
|
|
|
|
// Tag album cards with staggered animation
|
|
const albumCards = document.querySelectorAll('#enh-albums-list .enh-compact-item, #enh-singles-list .enh-compact-item');
|
|
const albumResults = result.albums || [];
|
|
let delay = 0;
|
|
albumCards.forEach((card, i) => {
|
|
if (albumResults[i]) {
|
|
setTimeout(() => {
|
|
const badge = document.createElement('div');
|
|
badge.className = 'enh-item-lib-badge';
|
|
badge.textContent = 'In Library';
|
|
card.appendChild(badge);
|
|
}, delay);
|
|
delay += 30;
|
|
}
|
|
});
|
|
|
|
// Tag track rows + wire up library playback
|
|
const trackCards = document.querySelectorAll('#enh-tracks-list .enh-compact-item');
|
|
const trackResults = result.tracks || [];
|
|
trackCards.forEach((card, i) => {
|
|
const tr = trackResults[i];
|
|
if (tr && tr.in_library) {
|
|
setTimeout(() => {
|
|
const badge = document.createElement('div');
|
|
badge.className = 'enh-item-lib-badge';
|
|
badge.textContent = 'In Library';
|
|
card.appendChild(badge);
|
|
|
|
// Replace stream button to play from library instead of searching
|
|
if (tr.file_path) {
|
|
const playBtn = card.querySelector('.enh-item-play-btn');
|
|
if (playBtn) {
|
|
const newBtn = playBtn.cloneNode(true);
|
|
newBtn.title = 'Play from library';
|
|
newBtn.textContent = '▶';
|
|
const trackInfo = tr;
|
|
newBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
playLibraryTrack(
|
|
{ id: trackInfo.track_id, title: trackInfo.title, file_path: trackInfo.file_path, _stats_image: trackInfo.album_thumb_url || null },
|
|
trackInfo.album_title || '',
|
|
trackInfo.artist_name || ''
|
|
);
|
|
});
|
|
playBtn.replaceWith(newBtn);
|
|
}
|
|
}
|
|
}, delay);
|
|
delay += 30;
|
|
} else if (tr && tr.in_wishlist) {
|
|
setTimeout(() => {
|
|
if (!card.querySelector('.enh-item-wishlist-badge')) {
|
|
const badge = document.createElement('div');
|
|
badge.className = 'enh-item-wishlist-badge';
|
|
badge.textContent = 'In Wishlist';
|
|
card.appendChild(badge);
|
|
}
|
|
}, delay);
|
|
delay += 30;
|
|
}
|
|
});
|
|
} catch (e) {
|
|
console.debug('Library check failed:', e);
|
|
}
|
|
}
|
|
|
|
function _queueAlternateSourceFetches(alternateSources, query, searchId) {
|
|
if (!Array.isArray(alternateSources) || alternateSources.length === 0) return;
|
|
|
|
// Fetch metadata sources first, then YouTube last so it does not compete
|
|
// with the primary artist/album/track results for early attention.
|
|
const orderedSources = ['spotify', 'itunes', 'deezer', 'discogs', 'musicbrainz', 'hydrabase', 'youtube_videos']
|
|
.filter(src => alternateSources.includes(src) && src !== _activeSearchSource);
|
|
|
|
orderedSources.forEach((src, index) => {
|
|
setTimeout(() => {
|
|
if (!_enhancedSearchData || _enhancedSearchData.searchId !== searchId) return;
|
|
_fetchAlternateSource(src, query, searchId);
|
|
}, index * 150);
|
|
});
|
|
}
|
|
|
|
async function _fetchAlternateSource(sourceName, query, searchId) {
|
|
try {
|
|
if (!_enhancedSearchData || _enhancedSearchData.searchId !== searchId) return;
|
|
|
|
const response = await fetch(`/api/enhanced-search/source/${sourceName}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ query }),
|
|
signal: _altSourceController?.signal,
|
|
});
|
|
if (!response.ok) return;
|
|
if (!_enhancedSearchData || _enhancedSearchData.searchId !== searchId) return;
|
|
|
|
// Stream NDJSON — render each search type (artists, albums, tracks) as it arrives
|
|
if (!_enhancedSearchData.sources[sourceName]) {
|
|
const loadingSet = sourceName === 'youtube_videos' ? new Set(['videos']) : new Set(['artists', 'albums', 'tracks']);
|
|
_enhancedSearchData.sources[sourceName] = { artists: [], albums: [], tracks: [], videos: [], available: true, _loading: loadingSet };
|
|
}
|
|
const sourceData = _enhancedSearchData.sources[sourceName];
|
|
|
|
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 });
|
|
|
|
let newlineIdx;
|
|
while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
|
|
const line = buffer.slice(0, newlineIdx).trim();
|
|
buffer = buffer.slice(newlineIdx + 1);
|
|
if (!line) continue;
|
|
if (!_enhancedSearchData || _enhancedSearchData.searchId !== searchId) return;
|
|
|
|
try {
|
|
const chunk = JSON.parse(line);
|
|
if (chunk.type === 'artists') { sourceData.artists = chunk.data; if (sourceData._loading) sourceData._loading.delete('artists'); }
|
|
else if (chunk.type === 'albums') { sourceData.albums = chunk.data; if (sourceData._loading) sourceData._loading.delete('albums'); }
|
|
else if (chunk.type === 'tracks') { sourceData.tracks = chunk.data; if (sourceData._loading) sourceData._loading.delete('tracks'); }
|
|
else if (chunk.type === 'videos') { sourceData.videos = chunk.data; if (sourceData._loading) sourceData._loading.delete('videos'); }
|
|
else if (chunk.type === 'done') { delete sourceData._loading; break; }
|
|
|
|
// Re-render tabs + content if this is the active source
|
|
if (_enhancedSearchData.primary_source) {
|
|
renderSourceTabs(_enhancedSearchData);
|
|
if (_activeSearchSource === sourceName) {
|
|
window._switchEnhSourceTab(sourceName);
|
|
}
|
|
}
|
|
} catch (parseErr) {
|
|
console.debug(`NDJSON parse error for ${sourceName}:`, parseErr);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Final render
|
|
if (_enhancedSearchData && _enhancedSearchData.searchId === searchId && _enhancedSearchData.primary_source) {
|
|
renderSourceTabs(_enhancedSearchData);
|
|
}
|
|
} catch (e) {
|
|
if (e.name !== 'AbortError') {
|
|
console.debug(`Alternate source ${sourceName} failed:`, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderSourceTabs(data) {
|
|
const tabBar = document.getElementById('enh-source-tabs');
|
|
if (!tabBar) return;
|
|
|
|
const sources = data.sources || {};
|
|
const primary = data.primary_source || 'spotify';
|
|
|
|
// Build tab list: primary first, then alternates sorted alphabetically.
|
|
// Hide completed zero-result sources so the bar stays focused.
|
|
const sourceNames = Object.keys(sources).filter(s => sources[s].available);
|
|
const visibleSources = sourceNames.filter(name => {
|
|
const src = sources[name] || {};
|
|
const count = name === 'youtube_videos'
|
|
? (src.videos?.length || 0)
|
|
: (src.artists?.length || 0) + (src.albums?.length || 0) + (src.tracks?.length || 0);
|
|
const isLoading = !!(src._loading && src._loading.size > 0);
|
|
return isLoading || count > 0 || name === _activeSearchSource;
|
|
});
|
|
if (visibleSources.length <= 1) {
|
|
tabBar.classList.add('hidden');
|
|
tabBar.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
// Primary tab first, then others
|
|
const ordered = [primary, ...visibleSources.filter(s => s !== primary).sort()];
|
|
|
|
tabBar.innerHTML = ordered.map(name => {
|
|
const info = SOURCE_LABELS[name] || { text: name, tabClass: '' };
|
|
const src = sources[name] || {};
|
|
const count = name === 'youtube_videos'
|
|
? (src.videos?.length || 0)
|
|
: (src.artists?.length || 0) + (src.albums?.length || 0) + (src.tracks?.length || 0);
|
|
const isActive = name === _activeSearchSource;
|
|
return `<button class="enh-source-tab ${info.tabClass} ${isActive ? 'active' : ''}"
|
|
onclick="window._switchEnhSourceTab('${name}')"
|
|
data-source="${name}">
|
|
${info.text}<span class="enh-tab-count">(${count})</span>
|
|
</button>`;
|
|
}).join('');
|
|
|
|
tabBar.classList.remove('hidden');
|
|
}
|
|
|
|
// Expose tab switch globally (onclick from HTML)
|
|
window._switchEnhSourceTab = function (sourceName) {
|
|
if (!_enhancedSearchData || !_enhancedSearchData.sources) return;
|
|
const src = _enhancedSearchData.sources[sourceName];
|
|
if (!src) return;
|
|
|
|
_activeSearchSource = sourceName;
|
|
|
|
// Update tab active states
|
|
document.querySelectorAll('.enh-source-tab').forEach(tab => {
|
|
tab.classList.toggle('active', tab.dataset.source === sourceName);
|
|
});
|
|
|
|
// Music Videos tab — render video cards instead of regular sections
|
|
if (sourceName === 'youtube_videos') {
|
|
// Hide ALL regular sections including wrappers
|
|
['enh-db-artists-section', 'enh-spotify-artists-section', 'enh-albums-section', 'enh-singles-section', 'enh-tracks-section'].forEach(id => {
|
|
const el = document.getElementById(id);
|
|
if (el) el.classList.add('hidden');
|
|
});
|
|
// Hide the artists wrapper div too
|
|
const artistsWrapper = document.querySelector('.enh-artists-wrapper');
|
|
if (artistsWrapper) artistsWrapper.style.display = 'none';
|
|
_renderVideoResults(src.videos || []);
|
|
resultsContainer.classList.remove('hidden');
|
|
return;
|
|
}
|
|
|
|
// Hide videos section and restore regular layout when switching to a metadata tab
|
|
const videosSec = document.getElementById('enh-videos-section');
|
|
if (videosSec) videosSec.classList.add('hidden');
|
|
const artistsWrapper = document.querySelector('.enh-artists-wrapper');
|
|
if (artistsWrapper) artistsWrapper.style.display = '';
|
|
|
|
// Build data in the shape renderDropdownResults expects
|
|
const viewData = {
|
|
db_artists: _enhancedSearchData.db_artists,
|
|
spotify_artists: src.artists || [],
|
|
spotify_albums: src.albums || [],
|
|
spotify_tracks: src.tracks || [],
|
|
metadata_source: sourceName,
|
|
};
|
|
|
|
renderDropdownResults(viewData);
|
|
resultsContainer.classList.remove('hidden');
|
|
|
|
// Show loading spinners for categories still streaming
|
|
if (src._loading && src._loading.size > 0) {
|
|
const loadingHtml = '<div class="enh-section-loading"><div class="server-search-spinner" style="width:16px;height:16px"></div><span>Loading...</span></div>';
|
|
if (src._loading.has('artists')) {
|
|
const sec = document.getElementById('enh-spotify-artists-section');
|
|
if (sec) { sec.classList.remove('hidden'); document.getElementById('enh-spotify-artists-list').innerHTML = loadingHtml; }
|
|
}
|
|
if (src._loading.has('albums')) {
|
|
const sec = document.getElementById('enh-albums-section');
|
|
if (sec) { sec.classList.remove('hidden'); document.getElementById('enh-albums-list').innerHTML = loadingHtml; }
|
|
const sec2 = document.getElementById('enh-singles-section');
|
|
if (sec2) { sec2.classList.remove('hidden'); document.getElementById('enh-singles-list').innerHTML = loadingHtml; }
|
|
}
|
|
if (src._loading.has('tracks')) {
|
|
const sec = document.getElementById('enh-tracks-section');
|
|
if (sec) { sec.classList.remove('hidden'); document.getElementById('enh-tracks-list').innerHTML = loadingHtml; }
|
|
}
|
|
}
|
|
};
|
|
|
|
function _renderVideoResults(videos) {
|
|
let section = document.getElementById('enh-videos-section');
|
|
if (!section) {
|
|
// Create the section dynamically if it doesn't exist
|
|
const container = document.getElementById('enhanced-results-container');
|
|
if (!container) return;
|
|
section = document.createElement('div');
|
|
section.id = 'enh-videos-section';
|
|
section.className = 'enh-dropdown-section';
|
|
section.innerHTML = `
|
|
<div class="enh-section-header">
|
|
<span class="enh-section-icon">🎬</span>
|
|
<h4 class="enh-section-title">Music Videos</h4>
|
|
<span class="enh-section-count" id="enh-videos-count">0</span>
|
|
</div>
|
|
<div class="enh-video-grid" id="enh-videos-list"></div>
|
|
`;
|
|
container.appendChild(section);
|
|
}
|
|
|
|
section.classList.remove('hidden');
|
|
const countEl = document.getElementById('enh-videos-count');
|
|
const listEl = document.getElementById('enh-videos-list');
|
|
if (countEl) countEl.textContent = videos.length;
|
|
|
|
if (!videos.length) {
|
|
listEl.innerHTML = '<div class="enh-empty-state">No music videos found</div>';
|
|
return;
|
|
}
|
|
|
|
listEl.innerHTML = videos.map(v => {
|
|
const duration = v.duration ? `${Math.floor(v.duration / 60)}:${String(v.duration % 60).padStart(2, '0')}` : '';
|
|
const views = v.view_count ? _formatViewCount(v.view_count) : '';
|
|
return `
|
|
<div class="enh-video-card" data-video-id="${v.video_id}" onclick="_downloadMusicVideo(this, ${JSON.stringify(v).replace(/"/g, '"')})">
|
|
<div class="enh-video-thumb">
|
|
<img src="${v.thumbnail}" alt="" loading="lazy" onerror="this.style.display='none'">
|
|
<div class="enh-video-play">▶</div>
|
|
<div class="enh-video-progress-ring hidden">
|
|
<svg viewBox="0 0 36 36">
|
|
<circle class="enh-video-progress-bg" cx="18" cy="18" r="15.5" fill="none" stroke="rgba(255,255,255,0.15)" stroke-width="3"/>
|
|
<circle class="enh-video-progress-bar" cx="18" cy="18" r="15.5" fill="none" stroke="rgb(var(--accent-rgb))" stroke-width="3" stroke-dasharray="97.4" stroke-dashoffset="97.4" stroke-linecap="round" transform="rotate(-90 18 18)"/>
|
|
</svg>
|
|
</div>
|
|
<div class="enh-video-done hidden">✓</div>
|
|
<div class="enh-video-error hidden">✗</div>
|
|
${duration ? `<span class="enh-video-duration">${duration}</span>` : ''}
|
|
</div>
|
|
<div class="enh-video-info">
|
|
<div class="enh-video-title" title="${v.title.replace(/"/g, '"')}">${v.title}</div>
|
|
<div class="enh-video-channel">${v.channel}${views ? ` · ${views} views` : ''}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function _formatViewCount(count) {
|
|
if (count >= 1000000000) return `${(count / 1000000000).toFixed(1)}B`;
|
|
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
|
|
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
|
|
return String(count);
|
|
}
|
|
|
|
// Lazy load artist images for enhanced search results
|
|
async function lazyLoadEnhancedSearchArtistImages() {
|
|
const artistLists = [
|
|
document.getElementById('enh-db-artists-list'),
|
|
document.getElementById('enh-spotify-artists-list')
|
|
];
|
|
|
|
for (const list of artistLists) {
|
|
if (!list) continue;
|
|
|
|
const cardsNeedingImages = list.querySelectorAll('[data-needs-image="true"]');
|
|
if (cardsNeedingImages.length === 0) continue;
|
|
|
|
console.log(`🖼️ Lazy loading ${cardsNeedingImages.length} artist images in enhanced search`);
|
|
|
|
for (const card of cardsNeedingImages) {
|
|
const artistId = card.dataset.artistId;
|
|
if (!artistId) continue;
|
|
|
|
try {
|
|
const imgUrl = _activeSearchSource && _activeSearchSource !== 'spotify'
|
|
? `/api/artist/${artistId}/image?source=${_activeSearchSource}`
|
|
: `/api/artist/${artistId}/image`;
|
|
const response = await fetch(imgUrl);
|
|
const data = await response.json();
|
|
|
|
if (data.success && data.image_url) {
|
|
// Find the placeholder and replace with image
|
|
const placeholder = card.querySelector('.enh-item-image-placeholder');
|
|
if (placeholder) {
|
|
const img = document.createElement('img');
|
|
img.src = data.image_url;
|
|
img.className = 'enh-item-image artist-image';
|
|
img.alt = card.querySelector('.enh-item-name')?.textContent || 'Artist';
|
|
placeholder.replaceWith(img);
|
|
|
|
// Apply dynamic glow
|
|
extractImageColors(data.image_url, (colors) => {
|
|
applyDynamicGlow(card, colors);
|
|
});
|
|
}
|
|
card.dataset.needsImage = 'false';
|
|
console.log(`✅ Loaded image for artist ${artistId}`);
|
|
}
|
|
} catch (error) {
|
|
console.warn(`⚠️ Failed to load image for artist ${artistId}:`, error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function formatDuration(durationMs) {
|
|
if (!durationMs) return '';
|
|
const totalSeconds = Math.floor(durationMs / 1000);
|
|
const minutes = Math.floor(totalSeconds / 60);
|
|
const seconds = totalSeconds % 60;
|
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
function renderCompactSection(sectionId, listId, countId, items, mapItem) {
|
|
const section = document.getElementById(sectionId);
|
|
const list = document.getElementById(listId);
|
|
const count = document.getElementById(countId);
|
|
|
|
if (!list) return;
|
|
|
|
list.innerHTML = '';
|
|
|
|
if (!items || items.length === 0) {
|
|
section.classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
section.classList.remove('hidden');
|
|
count.textContent = items.length;
|
|
|
|
// Determine type based on section ID
|
|
const isArtist = sectionId.includes('artists');
|
|
const isAlbum = sectionId.includes('albums') || sectionId.includes('singles');
|
|
const isTrack = sectionId.includes('tracks');
|
|
|
|
// Add appropriate grid class to list
|
|
if (isArtist) {
|
|
list.classList.add('enh-artists-grid');
|
|
} else if (isAlbum) {
|
|
list.classList.add('enh-albums-grid');
|
|
} else if (isTrack) {
|
|
list.classList.add('enh-tracks-list');
|
|
}
|
|
|
|
items.forEach(item => {
|
|
const config = mapItem(item);
|
|
const elem = document.createElement('div');
|
|
|
|
// Add appropriate card class
|
|
if (isArtist) {
|
|
elem.className = 'enh-compact-item artist-card';
|
|
// Add data attributes for lazy loading
|
|
if (item.id) {
|
|
elem.dataset.artistId = item.id;
|
|
elem.dataset.needsImage = config.image ? 'false' : 'true';
|
|
}
|
|
} else if (isAlbum) {
|
|
elem.className = 'enh-compact-item album-card';
|
|
} else if (isTrack) {
|
|
elem.className = 'enh-compact-item track-item';
|
|
}
|
|
|
|
// Build image HTML with type-specific classes
|
|
let imageClass = 'enh-item-image';
|
|
let placeholderClass = 'enh-item-image-placeholder';
|
|
|
|
if (isArtist) {
|
|
imageClass += ' artist-image';
|
|
placeholderClass += ' artist-placeholder';
|
|
} else if (isAlbum) {
|
|
imageClass += ' album-cover';
|
|
placeholderClass += ' album-placeholder';
|
|
} else if (isTrack) {
|
|
imageClass += ' track-cover';
|
|
placeholderClass += ' track-placeholder';
|
|
}
|
|
|
|
const imageHtml = config.image
|
|
? `<img src="${escapeHtml(config.image)}" class="${imageClass}" alt="${escapeHtml(config.name)}">`
|
|
: `<div class="${placeholderClass}" data-lazy-image="true">${config.placeholder}</div>`;
|
|
|
|
const badgeHtml = config.badge
|
|
? `<div class="enh-item-badge ${config.badge.class}">${config.badge.text}</div>`
|
|
: '';
|
|
|
|
const durationHtml = config.duration && isTrack
|
|
? `<div class="enh-item-duration">
|
|
${escapeHtml(config.duration)}
|
|
<button class="enh-item-play-btn" title="Stream this track">▶</button>
|
|
</div>`
|
|
: '';
|
|
|
|
elem.innerHTML = `
|
|
${imageHtml}
|
|
<div class="enh-item-info">
|
|
<div class="enh-item-name">${escapeHtml(config.name)}</div>
|
|
<div class="enh-item-meta">${escapeHtml(config.meta)}</div>
|
|
</div>
|
|
${durationHtml}
|
|
${badgeHtml}
|
|
`;
|
|
|
|
elem.addEventListener('click', config.onClick);
|
|
|
|
// Add play button handler for tracks
|
|
if (isTrack && config.onPlay) {
|
|
const playBtn = elem.querySelector('.enh-item-play-btn');
|
|
if (playBtn) {
|
|
playBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation(); // Don't trigger main onClick
|
|
config.onPlay();
|
|
});
|
|
}
|
|
}
|
|
|
|
list.appendChild(elem);
|
|
|
|
// Extract colors from image for dynamic glow effect
|
|
if (config.image) {
|
|
extractImageColors(config.image, (colors) => {
|
|
applyDynamicGlow(elem, colors);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
async function handleEnhancedSearchAlbumClick(album) {
|
|
console.log(`💿 Enhanced search album clicked: ${album.name} by ${album.artist}`);
|
|
|
|
hideDropdown();
|
|
showLoadingOverlay('Loading album...');
|
|
|
|
try {
|
|
// Fetch full album data with tracks — pass source for correct routing
|
|
const albumParams = new URLSearchParams({ name: album.name || '', artist: album.artist || '' });
|
|
if (_activeSearchSource && _activeSearchSource !== 'spotify') {
|
|
albumParams.set('source', _activeSearchSource);
|
|
}
|
|
// Pass Hydrabase plugin origin so server routes to correct client
|
|
if (album.external_urls?.hydrabase_plugin) {
|
|
albumParams.set('plugin', album.external_urls.hydrabase_plugin);
|
|
}
|
|
const response = await fetch(`/api/spotify/album/${album.id}?${albumParams}`);
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 401) {
|
|
throw new Error('Spotify not authenticated. Please check your API settings.');
|
|
}
|
|
throw new Error(`Failed to load album: ${response.status}`);
|
|
}
|
|
|
|
const albumData = await response.json();
|
|
|
|
if (!albumData || !albumData.tracks || albumData.tracks.length === 0) {
|
|
hideLoadingOverlay();
|
|
showToast(`No tracks available for "${album.name}". This release may have been delisted or is not available in your region.`, 'warning');
|
|
return;
|
|
}
|
|
|
|
console.log(`✅ Loaded ${albumData.tracks.length} tracks for ${albumData.name}`);
|
|
|
|
// Create virtual playlist ID for enhanced search albums
|
|
const virtualPlaylistId = `enhanced_search_album_${album.id}`;
|
|
|
|
// Check if modal already exists and show it
|
|
if (activeDownloadProcesses[virtualPlaylistId]) {
|
|
console.log(`📱 Reopening existing modal for ${album.name}`);
|
|
const process = activeDownloadProcesses[virtualPlaylistId];
|
|
if (process.modalElement) {
|
|
if (process.status === 'complete') {
|
|
showToast('Showing previous results. Close this modal to start a new analysis.', 'info');
|
|
}
|
|
process.modalElement.style.display = 'flex';
|
|
hideLoadingOverlay();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Enrich each track with full album object (needed for wishlist functionality)
|
|
const enrichedTracks = albumData.tracks.map(track => ({
|
|
...track,
|
|
album: {
|
|
name: albumData.name,
|
|
id: albumData.id,
|
|
album_type: albumData.album_type || 'album',
|
|
images: albumData.images || [],
|
|
release_date: albumData.release_date,
|
|
total_tracks: albumData.total_tracks
|
|
}
|
|
}));
|
|
|
|
console.log(`📦 Enriched ${enrichedTracks.length} tracks with album metadata`);
|
|
|
|
// Format playlist name
|
|
const playlistName = `[${album.artist}] ${albumData.name}`;
|
|
|
|
// Create artist object for the modal — extract ID from album data
|
|
const firstArtist = (albumData.artists || [])[0] || {};
|
|
const artistObject = {
|
|
id: firstArtist.id || album.id?.split?.('_')?.[0] || '',
|
|
name: firstArtist.name || album.artist,
|
|
image_url: firstArtist.image_url || firstArtist.images?.[0]?.url || '',
|
|
source: _activeSearchSource || '',
|
|
};
|
|
|
|
// Prepare full album object for modal
|
|
const fullAlbumObject = {
|
|
name: albumData.name,
|
|
id: albumData.id,
|
|
album_type: albumData.album_type || 'album',
|
|
images: albumData.images || [],
|
|
release_date: albumData.release_date,
|
|
total_tracks: albumData.total_tracks,
|
|
artists: albumData.artists || [{ name: album.artist }]
|
|
};
|
|
|
|
// Open download missing tracks modal
|
|
await openDownloadMissingModalForArtistAlbum(
|
|
virtualPlaylistId,
|
|
playlistName,
|
|
enrichedTracks,
|
|
fullAlbumObject,
|
|
artistObject,
|
|
false // Don't show loading overlay, we already have one
|
|
);
|
|
|
|
// Register this download in search bubbles
|
|
registerSearchDownload(
|
|
{
|
|
id: album.id,
|
|
name: albumData.name,
|
|
artist: album.artist,
|
|
image_url: albumData.images?.[0]?.url || null,
|
|
images: albumData.images || []
|
|
},
|
|
'album',
|
|
virtualPlaylistId,
|
|
album.artist // artistName for grouping
|
|
);
|
|
|
|
hideLoadingOverlay();
|
|
|
|
} catch (error) {
|
|
hideLoadingOverlay();
|
|
console.error('❌ Error handling enhanced search album click:', error);
|
|
showToast(`Error opening album: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
async function streamEnhancedSearchTrack(track) {
|
|
console.log(`▶️ Stream enhanced search track: ${track.name} by ${track.artist}`);
|
|
|
|
hideDropdown();
|
|
showLoadingOverlay(`Searching for ${track.name}...`);
|
|
|
|
try {
|
|
// Send track metadata to backend for quick slskd search
|
|
const response = await fetch('/api/enhanced-search/stream-track', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
track_name: track.name,
|
|
artist_name: track.artist,
|
|
album_name: track.album,
|
|
duration_ms: track.duration_ms
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
throw new Error(errorData.error || 'Failed to search for track');
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (!data.success || !data.result) {
|
|
throw new Error('No suitable track found');
|
|
}
|
|
|
|
const slskdResult = data.result;
|
|
|
|
// Check if audio format is supported (YouTube/Tidal use encoded filenames, skip check)
|
|
const isStreamingSource = slskdResult.username === 'youtube' || slskdResult.username === 'tidal' || slskdResult.username === 'qobuz' || slskdResult.username === 'hifi';
|
|
if (!isStreamingSource && slskdResult.filename && !isAudioFormatSupported(slskdResult.filename)) {
|
|
const format = getFileExtension(slskdResult.filename);
|
|
hideLoadingOverlay();
|
|
showToast(`Sorry, ${format.toUpperCase()} format is not supported in your browser. Try downloading instead.`, 'error');
|
|
return;
|
|
}
|
|
|
|
console.log(`✅ Found track to stream:`, slskdResult);
|
|
console.log(`🎵 Track details - Username: ${slskdResult.username}, Filename: ${slskdResult.filename}`);
|
|
|
|
hideLoadingOverlay();
|
|
|
|
// Use existing startStream function to play the track
|
|
console.log(`📡 Calling startStream() with result...`);
|
|
await startStream(slskdResult);
|
|
console.log(`✅ startStream() completed`);
|
|
|
|
} catch (error) {
|
|
hideLoadingOverlay();
|
|
console.error('❌ Error streaming enhanced search track:', error);
|
|
showToast(`Failed to stream track: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
async function handleEnhancedSearchTrackClick(track) {
|
|
console.log(`🎵 Enhanced search track clicked: ${track.name} by ${track.artist}`);
|
|
|
|
hideDropdown();
|
|
showLoadingOverlay('Loading track...');
|
|
|
|
try {
|
|
// Create virtual playlist ID for enhanced search tracks
|
|
const virtualPlaylistId = `enhanced_search_track_${track.id}`;
|
|
|
|
// Check if modal already exists and show it
|
|
if (activeDownloadProcesses[virtualPlaylistId]) {
|
|
console.log(`📱 Reopening existing modal for ${track.name}`);
|
|
const process = activeDownloadProcesses[virtualPlaylistId];
|
|
if (process.modalElement) {
|
|
if (process.status === 'complete') {
|
|
showToast('Showing previous results. Close this modal to start a new analysis.', 'info');
|
|
}
|
|
process.modalElement.style.display = 'flex';
|
|
hideLoadingOverlay();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Enrich track with album object (needed for wishlist functionality)
|
|
const enrichedTrack = {
|
|
id: track.id,
|
|
name: track.name,
|
|
artists: [track.artist], // Convert string to array for modal compatibility
|
|
album: {
|
|
name: track.album,
|
|
id: null,
|
|
album_type: 'single',
|
|
images: track.image_url ? [{ url: track.image_url }] : [],
|
|
release_date: track.release_date || null,
|
|
total_tracks: 1
|
|
},
|
|
duration_ms: track.duration_ms,
|
|
popularity: track.popularity || 0,
|
|
preview_url: track.preview_url || null,
|
|
external_urls: track.external_urls || null,
|
|
image_url: track.image_url
|
|
};
|
|
|
|
console.log(`📦 Enriched track with album metadata`);
|
|
|
|
// Format playlist name
|
|
const playlistName = `${track.artist} - ${track.name}`;
|
|
|
|
// Create minimal artist object for the modal
|
|
const artistObject = {
|
|
id: null,
|
|
name: track.artist
|
|
};
|
|
|
|
// Prepare album object for modal (single track)
|
|
const albumObject = {
|
|
name: track.album,
|
|
id: null,
|
|
album_type: 'single',
|
|
images: track.image_url ? [{ url: track.image_url }] : [],
|
|
release_date: track.release_date || null,
|
|
total_tracks: 1,
|
|
artists: [{ name: track.artist }]
|
|
};
|
|
|
|
// Open download missing tracks modal with single track
|
|
await openDownloadMissingModalForArtistAlbum(
|
|
virtualPlaylistId,
|
|
playlistName,
|
|
[enrichedTrack], // Array with single track
|
|
albumObject,
|
|
artistObject,
|
|
false
|
|
);
|
|
|
|
// Register this download in search bubbles
|
|
registerSearchDownload(
|
|
{
|
|
id: track.id,
|
|
name: track.name,
|
|
artist: track.artist,
|
|
image_url: track.image_url,
|
|
images: track.image_url ? [{ url: track.image_url }] : []
|
|
},
|
|
'track',
|
|
virtualPlaylistId,
|
|
track.artist // artistName for grouping
|
|
);
|
|
|
|
hideLoadingOverlay();
|
|
|
|
} catch (error) {
|
|
hideLoadingOverlay();
|
|
console.error('❌ Error handling enhanced search track click:', error);
|
|
showToast(`Error opening track: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
async function searchSlskdFor(type, item) {
|
|
const mainResultsArea = document.getElementById('enhanced-main-results-area');
|
|
if (!mainResultsArea) return;
|
|
|
|
// Show loading in main results area
|
|
mainResultsArea.innerHTML = `
|
|
<div style="text-align: center; padding: 60px 20px; color: rgba(255,255,255,0.7);">
|
|
<div style="width: 40px; height: 40px; margin: 0 auto 16px; border: 3px solid rgba(138,43,226,0.2); border-top-color: rgba(138,43,226,0.8); border-radius: 50%; animation: spin 1s linear infinite;"></div>
|
|
<p>Searching for ${type === 'album' ? 'album' : 'track'}...</p>
|
|
</div>
|
|
`;
|
|
|
|
const query = `${item.artist} ${item.name}`;
|
|
|
|
try {
|
|
const response = await fetch('/api/search', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ query })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.error) {
|
|
showToast(`Search error: ${data.error}`, 'error');
|
|
return;
|
|
}
|
|
|
|
// Filter results
|
|
const filtered = data.results.filter(r => r.result_type === type);
|
|
|
|
// Render slskd results in main area
|
|
renderSlskdInMainArea(filtered, type, item);
|
|
|
|
} catch (error) {
|
|
console.error('Slskd search error:', error);
|
|
showToast('Search failed', 'error');
|
|
mainResultsArea.innerHTML = '<div class="search-results-placeholder"><p>Search failed. Please try again.</p></div>';
|
|
}
|
|
}
|
|
|
|
function renderSlskdInMainArea(results, type, originalItem) {
|
|
const mainResultsArea = document.getElementById('enhanced-main-results-area');
|
|
if (!mainResultsArea) return;
|
|
|
|
if (!results || results.length === 0) {
|
|
mainResultsArea.innerHTML = '<div class="search-results-placeholder"><p>No matches found for this ' + type + '.</p></div>';
|
|
return;
|
|
}
|
|
|
|
// Render results using same style as basic search
|
|
mainResultsArea.innerHTML = results.map(result => {
|
|
const title = type === 'album'
|
|
? `${result.album_title} (${result.tracks ? result.tracks.length : 0} tracks)`
|
|
: result.title;
|
|
|
|
return `
|
|
<div class="result-card">
|
|
<div class="result-card-header">
|
|
<h4 class="result-title">${escapeHtml(title)}</h4>
|
|
<button class="download-result-btn" data-result='${JSON.stringify(result).replace(/'/g, "'")}' data-type="${type}">
|
|
💾 Download
|
|
</button>
|
|
</div>
|
|
<div class="result-meta">
|
|
${result.bitrate ? `<span class="meta-badge">${result.bitrate} kbps</span>` : ''}
|
|
${result.format ? `<span class="meta-badge">${result.format.toUpperCase()}</span>` : ''}
|
|
${result.size ? `<span class="meta-badge">${(result.size / 1024 / 1024).toFixed(1)} MB</span>` : ''}
|
|
${result.username ? `<span class="meta-badge">👤 ${escapeHtml(result.username)}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
// Attach download handlers
|
|
mainResultsArea.querySelectorAll('.download-result-btn').forEach(btn => {
|
|
btn.addEventListener('click', async function () {
|
|
const result = JSON.parse(this.dataset.result);
|
|
const type = this.dataset.type;
|
|
|
|
this.disabled = true;
|
|
this.textContent = 'Downloading...';
|
|
|
|
try {
|
|
const downloadData = type === 'album'
|
|
? { result_type: 'album', tracks: result.tracks || [] }
|
|
: { result_type: 'track', username: result.username, filename: result.filename, size: result.size };
|
|
|
|
const response = await fetch('/api/download', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(downloadData)
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.error) {
|
|
showToast(`Download error: ${data.error}`, 'error');
|
|
this.disabled = false;
|
|
this.innerHTML = '💾 Download';
|
|
} else {
|
|
showToast('Download started!', 'success');
|
|
this.innerHTML = '✅ Added';
|
|
}
|
|
} catch (error) {
|
|
console.error('Download error:', error);
|
|
showToast('Download failed', 'error');
|
|
this.disabled = false;
|
|
this.innerHTML = '💾 Download';
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function showDropdown() {
|
|
const dropdown = document.getElementById('enhanced-dropdown');
|
|
if (dropdown) {
|
|
dropdown.classList.remove('hidden');
|
|
updateToggleButtonState();
|
|
}
|
|
// Hide the page header + search mode toggle to reclaim space
|
|
const header = document.querySelector('#downloads-page .downloads-header');
|
|
const modeToggle = document.querySelector('.search-mode-toggle-container');
|
|
const slskdPlaceholder = document.querySelector('#enhanced-search-section .search-results-container');
|
|
if (header) header.classList.add('enh-results-active-hide');
|
|
if (modeToggle) modeToggle.classList.add('enh-results-active-hide');
|
|
if (slskdPlaceholder) slskdPlaceholder.classList.add('enh-results-active-hide');
|
|
}
|
|
|
|
function hideDropdown() {
|
|
const dropdown = document.getElementById('enhanced-dropdown');
|
|
if (dropdown) {
|
|
dropdown.classList.add('hidden');
|
|
updateToggleButtonState();
|
|
}
|
|
// Restore hidden elements
|
|
const header = document.querySelector('#downloads-page .downloads-header');
|
|
const modeToggle = document.querySelector('.search-mode-toggle-container');
|
|
const slskdPlaceholder = document.querySelector('#enhanced-search-section .search-results-container');
|
|
if (header) header.classList.remove('enh-results-active-hide');
|
|
if (modeToggle) modeToggle.classList.remove('enh-results-active-hide');
|
|
if (slskdPlaceholder) slskdPlaceholder.classList.remove('enh-results-active-hide');
|
|
}
|
|
|
|
function updateToggleButtonState() {
|
|
// Get fresh references
|
|
const btn = document.getElementById('enhanced-search-btn');
|
|
const dropdown = document.getElementById('enhanced-dropdown');
|
|
|
|
if (!btn || !dropdown) return;
|
|
|
|
const btnIcon = btn.querySelector('.btn-icon');
|
|
const btnText = btn.querySelector('.btn-text');
|
|
|
|
if (dropdown.classList.contains('hidden')) {
|
|
// Dropdown is hidden - button should say "Show Results"
|
|
if (btnIcon) btnIcon.textContent = '👁️';
|
|
if (btnText) btnText.textContent = 'Show Results';
|
|
} else {
|
|
// Dropdown is visible - button should say "Hide Results"
|
|
if (btnIcon) btnIcon.textContent = '🙈';
|
|
if (btnText) btnText.textContent = 'Hide Results';
|
|
}
|
|
}
|
|
}
|
|
|
|
async function performSearch() {
|
|
const query = document.getElementById('search-input').value.trim();
|
|
if (!query) {
|
|
showToast('Please enter a search term', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
showLoadingOverlay('Searching...');
|
|
displaySearchResults([]); // Clear previous results
|
|
|
|
const response = await fetch(API.search, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ query })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.error) {
|
|
showToast(`Search error: ${data.error}`, 'error');
|
|
return;
|
|
}
|
|
|
|
searchResults = data.results || [];
|
|
displaySearchResults(searchResults);
|
|
|
|
if (searchResults.length === 0) {
|
|
showToast('No results found', 'error');
|
|
} else {
|
|
showToast(`Found ${searchResults.length} results`, 'success');
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error performing search:', error);
|
|
showToast('Search failed', 'error');
|
|
} finally {
|
|
hideLoadingOverlay();
|
|
}
|
|
}
|
|
|
|
function displaySearchResults(results) {
|
|
const resultsContainer = document.getElementById('search-results');
|
|
|
|
if (!results.length) {
|
|
resultsContainer.innerHTML = '<div class="no-results">No search results</div>';
|
|
return;
|
|
}
|
|
|
|
resultsContainer.innerHTML = results.map((result, index) => {
|
|
const isAlbum = result.type === 'album';
|
|
const sizeText = isAlbum ?
|
|
`${result.track_count || 0} tracks, ${(result.size_mb || 0).toFixed(1)} MB` :
|
|
`${(result.file_size / 1024 / 1024).toFixed(1)} MB, ${result.bitrate || 0}kbps`;
|
|
|
|
return `
|
|
<div class="search-result-item" onclick="selectResult(${index})">
|
|
<div class="result-header">
|
|
<div class="result-info">
|
|
<div class="result-title">${escapeHtml(result.title)}</div>
|
|
<div class="result-artist">${escapeHtml(result.artist)}</div>
|
|
${result.album ? `<div class="result-album">${escapeHtml(result.album)}</div>` : ''}
|
|
</div>
|
|
<div class="result-actions">
|
|
<button class="stream-button" onclick="event.stopPropagation(); streamTrack(${index})">
|
|
▷ Stream
|
|
</button>
|
|
<button class="download-button" onclick="event.stopPropagation(); startDownload(${index})">
|
|
⬇ Download
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="result-details">
|
|
<span class="result-size">${sizeText}</span>
|
|
<span class="result-user">by ${escapeHtml(result.username)}</span>
|
|
${result.quality ? `<span class="result-quality">${escapeHtml(result.quality)}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function selectResult(index) {
|
|
const result = searchResults[index];
|
|
if (!result) return;
|
|
|
|
console.log('Selected result:', result);
|
|
// Could show detailed view or additional actions here
|
|
}
|
|
|
|
|
|
async function startDownload(index) {
|
|
const result = searchResults[index];
|
|
if (!result) return;
|
|
|
|
try {
|
|
const response = await fetch('/api/downloads/start', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(result)
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
showToast('Download started', 'success');
|
|
} else {
|
|
showToast(`Download failed: ${data.error}`, 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error starting download:', error);
|
|
showToast('Failed to start download', 'error');
|
|
}
|
|
}
|
|
|
|
// ===============================
|
|
// PAGE DATA LOADING
|
|
// ===============================
|
|
|
|
async function loadInitialData() {
|
|
try {
|
|
// Load artist bubble state first
|
|
await hydrateArtistBubblesFromSnapshot();
|
|
|
|
// Load search bubble state
|
|
await hydrateSearchBubblesFromSnapshot();
|
|
|
|
// Load discover download state
|
|
await hydrateDiscoverDownloadsFromSnapshot();
|
|
|
|
// Navigate to user's home page (or dashboard for admin)
|
|
const homePage = getProfileHomePage();
|
|
const urlPage = _getPageFromPath();
|
|
const targetPage = (urlPage && urlPage !== 'dashboard' && isPageAllowed(urlPage))
|
|
? urlPage
|
|
: homePage;
|
|
|
|
history.replaceState({ page: targetPage }, '', (targetPage === 'dashboard' ? '/' : '/' + targetPage) + window.location.search + window.location.hash);
|
|
|
|
if (targetPage !== 'dashboard') {
|
|
navigateToPage(targetPage, { skipPushState: true });
|
|
} else {
|
|
await loadDashboardData();
|
|
loadDashboardSyncHistory();
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading initial data:', error);
|
|
}
|
|
}
|
|
|
|
async function loadDashboardData() {
|
|
try {
|
|
const response = await fetch(API.activity);
|
|
const data = await response.json();
|
|
|
|
const activityFeed = document.getElementById('activity-feed');
|
|
if (data.activities && data.activities.length) {
|
|
activityFeed.innerHTML = data.activities.map(activity => `
|
|
<div class="activity-item">
|
|
<span class="activity-time">${activity.time}</span>
|
|
<span class="activity-text">${escapeHtml(activity.text)}</span>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
// Initialize wishlist count when dashboard loads
|
|
await updateWishlistCount();
|
|
|
|
// Start periodic refresh of wishlist count (every 30 seconds, matching GUI behavior)
|
|
stopWishlistCountPolling(); // Ensure no duplicates
|
|
wishlistCountInterval = setInterval(updateWishlistCount, 30000);
|
|
|
|
} catch (error) {
|
|
console.error('Error loading dashboard data:', error);
|
|
}
|
|
}
|
|
|
|
// ===========================================
|
|
|