sync working

pull/15/head
Broque Thomas 9 months ago
parent 3b0a626b6e
commit 628e8b7709

@ -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, Playlist as SpotifyPlaylist
from core.spotify_client import SpotifyClient, Playlist as SpotifyPlaylist, Track as SpotifyTrack
from core.plex_client import PlexClient
from core.jellyfin_client import JellyfinClient
from core.soulseek_client import SoulseekClient
@ -97,13 +97,13 @@ db_update_state = {
"total": 0,
"error_message": ""
}
db_update_lock = threading.Lock()
# --- Add these globals for the Sync Page ---
# --- Sync Page Globals ---
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()
db_update_lock = threading.Lock()
# --- Global Matched Downloads Context Management ---
# Thread-safe storage for matched download contexts
@ -2455,12 +2455,326 @@ def get_playlist_tracks(playlist_id):
'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]
'tracks': [{'id': t.id, 'name': t.name, 'artists': t.artists, 'album': t.album, 'duration_ms': t.duration_ms, 'popularity': t.popularity} for t in full_playlist.tracks]
}
return jsonify(playlist_dict)
except Exception as e:
return jsonify({"error": str(e)}), 500
# Add these new endpoints to the end of web_server.py
def _run_sync_task(playlist_id, playlist_name, tracks_json):
"""The actual sync function that runs in the background thread."""
global sync_states, sync_service
print(f"🚀 _run_sync_task STARTED for playlist '{playlist_name}' (ID: {playlist_id})")
print(f"📊 Received {len(tracks_json)} tracks from frontend")
try:
# Recreate a Playlist object from the JSON data sent by the frontend
# This avoids needing to re-fetch it from Spotify
print(f"🔄 Converting JSON tracks to SpotifyTrack objects...")
tracks = []
for i, t in enumerate(tracks_json):
# Create SpotifyTrack objects with proper default values for missing fields
track = SpotifyTrack(
id=t.get('id', ''), # Provide default empty string
name=t.get('name', ''),
artists=t.get('artists', []),
album=t.get('album', ''),
duration_ms=t.get('duration_ms', 0),
popularity=t.get('popularity', 0), # Default value
preview_url=t.get('preview_url'),
external_urls=t.get('external_urls')
)
tracks.append(track)
if i < 3: # Log first 3 tracks for debugging
print(f" Track {i+1}: '{track.name}' by {track.artists}")
print(f"✅ Created {len(tracks)} SpotifyTrack objects")
playlist = SpotifyPlaylist(
id=playlist_id,
name=playlist_name,
description=None, # Not needed for sync
owner="web_user", # Placeholder
public=False, # Default
collaborative=False, # Default
tracks=tracks,
total_tracks=len(tracks)
)
print(f"✅ Created SpotifyPlaylist object: '{playlist.name}' with {playlist.total_tracks} tracks")
def progress_callback(progress):
"""Callback to update the shared state."""
print(f"⚡ PROGRESS CALLBACK: {progress.current_step} - {progress.current_track}")
print(f" 📊 Progress: {progress.progress}% ({progress.matched_tracks}/{progress.total_tracks} matched, {progress.failed_tracks} failed)")
with sync_lock:
sync_states[playlist_id] = {
"status": "syncing",
"progress": progress.__dict__ # Convert dataclass to dict
}
print(f" ✅ Updated sync_states for {playlist_id}")
except Exception as setup_error:
print(f"❌ SETUP ERROR in _run_sync_task: {setup_error}")
import traceback
traceback.print_exc()
with sync_lock:
sync_states[playlist_id] = {
"status": "error",
"error": f"Setup error: {str(setup_error)}"
}
return
try:
print(f"🔧 Setting up sync service...")
print(f" sync_service available: {sync_service is not None}")
if sync_service is None:
raise Exception("sync_service is None - not initialized properly")
# Check sync service components
print(f" spotify_client: {sync_service.spotify_client is not None}")
print(f" plex_client: {sync_service.plex_client is not None}")
print(f" jellyfin_client: {sync_service.jellyfin_client is not None}")
# Check media server connection before starting
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
print(f" Active media server: {active_server}")
media_client, server_type = sync_service._get_active_media_client()
print(f" Media client available: {media_client is not None}")
if media_client:
is_connected = media_client.is_connected()
print(f" Media client connected: {is_connected}")
# Check database access
try:
from database.music_database import MusicDatabase
db = MusicDatabase()
print(f" Database initialized: {db is not None}")
except Exception as db_error:
print(f" ❌ Database initialization failed: {db_error}")
print(f"🔄 Attaching progress callback...")
# Attach the progress callback
sync_service.set_progress_callback(progress_callback, playlist.name)
print(f"✅ Progress callback attached for playlist: {playlist.name}")
# CRITICAL FIX: Add database-only fallback for web context
# If media client is not connected, patch the sync service to use database-only matching
if media_client is None or not media_client.is_connected():
print(f"⚠️ Media client not connected - patching sync service for database-only matching")
# Store original method
original_find_track = sync_service._find_track_in_media_server
# Create database-only replacement method
async def database_only_find_track(spotify_track):
print(f"🗃️ Database-only search for: '{spotify_track.name}' by {spotify_track.artists}")
try:
from database.music_database import MusicDatabase
from config.settings import config_manager
db = MusicDatabase()
active_server = config_manager.get_active_media_server()
original_title = spotify_track.name
# Try each artist (same logic as original)
for artist in spotify_track.artists:
artist_name = artist if isinstance(artist, str) else str(artist)
db_track, confidence = db.check_track_exists(
original_title, artist_name,
confidence_threshold=0.7,
server_source=active_server
)
if db_track and confidence >= 0.7:
print(f"✅ Database match: '{db_track.title}' (confidence: {confidence:.2f})")
# Create mock track object for playlist creation
class DatabaseTrackMock:
def __init__(self, db_track):
self.ratingKey = db_track.id
self.title = db_track.title
self.id = db_track.id
# Add any other attributes needed for playlist creation
return DatabaseTrackMock(db_track), confidence
print(f"❌ No database match found for: '{original_title}'")
return None, 0.0
except Exception as e:
print(f"❌ Database search error: {e}")
return None, 0.0
# Patch the method
sync_service._find_track_in_media_server = database_only_find_track
print(f"✅ Patched sync service to use database-only matching")
print(f"🚀 Starting actual sync process with asyncio.run()...")
# Run the sync (this is a blocking call within this thread)
result = asyncio.run(sync_service.sync_playlist(playlist, download_missing=False))
print(f"✅ Sync process completed! Result type: {type(result)}")
print(f" Result details: matched={getattr(result, 'matched_tracks', 'N/A')}, total={getattr(result, 'total_tracks', 'N/A')}")
# Update final state on completion
with sync_lock:
sync_states[playlist_id] = {
"status": "finished",
"result": result.__dict__ # Convert dataclass to dict
}
print(f"🏁 Sync finished for {playlist_id} - state updated")
except Exception as e:
print(f"❌ SYNC FAILED for {playlist_id}: {e}")
import traceback
traceback.print_exc()
with sync_lock:
sync_states[playlist_id] = {
"status": "error",
"error": str(e)
}
finally:
print(f"🧹 Cleaning up progress callback for {playlist.name}")
# Clean up the callback
if sync_service:
sync_service.clear_progress_callback(playlist.name)
print(f"✅ Cleanup completed for {playlist_id}")
@app.route('/api/sync/start', methods=['POST'])
def start_playlist_sync():
"""Starts a new sync process for a given playlist."""
data = request.get_json()
playlist_id = data.get('playlist_id')
playlist_name = data.get('playlist_name')
tracks_json = data.get('tracks') # Pass the full track list
if not all([playlist_id, playlist_name, tracks_json]):
return jsonify({"success": False, "error": "Missing playlist_id, name, or tracks."}), 400
with sync_lock:
if playlist_id in active_sync_workers and not active_sync_workers[playlist_id].done():
return jsonify({"success": False, "error": "Sync is already in progress for this playlist."}), 409
# Initial state
sync_states[playlist_id] = {"status": "starting", "progress": {}}
# Submit the task to the thread pool
future = sync_executor.submit(_run_sync_task, playlist_id, playlist_name, tracks_json)
active_sync_workers[playlist_id] = future
return jsonify({"success": True, "message": "Sync started."})
@app.route('/api/sync/status/<playlist_id>', methods=['GET'])
def get_sync_status(playlist_id):
"""Polls for the status of an ongoing sync."""
with sync_lock:
state = sync_states.get(playlist_id)
if not state:
return jsonify({"status": "not_found"}), 404
# If the task is finished but the state hasn't been updated, check the future
if state['status'] not in ['finished', 'error'] and playlist_id in active_sync_workers:
if active_sync_workers[playlist_id].done():
# The task might have finished between polls, trigger final state update
# This is handled by the _run_sync_task itself
pass
return jsonify(state)
@app.route('/api/sync/cancel', methods=['POST'])
def cancel_playlist_sync():
"""Cancels an ongoing sync process."""
data = request.get_json()
playlist_id = data.get('playlist_id')
if not playlist_id:
return jsonify({"success": False, "error": "Missing playlist_id."}), 400
with sync_lock:
future = active_sync_workers.get(playlist_id)
if not future or future.done():
return jsonify({"success": False, "error": "Sync not running or already complete."}), 404
# The GUI's sync_service has a cancel_sync method. We'll replicate that idea.
# Since we can't easily stop the thread, we'll set a flag.
# The elegant solution is to have the sync_service check for a cancellation flag.
# Your `sync_service.py` already has this logic with `self._cancelled`.
sync_service.cancel_sync()
# We can't guarantee immediate stop, but we can update the state
sync_states[playlist_id] = {"status": "cancelled"}
# It's best practice to let the task finish and clean itself up.
# We don't use future.cancel() as it may not work if the task is already running.
return jsonify({"success": True, "message": "Sync cancellation requested."})
@app.route('/api/sync/test-database', methods=['GET'])
def test_database_access():
"""Test endpoint to verify database connectivity for sync operations"""
try:
print(f"🧪 Testing database access for sync operations...")
# Test database initialization
from database.music_database import MusicDatabase
db = MusicDatabase()
print(f" ✅ Database initialized: {db is not None}")
# Test basic database query
stats = db.get_database_info_for_server()
print(f" ✅ Database stats retrieved: {stats}")
# Test track existence check (like sync service does)
db_track, confidence = db.check_track_exists("test track", "test artist", confidence_threshold=0.7)
print(f" ✅ Track existence check works: found={db_track is not None}, confidence={confidence}")
# Test config manager
from config.settings import config_manager
active_server = config_manager.get_active_media_server()
print(f" ✅ Active media server: {active_server}")
# Test media clients
print(f" Media clients status:")
print(f" plex_client: {plex_client is not None}")
if plex_client:
print(f" plex_client.is_connected(): {plex_client.is_connected()}")
print(f" jellyfin_client: {jellyfin_client is not None}")
if jellyfin_client:
print(f" jellyfin_client.is_connected(): {jellyfin_client.is_connected()}")
return jsonify({
"success": True,
"message": "Database access test successful",
"details": {
"database_initialized": db is not None,
"database_stats": stats,
"active_server": active_server,
"plex_connected": plex_client.is_connected() if plex_client else False,
"jellyfin_connected": jellyfin_client.is_connected() if jellyfin_client else False,
}
})
except Exception as e:
print(f" ❌ Database test failed: {e}")
import traceback
traceback.print_exc()
return jsonify({
"success": False,
"error": str(e),
"message": "Database access test failed"
}), 500
# --- Main Execution ---
if __name__ == '__main__':

@ -29,6 +29,7 @@ let dbUpdateStatusInterval = null;
let spotifyPlaylists = [];
let selectedPlaylists = new Set();
let activeSyncPollers = {}; // Key: playlist_id, Value: intervalId
let playlistTrackCache = {}; // Key: playlist_id, Value: tracks array
let spotifyPlaylistsLoaded = false;
// API endpoints
@ -1504,17 +1505,33 @@ function updateSyncActionsUI() {
async function openPlaylistDetailsModal(event, playlistId) {
event.stopPropagation();
const playlist = spotifyPlaylists.find(p => p.id === playlistId);
if (!playlist) return;
showLoadingOverlay(`Fetching tracks for ${playlist.name}...`);
showLoadingOverlay(`Loading playlist: ${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);
// --- CACHING LOGIC START ---
if (playlistTrackCache[playlistId]) {
console.log(`Cache HIT for playlist ${playlistId}. Using cached tracks.`);
// Use the cached tracks instead of fetching
const fullPlaylist = { ...playlist, tracks: playlistTrackCache[playlistId] };
showPlaylistDetailsModal(fullPlaylist);
} else {
console.log(`Cache MISS for playlist ${playlistId}. Fetching from server...`);
// Fetch from the server if not in cache
const response = await fetch(`/api/spotify/playlist/${playlistId}`);
const fullPlaylist = await response.json();
if (fullPlaylist.error) throw new Error(fullPlaylist.error);
// Store the fetched tracks in the cache
playlistTrackCache[playlistId] = fullPlaylist.tracks;
console.log(`Cached ${fullPlaylist.tracks.length} tracks for playlist ${playlistId}.`);
showPlaylistDetailsModal(fullPlaylist);
}
// --- CACHING LOGIC END ---
} catch (error) {
showToast(`Error: ${error.message}`, 'error');
@ -1542,6 +1559,15 @@ function showPlaylistDetailsModal(playlist) {
<span class="playlist-track-count">${playlist.track_count} tracks</span>
<span class="playlist-owner">by ${escapeHtml(playlist.owner)}</span>
</div>
<!-- Sync status display (hidden by default, matches GUI) -->
<div class="playlist-modal-sync-status" id="modal-sync-status-${playlist.id}" style="display: none;">
<span class="sync-stat total-tracks"> <span id="modal-total-${playlist.id}">0</span></span>
<span class="sync-separator">/</span>
<span class="sync-stat matched-tracks"> <span id="modal-matched-${playlist.id}">0</span></span>
<span class="sync-separator">/</span>
<span class="sync-stat failed-tracks"> <span id="modal-failed-${playlist.id}">0</span></span>
<span class="sync-stat percentage">(<span id="modal-percentage-${playlist.id}">0</span>%)</span>
</div>
</div>
<span class="playlist-modal-close" onclick="closePlaylistDetailsModal()">&times;</span>
</div>
@ -1567,7 +1593,7 @@ function showPlaylistDetailsModal(playlist) {
<div class="playlist-modal-footer">
<button class="playlist-modal-btn playlist-modal-btn-secondary" onclick="closePlaylistDetailsModal()">Close</button>
<button class="playlist-modal-btn playlist-modal-btn-primary" onclick="startPlaylistSyncFromModal('${playlist.id}')">Sync Playlist</button>
<button class="playlist-modal-btn playlist-modal-btn-primary" onclick="startPlaylistSync('${playlist.id}')">Sync Playlist</button>
</div>
</div>
`;
@ -1588,11 +1614,242 @@ function formatDuration(ms) {
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
function startPlaylistSyncFromModal(playlistId) {
closePlaylistDetailsModal();
showToast('Sync functionality will be implemented next!', 'info');
// Find and REPLACE the old startPlaylistSyncFromModal function
async function startPlaylistSync(playlistId) {
console.log(`🚀 Starting sync for playlist: ${playlistId}`);
const playlist = spotifyPlaylists.find(p => p.id === playlistId);
if (!playlist) {
console.error(`❌ Could not find playlist data for ID: ${playlistId}`);
showToast('Could not find playlist data.', 'error');
return;
}
console.log(`✅ Found playlist: ${playlist.name} with ${playlist.track_count || 'unknown'} tracks`);
// Ensure we have the full track list before starting
let tracks = playlistTrackCache[playlistId];
if (!tracks) {
console.log(`🔄 Cache miss - fetching tracks for playlist ${playlistId}`);
try {
const response = await fetch(`/api/spotify/playlist/${playlistId}`);
const fullPlaylist = await response.json();
if (fullPlaylist.error) throw new Error(fullPlaylist.error);
tracks = fullPlaylist.tracks;
playlistTrackCache[playlistId] = tracks; // Cache it
console.log(`✅ Fetched and cached ${tracks.length} tracks`);
} catch (error) {
console.error(`❌ Failed to fetch tracks:`, error);
showToast(`Failed to fetch tracks for sync: ${error.message}`, 'error');
return;
}
} else {
console.log(`✅ Using cached tracks: ${tracks.length} tracks`);
}
// DON'T close the modal - let it show live progress like the GUI
try {
// First test database access
console.log(`🧪 Testing database access before sync...`);
try {
const testResponse = await fetch('/api/sync/test-database');
const testData = await testResponse.json();
console.log(`🧪 Database test result:`, testData);
} catch (testError) {
console.warn(`⚠️ Database test failed:`, testError);
}
console.log(`🔄 Making API call to /api/sync/start with ${tracks.length} tracks`);
const response = await fetch('/api/sync/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playlist_id: playlist.id,
playlist_name: playlist.name,
tracks: tracks // Send the full track list
})
});
console.log(`📡 API response status: ${response.status}`);
const data = await response.json();
console.log(`📡 API response data:`, data);
if (!data.success) throw new Error(data.error);
console.log(`✅ Sync started successfully for "${playlist.name}"`);
showToast(`Sync started for "${playlist.name}"`, 'success');
// Show initial sync state in modal if open
const modal = document.getElementById('playlist-details-modal');
if (modal && modal.style.display !== 'none') {
const statusDisplay = document.getElementById(`modal-sync-status-${playlist.id}`);
if (statusDisplay) {
statusDisplay.style.display = 'flex';
console.log(`📊 Showing modal sync status for ${playlist.id}`);
}
}
updateCardToSyncing(playlist.id, 0); // Initial state
startSyncPolling(playlist.id);
} catch (error) {
console.error(`❌ Failed to start sync:`, error);
showToast(`Failed to start sync: ${error.message}`, 'error');
updateCardToDefault(playlist.id);
}
}
// Add these new helper functions to script.js
function startSyncPolling(playlistId) {
// Clear any existing poller for this playlist
if (activeSyncPollers[playlistId]) {
clearInterval(activeSyncPollers[playlistId]);
}
// Start a new poller that checks every 2 seconds
console.log(`🔄 Starting sync polling for playlist: ${playlistId}`);
activeSyncPollers[playlistId] = setInterval(async () => {
try {
console.log(`📊 Polling sync status for: ${playlistId}`);
const response = await fetch(`/api/sync/status/${playlistId}`);
const state = await response.json();
console.log(`📊 Poll response:`, state);
if (state.status === 'syncing') {
const progress = state.progress;
console.log(`📊 Sync progress:`, progress);
console.log(` 📊 Progress values: ${progress.progress}% | Total: ${progress.total_tracks} | Matched: ${progress.matched_tracks} | Failed: ${progress.failed_tracks}`);
console.log(` 📊 Current step: "${progress.current_step}" | Current track: "${progress.current_track}"`);
// Use the actual progress percentage from the sync service
updateCardToSyncing(playlistId, progress.progress, progress);
// Also update the modal if it's open
updateModalSyncProgress(playlistId, progress);
} else if (state.status === 'finished' || state.status === 'error' || state.status === 'cancelled') {
console.log(`🏁 Sync completed with status: ${state.status}`);
stopSyncPolling(playlistId);
updateCardToDefault(playlistId, state);
// Also update the modal if it's open
closePlaylistDetailsModal(); // Close modal on completion/error
}
} catch (error) {
console.error(`❌ Error polling sync status for ${playlistId}:`, error);
stopSyncPolling(playlistId);
updateCardToDefault(playlistId, { status: 'error', error: 'Polling failed' });
}
}, 2000); // Poll every 2 seconds
}
function stopSyncPolling(playlistId) {
if (activeSyncPollers[playlistId]) {
clearInterval(activeSyncPollers[playlistId]);
delete activeSyncPollers[playlistId];
}
}
function updateCardToSyncing(playlistId, percent, progress = null) {
const card = document.querySelector(`.playlist-card[data-playlist-id="${playlistId}"]`);
if (!card) return;
const progressBar = card.querySelector('.sync-progress-indicator');
progressBar.style.display = 'block';
let progressText = 'Starting...';
let actualPercent = percent || 0;
if (progress) {
// Use the actual progress percentage from the sync service
actualPercent = progress.progress || 0;
// Create detailed progress text like the GUI
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
const total = progress.total_tracks || 0;
const currentStep = progress.current_step || 'Processing';
if (total > 0) {
const processed = matched + failed;
progressText = `${currentStep}: ${processed}/${total} (${matched} matched, ${failed} failed)`;
} else {
progressText = currentStep;
}
// If there's a current track being processed, show it
if (progress.current_track) {
progressText += ` - ${progress.current_track}`;
}
}
progressBar.innerHTML = `
<div class="progress-bar-sync">
<div class="progress-fill-sync" style="width: ${actualPercent}%;"></div>
</div>
<div class="progress-text-sync">${progressText}</div>
`;
}
function updateCardToDefault(playlistId, finalState = null) {
const card = document.querySelector(`.playlist-card[data-playlist-id="${playlistId}"]`);
if (!card) return;
const progressBar = card.querySelector('.sync-progress-indicator');
progressBar.style.display = 'none';
progressBar.innerHTML = '';
const statusEl = card.querySelector('.playlist-card-status');
if (finalState) {
if (finalState.status === 'finished') {
statusEl.textContent = `Synced: Just now`;
statusEl.className = 'playlist-card-status status-synced';
showToast(`Sync complete for "${card.querySelector('.playlist-card-name').textContent}"`, 'success');
} else {
statusEl.textContent = `Sync Failed`;
statusEl.className = 'playlist-card-status status-needs-sync'; // Or a new error class
showToast(`Sync failed: ${finalState.error || 'Unknown error'}`, 'error');
}
}
}
// Update the modal's sync progress display (matches GUI functionality)
function updateModalSyncProgress(playlistId, progress) {
const modal = document.getElementById('playlist-details-modal');
if (modal && modal.style.display !== 'none') {
console.log(`📊 Updating modal sync progress for ${playlistId}:`, progress);
// Show sync status display
const statusDisplay = document.getElementById(`modal-sync-status-${playlistId}`);
if (statusDisplay) {
statusDisplay.style.display = 'flex';
// Update counters (matching GUI exactly)
const totalEl = document.getElementById(`modal-total-${playlistId}`);
const matchedEl = document.getElementById(`modal-matched-${playlistId}`);
const failedEl = document.getElementById(`modal-failed-${playlistId}`);
const percentageEl = document.getElementById(`modal-percentage-${playlistId}`);
const total = progress.total_tracks || 0;
const matched = progress.matched_tracks || 0;
const failed = progress.failed_tracks || 0;
if (totalEl) totalEl.textContent = total;
if (matchedEl) matchedEl.textContent = matched;
if (failedEl) failedEl.textContent = failed;
// Calculate percentage like GUI
if (total > 0) {
const processed = matched + failed;
const percentage = Math.round((processed / total) * 100);
if (percentageEl) percentageEl.textContent = percentage;
}
console.log(`📊 Modal updated: ♪ ${total} / ✓ ${matched} / ✗ ${failed} (${Math.round((matched + failed) / total * 100)}%)`);
} else {
console.warn(`❌ Modal sync status display not found for ${playlistId}`);
}
} else {
console.log(`📊 Modal not open for ${playlistId}, skipping update`);
}
}
// Download tracking state management - matching GUI functionality

@ -4465,4 +4465,73 @@ body {
.playlist-modal-btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(29, 185, 84, 0.4);
}
/* Add these styles to the end of style.css */
.sync-progress-indicator {
margin-top: 10px;
display: none; /* Hidden by default */
}
.progress-bar-sync {
height: 8px;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
}
.progress-fill-sync {
height: 100%;
background-color: #1ed760;
width: 0%;
border-radius: 4px;
transition: width 0.5s ease-in-out;
}
.progress-text-sync {
font-size: 11px;
color: #b3b3b3;
text-align: center;
margin-top: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
line-height: 1.2;
}
/* Modal sync status display (matches GUI) */
.playlist-modal-sync-status {
background: rgba(29, 185, 84, 0.1);
border: 1px solid rgba(29, 185, 84, 0.3);
border-radius: 12px;
padding: 8px 12px;
margin-top: 8px;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 500;
}
.sync-stat.total-tracks {
color: #ffa500;
}
.sync-stat.matched-tracks {
color: #1db954;
}
.sync-stat.failed-tracks {
color: #e22134;
}
.sync-stat.percentage {
color: #1db954;
font-weight: 700;
}
.sync-separator {
color: #666666;
}
Loading…
Cancel
Save