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>
|
||||
|
||||
<!-- Decade Browser Section -->
|
||||
<!-- Time Machine (Tabbed by Decade) -->
|
||||
<div class="discover-section">
|
||||
<div class="discover-section-header">
|
||||
<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 class="discover-carousel" id="decade-browser-carousel">
|
||||
<!-- Content will be populated dynamically -->
|
||||
<div class="discover-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Loading decades...</p>
|
||||
</div>
|
||||
|
||||
<!-- Decade Tabs (will be populated dynamically) -->
|
||||
<div class="decade-tabs" id="decade-tabs">
|
||||
<div class="discover-loading"><div class="loading-spinner"></div><p>Loading decades...</p></div>
|
||||
</div>
|
||||
|
||||
<!-- Decade Tab Contents (will be populated dynamically) -->
|
||||
<div id="decade-tab-contents"></div>
|
||||
</div>
|
||||
|
||||
<!-- Genre Browser Section -->
|
||||
<!-- Browse by Genre (Tabbed by Genre) -->
|
||||
<div class="discover-section">
|
||||
<div class="discover-section-header">
|
||||
<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 class="discover-carousel" id="genre-browser-carousel">
|
||||
<!-- Content will be populated dynamically -->
|
||||
<div class="discover-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Loading genres...</p>
|
||||
</div>
|
||||
|
||||
<!-- Genre Tabs (will be populated dynamically) -->
|
||||
<div class="genre-tabs" id="genre-tabs">
|
||||
<div class="discover-loading"><div class="loading-spinner"></div><p>Loading genres...</p></div>
|
||||
</div>
|
||||
|
||||
<!-- Genre Tab Contents (will be populated dynamically) -->
|
||||
<div id="genre-tab-contents"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -24738,8 +24738,8 @@ async function loadDiscoverPage() {
|
|||
loadPersonalizedForgottenFavorites(), // NEW: Forgotten favorites
|
||||
loadDiscoveryShuffle(), // NEW: Discovery Shuffle
|
||||
loadFamiliarFavorites(), // NEW: Familiar Favorites
|
||||
loadDecadeBrowser(), // Decade browser
|
||||
loadGenreBrowser() // Genre browser
|
||||
loadDecadeBrowserTabs(), // Time Machine (tabbed by decade)
|
||||
loadGenreBrowserTabs() // Browse by Genre (tabbed by genre)
|
||||
]);
|
||||
|
||||
// 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
|
||||
// ===============================
|
||||
|
|
|
|||
|
|
@ -17383,3 +17383,74 @@ body {
|
|||
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