From 8cee3f0e33e9b93f1c043aa88f13bc6685bde445 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Sun, 24 Aug 2025 17:06:00 -0700 Subject: [PATCH] sync page --- web_server.py | 85 ++++++++- webui/static/script.js | 212 +++++++++++++++++++-- webui/static/style.css | 409 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 688 insertions(+), 18 deletions(-) diff --git a/web_server.py b/web_server.py index 03309721..b2593aeb 100644 --- a/web_server.py +++ b/web_server.py @@ -18,7 +18,7 @@ from flask import Flask, render_template, request, jsonify, redirect, send_file # --- Core Application Imports --- # Import the same core clients and config manager used by the GUI app from config.settings import config_manager -from core.spotify_client import SpotifyClient +from core.spotify_client import SpotifyClient, Playlist as SpotifyPlaylist from core.plex_client import PlexClient from core.jellyfin_client import JellyfinClient from core.soulseek_client import SoulseekClient @@ -26,6 +26,8 @@ from core.tidal_client import TidalClient # Added import for Tidal from core.matching_engine import MusicMatchingEngine from core.database_update_worker import DatabaseUpdateWorker, DatabaseStatsWorker from database.music_database import get_database +from services.sync_service import PlaylistSyncService +from datetime import datetime # --- Flask App Setup --- base_dir = os.path.abspath(os.path.dirname(__file__)) @@ -65,10 +67,11 @@ try: soulseek_client = SoulseekClient() tidal_client = TidalClient() matching_engine = MusicMatchingEngine() + sync_service = PlaylistSyncService(spotify_client, plex_client, soulseek_client, jellyfin_client) print("✅ Core service clients initialized.") except Exception as e: print(f"🔴 FATAL: Error initializing service clients: {e}") - spotify_client = plex_client = jellyfin_client = soulseek_client = tidal_client = matching_engine = None + spotify_client = plex_client = jellyfin_client = soulseek_client = tidal_client = matching_engine = sync_service = None # --- Global Streaming State Management --- # Thread-safe state tracking for streaming functionality @@ -96,6 +99,12 @@ db_update_state = { } db_update_lock = threading.Lock() +# --- Add these globals for the Sync Page --- +sync_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="SyncWorker") +active_sync_workers = {} # Key: playlist_id, Value: Future object +sync_states = {} # Key: playlist_id, Value: dict with progress info +sync_lock = threading.Lock() + # --- Global Matched Downloads Context Management --- # Thread-safe storage for matched download contexts # Key: slskd download ID, Value: dict containing Spotify artist/album data @@ -2378,9 +2387,79 @@ def stop_database_update(): else: return jsonify({"success": False, "error": "No update is currently running."}), 404 +# =============================== +# == SYNC PAGE API == +# =============================== - +def _load_sync_status_file(): + """Helper function to read the sync status JSON file.""" + status_file = os.path.join(project_root, 'storage', 'sync_status.json') + if not os.path.exists(status_file): return {} + try: + with open(status_file, 'r') as f: + content = f.read() + return json.loads(content) if content else {} + except (json.JSONDecodeError, FileNotFoundError): return {} + +@app.route('/api/spotify/playlists', methods=['GET']) +def get_spotify_playlists(): + """Fetches all user playlists from Spotify and enriches them with local sync status.""" + if not spotify_client or not spotify_client.is_authenticated(): + return jsonify({"error": "Spotify not authenticated."}), 401 + try: + playlists = spotify_client.get_user_playlists_metadata_only() + sync_statuses = _load_sync_status_file() + + playlist_data = [] + for p in playlists: + status_info = sync_statuses.get(p.id, {}) + sync_status = "Never Synced" + # Handle snapshot_id safely - may not exist in core Playlist class + playlist_snapshot = getattr(p, 'snapshot_id', '') + if 'last_synced' in status_info: + if playlist_snapshot != status_info.get('snapshot_id'): + sync_status = "Needs Sync" + else: + sync_status = f"Synced: {datetime.fromisoformat(status_info['last_synced']).strftime('%b %d, %H:%M')}" + + playlist_data.append({ + "id": p.id, "name": p.name, "owner": p.owner, + "track_count": p.total_tracks, + "image_url": getattr(p, 'image_url', None), + "sync_status": sync_status, + "snapshot_id": playlist_snapshot + }) + return jsonify(playlist_data) + except Exception as e: + return jsonify({"error": str(e)}), 500 +@app.route('/api/spotify/playlist/', methods=['GET']) +def get_playlist_tracks(playlist_id): + """Fetches full track details for a specific playlist.""" + if not spotify_client or not spotify_client.is_authenticated(): + return jsonify({"error": "Spotify not authenticated."}), 401 + try: + # This reuses the robust track fetching logic from your GUI's sync.py + 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, + 'name': full_playlist.name, + 'description': full_playlist.description, + 'owner': full_playlist.owner, + 'public': full_playlist.public, + 'collaborative': full_playlist.collaborative, + 'track_count': full_playlist.total_tracks, + 'image_url': getattr(full_playlist, 'image_url', None), + 'snapshot_id': getattr(full_playlist, 'snapshot_id', ''), + 'tracks': [{'name': t.name, 'artists': t.artists, 'album': t.album, 'duration_ms': t.duration_ms} for t in full_playlist.tracks] + } + return jsonify(playlist_dict) + except Exception as e: + return jsonify({"error": str(e)}), 500 # --- Main Execution --- diff --git a/webui/static/script.js b/webui/static/script.js index 4bdd53b5..3e156e7c 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -25,6 +25,11 @@ let searchAbortController = null; let dbStatsInterval = null; let dbUpdateStatusInterval = null; +// --- Add these globals for the Sync Page --- +let spotifyPlaylists = []; +let selectedPlaylists = new Set(); +let activeSyncPollers = {}; // Key: playlist_id, Value: intervalId + // API endpoints const API = { status: '/status', @@ -55,6 +60,7 @@ document.addEventListener('DOMContentLoaded', function() { initializeNavigation(); initializeMediaPlayer(); initializeDonationWidget(); + initializeSyncPage(); // Start periodic updates @@ -1393,25 +1399,201 @@ async function loadDashboardData() { } } +// =========================================== +// == SYNC PAGE SPOTIFY FUNCTIONALITY == +// =========================================== + async function loadSyncData() { + // This is called when the sync page is navigated to. + await loadSpotifyPlaylists(); +} + +async function loadSpotifyPlaylists() { + const container = document.getElementById('spotify-playlist-container'); + const refreshBtn = document.getElementById('spotify-refresh-btn'); + + container.innerHTML = `
🔄 Loading playlists...
`; + refreshBtn.disabled = true; + refreshBtn.textContent = '🔄 Loading...'; + try { - const response = await fetch(API.playlists); - const data = await response.json(); - - const playlistSelector = document.getElementById('playlist-selector'); - if (data.playlists && data.playlists.length) { - playlistSelector.innerHTML = [ - '', - ...data.playlists.map(playlist => - `` - ) - ].join(''); - } else { - playlistSelector.innerHTML = ''; + const response = await fetch('/api/spotify/playlists'); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to fetch playlists'); } + spotifyPlaylists = await response.json(); + renderSpotifyPlaylists(); + } catch (error) { + container.innerHTML = `
❌ Error: ${error.message}
`; + showToast(`Error loading playlists: ${error.message}`, 'error'); + } finally { + refreshBtn.disabled = false; + refreshBtn.textContent = '🔄 Refresh'; + } +} + +function renderSpotifyPlaylists() { + const container = document.getElementById('spotify-playlist-container'); + if (spotifyPlaylists.length === 0) { + container.innerHTML = `
No Spotify playlists found.
`; + return; + } + + container.innerHTML = spotifyPlaylists.map(p => { + let statusClass = 'status-never-synced'; + if (p.sync_status.startsWith('Synced')) statusClass = 'status-synced'; + if (p.sync_status === 'Needs Sync') statusClass = 'status-needs-sync'; + + // This HTML structure creates the interactive playlist cards + return ` +
+
+
+
${escapeHtml(p.name)}
+
+ ${p.track_count} tracks • + ${p.sync_status} +
+
+
+
+ +
+
+
+ `; + }).join(''); +} + +function togglePlaylistSelection(event) { + const card = event.currentTarget; + const playlistId = card.dataset.playlistId; + + // Don't toggle if clicking the button + if (event.target.tagName === 'BUTTON') return; + + const isSelected = !card.classList.contains('selected'); + card.classList.toggle('selected', isSelected); + + if (isSelected) { + selectedPlaylists.add(playlistId); + } else { + selectedPlaylists.delete(playlistId); + } + updateSyncActionsUI(); +} + +function updateSyncActionsUI() { + const selectionInfo = document.getElementById('selection-info'); + const startSyncBtn = document.getElementById('start-sync-btn'); + const count = selectedPlaylists.size; + + if (count === 0) { + if (selectionInfo) selectionInfo.textContent = 'Select playlists to sync'; + if (startSyncBtn) startSyncBtn.disabled = true; + } else { + if (selectionInfo) selectionInfo.textContent = `${count} playlist${count > 1 ? 's' : ''} selected`; + if (startSyncBtn) startSyncBtn.disabled = false; + } +} + +async function openPlaylistDetailsModal(event, playlistId) { + event.stopPropagation(); + + const playlist = spotifyPlaylists.find(p => p.id === playlistId); + if (!playlist) return; + + showLoadingOverlay(`Fetching tracks for ${playlist.name}...`); + try { + const response = await fetch(`/api/spotify/playlist/${playlistId}`); + const fullPlaylist = await response.json(); + if (fullPlaylist.error) throw new Error(fullPlaylist.error); + + showPlaylistDetailsModal(fullPlaylist); + } catch (error) { - console.error('Error loading sync data:', error); - document.getElementById('playlist-selector').innerHTML = ''; + showToast(`Error: ${error.message}`, 'error'); + } finally { + hideLoadingOverlay(); + } +} + +function showPlaylistDetailsModal(playlist) { + // Create modal if it doesn't exist + let modal = document.getElementById('playlist-details-modal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'playlist-details-modal'; + modal.className = 'modal-overlay'; + document.body.appendChild(modal); + } + + modal.innerHTML = ` + + `; + + modal.style.display = 'flex'; +} + +function closePlaylistDetailsModal() { + const modal = document.getElementById('playlist-details-modal'); + if (modal) { + modal.style.display = 'none'; + } +} + +function formatDuration(ms) { + const minutes = Math.floor(ms / 60000); + const seconds = Math.floor((ms % 60000) / 1000); + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} + +function startPlaylistSyncFromModal(playlistId) { + closePlaylistDetailsModal(); + showToast('Sync functionality will be implemented next!', 'info'); +} + +function initializeSyncPage() { + // Initialize refresh button + const refreshBtn = document.getElementById('spotify-refresh-btn'); + if (refreshBtn) { + refreshBtn.addEventListener('click', loadSpotifyPlaylists); } } diff --git a/webui/static/style.css b/webui/static/style.css index 22c59ee3..3acf797f 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -4056,4 +4056,413 @@ body { font-family: monospace; padding: 8px; resize: none; +} + +/* Playlist Cards Styling - Premium Glassmorphic Design */ +.playlist-card { + /* Premium glassmorphic foundation matching search results */ + background: linear-gradient(135deg, + rgba(26, 26, 26, 0.95) 0%, + rgba(18, 18, 18, 0.98) 100%); + backdrop-filter: blur(12px) saturate(1.1); + border-radius: 20px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-top: 1px solid rgba(255, 255, 255, 0.12); + margin: 12px 8px; + padding: 24px; + cursor: pointer; + position: relative; + + /* Premium shadow effect */ + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.4), + 0 2px 8px rgba(0, 0, 0, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.1); + + /* Smooth transitions */ + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +.playlist-card:hover { + /* Enhanced glassmorphic hover state */ + background: linear-gradient(135deg, + rgba(30, 30, 30, 0.98) 0%, + rgba(22, 22, 22, 1.0) 100%); + backdrop-filter: blur(16px) saturate(1.2); + border-color: rgba(29, 185, 84, 0.3); + border-top-color: rgba(29, 185, 84, 0.4); + + /* More dramatic elevated effect */ + box-shadow: + 0 16px 48px rgba(0, 0, 0, 0.5), + 0 8px 16px rgba(0, 0, 0, 0.3), + 0 0 20px rgba(29, 185, 84, 0.15), + inset 0 1px 0 rgba(255, 255, 255, 0.15); + + transform: translateY(-4px) scale(1.02); +} + +.playlist-card.selected { + /* Selection state with Spotify green accent */ + border-color: rgba(29, 185, 84, 0.5); + border-top-color: rgba(29, 185, 84, 0.6); + background: linear-gradient(135deg, + rgba(29, 185, 84, 0.08) 0%, + rgba(26, 26, 26, 0.98) 50%); + + box-shadow: + 0 12px 36px rgba(0, 0, 0, 0.4), + 0 4px 12px rgba(0, 0, 0, 0.25), + 0 0 24px rgba(29, 185, 84, 0.25), + inset 0 1px 0 rgba(255, 255, 255, 0.12); +} + + +.playlist-card-main { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} + +.playlist-card-content { + flex: 1; + min-width: 0; /* Prevents text overflow issues */ +} + +.playlist-card-name { + font-size: 18px; + font-weight: 600; + color: #ffffff; + margin-bottom: 8px; + line-height: 1.3; +} + +.playlist-card-info { + font-size: 14px; + color: #b3b3b3; + margin-bottom: 8px; +} + +.playlist-card-status { + font-weight: 600; + padding: 6px 12px; + border-radius: 8px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + + /* Glassmorphic badge styling */ + backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: + 0 2px 8px rgba(0, 0, 0, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.1); +} + +.status-never-synced { + background: linear-gradient(135deg, + rgba(128, 128, 128, 0.3) 0%, + rgba(96, 96, 96, 0.4) 100%); + color: #e0e0e0; + border-color: rgba(128, 128, 128, 0.2); +} + +.status-synced { + background: linear-gradient(135deg, + rgba(29, 185, 84, 0.3) 0%, + rgba(24, 158, 72, 0.4) 100%); + color: #1ed760; + border-color: rgba(29, 185, 84, 0.3); + box-shadow: + 0 2px 8px rgba(29, 185, 84, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.1); +} + +.status-needs-sync { + background: linear-gradient(135deg, + rgba(255, 149, 0, 0.3) 0%, + rgba(230, 130, 0, 0.4) 100%); + color: #ffb84d; + border-color: rgba(255, 149, 0, 0.3); + box-shadow: + 0 2px 8px rgba(255, 149, 0, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.1); +} + +.playlist-card-actions { + flex-shrink: 0; + margin-left: 20px; +} + +.playlist-card-actions button { + /* Premium glassmorphic button matching search results */ + background: linear-gradient(135deg, + rgba(29, 185, 84, 0.9) 0%, + rgba(24, 158, 72, 0.95) 100%); + backdrop-filter: blur(8px); + border: 1px solid rgba(29, 185, 84, 0.3); + border-top: 1px solid rgba(29, 185, 84, 0.5); + color: #ffffff; + padding: 10px 20px; + border-radius: 12px; + cursor: pointer; + font-size: 13px; + font-weight: 600; + + /* Premium shadow effect */ + box-shadow: + 0 4px 16px rgba(29, 185, 84, 0.25), + 0 2px 4px rgba(0, 0, 0, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.2); + + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.playlist-card-actions button:hover { + /* Enhanced hover state */ + background: linear-gradient(135deg, + rgba(29, 185, 84, 1.0) 0%, + rgba(24, 158, 72, 1.0) 100%); + border-color: rgba(29, 185, 84, 0.6); + border-top-color: rgba(29, 185, 84, 0.8); + + box-shadow: + 0 6px 20px rgba(29, 185, 84, 0.35), + 0 3px 6px rgba(0, 0, 0, 0.25), + inset 0 1px 0 rgba(255, 255, 255, 0.25); + + transform: translateY(-2px); +} + +.sync-progress-indicator { + font-size: 12px; + color: #1db954; + margin-top: 8px; + display: none; +} + +/* Playlist Details Modal - Clean Premium Design */ +.playlist-modal { + max-width: 800px; + width: 90%; + max-height: 85vh; + display: flex; + flex-direction: column; + + /* Premium glassmorphic foundation */ + background: linear-gradient(135deg, + rgba(20, 20, 20, 0.95) 0%, + rgba(12, 12, 12, 0.98) 100%); + backdrop-filter: blur(20px) saturate(1.2); + border-radius: 20px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-top: 1px solid rgba(255, 255, 255, 0.18); + + /* Premium shadow effect */ + box-shadow: + 0 20px 60px rgba(0, 0, 0, 0.6), + 0 8px 32px rgba(0, 0, 0, 0.4), + 0 0 40px rgba(29, 185, 84, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.15); + + overflow: hidden; +} + +.playlist-modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 24px 28px 16px 28px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.playlist-header-content h2 { + color: #ffffff; + font-size: 22px; + font-weight: 700; + margin: 0 0 8px 0; + line-height: 1.2; +} + +.playlist-quick-info { + display: flex; + gap: 16px; + font-size: 14px; +} + +.playlist-track-count { + color: #1ed760; + font-weight: 600; +} + +.playlist-owner { + color: #b3b3b3; +} + +.playlist-modal-close { + color: #b3b3b3; + font-size: 28px; + cursor: pointer; + line-height: 1; + transition: all 0.2s ease; + padding: 4px; + margin: -4px; +} + +.playlist-modal-close:hover { + color: #ffffff; + transform: scale(1.1); +} + +.playlist-modal-body { + flex: 1; + padding: 0 28px; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.playlist-description { + color: #e0e0e0; + font-size: 14px; + line-height: 1.5; + margin-bottom: 20px; + padding: 16px; + background: rgba(29, 185, 84, 0.05); + border-radius: 12px; + border-left: 3px solid #1ed760; +} + +.playlist-tracks-container { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.playlist-tracks-list { + flex: 1; + overflow-y: auto; + margin: 0; /* Negative margin for better scrolling */ + padding: 0 12px; +} + + /* Custom scrollbar for playlist tracks list */ +*::-webkit-scrollbar { + width: 12px; +} + +*::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.1); + border-radius: 6px; +} + +*::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.3); + border-radius: 6px; + border: 2px solid rgba(255, 255, 255, 0.1); +} + +*::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.5); +} + +.playlist-track-item { + display: flex; + align-items: center; + padding: 12px 16px; + border-radius: 8px; + margin-bottom: 2px; + transition: all 0.2s ease; +} + +.playlist-track-item:hover { + background: rgba(255, 255, 255, 0.05); +} + +.playlist-track-number { + width: 32px; + text-align: center; + color: #666666; + font-size: 13px; + font-weight: 500; +} + +.playlist-track-info { + flex: 1; + margin-left: 16px; + min-width: 0; +} + +.playlist-track-name { + color: #ffffff; + font-size: 14px; + font-weight: 500; + margin-bottom: 3px; + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.playlist-track-artists { + color: #b3b3b3; + font-size: 12px; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.playlist-track-duration { + color: #b3b3b3; + font-size: 12px; + font-weight: 500; + margin-left: 16px; + min-width: 40px; + text-align: right; +} + +.playlist-modal-footer { + padding: 20px 28px 24px 28px; + border-top: 1px solid rgba(255, 255, 255, 0.08); + display: flex; + gap: 12px; + justify-content: flex-end; +} + +.playlist-modal-btn { + padding: 12px 24px; + border-radius: 10px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + border: none; + min-width: 100px; +} + +.playlist-modal-btn-secondary { + background: rgba(255, 255, 255, 0.08); + color: #e0e0e0; + border: 1px solid rgba(255, 255, 255, 0.12); +} + +.playlist-modal-btn-secondary:hover { + background: rgba(255, 255, 255, 0.12); + color: #ffffff; + transform: translateY(-1px); +} + +.playlist-modal-btn-primary { + background: linear-gradient(135deg, #1db954, #1ed760); + color: #000000; + font-weight: 700; + box-shadow: 0 4px 16px rgba(29, 185, 84, 0.3); +} + +.playlist-modal-btn-primary:hover { + transform: translateY(-1px); + box-shadow: 0 6px 20px rgba(29, 185, 84, 0.4); } \ No newline at end of file