fix sync functionality on discover page for genre playlists
This commit is contained in:
parent
c08df3041e
commit
c28d7ff407
3 changed files with 825 additions and 18 deletions
|
|
@ -2134,34 +2134,36 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Decade Browser Section -->
|
<!-- Time Machine (Tabbed by Decade) -->
|
||||||
<div class="discover-section">
|
<div class="discover-section">
|
||||||
<div class="discover-section-header">
|
<div class="discover-section-header">
|
||||||
<h2 class="discover-section-title">⏰ Time Machine</h2>
|
<h2 class="discover-section-title">⏰ Time Machine</h2>
|
||||||
<p class="discover-section-subtitle">Travel through the decades</p>
|
<p class="discover-section-subtitle">Explore music from different decades</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="discover-carousel" id="decade-browser-carousel">
|
|
||||||
<!-- Content will be populated dynamically -->
|
<!-- Decade Tabs (will be populated dynamically) -->
|
||||||
<div class="discover-loading">
|
<div class="decade-tabs" id="decade-tabs">
|
||||||
<div class="loading-spinner"></div>
|
<div class="discover-loading"><div class="loading-spinner"></div><p>Loading decades...</p></div>
|
||||||
<p>Loading decades...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Decade Tab Contents (will be populated dynamically) -->
|
||||||
|
<div id="decade-tab-contents"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Genre Browser Section -->
|
<!-- Browse by Genre (Tabbed by Genre) -->
|
||||||
<div class="discover-section">
|
<div class="discover-section">
|
||||||
<div class="discover-section-header">
|
<div class="discover-section-header">
|
||||||
<h2 class="discover-section-title">🎵 Browse by Genre</h2>
|
<h2 class="discover-section-title">🎵 Browse by Genre</h2>
|
||||||
<p class="discover-section-subtitle">Explore music by style</p>
|
<p class="discover-section-subtitle">Discover music by your favorite genres</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="discover-carousel" id="genre-browser-carousel">
|
|
||||||
<!-- Content will be populated dynamically -->
|
<!-- Genre Tabs (will be populated dynamically) -->
|
||||||
<div class="discover-loading">
|
<div class="genre-tabs" id="genre-tabs">
|
||||||
<div class="loading-spinner"></div>
|
<div class="discover-loading"><div class="loading-spinner"></div><p>Loading genres...</p></div>
|
||||||
<p>Loading genres...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Genre Tab Contents (will be populated dynamically) -->
|
||||||
|
<div id="genre-tab-contents"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -24738,8 +24738,8 @@ async function loadDiscoverPage() {
|
||||||
loadPersonalizedForgottenFavorites(), // NEW: Forgotten favorites
|
loadPersonalizedForgottenFavorites(), // NEW: Forgotten favorites
|
||||||
loadDiscoveryShuffle(), // NEW: Discovery Shuffle
|
loadDiscoveryShuffle(), // NEW: Discovery Shuffle
|
||||||
loadFamiliarFavorites(), // NEW: Familiar Favorites
|
loadFamiliarFavorites(), // NEW: Familiar Favorites
|
||||||
loadDecadeBrowser(), // Decade browser
|
loadDecadeBrowserTabs(), // Time Machine (tabbed by decade)
|
||||||
loadGenreBrowser() // Genre browser
|
loadGenreBrowserTabs() // Browse by Genre (tabbed by genre)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Check for active syncs after page load
|
// Check for active syncs after page load
|
||||||
|
|
@ -25520,6 +25520,740 @@ async function openGenrePlaylist(genre) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===============================
|
||||||
|
// TIME MACHINE (TABBED BY DECADE)
|
||||||
|
// ===============================
|
||||||
|
|
||||||
|
let decadeTracksCache = {}; // Store tracks for each decade
|
||||||
|
let activeDecade = null;
|
||||||
|
|
||||||
|
async function loadDecadeBrowserTabs() {
|
||||||
|
try {
|
||||||
|
const tabsContainer = document.getElementById('decade-tabs');
|
||||||
|
const contentsContainer = document.getElementById('decade-tab-contents');
|
||||||
|
|
||||||
|
if (!tabsContainer || !contentsContainer) return;
|
||||||
|
|
||||||
|
// Fetch available decades from backend
|
||||||
|
const response = await fetch('/api/discover/decades/available');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch available decades');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.success || !data.decades || data.decades.length === 0) {
|
||||||
|
tabsContainer.innerHTML = '<div class="discover-empty"><p>No decade content available yet. Run a watchlist scan to populate your discovery pool!</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build decade tabs
|
||||||
|
let tabsHTML = '';
|
||||||
|
let contentsHTML = '';
|
||||||
|
|
||||||
|
data.decades.forEach((decade, index) => {
|
||||||
|
const isActive = index === 0;
|
||||||
|
const icon = getDecadeIcon(decade.year);
|
||||||
|
const tabId = `decade-${decade.year}`;
|
||||||
|
|
||||||
|
// Tab button
|
||||||
|
tabsHTML += `
|
||||||
|
<button class="decade-tab ${isActive ? 'active' : ''}"
|
||||||
|
data-decade="${decade.year}"
|
||||||
|
onclick="switchDecadeTab(${decade.year})">
|
||||||
|
${icon} ${decade.year}s
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Tab content
|
||||||
|
contentsHTML += `
|
||||||
|
<div class="decade-tab-content ${isActive ? 'active' : ''}" id="${tabId}-content">
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="decade-actions">
|
||||||
|
<div class="discover-section-header">
|
||||||
|
<div>
|
||||||
|
<h3 style="margin: 0; color: #fff; font-size: 18px;">${decade.year}s Classics</h3>
|
||||||
|
<p id="${tabId}-subtitle" style="margin: 4px 0 0 0; color: #999; font-size: 13px;">${decade.track_count} tracks</p>
|
||||||
|
</div>
|
||||||
|
<div class="discover-section-actions">
|
||||||
|
<button class="action-button secondary" onclick="openDownloadModalForDecade(${decade.year})" title="Download missing tracks">
|
||||||
|
<span class="button-icon">↓</span>
|
||||||
|
<span class="button-text">Download</span>
|
||||||
|
</button>
|
||||||
|
<button class="action-button primary" id="${tabId}-sync-btn" onclick="startDecadeSync(${decade.year})" title="Sync to media server">
|
||||||
|
<span class="button-icon">⟳</span>
|
||||||
|
<span class="button-text">Sync</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sync Status Display -->
|
||||||
|
<div class="discover-sync-status" id="${tabId}-sync-status" style="display: none;">
|
||||||
|
<div class="sync-status-content">
|
||||||
|
<div class="sync-status-label">
|
||||||
|
<span class="sync-icon">⟳</span>
|
||||||
|
<span>Syncing to media server...</span>
|
||||||
|
</div>
|
||||||
|
<div class="sync-status-stats">
|
||||||
|
<span class="sync-stat">✓ <span id="${tabId}-sync-completed">0</span></span>
|
||||||
|
<span class="sync-stat">⏳ <span id="${tabId}-sync-pending">0</span></span>
|
||||||
|
<span class="sync-stat">✗ <span id="${tabId}-sync-failed">0</span></span>
|
||||||
|
<span class="sync-stat">(<span id="${tabId}-sync-percentage">0</span>%)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Track List -->
|
||||||
|
<div class="discover-playlist-container compact" id="${tabId}-playlist">
|
||||||
|
<div class="discover-loading"><div class="loading-spinner"></div><p>Loading ${decade.year}s tracks...</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
tabsContainer.innerHTML = tabsHTML;
|
||||||
|
contentsContainer.innerHTML = contentsHTML;
|
||||||
|
|
||||||
|
// Load first decade's tracks
|
||||||
|
if (data.decades.length > 0) {
|
||||||
|
await loadDecadeTracks(data.decades[0].year);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading decade browser tabs:', error);
|
||||||
|
const tabsContainer = document.getElementById('decade-tabs');
|
||||||
|
if (tabsContainer) {
|
||||||
|
tabsContainer.innerHTML = '<div class="discover-empty"><p>Failed to load decades</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchDecadeTab(decade) {
|
||||||
|
// Update tab buttons
|
||||||
|
const tabs = document.querySelectorAll('.decade-tab');
|
||||||
|
tabs.forEach(tab => {
|
||||||
|
if (parseInt(tab.getAttribute('data-decade')) === decade) {
|
||||||
|
tab.classList.add('active');
|
||||||
|
} else {
|
||||||
|
tab.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update tab content
|
||||||
|
const tabContents = document.querySelectorAll('.decade-tab-content');
|
||||||
|
tabContents.forEach(content => {
|
||||||
|
if (content.id === `decade-${decade}-content`) {
|
||||||
|
content.classList.add('active');
|
||||||
|
} else {
|
||||||
|
content.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load tracks if not already loaded
|
||||||
|
if (!decadeTracksCache[decade]) {
|
||||||
|
loadDecadeTracks(decade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDecadeTracks(decade) {
|
||||||
|
try {
|
||||||
|
const playlistContainer = document.getElementById(`decade-${decade}-playlist`);
|
||||||
|
if (!playlistContainer) return;
|
||||||
|
|
||||||
|
const response = await fetch(`/api/discover/decade/${decade}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch decade playlist');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.success || !data.tracks || data.tracks.length === 0) {
|
||||||
|
playlistContainer.innerHTML = '<div class="discover-empty"><p>No tracks found for the ' + decade + 's</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store tracks in cache
|
||||||
|
decadeTracksCache[decade] = data.tracks;
|
||||||
|
activeDecade = decade;
|
||||||
|
|
||||||
|
// Build compact playlist HTML
|
||||||
|
let html = '<div class="discover-playlist-tracks-compact">';
|
||||||
|
data.tracks.forEach((track, index) => {
|
||||||
|
// Extract track data from track_data_json if available
|
||||||
|
let trackData = track;
|
||||||
|
if (track.track_data_json) {
|
||||||
|
trackData = track.track_data_json;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get track properties with fallbacks
|
||||||
|
const trackName = trackData.name || trackData.track_name || track.track_name || 'Unknown Track';
|
||||||
|
const artistName = trackData.artists?.[0]?.name || trackData.artists?.[0] || trackData.artist_name || track.artist_name || 'Unknown Artist';
|
||||||
|
const albumName = trackData.album?.name || trackData.album_name || track.album_name || 'Unknown Album';
|
||||||
|
const coverUrl = trackData.album?.images?.[0]?.url || track.album_cover_url || '/static/placeholder-album.png';
|
||||||
|
const durationMs = trackData.duration_ms || track.duration_ms || 0;
|
||||||
|
|
||||||
|
const durationMin = Math.floor(durationMs / 60000);
|
||||||
|
const durationSec = Math.floor((durationMs % 60000) / 1000);
|
||||||
|
const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="discover-playlist-track-compact" data-track-index="${index}">
|
||||||
|
<div class="track-compact-number">${index + 1}</div>
|
||||||
|
<div class="track-compact-image">
|
||||||
|
<img src="${coverUrl}" alt="${albumName}">
|
||||||
|
</div>
|
||||||
|
<div class="track-compact-info">
|
||||||
|
<div class="track-compact-name">${trackName}</div>
|
||||||
|
<div class="track-compact-artist">${artistName}</div>
|
||||||
|
</div>
|
||||||
|
<div class="track-compact-album">${albumName}</div>
|
||||||
|
<div class="track-compact-duration">${duration}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
playlistContainer.innerHTML = html;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading decade tracks:', error);
|
||||||
|
const playlistContainer = document.getElementById(`decade-${decade}-playlist`);
|
||||||
|
if (playlistContainer) {
|
||||||
|
playlistContainer.innerHTML = '<div class="discover-empty"><p>Failed to load decade tracks</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startDecadeSync(decade) {
|
||||||
|
const tracks = decadeTracksCache[decade];
|
||||||
|
if (!tracks || tracks.length === 0) {
|
||||||
|
showToast('No tracks available for this decade', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to format expected by sync API
|
||||||
|
const spotifyTracks = tracks.map(track => {
|
||||||
|
// Extract track data from track_data_json if available
|
||||||
|
let trackData = track;
|
||||||
|
if (track.track_data_json) {
|
||||||
|
trackData = track.track_data_json;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build properly formatted Spotify track object
|
||||||
|
let spotifyTrack = {
|
||||||
|
id: trackData.id || track.spotify_track_id,
|
||||||
|
name: trackData.name || trackData.track_name || track.track_name,
|
||||||
|
artists: trackData.artists || [{ name: trackData.artist_name || track.artist_name }],
|
||||||
|
album: trackData.album || {
|
||||||
|
name: trackData.album_name || track.album_name,
|
||||||
|
images: trackData.album?.images || (track.album_cover_url ? [{ url: track.album_cover_url }] : [])
|
||||||
|
},
|
||||||
|
duration_ms: trackData.duration_ms || track.duration_ms || 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Normalize artists to array of strings for sync compatibility
|
||||||
|
if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) {
|
||||||
|
spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a);
|
||||||
|
}
|
||||||
|
|
||||||
|
return spotifyTrack;
|
||||||
|
});
|
||||||
|
|
||||||
|
const virtualPlaylistId = `discover_decade_${decade}`;
|
||||||
|
playlistTrackCache[virtualPlaylistId] = spotifyTracks;
|
||||||
|
|
||||||
|
const virtualPlaylist = {
|
||||||
|
id: virtualPlaylistId,
|
||||||
|
name: `${decade}s Classics`,
|
||||||
|
track_count: spotifyTracks.length
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!spotifyPlaylists.find(p => p.id === virtualPlaylistId)) {
|
||||||
|
spotifyPlaylists.push(virtualPlaylist);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show sync status display
|
||||||
|
const statusDisplay = document.getElementById(`decade-${decade}-sync-status`);
|
||||||
|
if (statusDisplay) statusDisplay.style.display = 'block';
|
||||||
|
|
||||||
|
// Disable sync button
|
||||||
|
const syncButton = document.getElementById(`decade-${decade}-sync-btn`);
|
||||||
|
if (syncButton) {
|
||||||
|
syncButton.disabled = true;
|
||||||
|
syncButton.style.opacity = '0.5';
|
||||||
|
syncButton.style.cursor = 'not-allowed';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start sync
|
||||||
|
await startPlaylistSync(virtualPlaylistId);
|
||||||
|
|
||||||
|
// Start polling
|
||||||
|
startDecadeSyncPolling(decade, virtualPlaylistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDecadeSyncPolling(decade, virtualPlaylistId) {
|
||||||
|
const pollerId = `decade_${decade}`;
|
||||||
|
|
||||||
|
if (discoverSyncPollers[pollerId]) {
|
||||||
|
clearInterval(discoverSyncPollers[pollerId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
discoverSyncPollers[pollerId] = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/sync/status/${virtualPlaylistId}`);
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const progress = data.progress || {};
|
||||||
|
|
||||||
|
const completedEl = document.getElementById(`decade-${decade}-sync-completed`);
|
||||||
|
const pendingEl = document.getElementById(`decade-${decade}-sync-pending`);
|
||||||
|
const failedEl = document.getElementById(`decade-${decade}-sync-failed`);
|
||||||
|
const percentageEl = document.getElementById(`decade-${decade}-sync-percentage`);
|
||||||
|
|
||||||
|
const total = progress.total_tracks || 0;
|
||||||
|
const matched = progress.matched_tracks || 0;
|
||||||
|
const failed = progress.failed_tracks || 0;
|
||||||
|
const processed = matched + failed;
|
||||||
|
const pending = total - processed;
|
||||||
|
const completionPercentage = total > 0 ? Math.round((processed / total) * 100) : 0;
|
||||||
|
|
||||||
|
if (completedEl) completedEl.textContent = matched;
|
||||||
|
if (pendingEl) pendingEl.textContent = pending;
|
||||||
|
if (failedEl) failedEl.textContent = failed;
|
||||||
|
if (percentageEl) percentageEl.textContent = completionPercentage;
|
||||||
|
|
||||||
|
if (data.status === 'finished') {
|
||||||
|
clearInterval(discoverSyncPollers[pollerId]);
|
||||||
|
delete discoverSyncPollers[pollerId];
|
||||||
|
|
||||||
|
const syncButton = document.getElementById(`decade-${decade}-sync-btn`);
|
||||||
|
if (syncButton) {
|
||||||
|
syncButton.disabled = false;
|
||||||
|
syncButton.style.opacity = '1';
|
||||||
|
syncButton.style.cursor = 'pointer';
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(`${decade}s Classics sync complete!`, 'success');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const statusDisplay = document.getElementById(`decade-${decade}-sync-status`);
|
||||||
|
if (statusDisplay) statusDisplay.style.display = 'none';
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error polling sync status for decade ${decade}:`, error);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDownloadModalForDecade(decade) {
|
||||||
|
const tracks = decadeTracksCache[decade];
|
||||||
|
if (!tracks || tracks.length === 0) {
|
||||||
|
showToast('No tracks available for this decade', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to format expected by download modal
|
||||||
|
const spotifyTracks = tracks.map(track => {
|
||||||
|
// Extract track data from track_data_json if available
|
||||||
|
let trackData = track;
|
||||||
|
if (track.track_data_json) {
|
||||||
|
trackData = track.track_data_json;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build properly formatted Spotify track object
|
||||||
|
let spotifyTrack = {
|
||||||
|
id: trackData.id || track.spotify_track_id,
|
||||||
|
name: trackData.name || trackData.track_name || track.track_name,
|
||||||
|
artists: trackData.artists || [{ name: trackData.artist_name || track.artist_name }],
|
||||||
|
album: trackData.album || {
|
||||||
|
name: trackData.album_name || track.album_name,
|
||||||
|
images: trackData.album?.images || (track.album_cover_url ? [{ url: track.album_cover_url }] : [])
|
||||||
|
},
|
||||||
|
duration_ms: trackData.duration_ms || track.duration_ms || 0
|
||||||
|
};
|
||||||
|
|
||||||
|
return spotifyTrack;
|
||||||
|
});
|
||||||
|
|
||||||
|
const playlistName = `${decade}s Classics`;
|
||||||
|
const virtualPlaylistId = `decade_${decade}`;
|
||||||
|
|
||||||
|
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===============================
|
||||||
|
// BROWSE BY GENRE (TABBED BY GENRE)
|
||||||
|
// ===============================
|
||||||
|
|
||||||
|
let genreTracksCache = {}; // Store tracks for each genre
|
||||||
|
let activeGenre = null;
|
||||||
|
let availableGenres = [];
|
||||||
|
|
||||||
|
async function loadGenreBrowserTabs() {
|
||||||
|
try {
|
||||||
|
const tabsContainer = document.getElementById('genre-tabs');
|
||||||
|
const contentsContainer = document.getElementById('genre-tab-contents');
|
||||||
|
|
||||||
|
if (!tabsContainer || !contentsContainer) return;
|
||||||
|
|
||||||
|
// Fetch available genres from backend
|
||||||
|
const response = await fetch('/api/discover/genres/available');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch available genres');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.success || !data.genres || data.genres.length === 0) {
|
||||||
|
tabsContainer.innerHTML = '<div class="discover-empty"><p>No genre content available yet. Run a watchlist scan to populate your discovery pool!</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
availableGenres = data.genres;
|
||||||
|
|
||||||
|
// Build genre tabs (limit to first 8-10 to avoid overcrowding)
|
||||||
|
const displayGenres = data.genres.slice(0, 10);
|
||||||
|
let tabsHTML = '';
|
||||||
|
let contentsHTML = '';
|
||||||
|
|
||||||
|
displayGenres.forEach((genre, index) => {
|
||||||
|
const isActive = index === 0;
|
||||||
|
const icon = getGenreIcon(genre.name);
|
||||||
|
const genreName = genre.name;
|
||||||
|
const genreId = genreName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '');
|
||||||
|
const tabId = `genre-${genreId}`;
|
||||||
|
|
||||||
|
// Tab button
|
||||||
|
tabsHTML += `
|
||||||
|
<button class="genre-tab ${isActive ? 'active' : ''}"
|
||||||
|
data-genre="${escapeHtml(genreName)}"
|
||||||
|
onclick="switchGenreTab('${escapeHtml(genreName)}')">
|
||||||
|
${icon} ${capitalizeGenre(genreName)}
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Tab content
|
||||||
|
contentsHTML += `
|
||||||
|
<div class="genre-tab-content ${isActive ? 'active' : ''}" id="${tabId}-content" data-genre="${escapeHtml(genreName)}">
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="genre-actions">
|
||||||
|
<div class="discover-section-header">
|
||||||
|
<div>
|
||||||
|
<h3 style="margin: 0; color: #fff; font-size: 18px;">${capitalizeGenre(genreName)} Mix</h3>
|
||||||
|
<p id="${tabId}-subtitle" style="margin: 4px 0 0 0; color: #999; font-size: 13px;">${genre.track_count} tracks</p>
|
||||||
|
</div>
|
||||||
|
<div class="discover-section-actions">
|
||||||
|
<button class="action-button secondary" onclick="openDownloadModalForGenre('${escapeHtml(genreName)}')" title="Download missing tracks">
|
||||||
|
<span class="button-icon">↓</span>
|
||||||
|
<span class="button-text">Download</span>
|
||||||
|
</button>
|
||||||
|
<button class="action-button primary" id="${tabId}-sync-btn" onclick="startGenreSync('${escapeHtml(genreName)}')" title="Sync to media server">
|
||||||
|
<span class="button-icon">⟳</span>
|
||||||
|
<span class="button-text">Sync</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sync Status Display -->
|
||||||
|
<div class="discover-sync-status" id="${tabId}-sync-status" style="display: none;">
|
||||||
|
<div class="sync-status-content">
|
||||||
|
<div class="sync-status-label">
|
||||||
|
<span class="sync-icon">⟳</span>
|
||||||
|
<span>Syncing to media server...</span>
|
||||||
|
</div>
|
||||||
|
<div class="sync-status-stats">
|
||||||
|
<span class="sync-stat">✓ <span id="${tabId}-sync-completed">0</span></span>
|
||||||
|
<span class="sync-stat">⏳ <span id="${tabId}-sync-pending">0</span></span>
|
||||||
|
<span class="sync-stat">✗ <span id="${tabId}-sync-failed">0</span></span>
|
||||||
|
<span class="sync-stat">(<span id="${tabId}-sync-percentage">0</span>%)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Track List -->
|
||||||
|
<div class="discover-playlist-container compact" id="${tabId}-playlist">
|
||||||
|
<div class="discover-loading"><div class="loading-spinner"></div><p>Loading ${capitalizeGenre(genreName)} tracks...</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
tabsContainer.innerHTML = tabsHTML;
|
||||||
|
contentsContainer.innerHTML = contentsHTML;
|
||||||
|
|
||||||
|
// Load first genre's tracks
|
||||||
|
if (displayGenres.length > 0) {
|
||||||
|
await loadGenreTracks(displayGenres[0].name);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading genre browser tabs:', error);
|
||||||
|
const tabsContainer = document.getElementById('genre-tabs');
|
||||||
|
if (tabsContainer) {
|
||||||
|
tabsContainer.innerHTML = '<div class="discover-empty"><p>Failed to load genres</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchGenreTab(genreName) {
|
||||||
|
// Update tab buttons
|
||||||
|
const tabs = document.querySelectorAll('.genre-tab');
|
||||||
|
tabs.forEach(tab => {
|
||||||
|
if (tab.getAttribute('data-genre') === genreName) {
|
||||||
|
tab.classList.add('active');
|
||||||
|
} else {
|
||||||
|
tab.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update tab content
|
||||||
|
const tabContents = document.querySelectorAll('.genre-tab-content');
|
||||||
|
tabContents.forEach(content => {
|
||||||
|
if (content.getAttribute('data-genre') === genreName) {
|
||||||
|
content.classList.add('active');
|
||||||
|
} else {
|
||||||
|
content.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load tracks if not already loaded
|
||||||
|
if (!genreTracksCache[genreName]) {
|
||||||
|
loadGenreTracks(genreName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGenreTracks(genreName) {
|
||||||
|
try {
|
||||||
|
const genreId = genreName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '');
|
||||||
|
const playlistContainer = document.getElementById(`genre-${genreId}-playlist`);
|
||||||
|
if (!playlistContainer) return;
|
||||||
|
|
||||||
|
const response = await fetch(`/api/discover/genre/${encodeURIComponent(genreName)}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch genre playlist');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.success || !data.tracks || data.tracks.length === 0) {
|
||||||
|
playlistContainer.innerHTML = `<div class="discover-empty"><p>No tracks found for ${capitalizeGenre(genreName)}</p></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store tracks in cache
|
||||||
|
genreTracksCache[genreName] = data.tracks;
|
||||||
|
activeGenre = genreName;
|
||||||
|
|
||||||
|
// Build compact playlist HTML
|
||||||
|
let html = '<div class="discover-playlist-tracks-compact">';
|
||||||
|
data.tracks.forEach((track, index) => {
|
||||||
|
// Extract track data from track_data_json if available
|
||||||
|
let trackData = track;
|
||||||
|
if (track.track_data_json) {
|
||||||
|
trackData = track.track_data_json;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get track properties with fallbacks
|
||||||
|
const trackName = trackData.name || trackData.track_name || track.track_name || 'Unknown Track';
|
||||||
|
const artistName = trackData.artists?.[0]?.name || trackData.artists?.[0] || trackData.artist_name || track.artist_name || 'Unknown Artist';
|
||||||
|
const albumName = trackData.album?.name || trackData.album_name || track.album_name || 'Unknown Album';
|
||||||
|
const coverUrl = trackData.album?.images?.[0]?.url || track.album_cover_url || '/static/placeholder-album.png';
|
||||||
|
const durationMs = trackData.duration_ms || track.duration_ms || 0;
|
||||||
|
|
||||||
|
const durationMin = Math.floor(durationMs / 60000);
|
||||||
|
const durationSec = Math.floor((durationMs % 60000) / 1000);
|
||||||
|
const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="discover-playlist-track-compact" data-track-index="${index}">
|
||||||
|
<div class="track-compact-number">${index + 1}</div>
|
||||||
|
<div class="track-compact-image">
|
||||||
|
<img src="${coverUrl}" alt="${albumName}">
|
||||||
|
</div>
|
||||||
|
<div class="track-compact-info">
|
||||||
|
<div class="track-compact-name">${trackName}</div>
|
||||||
|
<div class="track-compact-artist">${artistName}</div>
|
||||||
|
</div>
|
||||||
|
<div class="track-compact-album">${albumName}</div>
|
||||||
|
<div class="track-compact-duration">${duration}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
playlistContainer.innerHTML = html;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading genre tracks:', error);
|
||||||
|
const genreId = genreName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '');
|
||||||
|
const playlistContainer = document.getElementById(`genre-${genreId}-playlist`);
|
||||||
|
if (playlistContainer) {
|
||||||
|
playlistContainer.innerHTML = '<div class="discover-empty"><p>Failed to load genre tracks</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startGenreSync(genreName) {
|
||||||
|
const tracks = genreTracksCache[genreName];
|
||||||
|
if (!tracks || tracks.length === 0) {
|
||||||
|
showToast('No tracks available for this genre', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const genreId = genreName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '');
|
||||||
|
|
||||||
|
// Convert to format expected by sync API
|
||||||
|
const spotifyTracks = tracks.map(track => {
|
||||||
|
// Extract track data from track_data_json if available
|
||||||
|
let trackData = track;
|
||||||
|
if (track.track_data_json) {
|
||||||
|
trackData = track.track_data_json;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build properly formatted Spotify track object
|
||||||
|
let spotifyTrack = {
|
||||||
|
id: trackData.id || track.spotify_track_id,
|
||||||
|
name: trackData.name || trackData.track_name || track.track_name,
|
||||||
|
artists: trackData.artists || [{ name: trackData.artist_name || track.artist_name }],
|
||||||
|
album: trackData.album || {
|
||||||
|
name: trackData.album_name || track.album_name,
|
||||||
|
images: trackData.album?.images || (track.album_cover_url ? [{ url: track.album_cover_url }] : [])
|
||||||
|
},
|
||||||
|
duration_ms: trackData.duration_ms || track.duration_ms || 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Normalize artists to array of strings for sync compatibility
|
||||||
|
if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) {
|
||||||
|
spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a);
|
||||||
|
}
|
||||||
|
|
||||||
|
return spotifyTrack;
|
||||||
|
});
|
||||||
|
|
||||||
|
const virtualPlaylistId = `discover_genre_${genreName.replace(/\s+/g, '_')}`;
|
||||||
|
playlistTrackCache[virtualPlaylistId] = spotifyTracks;
|
||||||
|
|
||||||
|
const virtualPlaylist = {
|
||||||
|
id: virtualPlaylistId,
|
||||||
|
name: `${capitalizeGenre(genreName)} Mix`,
|
||||||
|
track_count: spotifyTracks.length
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!spotifyPlaylists.find(p => p.id === virtualPlaylistId)) {
|
||||||
|
spotifyPlaylists.push(virtualPlaylist);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show sync status display
|
||||||
|
const statusDisplay = document.getElementById(`genre-${genreId}-sync-status`);
|
||||||
|
if (statusDisplay) statusDisplay.style.display = 'block';
|
||||||
|
|
||||||
|
// Disable sync button
|
||||||
|
const syncButton = document.getElementById(`genre-${genreId}-sync-btn`);
|
||||||
|
if (syncButton) {
|
||||||
|
syncButton.disabled = true;
|
||||||
|
syncButton.style.opacity = '0.5';
|
||||||
|
syncButton.style.cursor = 'not-allowed';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start sync
|
||||||
|
await startPlaylistSync(virtualPlaylistId);
|
||||||
|
|
||||||
|
// Start polling
|
||||||
|
startGenreSyncPolling(genreName, genreId, virtualPlaylistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startGenreSyncPolling(genreName, genreId, virtualPlaylistId) {
|
||||||
|
const pollerId = `genre_${genreId}`;
|
||||||
|
|
||||||
|
if (discoverSyncPollers[pollerId]) {
|
||||||
|
clearInterval(discoverSyncPollers[pollerId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
discoverSyncPollers[pollerId] = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/sync/status/${virtualPlaylistId}`);
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const progress = data.progress || {};
|
||||||
|
|
||||||
|
const completedEl = document.getElementById(`genre-${genreId}-sync-completed`);
|
||||||
|
const pendingEl = document.getElementById(`genre-${genreId}-sync-pending`);
|
||||||
|
const failedEl = document.getElementById(`genre-${genreId}-sync-failed`);
|
||||||
|
const percentageEl = document.getElementById(`genre-${genreId}-sync-percentage`);
|
||||||
|
|
||||||
|
const total = progress.total_tracks || 0;
|
||||||
|
const matched = progress.matched_tracks || 0;
|
||||||
|
const failed = progress.failed_tracks || 0;
|
||||||
|
const processed = matched + failed;
|
||||||
|
const pending = total - processed;
|
||||||
|
const completionPercentage = total > 0 ? Math.round((processed / total) * 100) : 0;
|
||||||
|
|
||||||
|
if (completedEl) completedEl.textContent = matched;
|
||||||
|
if (pendingEl) pendingEl.textContent = pending;
|
||||||
|
if (failedEl) failedEl.textContent = failed;
|
||||||
|
if (percentageEl) percentageEl.textContent = completionPercentage;
|
||||||
|
|
||||||
|
if (data.status === 'finished') {
|
||||||
|
clearInterval(discoverSyncPollers[pollerId]);
|
||||||
|
delete discoverSyncPollers[pollerId];
|
||||||
|
|
||||||
|
const syncButton = document.getElementById(`genre-${genreId}-sync-btn`);
|
||||||
|
if (syncButton) {
|
||||||
|
syncButton.disabled = false;
|
||||||
|
syncButton.style.opacity = '1';
|
||||||
|
syncButton.style.cursor = 'pointer';
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(`${capitalizeGenre(genreName)} Mix sync complete!`, 'success');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const statusDisplay = document.getElementById(`genre-${genreId}-sync-status`);
|
||||||
|
if (statusDisplay) statusDisplay.style.display = 'none';
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error polling sync status for genre ${genreName}:`, error);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDownloadModalForGenre(genreName) {
|
||||||
|
const tracks = genreTracksCache[genreName];
|
||||||
|
if (!tracks || tracks.length === 0) {
|
||||||
|
showToast('No tracks available for this genre', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to format expected by download modal
|
||||||
|
const spotifyTracks = tracks.map(track => {
|
||||||
|
// Extract track data from track_data_json if available
|
||||||
|
let trackData = track;
|
||||||
|
if (track.track_data_json) {
|
||||||
|
trackData = track.track_data_json;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build properly formatted Spotify track object
|
||||||
|
let spotifyTrack = {
|
||||||
|
id: trackData.id || track.spotify_track_id,
|
||||||
|
name: trackData.name || trackData.track_name || track.track_name,
|
||||||
|
artists: trackData.artists || [{ name: trackData.artist_name || track.artist_name }],
|
||||||
|
album: trackData.album || {
|
||||||
|
name: trackData.album_name || track.album_name,
|
||||||
|
images: trackData.album?.images || (track.album_cover_url ? [{ url: track.album_cover_url }] : [])
|
||||||
|
},
|
||||||
|
duration_ms: trackData.duration_ms || track.duration_ms || 0
|
||||||
|
};
|
||||||
|
|
||||||
|
return spotifyTrack;
|
||||||
|
});
|
||||||
|
|
||||||
|
const playlistName = `${capitalizeGenre(genreName)} Mix`;
|
||||||
|
const virtualPlaylistId = `genre_${genreName.replace(/\s+/g, '_')}`;
|
||||||
|
|
||||||
|
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks);
|
||||||
|
}
|
||||||
|
|
||||||
// ===============================
|
// ===============================
|
||||||
// SEASONAL DISCOVERY
|
// SEASONAL DISCOVERY
|
||||||
// ===============================
|
// ===============================
|
||||||
|
|
|
||||||
|
|
@ -17383,3 +17383,74 @@ body {
|
||||||
transform: scale(1.1) rotate(5deg);
|
transform: scale(1.1) rotate(5deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===============================
|
||||||
|
TIME MACHINE (DECADE TABS)
|
||||||
|
=============================== */
|
||||||
|
|
||||||
|
.decade-tabs,
|
||||||
|
.genre-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-bottom: 2px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding-bottom: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decade-tab,
|
||||||
|
.genre-tab {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #999;
|
||||||
|
padding: 12px 20px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
position: relative;
|
||||||
|
bottom: -2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decade-tab:hover,
|
||||||
|
.genre-tab:hover {
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.decade-tab.active,
|
||||||
|
.genre-tab.active {
|
||||||
|
color: #fff;
|
||||||
|
border-bottom-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decade-tab-content,
|
||||||
|
.genre-tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decade-tab-content.active,
|
||||||
|
.genre-tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decade-actions,
|
||||||
|
.genre-actions {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments for Decade/Genre tabs */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.decade-tabs,
|
||||||
|
.genre-tabs {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decade-tab,
|
||||||
|
.genre-tab {
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue