status updates and album card download status

pull/49/head
Broque Thomas 5 months ago
parent 4ffdf12460
commit 85d7dce943

@ -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")

@ -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

@ -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%,

Loading…
Cancel
Save