discover progress

This commit is contained in:
Broque Thomas 2025-11-11 16:00:37 -08:00
parent 7f1b8cf3a8
commit 65cbf6ce28
5 changed files with 649 additions and 36 deletions

View file

@ -1037,27 +1037,66 @@ class WatchlistScanner:
albums_by_artist[artist].append(album)
# Get tracks from each album, grouped by artist
# Also add these tracks to discovery pool for fast lookup
artist_tracks = {}
artist_track_data = {} # Store full track data for discovery pool
for artist, albums in albums_by_artist.items():
artist_tracks[artist] = []
artist_track_data[artist] = []
for album in albums:
try:
album_data = self.spotify_client.get_album(album['album_spotify_id'])
if album_data and 'tracks' in album_data:
for track in album_data['tracks']['items']:
artist_tracks[artist].append(track['id'])
track_id = track['id']
artist_tracks[artist].append(track_id)
# Store full track data for discovery pool
full_track = {
'id': track_id,
'name': track['name'],
'artists': track.get('artists', []),
'album': album_data,
'duration_ms': track.get('duration_ms', 0)
}
artist_track_data[artist].append(full_track)
except Exception as e:
continue
# Balance by artist - max 6 tracks per artist
balanced_tracks = []
balanced_track_data = []
for artist, tracks in artist_tracks.items():
random.shuffle(tracks)
balanced_tracks.extend(tracks[:6]) # Max 6 per artist
# Shuffle and get indices
indices = list(range(len(tracks)))
random.shuffle(indices)
selected_indices = indices[:6]
# Add selected tracks
for idx in selected_indices:
balanced_tracks.append(tracks[idx])
balanced_track_data.append(artist_track_data[artist][idx])
# Shuffle and limit to 50
random.shuffle(balanced_tracks)
release_radar_tracks = balanced_tracks[:50]
combined = list(zip(balanced_tracks, balanced_track_data))
random.shuffle(combined)
combined = combined[:50]
release_radar_tracks = [track_id for track_id, _ in combined]
release_radar_track_data = [track_data for _, track_data in combined]
# Add Release Radar tracks to discovery pool so they're available for fast lookup
logger.info(f"Adding {len(release_radar_track_data)} Release Radar tracks to discovery pool...")
for track_data in release_radar_track_data:
try:
self.database.add_to_discovery_pool(track_data, is_new_release=True)
except Exception as e:
logger.warning(f"Failed to add track {track_data['name']} to discovery pool: {e}")
continue
self.database.save_curated_playlist('release_radar', release_radar_tracks)
logger.info(f"Release Radar curated: {len(release_radar_tracks)} tracks")

View file

@ -11613,7 +11613,7 @@ def get_playlist_tracks(playlist_id):
full_playlist = spotify_client.get_playlist_by_id(playlist_id)
if not full_playlist:
return jsonify({})
# Convert playlist to dict manually since core class doesn't have to_dict method
playlist_dict = {
'id': full_playlist.id,
@ -11631,6 +11631,33 @@ def get_playlist_tracks(playlist_id):
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/api/spotify/album/<album_id>', methods=['GET'])
def get_album_tracks(album_id):
"""Fetches full track details for a specific album."""
if not spotify_client or not spotify_client.is_authenticated():
return jsonify({"error": "Spotify not authenticated."}), 401
try:
album_data = spotify_client.get_album(album_id)
if not album_data:
return jsonify({"error": "Album not found"}), 404
# Extract tracks from album data
tracks = album_data.get('tracks', {}).get('items', [])
# Format response
album_dict = {
'id': album_data['id'],
'name': album_data['name'],
'artists': album_data.get('artists', []),
'release_date': album_data.get('release_date', ''),
'total_tracks': album_data.get('total_tracks', 0),
'images': album_data.get('images', []),
'tracks': tracks
}
return jsonify(album_dict)
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/api/spotify/search_tracks', methods=['GET'])
def search_spotify_tracks():
@ -14477,33 +14504,32 @@ def get_discover_release_radar():
curated_track_ids = database.get_curated_playlist('release_radar')
if curated_track_ids:
# Fetch track data directly from Spotify (release radar tracks may not be in discovery pool)
# Use curated selection - fetch track data from discovery pool (same as Discovery Weekly)
discovery_tracks = database.get_discovery_pool_tracks(limit=5000, new_releases_only=False)
tracks_by_id = {track.spotify_track_id: track for track in discovery_tracks}
selected_tracks = []
for track_id in curated_track_ids:
try:
# Get track data from Spotify
track_data = spotify_client.get_track_details(track_id)
if track_id in tracks_by_id:
track = tracks_by_id[track_id]
if track_data:
# Get album cover from raw_data (enhanced data doesn't include images)
album_cover = None
if track_data.get('raw_data') and track_data['raw_data'].get('album', {}).get('images'):
album_cover = track_data['raw_data']['album']['images'][0]['url']
# Parse track_data_json if it's a string
track_data = track.track_data_json
if isinstance(track_data, str):
try:
track_data = json.loads(track_data)
except:
track_data = None
selected_tracks.append({
"spotify_track_id": track_data['id'],
"track_name": track_data['name'],
"artist_name": track_data.get('primary_artist', 'Unknown'),
"album_name": track_data['album']['name'] if track_data.get('album') else 'Unknown',
"album_cover_url": album_cover,
"duration_ms": track_data.get('duration_ms', 0),
"track_data_json": track_data.get('raw_data', track_data)
})
except Exception as track_error:
# Skip tracks that fail to fetch
print(f"Error fetching track {track_id}: {track_error}")
continue
selected_tracks.append({
"spotify_track_id": track.spotify_track_id,
"track_name": track.track_name,
"artist_name": track.artist_name,
"album_name": track.album_name,
"album_cover_url": track.album_cover_url,
"duration_ms": track.duration_ms,
"track_data_json": track_data # Now properly parsed
})
return jsonify({"success": True, "tracks": selected_tracks})
@ -14534,6 +14560,15 @@ def get_discover_weekly():
for track_id in curated_track_ids:
if track_id in tracks_by_id:
track = tracks_by_id[track_id]
# Parse track_data_json if it's a string
track_data = track.track_data_json
if isinstance(track_data, str):
try:
track_data = json.loads(track_data)
except:
track_data = None
selected_tracks.append({
"spotify_track_id": track.spotify_track_id,
"track_name": track.track_name,
@ -14541,7 +14576,7 @@ def get_discover_weekly():
"album_name": track.album_name,
"album_cover_url": track.album_cover_url,
"duration_ms": track.duration_ms,
"track_data_json": track.track_data_json
"track_data_json": track_data # Now properly parsed
})
return jsonify({"success": True, "tracks": selected_tracks})

View file

@ -1668,8 +1668,37 @@
<!-- Release Radar Section -->
<div class="discover-section">
<div class="discover-section-header">
<h2 class="discover-section-title">Release Radar</h2>
<p class="discover-section-subtitle">Fresh tracks from new releases</p>
<div>
<h2 class="discover-section-title">Release Radar</h2>
<p class="discover-section-subtitle">Fresh tracks from new releases</p>
</div>
<div class="discover-section-actions">
<button class="action-button secondary" onclick="openDownloadModalForDiscoverPlaylist('release_radar', 'Release Radar')" title="Download missing tracks">
<span class="button-icon"></span>
<span class="button-text">Download</span>
</button>
<button class="action-button primary" id="release-radar-sync-btn" onclick="startDiscoverPlaylistSync('release_radar', 'Release Radar')" 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="release-radar-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="release-radar-sync-total">0</span></span>
<span class="sync-separator">/</span>
<span class="sync-stat"><span id="release-radar-sync-matched">0</span></span>
<span class="sync-separator">/</span>
<span class="sync-stat"><span id="release-radar-sync-failed">0</span></span>
<span class="sync-stat">(<span id="release-radar-sync-percentage">0</span>%)</span>
</div>
</div>
</div>
<div class="discover-playlist-container compact" id="release-radar-playlist">
<!-- Content will be populated dynamically -->
@ -1683,8 +1712,37 @@
<!-- Discovery Weekly Section -->
<div class="discover-section">
<div class="discover-section-header">
<h2 class="discover-section-title">Discovery Weekly</h2>
<p class="discover-section-subtitle">A personalized playlist just for you</p>
<div>
<h2 class="discover-section-title">Discovery Weekly</h2>
<p class="discover-section-subtitle">A personalized playlist just for you</p>
</div>
<div class="discover-section-actions">
<button class="action-button secondary" onclick="openDownloadModalForDiscoverPlaylist('discovery_weekly', 'Discovery Weekly')" title="Download missing tracks">
<span class="button-icon"></span>
<span class="button-text">Download</span>
</button>
<button class="action-button primary" id="discovery-weekly-sync-btn" onclick="startDiscoverPlaylistSync('discovery_weekly', 'Discovery Weekly')" 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="discovery-weekly-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="discovery-weekly-sync-total">0</span></span>
<span class="sync-separator">/</span>
<span class="sync-stat"><span id="discovery-weekly-sync-matched">0</span></span>
<span class="sync-separator">/</span>
<span class="sync-stat"><span id="discovery-weekly-sync-failed">0</span></span>
<span class="sync-stat">(<span id="discovery-weekly-sync-percentage">0</span>%)</span>
</div>
</div>
</div>
<div class="discover-playlist-container compact" id="discovery-weekly-playlist">
<!-- Content will be populated dynamically -->

View file

@ -24213,6 +24213,11 @@ let discoverHeroIndex = 0;
let discoverHeroArtists = [];
let discoverHeroInterval = null;
// Store discover playlist tracks for download/sync functionality
let discoverReleaseRadarTracks = [];
let discoverWeeklyTracks = [];
let discoverRecentAlbums = [];
async function loadDiscoverPage() {
console.log('Loading discover page...');
@ -24224,6 +24229,71 @@ async function loadDiscoverPage() {
loadDiscoverWeekly(),
loadMoreForYou()
]);
// Check for active syncs after page load
checkForActiveDiscoverSyncs();
}
async function checkForActiveDiscoverSyncs() {
// Check if Release Radar sync is active
try {
const releaseRadarResponse = await fetch('/api/sync/status/discover_release_radar');
if (releaseRadarResponse.ok) {
const data = await releaseRadarResponse.json();
if (data.status === 'syncing' || data.status === 'starting') {
console.log('🔄 Resuming Release Radar sync polling after page refresh');
// Show status display
const statusDisplay = document.getElementById('release-radar-sync-status');
if (statusDisplay) {
statusDisplay.style.display = 'block';
}
// Disable button
const syncButton = document.getElementById('release-radar-sync-btn');
if (syncButton) {
syncButton.disabled = true;
syncButton.style.opacity = '0.5';
syncButton.style.cursor = 'not-allowed';
}
// Resume polling
startDiscoverSyncPolling('release_radar', 'discover_release_radar');
}
}
} catch (error) {
// Sync not active, ignore
}
// Check if Discovery Weekly sync is active
try {
const discoveryWeeklyResponse = await fetch('/api/sync/status/discover_discovery_weekly');
if (discoveryWeeklyResponse.ok) {
const data = await discoveryWeeklyResponse.json();
if (data.status === 'syncing' || data.status === 'starting') {
console.log('🔄 Resuming Discovery Weekly sync polling after page refresh');
// Show status display
const statusDisplay = document.getElementById('discovery-weekly-sync-status');
if (statusDisplay) {
statusDisplay.style.display = 'block';
}
// Disable button
const syncButton = document.getElementById('discovery-weekly-sync-btn');
if (syncButton) {
syncButton.disabled = true;
syncButton.style.opacity = '0.5';
syncButton.style.cursor = 'not-allowed';
}
// Resume polling
startDiscoverSyncPolling('discovery_weekly', 'discover_discovery_weekly');
}
}
} catch (error) {
// Sync not active, ignore
}
}
async function loadDiscoverHero() {
@ -24320,12 +24390,15 @@ async function loadDiscoverRecentReleases() {
return;
}
// Store albums for download functionality
discoverRecentAlbums = data.albums;
// Build carousel HTML
let html = '';
data.albums.forEach(album => {
data.albums.forEach((album, index) => {
const coverUrl = album.album_cover_url || '/static/placeholder-album.png';
html += `
<div class="discover-card">
<div class="discover-card" onclick="openDownloadModalForRecentAlbum(${index})" style="cursor: pointer;">
<div class="discover-card-image">
<img src="${coverUrl}" alt="${album.album_name}">
</div>
@ -24367,6 +24440,9 @@ async function loadDiscoverReleaseRadar() {
return;
}
// Store tracks for download/sync functionality
discoverReleaseRadarTracks = data.tracks;
// Build compact playlist HTML
let html = '<div class="discover-playlist-tracks-compact">';
data.tracks.forEach((track, index) => {
@ -24421,6 +24497,9 @@ async function loadDiscoverWeekly() {
return;
}
// Store tracks for download/sync functionality
discoverWeeklyTracks = data.tracks;
// Build compact playlist HTML
let html = '<div class="discover-playlist-tracks-compact">';
data.tracks.forEach((track, index) => {
@ -24563,3 +24642,286 @@ async function loadMoreForYou() {
}
}
}
// ===============================
// DISCOVER PLAYLIST ACTIONS
// ===============================
async function openDownloadModalForDiscoverPlaylist(playlistType, playlistName) {
console.log(`📥 Opening Download Missing Tracks modal for ${playlistName}`);
try {
// Get tracks based on playlist type
let tracks = [];
if (playlistType === 'release_radar') {
tracks = discoverReleaseRadarTracks;
} else if (playlistType === 'discovery_weekly') {
tracks = discoverWeeklyTracks;
}
if (!tracks || tracks.length === 0) {
showToast(`No tracks available for ${playlistName}`, 'warning');
return;
}
// Convert discover tracks to format expected by download modal
const spotifyTracks = tracks.map(track => {
let spotifyTrack;
// Use track_data_json if available, otherwise construct from track data
if (track.track_data_json) {
spotifyTrack = track.track_data_json;
} else {
// Fallback: construct track object from available data
spotifyTrack = {
id: track.spotify_track_id,
name: track.track_name,
artists: [{ name: track.artist_name }],
album: {
name: track.album_name,
images: track.album_cover_url ? [{ url: track.album_cover_url }] : []
},
duration_ms: track.duration_ms || 0
};
}
// Normalize artists to array of strings for modal compatibility
if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) {
spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a);
}
return spotifyTrack;
});
// Create virtual playlist ID
const virtualPlaylistId = `discover_${playlistType}`;
// Use existing modal system (same as YouTube/Tidal playlists)
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks);
} catch (error) {
console.error('Error opening download modal for discover playlist:', error);
showToast(`Failed to open download modal: ${error.message}`, 'error');
hideLoadingOverlay(); // Ensure overlay is hidden on error
}
}
async function startDiscoverPlaylistSync(playlistType, playlistName) {
console.log(`🔄 Starting sync for ${playlistName}`);
// Get tracks based on playlist type
let tracks = [];
if (playlistType === 'release_radar') {
tracks = discoverReleaseRadarTracks;
} else if (playlistType === 'discovery_weekly') {
tracks = discoverWeeklyTracks;
}
if (!tracks || tracks.length === 0) {
showToast(`No tracks available for ${playlistName}`, 'warning');
return;
}
// Convert to format expected by sync API
const spotifyTracks = tracks.map(track => {
let spotifyTrack;
// Use track_data_json if available
if (track.track_data_json) {
spotifyTrack = track.track_data_json;
} else {
// Fallback: construct track object
spotifyTrack = {
id: track.spotify_track_id,
name: track.track_name,
artists: [{ name: track.artist_name }],
album: {
name: track.album_name,
images: track.album_cover_url ? [{ url: track.album_cover_url }] : []
},
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;
});
// Create virtual playlist ID
const virtualPlaylistId = `discover_${playlistType}`;
// Store in cache for sync function
playlistTrackCache[virtualPlaylistId] = spotifyTracks;
// Create virtual playlist object
const virtualPlaylist = {
id: virtualPlaylistId,
name: playlistName,
track_count: spotifyTracks.length
};
// Add to spotify playlists array if not already there
if (!spotifyPlaylists.find(p => p.id === virtualPlaylistId)) {
spotifyPlaylists.push(virtualPlaylist);
}
// Show sync status display
const statusId = playlistType === 'release_radar' ? 'release-radar-sync-status' : 'discovery-weekly-sync-status';
const statusDisplay = document.getElementById(statusId);
if (statusDisplay) {
statusDisplay.style.display = 'block';
}
// Disable sync button to prevent duplicate syncs
const buttonId = playlistType === 'release_radar' ? 'release-radar-sync-btn' : 'discovery-weekly-sync-btn';
const syncButton = document.getElementById(buttonId);
if (syncButton) {
syncButton.disabled = true;
syncButton.style.opacity = '0.5';
syncButton.style.cursor = 'not-allowed';
}
// Start sync using existing function
await startPlaylistSync(virtualPlaylistId);
// Start polling for progress updates
startDiscoverSyncPolling(playlistType, virtualPlaylistId);
}
// Track active discover sync pollers
const discoverSyncPollers = {};
function startDiscoverSyncPolling(playlistType, virtualPlaylistId) {
// Stop any existing poller for this playlist type
if (discoverSyncPollers[playlistType]) {
clearInterval(discoverSyncPollers[playlistType]);
}
console.log(`🔄 Starting sync polling for ${playlistType} (${virtualPlaylistId})`);
// Poll every 500ms for progress updates
discoverSyncPollers[playlistType] = setInterval(async () => {
try {
const response = await fetch(`/api/sync/status/${virtualPlaylistId}`);
if (!response.ok) {
console.log(`⚠️ Sync status response not OK: ${response.status}`);
return;
}
const data = await response.json();
console.log(`📊 Sync status for ${playlistType}:`, data);
// Update UI with progress (data structure: {status: ..., progress: {...}})
const prefix = playlistType === 'release_radar' ? 'release-radar' : 'discovery-weekly';
const progress = data.progress || {};
const totalEl = document.getElementById(`${prefix}-sync-total`);
const matchedEl = document.getElementById(`${prefix}-sync-matched`);
const failedEl = document.getElementById(`${prefix}-sync-failed`);
const percentageEl = document.getElementById(`${prefix}-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 completionPercentage = total > 0 ? Math.round((processed / total) * 100) : 0;
if (totalEl) totalEl.textContent = total;
if (matchedEl) matchedEl.textContent = matched;
if (failedEl) failedEl.textContent = failed;
if (percentageEl) percentageEl.textContent = completionPercentage;
// If complete, stop polling and hide status after delay
if (data.status === 'finished') {
console.log(`✅ Sync complete for ${playlistType}`);
clearInterval(discoverSyncPollers[playlistType]);
delete discoverSyncPollers[playlistType];
// Re-enable sync button
const buttonId = playlistType === 'release_radar' ? 'release-radar-sync-btn' : 'discovery-weekly-sync-btn';
const syncButton = document.getElementById(buttonId);
if (syncButton) {
syncButton.disabled = false;
syncButton.style.opacity = '1';
syncButton.style.cursor = 'pointer';
}
// Show completion toast
showToast(`${playlistType === 'release_radar' ? 'Release Radar' : 'Discovery Weekly'} sync complete!`, 'success');
// Hide status display after 3 seconds
setTimeout(() => {
const statusDisplay = document.getElementById(`${prefix}-sync-status`);
if (statusDisplay) {
statusDisplay.style.display = 'none';
}
}, 3000);
}
} catch (error) {
console.error(`❌ Error polling sync status for ${playlistType}:`, error);
}
}, 500);
}
async function openDownloadModalForRecentAlbum(albumIndex) {
const album = discoverRecentAlbums[albumIndex];
if (!album) {
showToast('Album data not found', 'error');
return;
}
console.log(`📥 Opening Download Missing Tracks modal for album: ${album.album_name}`);
showLoadingOverlay(`Loading tracks for ${album.album_name}...`);
try {
// Fetch album tracks from Spotify API via backend
const response = await fetch(`/api/spotify/album/${album.album_spotify_id}`);
if (!response.ok) {
throw new Error('Failed to fetch album tracks');
}
const albumData = await response.json();
if (!albumData.tracks || albumData.tracks.length === 0) {
throw new Error('No tracks found in album');
}
// Convert to expected format
const spotifyTracks = albumData.tracks.map(track => {
// Normalize artists to array of strings
let artists = track.artists || [{ name: album.artist_name }];
if (Array.isArray(artists)) {
artists = artists.map(a => a.name || a);
}
return {
id: track.id,
name: track.name,
artists: artists,
album: {
name: album.album_name,
images: album.album_cover_url ? [{ url: album.album_cover_url }] : []
},
duration_ms: track.duration_ms || 0
};
});
// Create virtual playlist ID
const virtualPlaylistId = `discover_album_${album.album_spotify_id}`;
const playlistName = `${album.album_name} - ${album.artist_name}`;
// Open download modal
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks);
hideLoadingOverlay();
} catch (error) {
console.error('Error opening album download modal:', error);
showToast(`Failed to load album: ${error.message}`, 'error');
hideLoadingOverlay();
}
}

View file

@ -16256,6 +16256,10 @@ body {
.discover-section-header {
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
}
.discover-section-title {
@ -16271,6 +16275,121 @@ body {
margin: 0;
}
.discover-section-actions {
display: flex;
gap: 10px;
flex-shrink: 0;
}
.discover-section-actions .action-button {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: 6px;
border: none;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.discover-section-actions .action-button .button-icon {
font-size: 16px;
}
.discover-section-actions .action-button.primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
.discover-section-actions .action-button.primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.discover-section-actions .action-button.secondary {
background: rgba(255, 255, 255, 0.05);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.discover-section-actions .action-button.secondary:hover {
background: rgba(255, 255, 255, 0.1);
transform: translateY(-2px);
}
/* Sync Status Display */
.discover-sync-status {
margin: 16px 20px 20px 20px;
padding: 16px 20px;
background: rgba(102, 126, 234, 0.1);
border: 1px solid rgba(102, 126, 234, 0.3);
border-radius: 8px;
animation: slideDown 0.3s ease;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.sync-status-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.sync-status-label {
display: flex;
align-items: center;
gap: 8px;
color: #fff;
font-size: 14px;
font-weight: 500;
}
.sync-icon {
display: inline-block;
font-size: 18px;
animation: spin 1s linear infinite;
transform-origin: center center;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.sync-status-stats {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #fff;
font-family: 'Courier New', monospace;
}
.sync-stat {
font-weight: 600;
}
.sync-separator {
color: rgba(255, 255, 255, 0.3);
font-weight: 300;
}
/* Carousel Styles */
.discover-carousel {
display: flex;