From 85d7dce9431d8f557003901d2c8e90f33c9b3d43 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Mon, 22 Sep 2025 11:34:39 -0700 Subject: [PATCH] status updates and album card download status --- web_server.py | 66 ++++++++++++-- webui/static/script.js | 194 ++++++++++++++++++++++++++++++++++++++++- webui/static/style.css | 49 ++++++++++- 3 files changed, 297 insertions(+), 12 deletions(-) diff --git a/web_server.py b/web_server.py index 45020fe..d0ace88 100644 --- a/web_server.py +++ b/web_server.py @@ -16,10 +16,14 @@ from pathlib import Path from concurrent.futures import ThreadPoolExecutor, as_completed from flask import Flask, render_template, request, jsonify, redirect, send_file, Response +from utils.logging_config import get_logger # --- Core Application Imports --- # Import the same core clients and config manager used by the GUI app from config.settings import config_manager + +# Initialize logger +logger = get_logger("web_server") from core.spotify_client import SpotifyClient, Playlist as SpotifyPlaylist, Track as SpotifyTrack from core.plex_client import PlexClient from core.jellyfin_client import JellyfinClient @@ -1635,6 +1639,46 @@ def get_recent_toasts(): except Exception as e: return jsonify({'error': str(e)}), 500 +@app.route('/api/logs') +def get_activity_logs(): + """Get formatted activity feed for display in sync page log area""" + try: + with activity_feed_lock: + # Get the last 50 activities (more than the dashboard shows) + recent_activities = activity_feed[-50:] if len(activity_feed) > 50 else activity_feed[:] + + # Reverse order so newest appears at top + recent_activities = recent_activities[::-1] + + # Format activities as readable log entries + formatted_logs = [] + + if not recent_activities: + formatted_logs = [ + "No recent activity.", + "Sync and download operations will appear here in real-time." + ] + else: + for activity in recent_activities: + # Format: [TIME] ICON TITLE - SUBTITLE + timestamp = activity.get('time', 'Unknown') + icon = activity.get('icon', '•') + title = activity.get('title', 'Activity') + subtitle = activity.get('subtitle', '') + + # Create a clean, readable log entry + if subtitle: + log_entry = f"[{timestamp}] {icon} {title} - {subtitle}" + else: + log_entry = f"[{timestamp}] {icon} {title}" + + formatted_logs.append(log_entry) + + return jsonify({'logs': formatted_logs}) + + except Exception as e: + return jsonify({'logs': [f'Error reading activity feed: {str(e)}']}) + def add_activity_item(icon: str, title: str, subtitle: str, time_ago: str = "Now", show_toast: bool = True): """Add activity item to the feed (replicates dashboard.py functionality)""" try: @@ -1928,8 +1972,8 @@ def search_music(): if not query: return jsonify({"error": "No search query provided."}), 400 - print(f"Web UI Search for: '{query}'") - + logger.info(f"Web UI Search initiated for: '{query}'") + # Add activity for search start add_activity_item("🔍", "Search Started", f"'{query}'", "Now") @@ -1989,11 +2033,12 @@ def start_download(): if download_id: started_downloads += 1 except Exception as e: - print(f"Failed to start track download: {e}") + logger.error(f"Failed to start track download: {e}") continue # Add activity for album download start album_name = data.get('album_name', 'Unknown Album') + logger.info(f"📥 Starting album download: '{album_name}' with {started_downloads}/{len(tracks)} tracks") add_activity_item("📥", "Album Download Started", f"'{album_name}' - {started_downloads} tracks", "Now") return jsonify({ @@ -2015,13 +2060,15 @@ def start_download(): if download_id: # Extract track name from filename for activity track_name = filename.split('/')[-1] if '/' in filename else filename.split('\\')[-1] if '\\' in filename else filename + logger.info(f"📥 Starting single track download: '{track_name}'") add_activity_item("📥", "Track Download Started", f"'{track_name}'", "Now") return jsonify({"success": True, "message": "Download started"}) else: + logger.error(f"Failed to start download for: {filename}") return jsonify({"error": "Failed to start download"}), 500 except Exception as e: - print(f"Download error: {e}") + logger.error(f"Download error: {e}") return jsonify({"error": str(e)}), 500 @@ -10147,8 +10194,9 @@ def start_playlist_sync(): # Add activity for sync start add_activity_item("🔄", "Spotify Sync Started", f"'{playlist_name}' - {len(tracks_json)} tracks", "Now") - - print(f"⏱️ [TIMING] Request parsed at {time.strftime('%H:%M:%S')} (took {(time.time()-request_start_time)*1000:.1f}ms)") + + logger.info(f"🔄 Starting playlist sync for '{playlist_name}' with {len(tracks_json)} tracks") + logger.debug(f"Request parsed at {time.strftime('%H:%M:%S')} (took {(time.time()-request_start_time)*1000:.1f}ms)") with sync_lock: if playlist_id in active_sync_workers and not active_sync_workers[playlist_id].done(): @@ -11689,6 +11737,12 @@ def start_oauth_callback_servers(): print("✅ OAuth callback servers started") if __name__ == '__main__': + # Initialize logging for web server + from utils.logging_config import setup_logging + log_level = config_manager.get('logging.level', 'INFO') + log_path = config_manager.get('logging.path', 'logs/app.log') + logger = setup_logging(log_level, log_path) + print("🚀 Starting SoulSync Web UI Server...") print("Open your browser and navigate to http://127.0.0.1:8008") diff --git a/webui/static/script.js b/webui/static/script.js index e84bc3e..aaeebbd 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -373,6 +373,7 @@ async function loadPageData(pageId) { stopDbStatsPolling(); stopDbUpdatePolling(); stopWishlistCountPolling(); + stopLogPolling(); switch (pageId) { case 'dashboard': await loadDashboardData(); @@ -3560,7 +3561,20 @@ async function startMissingTracksProcess(playlistId) { process.status = 'running'; updatePlaylistCardUI(playlistId); updateRefreshButtonState(); - + + // Set album to downloading status if this is an artist album + if (playlistId.startsWith('artist_album_')) { + // Format: artist_album_{artist.id}_{album.id} + const parts = playlistId.split('_'); + if (parts.length >= 4) { + const albumId = parts.slice(3).join('_'); // In case album ID has underscores + const totalTracks = process.tracks ? process.tracks.length : 0; + setAlbumDownloadingStatus(albumId, 0, totalTracks); + console.log(`🔄 Set album ${albumId} to downloading status (0/${totalTracks} tracks)`); + console.log(`🔍 Virtual playlist ID: ${playlistId} → Album ID: ${albumId}`); + } + } + // Update YouTube playlist phase to 'downloading' if this is a YouTube playlist if (playlistId.startsWith('youtube_')) { const urlHash = playlistId.replace('youtube_', ''); @@ -4022,6 +4036,15 @@ function processModalStatusUpdate(playlistId, data) { process.status = 'complete'; updatePlaylistCardUI(playlistId); + // Set album to downloaded status if this is an artist album + if (playlistId.startsWith('artist_album_')) { + const parts = playlistId.split('_'); + if (parts.length >= 4) { + const albumId = parts.slice(3).join('_'); + setTimeout(() => setAlbumDownloadedStatus(albumId), 500); // Small delay to ensure UI updates + } + } + // Show completion message const completionMessage = `Download complete! ${completedCount} downloaded, ${failedOrCancelledCount} failed.`; showToast(completionMessage, 'success'); @@ -8509,6 +8532,9 @@ function initializeSyncPage() { } }); } + + // Initialize live log viewer + initializeLiveLogViewer(); } @@ -10562,8 +10588,8 @@ function updateAlbumCompletionOverlay(completionData, containerType) { } // Remove existing status classes - overlay.classList.remove('checking', 'completed', 'nearly_complete', 'partial', 'missing', 'error'); - + overlay.classList.remove('checking', 'completed', 'nearly_complete', 'partial', 'missing', 'downloading', 'downloaded', 'error'); + // Add new status class overlay.classList.add(completionData.status); @@ -10600,6 +10626,10 @@ function getCompletionStatusText(completionData) { return 'Partial'; case 'missing': return 'Missing'; + case 'downloading': + return 'Downloading...'; + case 'downloaded': + return 'Downloaded'; case 'error': return 'Error'; default: @@ -10607,6 +10637,71 @@ function getCompletionStatusText(completionData) { } } +/** + * Set album to downloaded status after download finishes + */ +function setAlbumDownloadedStatus(albumId) { + console.log(`✅ [DOWNLOAD COMPLETE] Setting album ${albumId} to downloaded status`); + + const completionData = { + id: albumId, + status: 'downloaded', + owned_tracks: 0, + expected_tracks: 0, + name: 'Downloaded', + completion_percentage: 100 + }; + + // Find if it's in albums or singles container + let containerType = 'albums'; + let albumCard = document.querySelector(`#album-cards-container [data-album-id="${albumId}"]`); + if (!albumCard) { + containerType = 'singles'; + albumCard = document.querySelector(`#singles-cards-container [data-album-id="${albumId}"]`); + } + + if (albumCard) { + updateAlbumCompletionOverlay(completionData, containerType); + console.log(`✅ [DOWNLOAD COMPLETE] Album ${albumId} set to Downloaded status`); + } else { + console.warn(`❌ [DOWNLOAD COMPLETE] Album card not found for ID: "${albumId}"`); + } +} + +/** + * Set album to downloading status + */ +function setAlbumDownloadingStatus(albumId, downloaded = 0, total = 0) { + console.log(`🔍 [DOWNLOAD STATUS] Searching for album card with ID: "${albumId}"`); + + const completionData = { + id: albumId, + status: 'downloading', + owned_tracks: downloaded, + expected_tracks: total, + name: 'Downloading', + completion_percentage: Math.round((downloaded / total) * 100) || 0 + }; + + // Find if it's in albums or singles container + let containerType = 'albums'; + let albumCard = document.querySelector(`#album-cards-container [data-album-id="${albumId}"]`); + if (!albumCard) { + containerType = 'singles'; + albumCard = document.querySelector(`#singles-cards-container [data-album-id="${albumId}"]`); + } + + if (albumCard) { + console.log(`✅ [DOWNLOAD STATUS] Found album card in ${containerType} container, updating overlay`); + updateAlbumCompletionOverlay(completionData, containerType); + } else { + console.warn(`❌ [DOWNLOAD STATUS] Album card not found for ID: "${albumId}"`); + // Debug: List all available album cards + const allAlbums = document.querySelectorAll('#album-cards-container [data-album-id], #singles-cards-container [data-album-id]'); + console.log(`🔍 [DEBUG] Available album IDs:`, Array.from(allAlbums).map(card => card.dataset.albumId)); + } +} + /** * Show error state on all completion overlays */ @@ -13020,5 +13115,98 @@ async function checkAndRestoreMetadataUpdateState() { } } +// --- Live Log Viewer Functions --- + +// Global state for log polling +let logPolling = false; +let logInterval = null; +let lastLogCount = 0; + +/** + * Initialize the live log viewer for sync page + */ +function initializeLiveLogViewer() { + const logArea = document.getElementById('sync-log-area'); + if (!logArea) return; + + // Set initial content + logArea.value = 'Loading activity feed...'; + + // Start log polling + startLogPolling(); + + // Initial load + loadLogs(); +} + +/** + * Start polling for logs + */ +function startLogPolling() { + if (logPolling) return; // Already polling + + logPolling = true; + logInterval = setInterval(loadLogs, 3000); // Poll every 3 seconds + console.log('📝 Started activity feed polling for sync page'); +} + +/** + * Stop polling for logs + */ +function stopLogPolling() { + logPolling = false; + if (logInterval) { + clearInterval(logInterval); + logInterval = null; + console.log('📝 Stopped log polling'); + } +} + +/** + * Load and display activity feed as logs + */ +async function loadLogs() { + try { + const response = await fetch('/api/logs'); + const data = await response.json(); + + if (data.logs && Array.isArray(data.logs)) { + const logArea = document.getElementById('sync-log-area'); + if (!logArea) return; + + // Join logs with newlines and update textarea + const logText = data.logs.join('\n'); + + // Store current scroll state + const wasAtTop = logArea.scrollTop <= 10; + const wasUserScrolled = logArea.scrollTop < logArea.scrollHeight - logArea.clientHeight - 10; + + // Update content only if it has changed + if (logArea.value !== logText) { + logArea.value = logText; + + // Smart scrolling: stay at top for new entries, preserve user position if scrolled + if (wasAtTop || !wasUserScrolled) { + logArea.scrollTop = 0; // Stay at top since newest entries are now at top + } + // If user had scrolled, keep their position (browser handles this automatically) + } + } + } catch (error) { + console.warn('Could not load activity logs for sync page:', error); + const logArea = document.getElementById('sync-log-area'); + if (logArea && (logArea.value === 'Loading logs...' || logArea.value === '')) { + logArea.value = 'Error loading activity feed. Check console for details.'; + } + } +} + +/** + * Stop log polling when leaving sync page + */ +function cleanupSyncPageLogs() { + stopLogPolling(); +} + // --- Global Cleanup on Page Unload --- // Note: Automatic wishlist processing now runs server-side and continues even when browser is closed \ No newline at end of file diff --git a/webui/static/style.css b/webui/static/style.css index 637f030..6f91e7d 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -6790,17 +6790,60 @@ body { } .completion-overlay.missing { - background: linear-gradient(135deg, - rgba(108, 117, 125, 0.9) 0%, + background: linear-gradient(135deg, + rgba(108, 117, 125, 0.9) 0%, rgba(73, 80, 87, 0.95) 100%); color: rgba(255, 255, 255, 0.9); border-color: rgba(108, 117, 125, 0.6); - box-shadow: + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(108, 117, 125, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.1); } +.completion-overlay.downloading { + background: linear-gradient(135deg, + rgba(255, 165, 0, 0.9) 0%, + rgba(255, 140, 0, 0.95) 100%); + color: #ffffff; + border-color: rgba(255, 165, 0, 0.6); + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.3), + 0 0 0 1px rgba(255, 165, 0, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.2); + animation: downloadingPulse 2s ease-in-out infinite; +} + +@keyframes downloadingPulse { + 0%, 100% { + transform: scale(1); + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.3), + 0 0 0 1px rgba(255, 165, 0, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.2); + } + 50% { + transform: scale(1.02); + box-shadow: + 0 6px 16px rgba(0, 0, 0, 0.4), + 0 0 0 1px rgba(255, 165, 0, 0.4), + inset 0 1px 0 rgba(255, 255, 255, 0.3), + 0 0 12px rgba(255, 165, 0, 0.3); + } +} + +.completion-overlay.downloaded { + background: linear-gradient(135deg, + rgba(40, 167, 69, 0.9) 0%, + rgba(34, 139, 58, 0.95) 100%); + color: #ffffff; + border-color: rgba(40, 167, 69, 0.6); + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.3), + 0 0 0 1px rgba(40, 167, 69, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.2); +} + .completion-overlay.error { background: linear-gradient(135deg, rgba(220, 53, 69, 0.9) 0%,