status updates and album card download status
This commit is contained in:
parent
4ffdf12460
commit
85d7dce943
3 changed files with 297 additions and 12 deletions
|
|
@ -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…
Reference in a new issue