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:
Broque Thomas 2026-03-17 20:39:41 -07:00
parent f8f113d0e7
commit 27a0cf8b81
5 changed files with 980 additions and 5 deletions

View file

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

View file

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

View file

@ -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()">&times;</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">

View file

@ -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\\'>&#x1F4E5;</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">&times;</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: '&#x1F3B5;', beatport: '&#x1F3B6;', youtube: '&#x25B6;',
tidal: '&#x1F30A;', deezer: '&#x1F3A7;', wishlist: '&#x2B50;',
library: '&#x1F4DA;', discover: '&#x1F50D;', mirrored: '&#x1F517;',
listenbrainz: '&#x1F3A7;', spotify_public: '&#x1F3B5;'
};
return icons[source] || '&#x1F4E5;';
}
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;

View file

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