Add Sync History feature with live re-sync progress and source detection
- New sync_history DB table tracks last 100 syncs with full cached context - Records history for all sync types: Spotify, Tidal, Deezer, YouTube, Beatport, ListenBrainz, Mirrored playlists, and Download Missing flows - Sync History button on sync page with modal showing entries, source filter tabs, stats badges, and pagination - Re-sync button: server syncs expand card inline with live progress bar, matched/failed counts, and cancel button; download syncs open download modal - Re-syncs update the original entry (moves to top) instead of creating duplicates - Delete button (x) on each entry with smooth remove animation - Fix mirrored playlist source detection (youtube_mirrored_ matched youtube_) - Fix broken server import thumbnails with URL validation
This commit is contained in:
parent
f8f113d0e7
commit
27a0cf8b81
5 changed files with 980 additions and 5 deletions
|
|
@ -491,6 +491,32 @@ class MusicDatabase:
|
|||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_lh_event_type ON library_history (event_type)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_lh_created_at ON library_history (created_at DESC)")
|
||||
|
||||
# Sync history table — tracks the last 100 sync operations with cached context for re-trigger
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS sync_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
batch_id TEXT NOT NULL,
|
||||
playlist_id TEXT,
|
||||
playlist_name TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
sync_type TEXT NOT NULL,
|
||||
artist_context TEXT,
|
||||
album_context TEXT,
|
||||
tracks_json TEXT NOT NULL,
|
||||
total_tracks INTEGER DEFAULT 0,
|
||||
tracks_found INTEGER DEFAULT 0,
|
||||
tracks_downloaded INTEGER DEFAULT 0,
|
||||
tracks_failed INTEGER DEFAULT 0,
|
||||
thumb_url TEXT,
|
||||
is_album_download INTEGER DEFAULT 0,
|
||||
playlist_folder_mode INTEGER DEFAULT 0,
|
||||
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP
|
||||
)
|
||||
""")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_sh_started_at ON sync_history (started_at DESC)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_sh_source ON sync_history (source)")
|
||||
|
||||
conn.commit()
|
||||
logger.info("Database initialized successfully")
|
||||
|
||||
|
|
@ -8083,6 +8109,132 @@ class MusicDatabase:
|
|||
logger.debug(f"Error getting library history stats: {e}")
|
||||
return {'downloads': 0, 'imports': 0}
|
||||
|
||||
# ── Sync History ──────────────────────────────────────────────
|
||||
|
||||
def add_sync_history_entry(self, batch_id, playlist_id, playlist_name, source, sync_type,
|
||||
tracks_json, artist_context=None, album_context=None,
|
||||
thumb_url=None, total_tracks=0, is_album_download=False,
|
||||
playlist_folder_mode=False):
|
||||
"""Record a new sync operation to sync_history."""
|
||||
try:
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO sync_history (batch_id, playlist_id, playlist_name, source, sync_type,
|
||||
tracks_json, artist_context, album_context, thumb_url, total_tracks,
|
||||
is_album_download, playlist_folder_mode)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (batch_id, playlist_id, playlist_name, source, sync_type,
|
||||
tracks_json, artist_context, album_context, thumb_url, total_tracks,
|
||||
int(is_album_download), int(playlist_folder_mode)))
|
||||
conn.commit()
|
||||
# Cap at 100 entries
|
||||
cursor.execute("""
|
||||
DELETE FROM sync_history WHERE id NOT IN (
|
||||
SELECT id FROM sync_history ORDER BY started_at DESC LIMIT 100
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"Error adding sync history entry: {e}")
|
||||
return False
|
||||
|
||||
def update_sync_history_completion(self, batch_id, tracks_found=0, tracks_downloaded=0, tracks_failed=0):
|
||||
"""Update a sync_history entry with completion stats."""
|
||||
try:
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
UPDATE sync_history SET tracks_found = ?, tracks_downloaded = ?,
|
||||
tracks_failed = ?, completed_at = CURRENT_TIMESTAMP
|
||||
WHERE batch_id = ?
|
||||
""", (tracks_found, tracks_downloaded, tracks_failed, batch_id))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
except Exception as e:
|
||||
logger.debug(f"Error updating sync history completion: {e}")
|
||||
return False
|
||||
|
||||
def refresh_sync_history_entry(self, entry_id, tracks_found=0, tracks_downloaded=0, tracks_failed=0):
|
||||
"""Update an existing sync_history entry with new stats and reset timestamps to move it to the top."""
|
||||
try:
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
UPDATE sync_history SET tracks_found = ?, tracks_downloaded = ?,
|
||||
tracks_failed = ?, started_at = CURRENT_TIMESTAMP,
|
||||
completed_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", (tracks_found, tracks_downloaded, tracks_failed, entry_id))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
except Exception as e:
|
||||
logger.debug(f"Error refreshing sync history entry: {e}")
|
||||
return False
|
||||
|
||||
def get_sync_history(self, source=None, page=1, limit=20):
|
||||
"""Return (entries, total) for sync_history, newest first. Full tracks_json excluded from list."""
|
||||
try:
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
where = "WHERE source = ?" if source else ""
|
||||
params = [source] if source else []
|
||||
|
||||
cursor.execute(f"SELECT COUNT(*) as cnt FROM sync_history {where}", params)
|
||||
total = cursor.fetchone()['cnt']
|
||||
|
||||
offset = (page - 1) * limit
|
||||
cursor.execute(f"""
|
||||
SELECT id, batch_id, playlist_id, playlist_name, source, sync_type,
|
||||
artist_context, album_context, thumb_url, total_tracks,
|
||||
tracks_found, tracks_downloaded, tracks_failed,
|
||||
is_album_download, playlist_folder_mode, started_at, completed_at
|
||||
FROM sync_history {where}
|
||||
ORDER BY started_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""", params + [limit, offset])
|
||||
entries = [dict(row) for row in cursor.fetchall()]
|
||||
return entries, total
|
||||
except Exception as e:
|
||||
logger.error(f"Error querying sync history: {e}")
|
||||
return [], 0
|
||||
|
||||
def get_sync_history_entry(self, entry_id):
|
||||
"""Return a single sync_history row with full tracks_json (for re-trigger)."""
|
||||
try:
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM sync_history WHERE id = ?", (entry_id,))
|
||||
row = cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting sync history entry: {e}")
|
||||
return None
|
||||
|
||||
def delete_sync_history_entry(self, entry_id):
|
||||
"""Delete a single sync_history entry."""
|
||||
try:
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM sync_history WHERE id = ?", (entry_id,))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
except Exception as e:
|
||||
logger.debug(f"Error deleting sync history entry: {e}")
|
||||
return False
|
||||
|
||||
def get_sync_history_stats(self):
|
||||
"""Return counts grouped by source."""
|
||||
try:
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT source, COUNT(*) as cnt FROM sync_history GROUP BY source")
|
||||
return {row['source']: row['cnt'] for row in cursor.fetchall()}
|
||||
except Exception as e:
|
||||
logger.debug(f"Error getting sync history stats: {e}")
|
||||
return {}
|
||||
|
||||
def api_get_recently_added(self, entity_type: str = "albums", limit: int = 50) -> List[Dict[str, Any]]:
|
||||
"""Get recently added entities, ordered by created_at DESC."""
|
||||
table = {"artists": "artists", "albums": "albums", "tracks": "tracks"}.get(entity_type)
|
||||
|
|
|
|||
213
web_server.py
213
web_server.py
|
|
@ -21241,6 +21241,9 @@ def _on_download_completed(batch_id, task_id, success=True):
|
|||
batch['phase'] = 'complete'
|
||||
batch['completion_time'] = time.time() # Track when batch completed
|
||||
|
||||
# Record sync history completion
|
||||
_record_sync_history_completion(batch_id, batch)
|
||||
|
||||
# Add activity for batch completion
|
||||
playlist_name = batch.get('playlist_name', 'Unknown Playlist')
|
||||
failed_count = len(batch.get('permanently_failed_tracks', []))
|
||||
|
|
@ -21466,6 +21469,14 @@ def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json):
|
|||
if not missing_tracks:
|
||||
print(f"✅ Analysis for batch {batch_id} complete. No missing tracks.")
|
||||
|
||||
# Record sync history — all tracks found, nothing to download
|
||||
tracks_found = sum(1 for r in analysis_results if r.get('found'))
|
||||
try:
|
||||
db_sh = MusicDatabase()
|
||||
db_sh.update_sync_history_completion(batch_id, tracks_found=tracks_found, tracks_downloaded=0, tracks_failed=0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
is_auto_batch = False
|
||||
with tasks_lock:
|
||||
if batch_id in download_batches:
|
||||
|
|
@ -24082,10 +24093,171 @@ def cleanup_batch():
|
|||
print(f"❌ Error during batch cleanup for '{batch_id}': {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
# ===============================
|
||||
# == SYNC HISTORY API ==
|
||||
# ===============================
|
||||
|
||||
@app.route('/api/sync/history', methods=['GET'])
|
||||
def get_sync_history():
|
||||
"""Get paginated sync history."""
|
||||
try:
|
||||
page = int(request.args.get('page', 1))
|
||||
limit = int(request.args.get('limit', 20))
|
||||
source = request.args.get('source') or None
|
||||
|
||||
db = MusicDatabase()
|
||||
entries, total = db.get_sync_history(source=source, page=page, limit=limit)
|
||||
stats = db.get_sync_history_stats()
|
||||
|
||||
# Parse artist/album names from JSON context for display
|
||||
for entry in entries:
|
||||
if entry.get('artist_context'):
|
||||
try:
|
||||
ac = json.loads(entry['artist_context'])
|
||||
entry['artist_name'] = ac.get('name', '')
|
||||
except:
|
||||
entry['artist_name'] = ''
|
||||
else:
|
||||
entry['artist_name'] = ''
|
||||
if entry.get('album_context'):
|
||||
try:
|
||||
alc = json.loads(entry['album_context'])
|
||||
entry['album_name'] = alc.get('name', '')
|
||||
except:
|
||||
entry['album_name'] = ''
|
||||
else:
|
||||
entry['album_name'] = ''
|
||||
# Remove raw JSON from list response
|
||||
entry.pop('artist_context', None)
|
||||
entry.pop('album_context', None)
|
||||
|
||||
return jsonify({"success": True, "entries": entries, "total": total,
|
||||
"page": page, "limit": limit, "stats": stats})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting sync history: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@app.route('/api/sync/history/<int:entry_id>', methods=['GET'])
|
||||
def get_sync_history_entry(entry_id):
|
||||
"""Get a single sync history entry with full cached data for re-trigger."""
|
||||
try:
|
||||
db = MusicDatabase()
|
||||
entry = db.get_sync_history_entry(entry_id)
|
||||
if not entry:
|
||||
return jsonify({"success": False, "error": "Entry not found"}), 404
|
||||
|
||||
# Parse JSON fields
|
||||
entry['tracks'] = json.loads(entry['tracks_json']) if entry.get('tracks_json') else []
|
||||
entry['artist_context'] = json.loads(entry['artist_context']) if entry.get('artist_context') else None
|
||||
entry['album_context'] = json.loads(entry['album_context']) if entry.get('album_context') else None
|
||||
entry.pop('tracks_json', None)
|
||||
|
||||
return jsonify({"success": True, "entry": entry})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting sync history entry: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@app.route('/api/sync/history/<int:entry_id>', methods=['DELETE'])
|
||||
def delete_sync_history_entry_api(entry_id):
|
||||
"""Delete a sync history entry."""
|
||||
try:
|
||||
db = MusicDatabase()
|
||||
deleted = db.delete_sync_history_entry(entry_id)
|
||||
return jsonify({"success": deleted})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
# ===============================
|
||||
# == UNIFIED MISSING TRACKS API ==
|
||||
# ===============================
|
||||
|
||||
def _detect_sync_source(playlist_id):
|
||||
"""Derive the sync source from the playlist_id prefix."""
|
||||
prefix_map = [
|
||||
# Mirrored playlists go through YouTube discovery, so youtube_mirrored_ must be checked first
|
||||
('auto_mirror_', 'mirrored'), ('youtube_mirrored_', 'mirrored'),
|
||||
('youtube_', 'youtube'), ('beatport_', 'beatport'),
|
||||
('tidal_', 'tidal'), ('deezer_', 'deezer'), ('listenbrainz_', 'listenbrainz'),
|
||||
('spotify_public_', 'spotify_public'), ('discover_album_', 'discover'),
|
||||
('seasonal_album_', 'discover'), ('library_redownload_', 'library'),
|
||||
('issue_download_', 'library'), ('artist_album_', 'spotify'),
|
||||
('enhanced_search_', 'spotify'), ('spotify_library_', 'spotify'),
|
||||
('beatport_release_', 'beatport'), ('beatport_chart_', 'beatport'),
|
||||
('beatport_top100_', 'beatport'), ('beatport_hype100_', 'beatport'),
|
||||
('beatport_sync_', 'beatport'),
|
||||
]
|
||||
for prefix, source in prefix_map:
|
||||
if playlist_id.startswith(prefix):
|
||||
return source
|
||||
if playlist_id == 'wishlist':
|
||||
return 'wishlist'
|
||||
return 'spotify'
|
||||
|
||||
def _record_sync_history_start(batch_id, playlist_id, playlist_name, tracks,
|
||||
is_album_download, album_context, artist_context,
|
||||
playlist_folder_mode):
|
||||
"""Record a sync start to the database."""
|
||||
try:
|
||||
source = _detect_sync_source(playlist_id)
|
||||
if playlist_id == 'wishlist':
|
||||
sync_type = 'wishlist'
|
||||
elif is_album_download:
|
||||
sync_type = 'album'
|
||||
else:
|
||||
sync_type = 'playlist'
|
||||
|
||||
# Extract thumb URL from album context or first track
|
||||
thumb_url = None
|
||||
if album_context:
|
||||
images = album_context.get('images', [])
|
||||
if images and isinstance(images, list) and len(images) > 0:
|
||||
thumb_url = images[0].get('url') if isinstance(images[0], dict) else images[0]
|
||||
if not thumb_url:
|
||||
thumb_url = album_context.get('image_url')
|
||||
if not thumb_url and tracks:
|
||||
first_album = tracks[0].get('album', {})
|
||||
if isinstance(first_album, dict):
|
||||
imgs = first_album.get('images', [])
|
||||
if imgs and isinstance(imgs, list) and len(imgs) > 0:
|
||||
thumb_url = imgs[0].get('url') if isinstance(imgs[0], dict) else imgs[0]
|
||||
|
||||
db = MusicDatabase()
|
||||
db.add_sync_history_entry(
|
||||
batch_id=batch_id,
|
||||
playlist_id=playlist_id,
|
||||
playlist_name=playlist_name,
|
||||
source=source,
|
||||
sync_type=sync_type,
|
||||
tracks_json=json.dumps(tracks, ensure_ascii=False),
|
||||
artist_context=json.dumps(artist_context, ensure_ascii=False) if artist_context else None,
|
||||
album_context=json.dumps(album_context, ensure_ascii=False) if album_context else None,
|
||||
thumb_url=thumb_url,
|
||||
total_tracks=len(tracks),
|
||||
is_album_download=is_album_download,
|
||||
playlist_folder_mode=playlist_folder_mode
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to record sync history start: {e}")
|
||||
|
||||
def _record_sync_history_completion(batch_id, batch):
|
||||
"""Update sync history with completion stats.
|
||||
NOTE: Called from within tasks_lock context — do NOT acquire tasks_lock here."""
|
||||
try:
|
||||
analysis_results = batch.get('analysis_results', [])
|
||||
tracks_found = sum(1 for r in analysis_results if r.get('found'))
|
||||
queue = batch.get('queue', [])
|
||||
completed_count = 0
|
||||
failed_count = len(batch.get('permanently_failed_tracks', []))
|
||||
# Already inside tasks_lock — safe to read download_tasks directly
|
||||
for task_id in queue:
|
||||
if task_id in download_tasks and download_tasks[task_id].get('status') == 'completed':
|
||||
completed_count += 1
|
||||
|
||||
db = MusicDatabase()
|
||||
db.update_sync_history_completion(batch_id, tracks_found, completed_count, failed_count)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to record sync history completion: {e}")
|
||||
|
||||
@app.route('/api/playlists/<playlist_id>/start-missing-process', methods=['POST'])
|
||||
def start_missing_tracks_process(playlist_id):
|
||||
"""
|
||||
|
|
@ -24152,6 +24324,11 @@ def start_missing_tracks_process(playlist_id):
|
|||
'artist_context': artist_context
|
||||
}
|
||||
|
||||
# Record sync history
|
||||
_record_sync_history_start(batch_id, playlist_id, playlist_name, tracks,
|
||||
is_album_download, album_context, artist_context,
|
||||
playlist_folder_mode)
|
||||
|
||||
# Link YouTube playlist to download process if this is a YouTube playlist
|
||||
if playlist_id.startswith('youtube_'):
|
||||
url_hash = playlist_id.replace('youtube_', '')
|
||||
|
|
@ -29276,6 +29453,28 @@ def _run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None):
|
|||
print(f"🚀 [TIMING] _run_sync_task STARTED for playlist '{playlist_name}' at {time.strftime('%H:%M:%S')}")
|
||||
print(f"📊 Received {len(tracks_json)} tracks from frontend")
|
||||
|
||||
# Record sync history start (skip for re-syncs triggered from history)
|
||||
_is_resync = playlist_id.startswith('resync_')
|
||||
_resync_entry_id = None
|
||||
sync_batch_id = f"sync_{playlist_id}_{int(time.time())}"
|
||||
if _is_resync:
|
||||
# Extract the original entry ID from resync_{entryId}_{timestamp}
|
||||
try:
|
||||
_resync_entry_id = int(playlist_id.split('_')[1])
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
else:
|
||||
_record_sync_history_start(
|
||||
batch_id=sync_batch_id,
|
||||
playlist_id=playlist_id,
|
||||
playlist_name=playlist_name,
|
||||
tracks=tracks_json,
|
||||
is_album_download=False,
|
||||
album_context=None,
|
||||
artist_context=None,
|
||||
playlist_folder_mode=False
|
||||
)
|
||||
|
||||
try:
|
||||
# Recreate a Playlist object from the JSON data sent by the frontend
|
||||
# This avoids needing to re-fetch it from Spotify
|
||||
|
|
@ -29554,6 +29753,20 @@ def _run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None):
|
|||
}
|
||||
print(f"🏁 Sync finished for {playlist_id} - state updated")
|
||||
|
||||
# Record sync history completion
|
||||
try:
|
||||
matched = getattr(result, 'matched_tracks', 0)
|
||||
failed = getattr(result, 'failed_tracks', 0)
|
||||
synced = getattr(result, 'synced_tracks', 0)
|
||||
db = MusicDatabase()
|
||||
if _is_resync and _resync_entry_id:
|
||||
# Re-sync: update the original entry's timestamp and stats (moves it to top)
|
||||
db.refresh_sync_history_entry(_resync_entry_id, matched, synced, failed)
|
||||
else:
|
||||
db.update_sync_history_completion(sync_batch_id, matched, synced, failed)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to record sync history completion: {e}")
|
||||
|
||||
if automation_id:
|
||||
matched = getattr(result, 'matched_tracks', 0)
|
||||
total = getattr(result, 'total_tracks', 0)
|
||||
|
|
|
|||
|
|
@ -988,9 +988,14 @@
|
|||
<div class="page" id="sync-page">
|
||||
<!-- Header -->
|
||||
<div class="sync-header">
|
||||
<h2 class="sync-title"><img src="/static/sync.png" class="page-header-icon" alt=""><span>Playlist Sync</span></h2>
|
||||
<p class="sync-subtitle">Synchronize your Spotify, Tidal, and YouTube playlists with your media
|
||||
server</p>
|
||||
<div class="sync-header-row">
|
||||
<div>
|
||||
<h2 class="sync-title"><img src="/static/sync.png" class="page-header-icon" alt=""><span>Playlist Sync</span></h2>
|
||||
<p class="sync-subtitle">Synchronize your Spotify, Tidal, and YouTube playlists with your media
|
||||
server</p>
|
||||
</div>
|
||||
<button class="sync-history-btn" onclick="openSyncHistoryModal()" title="View sync history">Sync History</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main two-column content area -->
|
||||
|
|
@ -5676,6 +5681,19 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync History Modal -->
|
||||
<div class="modal-overlay hidden" id="sync-history-overlay" onclick="if(event.target===this)closeSyncHistoryModal()">
|
||||
<div class="sync-history-modal">
|
||||
<div class="sync-history-modal-header">
|
||||
<h3>Sync History</h3>
|
||||
<button class="sync-history-modal-close" onclick="closeSyncHistoryModal()">×</button>
|
||||
</div>
|
||||
<div class="sync-history-tabs" id="sync-history-tabs"></div>
|
||||
<div class="sync-history-list" id="sync-history-list"></div>
|
||||
<div class="sync-history-pagination" id="sync-history-pagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata Cache Browse Modal -->
|
||||
<div class="mcache-modal-overlay" id="mcache-browse-modal" style="display:none;">
|
||||
<div class="mcache-modal">
|
||||
|
|
|
|||
|
|
@ -18155,8 +18155,10 @@ async function loadLibraryHistory() {
|
|||
}
|
||||
|
||||
function renderHistoryEntry(entry) {
|
||||
const thumb = entry.thumb_url
|
||||
? `<img src="${escapeHtml(entry.thumb_url)}" class="library-history-thumb" loading="lazy">`
|
||||
// Server import thumb_urls are relative paths (e.g. /library/metadata/...) — use placeholder
|
||||
const hasValidThumb = entry.thumb_url && (entry.thumb_url.startsWith('http://') || entry.thumb_url.startsWith('https://'));
|
||||
const thumb = hasValidThumb
|
||||
? `<img src="${escapeHtml(entry.thumb_url)}" class="library-history-thumb" loading="lazy" onerror="this.outerHTML='<div class=\\'library-history-thumb-placeholder\\'>${entry.event_type === 'download' ? '📥' : '📚'}</div>'">`
|
||||
: `<div class="library-history-thumb-placeholder">${entry.event_type === 'download' ? '📥' : '📚'}</div>`;
|
||||
|
||||
let badge = '';
|
||||
|
|
@ -18217,6 +18219,411 @@ function changeHistoryPage(newPage) {
|
|||
loadLibraryHistory();
|
||||
}
|
||||
|
||||
// ── Sync History Modal ──────────────────────────────────────────────
|
||||
const _syncHistoryState = { source: null, page: 1, limit: 20 };
|
||||
|
||||
function openSyncHistoryModal() {
|
||||
const overlay = document.getElementById('sync-history-overlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('hidden');
|
||||
_syncHistoryState.page = 1;
|
||||
_syncHistoryState.source = null;
|
||||
loadSyncHistory();
|
||||
}
|
||||
}
|
||||
|
||||
function closeSyncHistoryModal() {
|
||||
const overlay = document.getElementById('sync-history-overlay');
|
||||
if (overlay) overlay.classList.add('hidden');
|
||||
}
|
||||
|
||||
function switchSyncHistoryTab(source) {
|
||||
_syncHistoryState.source = source;
|
||||
_syncHistoryState.page = 1;
|
||||
document.querySelectorAll('.sync-history-tab').forEach(t => {
|
||||
t.classList.toggle('active', t.dataset.source === (source || 'all'));
|
||||
});
|
||||
loadSyncHistory();
|
||||
}
|
||||
|
||||
async function loadSyncHistory() {
|
||||
const { source, page, limit } = _syncHistoryState;
|
||||
const list = document.getElementById('sync-history-list');
|
||||
const tabsContainer = document.getElementById('sync-history-tabs');
|
||||
if (!list) return;
|
||||
list.innerHTML = '<div class="sync-history-loading">Loading...</div>';
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ page, limit });
|
||||
if (source) params.set('source', source);
|
||||
const resp = await fetch(`/api/sync/history?${params}`);
|
||||
const data = await resp.json();
|
||||
|
||||
// Build tabs from stats
|
||||
if (tabsContainer && data.stats) {
|
||||
const totalCount = Object.values(data.stats).reduce((a, b) => a + b, 0);
|
||||
const sourceLabels = {
|
||||
spotify: 'Spotify', beatport: 'Beatport', youtube: 'YouTube',
|
||||
tidal: 'Tidal', deezer: 'Deezer', wishlist: 'Wishlist',
|
||||
library: 'Library', discover: 'Discover', listenbrainz: 'ListenBrainz',
|
||||
spotify_public: 'Spotify Public', mirrored: 'Mirrored'
|
||||
};
|
||||
let tabsHtml = `<button class="sync-history-tab ${!source ? 'active' : ''}" data-source="all" onclick="switchSyncHistoryTab(null)">All <span class="sync-history-tab-count">${totalCount}</span></button>`;
|
||||
for (const [src, count] of Object.entries(data.stats).sort((a, b) => b[1] - a[1])) {
|
||||
const label = sourceLabels[src] || src;
|
||||
const isActive = source === src ? ' active' : '';
|
||||
tabsHtml += `<button class="sync-history-tab${isActive}" data-source="${src}" onclick="switchSyncHistoryTab('${src}')">${label} <span class="sync-history-tab-count">${count}</span></button>`;
|
||||
}
|
||||
tabsContainer.innerHTML = tabsHtml;
|
||||
}
|
||||
|
||||
if (!data.entries || data.entries.length === 0) {
|
||||
list.innerHTML = '<div class="sync-history-empty">No sync history yet. Completed syncs will appear here.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = data.entries.map(renderSyncHistoryEntry).join('');
|
||||
renderSyncHistoryPagination(data.total, page, limit);
|
||||
} catch (err) {
|
||||
console.error('Error loading sync history:', err);
|
||||
list.innerHTML = '<div class="sync-history-empty">Error loading sync history</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderSyncHistoryEntry(entry) {
|
||||
const thumb = entry.thumb_url
|
||||
? `<img src="${escapeHtml(entry.thumb_url)}" class="sync-history-thumb" loading="lazy" onerror="this.outerHTML='<div class=\\'sync-history-thumb-placeholder\\'>📥</div>'">`
|
||||
: `<div class="sync-history-thumb-placeholder">${_syncSourceIcon(entry.source)}</div>`;
|
||||
|
||||
const sourceBadge = `<span class="sync-history-source-badge ${entry.source}">${escapeHtml(entry.source)}</span>`;
|
||||
|
||||
const title = entry.playlist_name || 'Unknown';
|
||||
const meta = [entry.artist_name, entry.album_name].filter(Boolean).join(' — ') || entry.sync_type;
|
||||
|
||||
// Stats
|
||||
let statsHtml = '';
|
||||
if (entry.completed_at) {
|
||||
const parts = [];
|
||||
if (entry.tracks_found > 0) parts.push(`<span class="sync-history-stat found">${entry.tracks_found} found</span>`);
|
||||
if (entry.tracks_downloaded > 0) parts.push(`<span class="sync-history-stat downloaded">${entry.tracks_downloaded} downloaded</span>`);
|
||||
if (entry.tracks_failed > 0) parts.push(`<span class="sync-history-stat failed">${entry.tracks_failed} failed</span>`);
|
||||
if (parts.length === 0) parts.push(`<span class="sync-history-stat found">${entry.total_tracks} in library</span>`);
|
||||
statsHtml = `<div class="sync-history-stats">${parts.join('')}</div>`;
|
||||
} else {
|
||||
statsHtml = `<div class="sync-history-stats"><span class="sync-history-stat pending">In progress</span></div>`;
|
||||
}
|
||||
|
||||
const timeStr = formatHistoryTime(entry.started_at);
|
||||
|
||||
return `<div class="sync-history-entry-wrapper" id="sync-history-wrapper-${entry.id}">
|
||||
<div class="sync-history-entry">
|
||||
${thumb}
|
||||
<div class="sync-history-entry-text">
|
||||
<div class="sync-history-entry-title">${escapeHtml(title)}</div>
|
||||
<div class="sync-history-entry-meta">${escapeHtml(meta)}</div>
|
||||
</div>
|
||||
${sourceBadge}
|
||||
${statsHtml}
|
||||
<div class="sync-history-entry-time">${timeStr}</div>
|
||||
<button class="sync-history-delete-btn" onclick="deleteSyncHistoryEntry(${entry.id})" title="Delete this entry">×</button>
|
||||
<button class="sync-history-resync-btn" id="resync-btn-${entry.id}" onclick="retriggerSync(${entry.id})" title="Re-sync this playlist">Re-sync</button>
|
||||
</div>
|
||||
<div class="sync-history-live-progress" id="sync-history-progress-${entry.id}" style="display:none;">
|
||||
<div class="sync-history-progress-bar-container">
|
||||
<div class="sync-history-progress-bar-fill" id="sync-history-bar-${entry.id}"></div>
|
||||
</div>
|
||||
<div class="sync-history-progress-text">
|
||||
<span class="sync-history-progress-step" id="sync-history-step-${entry.id}">Starting sync...</span>
|
||||
<div class="sync-history-progress-stats">
|
||||
<span class="matched" id="sync-history-matched-${entry.id}">0 matched</span>
|
||||
<span class="failed" id="sync-history-failed-${entry.id}">0 failed</span>
|
||||
</div>
|
||||
<button class="sync-history-cancel-btn" id="sync-history-cancel-${entry.id}" onclick="cancelSyncHistoryResync(${entry.id})">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _syncSourceIcon(source) {
|
||||
const icons = {
|
||||
spotify: '🎵', beatport: '🎶', youtube: '▶',
|
||||
tidal: '🌊', deezer: '🎧', wishlist: '⭐',
|
||||
library: '📚', discover: '🔍', mirrored: '🔗',
|
||||
listenbrainz: '🎧', spotify_public: '🎵'
|
||||
};
|
||||
return icons[source] || '📥';
|
||||
}
|
||||
|
||||
function renderSyncHistoryPagination(total, page, limit) {
|
||||
const pagination = document.getElementById('sync-history-pagination');
|
||||
if (!pagination) return;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
if (totalPages <= 1) { pagination.innerHTML = ''; return; }
|
||||
pagination.innerHTML = `
|
||||
<button class="sync-history-page-btn" onclick="changeSyncHistoryPage(${page - 1})" ${page <= 1 ? 'disabled' : ''}>Prev</button>
|
||||
<span class="sync-history-page-info">Page ${page} of ${totalPages}</span>
|
||||
<button class="sync-history-page-btn" onclick="changeSyncHistoryPage(${page + 1})" ${page >= totalPages ? 'disabled' : ''}>Next</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function changeSyncHistoryPage(newPage) {
|
||||
if (newPage < 1) return;
|
||||
_syncHistoryState.page = newPage;
|
||||
loadSyncHistory();
|
||||
}
|
||||
|
||||
// Track active re-syncs from history
|
||||
let _activeSyncHistoryResyncs = {};
|
||||
|
||||
// Sources that do server playlist sync (match to media server) vs download (Soulseek download)
|
||||
const _serverSyncSources = new Set(['spotify', 'tidal', 'deezer', 'youtube', 'mirrored', 'listenbrainz', 'spotify_public', 'beatport']);
|
||||
const _downloadSyncSources = new Set(['discover', 'library', 'wishlist']);
|
||||
|
||||
async function retriggerSync(entryId) {
|
||||
try {
|
||||
const resp = await fetch(`/api/sync/history/${entryId}`);
|
||||
const data = await resp.json();
|
||||
|
||||
if (!data.success || !data.entry) {
|
||||
showToast('Failed to load sync data', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = data.entry;
|
||||
|
||||
// Determine if this is a download-type sync or a server-sync-type
|
||||
const isDownloadSync = entry.is_album_download || _downloadSyncSources.has(entry.source);
|
||||
const isServerSync = _serverSyncSources.has(entry.source) && !entry.is_album_download;
|
||||
|
||||
if (isDownloadSync) {
|
||||
// Download syncs open the download modal (existing behavior)
|
||||
closeSyncHistoryModal();
|
||||
|
||||
const virtualPlaylistId = entry.playlist_id || `resync_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const albumObj = entry.album_context || {
|
||||
id: `resync_album_${entryId}`,
|
||||
name: entry.playlist_name,
|
||||
album_type: entry.sync_type === 'album' ? 'album' : 'compilation',
|
||||
images: entry.thumb_url ? [{ url: entry.thumb_url }] : [],
|
||||
total_tracks: entry.total_tracks
|
||||
};
|
||||
const artistObj = entry.artist_context || { id: 'resync_artist', name: 'Various Artists' };
|
||||
const contextType = entry.sync_type === 'album' ? 'artist_album' : 'playlist';
|
||||
|
||||
await openDownloadMissingModalForArtistAlbum(
|
||||
virtualPlaylistId, entry.playlist_name, entry.tracks,
|
||||
albumObj, artistObj, false, contextType
|
||||
);
|
||||
} else {
|
||||
// Server sync — start sync and show live progress in the card
|
||||
await _startSyncHistoryResync(entryId, entry);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error re-triggering sync:', err);
|
||||
showToast('Error loading sync data', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function _startSyncHistoryResync(entryId, entry) {
|
||||
// Disable the re-sync button
|
||||
const btn = document.getElementById(`resync-btn-${entryId}`);
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Syncing...'; }
|
||||
|
||||
// Show the progress area
|
||||
const wrapper = document.getElementById(`sync-history-wrapper-${entryId}`);
|
||||
const progressArea = document.getElementById(`sync-history-progress-${entryId}`);
|
||||
if (wrapper) wrapper.classList.add('syncing');
|
||||
if (progressArea) progressArea.style.display = '';
|
||||
|
||||
// Build a unique sync playlist ID for this re-sync
|
||||
const syncPlaylistId = `resync_${entryId}_${Date.now()}`;
|
||||
|
||||
// Prepare tracks for the sync API
|
||||
const tracks = (entry.tracks || []).map(t => {
|
||||
const artists = Array.isArray(t.artists)
|
||||
? (typeof t.artists[0] === 'object' ? t.artists.map(a => a.name || a) : t.artists)
|
||||
: [t.artists || 'Unknown Artist'];
|
||||
const albumName = typeof t.album === 'object' ? (t.album?.name || '') : (t.album || '');
|
||||
return {
|
||||
id: t.id || '',
|
||||
name: t.name || '',
|
||||
artists: artists,
|
||||
album: albumName,
|
||||
duration_ms: t.duration_ms || 0,
|
||||
popularity: t.popularity || 0
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/sync/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
playlist_id: syncPlaylistId,
|
||||
playlist_name: entry.playlist_name,
|
||||
tracks: tracks
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
showToast(`Sync failed: ${result.error || 'Unknown error'}`, 'error');
|
||||
_cleanupSyncHistoryResync(entryId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store active re-sync state
|
||||
_activeSyncHistoryResyncs[entryId] = { syncPlaylistId, entryId };
|
||||
|
||||
// Start polling for progress
|
||||
_pollSyncHistoryProgress(entryId, syncPlaylistId);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error starting re-sync:', err);
|
||||
showToast('Failed to start sync', 'error');
|
||||
_cleanupSyncHistoryResync(entryId);
|
||||
}
|
||||
}
|
||||
|
||||
function _pollSyncHistoryProgress(entryId, syncPlaylistId) {
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/sync/status/${syncPlaylistId}`);
|
||||
if (!resp.ok) {
|
||||
clearInterval(pollInterval);
|
||||
_cleanupSyncHistoryResync(entryId, 'error');
|
||||
return;
|
||||
}
|
||||
const state = await resp.json();
|
||||
|
||||
if (state.status === 'syncing' || state.status === 'starting') {
|
||||
const progress = state.progress || {};
|
||||
const matched = progress.matched_tracks || 0;
|
||||
const failed = progress.failed_tracks || 0;
|
||||
const total = progress.total_tracks || 0;
|
||||
const step = progress.current_step || 'Processing';
|
||||
const currentTrack = progress.current_track || '';
|
||||
const processed = matched + failed;
|
||||
const percent = total > 0 ? Math.round((processed / total) * 100) : 0;
|
||||
|
||||
const bar = document.getElementById(`sync-history-bar-${entryId}`);
|
||||
const stepEl = document.getElementById(`sync-history-step-${entryId}`);
|
||||
const matchedEl = document.getElementById(`sync-history-matched-${entryId}`);
|
||||
const failedEl = document.getElementById(`sync-history-failed-${entryId}`);
|
||||
|
||||
if (bar) bar.style.width = `${percent}%`;
|
||||
if (stepEl) stepEl.textContent = currentTrack ? `${step} — ${currentTrack}` : step;
|
||||
if (matchedEl) matchedEl.textContent = `${matched} matched`;
|
||||
if (failedEl) failedEl.textContent = `${failed} failed`;
|
||||
|
||||
} else if (state.status === 'finished') {
|
||||
clearInterval(pollInterval);
|
||||
const progress = state.progress || state.result || {};
|
||||
const matched = progress.matched_tracks || 0;
|
||||
const failed = progress.failed_tracks || 0;
|
||||
const total = progress.total_tracks || 0;
|
||||
const synced = progress.synced_tracks || 0;
|
||||
|
||||
const bar = document.getElementById(`sync-history-bar-${entryId}`);
|
||||
const stepEl = document.getElementById(`sync-history-step-${entryId}`);
|
||||
const matchedEl = document.getElementById(`sync-history-matched-${entryId}`);
|
||||
const failedEl = document.getElementById(`sync-history-failed-${entryId}`);
|
||||
|
||||
if (bar) bar.style.width = '100%';
|
||||
if (stepEl) stepEl.textContent = `Sync complete — ${matched}/${total} matched, ${synced} synced`;
|
||||
if (matchedEl) matchedEl.textContent = `${matched} matched`;
|
||||
if (failedEl) failedEl.textContent = `${failed} failed`;
|
||||
|
||||
// Hide cancel button
|
||||
const cancelBtn = document.getElementById(`sync-history-cancel-${entryId}`);
|
||||
if (cancelBtn) cancelBtn.style.display = 'none';
|
||||
|
||||
showToast(`Re-sync complete: ${matched}/${total} matched`, 'success');
|
||||
|
||||
// Auto-collapse after 5 seconds
|
||||
setTimeout(() => _cleanupSyncHistoryResync(entryId, 'finished'), 5000);
|
||||
|
||||
} else if (state.status === 'cancelled' || state.status === 'error') {
|
||||
clearInterval(pollInterval);
|
||||
const stepEl = document.getElementById(`sync-history-step-${entryId}`);
|
||||
if (stepEl) stepEl.textContent = state.status === 'cancelled' ? 'Sync cancelled' : `Sync error: ${state.error || 'Unknown'}`;
|
||||
|
||||
const cancelBtn = document.getElementById(`sync-history-cancel-${entryId}`);
|
||||
if (cancelBtn) cancelBtn.style.display = 'none';
|
||||
|
||||
setTimeout(() => _cleanupSyncHistoryResync(entryId, state.status), 3000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error polling sync status:', err);
|
||||
clearInterval(pollInterval);
|
||||
_cleanupSyncHistoryResync(entryId, 'error');
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
// Store interval so cancel can clear it
|
||||
if (_activeSyncHistoryResyncs[entryId]) {
|
||||
_activeSyncHistoryResyncs[entryId].pollInterval = pollInterval;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelSyncHistoryResync(entryId) {
|
||||
const active = _activeSyncHistoryResyncs[entryId];
|
||||
if (!active) return;
|
||||
|
||||
try {
|
||||
await fetch('/api/sync/cancel', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ playlist_id: active.syncPlaylistId })
|
||||
});
|
||||
|
||||
const stepEl = document.getElementById(`sync-history-step-${entryId}`);
|
||||
if (stepEl) stepEl.textContent = 'Cancelling...';
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error cancelling sync:', err);
|
||||
showToast('Failed to cancel sync', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function _cleanupSyncHistoryResync(entryId, finalStatus) {
|
||||
const active = _activeSyncHistoryResyncs[entryId];
|
||||
if (active && active.pollInterval) {
|
||||
clearInterval(active.pollInterval);
|
||||
}
|
||||
delete _activeSyncHistoryResyncs[entryId];
|
||||
|
||||
const wrapper = document.getElementById(`sync-history-wrapper-${entryId}`);
|
||||
const progressArea = document.getElementById(`sync-history-progress-${entryId}`);
|
||||
const btn = document.getElementById(`resync-btn-${entryId}`);
|
||||
|
||||
if (wrapper) wrapper.classList.remove('syncing');
|
||||
if (progressArea) progressArea.style.display = 'none';
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Re-sync'; }
|
||||
}
|
||||
|
||||
async function deleteSyncHistoryEntry(entryId) {
|
||||
try {
|
||||
const resp = await fetch(`/api/sync/history/${entryId}`, { method: 'DELETE' });
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
const wrapper = document.getElementById(`sync-history-wrapper-${entryId}`);
|
||||
if (wrapper) {
|
||||
wrapper.style.transition = 'opacity 0.2s ease, max-height 0.3s ease';
|
||||
wrapper.style.opacity = '0';
|
||||
wrapper.style.maxHeight = wrapper.offsetHeight + 'px';
|
||||
requestAnimationFrame(() => { wrapper.style.maxHeight = '0'; wrapper.style.overflow = 'hidden'; });
|
||||
setTimeout(() => wrapper.remove(), 300);
|
||||
}
|
||||
} else {
|
||||
showToast('Failed to delete entry', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting sync history entry:', err);
|
||||
showToast('Failed to delete entry', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Metadata Cache Modal ────────────────────────────────────────────
|
||||
let _mcacheCurrentTab = 'artist';
|
||||
let _mcachePage = 0;
|
||||
|
|
|
|||
|
|
@ -7025,6 +7025,185 @@ body {
|
|||
padding: 0 8px;
|
||||
}
|
||||
|
||||
/* ── Sync History Modal ─────────────────────────────────── */
|
||||
.sync-history-modal {
|
||||
width: 750px;
|
||||
max-width: 95vw;
|
||||
max-height: 80vh;
|
||||
background: linear-gradient(135deg, rgba(26, 26, 26, 0.98) 0%, rgba(16, 16, 16, 0.99) 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.sync-history-modal-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 18px 24px; border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.sync-history-modal-header h3 { margin: 0; font-size: 18px; font-weight: 600; color: rgba(255, 255, 255, 0.9); }
|
||||
.sync-history-modal-close {
|
||||
background: none; border: none; color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 22px; cursor: pointer; padding: 0 4px; line-height: 1; transition: color 0.2s ease;
|
||||
}
|
||||
.sync-history-modal-close:hover { color: #fff; }
|
||||
.sync-history-tabs {
|
||||
display: flex; gap: 4px; padding: 12px 24px; border-bottom: 1px solid rgba(255, 255, 255, 0.06); flex-wrap: wrap;
|
||||
}
|
||||
.sync-history-tab {
|
||||
font-size: 12px; font-weight: 500; color: rgba(255, 255, 255, 0.45);
|
||||
background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 8px; padding: 6px 12px; cursor: pointer; transition: all 0.2s ease;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.sync-history-tab:hover { color: rgba(255, 255, 255, 0.7); background: rgba(255, 255, 255, 0.08); }
|
||||
.sync-history-tab.active { color: rgb(var(--accent-rgb)); background: rgba(var(--accent-rgb), 0.12); border-color: rgba(var(--accent-rgb), 0.3); }
|
||||
.sync-history-tab-count {
|
||||
font-size: 10px; font-weight: 600; background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.4); padding: 1px 6px; border-radius: 10px; min-width: 18px; text-align: center;
|
||||
}
|
||||
.sync-history-tab.active .sync-history-tab-count { background: rgba(var(--accent-rgb), 0.2); color: rgb(var(--accent-rgb)); }
|
||||
.sync-history-list { flex: 1; overflow-y: auto; padding: 8px 16px; min-height: 200px; }
|
||||
.sync-history-list::-webkit-scrollbar { width: 6px; }
|
||||
.sync-history-list::-webkit-scrollbar-track { background: transparent; }
|
||||
.sync-history-list::-webkit-scrollbar-thumb { background: rgba(var(--accent-rgb), 0.25); border-radius: 3px; }
|
||||
.sync-history-entry {
|
||||
display: flex; align-items: center; gap: 12px; padding: 10px 12px;
|
||||
border-left: 3px solid rgba(var(--accent-rgb), 0.15);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.03); border-radius: 8px;
|
||||
transition: background 0.15s ease; cursor: default;
|
||||
}
|
||||
.sync-history-entry:hover { background: rgba(255, 255, 255, 0.03); }
|
||||
.sync-history-thumb {
|
||||
width: 44px; height: 44px; border-radius: 6px; object-fit: cover; flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.sync-history-thumb-placeholder {
|
||||
width: 44px; height: 44px; border-radius: 6px; flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.05); display: flex; align-items: center;
|
||||
justify-content: center; font-size: 20px;
|
||||
}
|
||||
.sync-history-entry-text { flex: 1; min-width: 0; }
|
||||
.sync-history-entry-title {
|
||||
font-size: 13px; font-weight: 500; color: rgba(255, 255, 255, 0.85);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.sync-history-entry-meta {
|
||||
font-size: 11px; color: rgba(255, 255, 255, 0.35); margin-top: 2px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.sync-history-stats {
|
||||
display: flex; gap: 6px; align-items: center; flex-shrink: 0;
|
||||
}
|
||||
.sync-history-stat {
|
||||
font-size: 10px; font-weight: 600; padding: 2px 7px; border-radius: 6px;
|
||||
}
|
||||
.sync-history-stat.found { background: rgba(76, 175, 80, 0.15); color: #4caf50; }
|
||||
.sync-history-stat.downloaded { background: rgba(var(--accent-rgb), 0.15); color: rgb(var(--accent-rgb)); }
|
||||
.sync-history-stat.failed { background: rgba(244, 67, 54, 0.15); color: #f44336; }
|
||||
.sync-history-stat.pending { background: rgba(255, 193, 7, 0.15); color: #ffc107; }
|
||||
.sync-history-source-badge {
|
||||
font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 6px;
|
||||
text-transform: uppercase; letter-spacing: 0.5px; flex-shrink: 0;
|
||||
}
|
||||
.sync-history-source-badge.spotify { background: rgba(30, 215, 96, 0.15); color: #1ed760; }
|
||||
.sync-history-source-badge.beatport { background: rgba(148, 210, 42, 0.15); color: #94d22a; }
|
||||
.sync-history-source-badge.youtube { background: rgba(255, 0, 0, 0.15); color: #ff4444; }
|
||||
.sync-history-source-badge.tidal { background: rgba(0, 255, 255, 0.15); color: #00cccc; }
|
||||
.sync-history-source-badge.deezer { background: rgba(160, 54, 204, 0.15); color: #cd5cf0; }
|
||||
.sync-history-source-badge.wishlist { background: rgba(255, 193, 7, 0.15); color: #ffc107; }
|
||||
.sync-history-source-badge.library { background: rgba(158, 158, 158, 0.15); color: #9e9e9e; }
|
||||
.sync-history-source-badge.discover { background: rgba(255, 152, 0, 0.15); color: #ff9800; }
|
||||
.sync-history-source-badge.mirrored { background: rgba(100, 181, 246, 0.15); color: #64b5f6; }
|
||||
.sync-history-source-badge.spotify_public { background: rgba(30, 215, 96, 0.1); color: #1ed760; }
|
||||
.sync-history-source-badge.listenbrainz { background: rgba(255, 107, 53, 0.15); color: #ff6b35; }
|
||||
.sync-history-entry-time {
|
||||
font-size: 11px; color: rgba(255, 255, 255, 0.25); flex-shrink: 0; text-align: right; min-width: 50px;
|
||||
}
|
||||
.sync-history-resync-btn {
|
||||
font-size: 11px; font-weight: 600; color: rgb(var(--accent-rgb));
|
||||
background: rgba(var(--accent-rgb), 0.1); border: 1px solid rgba(var(--accent-rgb), 0.25);
|
||||
border-radius: 6px; padding: 4px 10px; cursor: pointer; transition: all 0.2s ease;
|
||||
flex-shrink: 0; white-space: nowrap;
|
||||
}
|
||||
.sync-history-resync-btn:hover {
|
||||
background: rgba(var(--accent-rgb), 0.2); border-color: rgba(var(--accent-rgb), 0.5);
|
||||
}
|
||||
.sync-history-delete-btn {
|
||||
font-size: 14px; line-height: 1; color: rgba(255, 255, 255, 0.2);
|
||||
background: none; border: none; cursor: pointer; padding: 2px 4px;
|
||||
border-radius: 4px; transition: all 0.15s ease; flex-shrink: 0;
|
||||
}
|
||||
.sync-history-delete-btn:hover { color: #f44336; background: rgba(244, 67, 54, 0.1); }
|
||||
.sync-history-resync-btn:disabled {
|
||||
opacity: 0.4; cursor: not-allowed; pointer-events: none;
|
||||
}
|
||||
/* Expandable sync progress within history entry */
|
||||
.sync-history-entry-wrapper { border-radius: 8px; overflow: hidden; }
|
||||
.sync-history-entry-wrapper.syncing {
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.2);
|
||||
background: rgba(var(--accent-rgb), 0.03);
|
||||
}
|
||||
.sync-history-live-progress {
|
||||
padding: 10px 14px; background: rgba(0, 0, 0, 0.15);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
animation: syncHistoryFadeIn 0.2s ease;
|
||||
}
|
||||
@keyframes syncHistoryFadeIn { from { opacity: 0; max-height: 0; } to { opacity: 1; max-height: 120px; } }
|
||||
.sync-history-progress-bar-container {
|
||||
height: 4px; background: rgba(255, 255, 255, 0.08); border-radius: 4px;
|
||||
overflow: hidden; margin-bottom: 8px;
|
||||
}
|
||||
.sync-history-progress-bar-fill {
|
||||
height: 100%; background: rgb(var(--accent-rgb)); border-radius: 4px;
|
||||
transition: width 0.3s ease; width: 0%;
|
||||
}
|
||||
.sync-history-progress-text {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
font-size: 11px; color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.sync-history-progress-step {
|
||||
flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.sync-history-progress-stats {
|
||||
display: flex; gap: 8px; flex-shrink: 0; font-size: 11px;
|
||||
}
|
||||
.sync-history-progress-stats span { color: rgba(255, 255, 255, 0.4); }
|
||||
.sync-history-progress-stats .matched { color: #4caf50; }
|
||||
.sync-history-progress-stats .failed { color: #f44336; }
|
||||
.sync-history-cancel-btn {
|
||||
font-size: 10px; font-weight: 600; color: #f44336;
|
||||
background: rgba(244, 67, 54, 0.1); border: 1px solid rgba(244, 67, 54, 0.25);
|
||||
border-radius: 4px; padding: 3px 8px; cursor: pointer; margin-left: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.sync-history-cancel-btn:hover {
|
||||
background: rgba(244, 67, 54, 0.2); border-color: rgba(244, 67, 54, 0.5);
|
||||
}
|
||||
.sync-history-empty {
|
||||
text-align: center; padding: 48px 24px; color: rgba(255, 255, 255, 0.3); font-size: 14px;
|
||||
}
|
||||
.sync-history-loading { text-align: center; padding: 48px; color: rgba(255, 255, 255, 0.3); }
|
||||
.sync-history-pagination {
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||
padding: 12px 24px; border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.sync-history-page-btn {
|
||||
font-size: 12px; font-weight: 500; color: rgba(255, 255, 255, 0.5);
|
||||
background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px; padding: 5px 12px; cursor: pointer; transition: all 0.2s ease;
|
||||
}
|
||||
.sync-history-page-btn:hover:not(:disabled) { color: rgb(var(--accent-rgb)); background: rgba(var(--accent-rgb), 0.1); }
|
||||
.sync-history-page-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.sync-history-page-info { font-size: 12px; color: rgba(255, 255, 255, 0.35); }
|
||||
.sync-history-btn {
|
||||
font-size: 12px; font-weight: 500; color: rgba(255, 255, 255, 0.5);
|
||||
background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px; padding: 5px 12px; cursor: pointer; transition: all 0.2s ease;
|
||||
}
|
||||
.sync-history-btn:hover { color: rgb(var(--accent-rgb)); background: rgba(var(--accent-rgb), 0.1); border-color: rgba(var(--accent-rgb), 0.3); }
|
||||
|
||||
/* Enhanced Progress Bar Animation */
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
|
|
@ -7105,6 +7284,12 @@ body {
|
|||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.sync-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sync-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
|
|
|
|||
Loading…
Reference in a new issue