From de3fba3f3762aa7b072eb81a2a8f43e91d0f19d7 Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Tue, 31 Mar 2026 15:26:46 -0700
Subject: [PATCH] Add Sync History dashboard with per-track match caching and
detail modal
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
New dashboard section shows recent syncs as scrolling cards with
playlist art, source badge, match percentage bar, and health color.
Click any card to open a detail modal showing every track's match
status, confidence score, album art, and download/wishlist status.
Per-track data is now cached in sync_history.track_results for all
sync paths: server-sync (playlistβmedia server), download missing
tracks, and wishlist processing. SyncResult carries match_details
from the sync service. Both image URLs and matched track info are
preserved for review.
Features:
- Staggered card entrance animation, delete button on hover
- Filter bar: All/Matched/Unmatched/Downloaded
- Color-coded confidence badges (green/amber/red)
- Unmatched tracks show "β Wishlist" status
- 32px album art thumbnails per track row
- Auto-refreshes every 30 seconds on dashboard
- Falls back gracefully for old syncs without track_results
---
services/sync_service.py | 46 +++++-
web_server.py | 69 +++++++-
webui/index.html | 8 +
webui/static/helper.js | 1 +
webui/static/script.js | 278 +++++++++++++++++++++++++++++++
webui/static/style.css | 343 +++++++++++++++++++++++++++++++++++++++
6 files changed, 739 insertions(+), 6 deletions(-)
diff --git a/services/sync_service.py b/services/sync_service.py
index a972b030..a296d1df 100644
--- a/services/sync_service.py
+++ b/services/sync_service.py
@@ -23,6 +23,7 @@ class SyncResult:
sync_time: datetime
errors: List[str]
wishlist_added_count: int = 0
+ match_details: list = None # Per-track match data for sync history
@property
def success_rate(self) -> float:
@@ -362,6 +363,48 @@ class PlaylistSyncService:
logger.warning(f"Failed to auto-add tracks to wishlist: {e}")
# Don't fail the sync if wishlist add fails
+ # Build per-track match details for sync history
+ _match_details = []
+ for i, mr in enumerate(match_results):
+ t = mr.spotify_track
+ artists = t.artists if hasattr(t, 'artists') and t.artists else []
+ first_artist = artists[0] if artists else ''
+ if isinstance(first_artist, dict):
+ first_artist = first_artist.get('name', '')
+ album = getattr(t, 'album', '')
+ if isinstance(album, dict):
+ album = album.get('name', '')
+
+ # Extract image URL from track's album data
+ image_url = getattr(t, 'image_url', None) or ''
+ if not image_url:
+ t_album = getattr(t, 'album', None)
+ if isinstance(t_album, dict):
+ imgs = t_album.get('images', [])
+ if imgs and isinstance(imgs, list) and len(imgs) > 0:
+ image_url = imgs[0].get('url', '') if isinstance(imgs[0], dict) else ''
+
+ detail = {
+ 'index': i,
+ 'name': t.name if hasattr(t, 'name') else '',
+ 'artist': str(first_artist),
+ 'album': str(album or ''),
+ 'duration_ms': getattr(t, 'duration_ms', 0),
+ 'source_track_id': getattr(t, 'id', ''),
+ 'image_url': image_url,
+ 'status': 'found' if mr.is_match else 'not_found',
+ 'confidence': round(mr.confidence, 3),
+ 'matched_track': None,
+ 'download_status': 'wishlist' if not mr.is_match else None,
+ }
+ if mr.plex_track:
+ detail['matched_track'] = {
+ 'title': getattr(mr.plex_track, 'title', ''),
+ 'artist_name': getattr(mr.plex_track, 'artist', ''),
+ 'album_title': getattr(mr.plex_track, 'album', ''),
+ }
+ _match_details.append(detail)
+
result = SyncResult(
playlist_name=playlist.name,
total_tracks=len(playlist.tracks),
@@ -371,7 +414,8 @@ class PlaylistSyncService:
failed_tracks=failed_tracks,
sync_time=datetime.now(),
errors=errors,
- wishlist_added_count=wishlist_added_count
+ wishlist_added_count=wishlist_added_count,
+ match_details=_match_details
)
logger.info(f"Sync completed: {result.success_rate:.1f}% success rate")
diff --git a/web_server.py b/web_server.py
index 01721efb..18bd58ac 100644
--- a/web_server.py
+++ b/web_server.py
@@ -19314,6 +19314,17 @@ def get_version_info():
"title": "What's New in SoulSync",
"subtitle": f"Version {SOULSYNC_VERSION} β Latest Changes",
"sections": [
+ {
+ "title": "π Sync History Dashboard with Per-Track Details",
+ "description": "Dashboard shows recent syncs as visual cards with full per-track match data",
+ "features": [
+ "β’ Recent Syncs section on dashboard with scrolling cards showing match percentage and health indicators",
+ "β’ Click any sync card to see per-track match details β status, confidence score, album art, download/wishlist status",
+ "β’ Filter by All, Matched, Unmatched, or Downloaded tracks in the detail modal",
+ "β’ Per-track data cached for all sync types β playlist-to-server, download missing tracks, wishlist processing",
+ "β’ Auto-refreshes every 30 seconds when viewing dashboard"
+ ]
+ },
{
"title": "π§ Fix Japanese Song Searches Producing Gibberish",
"description": "CJK text no longer mangled by unidecode in Soulseek search queries",
@@ -24327,11 +24338,19 @@ def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json):
artists = td.get('artists', [])
first_artist = (artists[0].get('name', artists[0]) if isinstance(artists[0], dict) else str(artists[0])) if artists else ''
alb = td.get('album', '')
+ # Extract image
+ _img = ''
+ _alb_obj = td.get('album', {})
+ if isinstance(_alb_obj, dict):
+ _alb_imgs = _alb_obj.get('images', [])
+ if _alb_imgs and isinstance(_alb_imgs, list) and len(_alb_imgs) > 0:
+ _img = _alb_imgs[0].get('url', '') if isinstance(_alb_imgs[0], dict) else ''
track_results.append({
'index': res.get('track_index', 0),
'name': td.get('name', ''),
'artist': first_artist,
'album': alb.get('name', '') if isinstance(alb, dict) else str(alb or ''),
+ 'image_url': _img,
'duration_ms': td.get('duration_ms', 0),
'source_track_id': td.get('id', ''),
'status': 'found' if res.get('found') else 'not_found',
@@ -27372,12 +27391,21 @@ def _record_sync_history_completion(batch_id, batch):
album = track_data.get('album', '')
album_name = album.get('name', '') if isinstance(album, dict) else str(album or '')
+ # Extract image URL
+ image_url = ''
+ album_obj = track_data.get('album', {})
+ if isinstance(album_obj, dict):
+ imgs = album_obj.get('images', [])
+ if imgs and isinstance(imgs, list) and len(imgs) > 0:
+ image_url = imgs[0].get('url', '') if isinstance(imgs[0], dict) else ''
+
idx = res.get('track_index', 0)
entry = {
'index': idx,
'name': track_data.get('name', ''),
'artist': artist_name,
'album': album_name,
+ 'image_url': image_url,
'duration_ms': track_data.get('duration_ms', 0),
'source_track_id': track_data.get('id', ''),
'status': 'found' if res.get('found') else 'not_found',
@@ -33026,16 +33054,26 @@ def _run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None,
else:
album_name = str(raw_album)
+ # Extract image URL from album data if available
+ _track_image = ''
+ if isinstance(raw_album, dict):
+ _imgs = raw_album.get('images', [])
+ if _imgs and isinstance(_imgs, list) and len(_imgs) > 0:
+ _track_image = _imgs[0].get('url', '') if isinstance(_imgs[0], dict) else ''
+ if not _track_image:
+ _track_image = t.get('image_url', '')
+
# Create SpotifyTrack objects with proper default values for missing fields
track = SpotifyTrack(
- id=t.get('id', ''), # Provide default empty string
+ id=t.get('id', ''),
name=t.get('name', ''),
artists=t.get('artists', []),
album=album_name,
duration_ms=t.get('duration_ms', 0),
- popularity=t.get('popularity', 0), # Default value
+ popularity=t.get('popularity', 0),
preview_url=t.get('preview_url'),
- external_urls=t.get('external_urls')
+ external_urls=t.get('external_urls'),
+ image_url=_track_image or None
)
tracks.append(track)
if i < 3: # Log first 3 tracks for debugging
@@ -33256,9 +33294,11 @@ def _run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None,
# Update final state on completion
# Convert result to JSON-serializable dict (datetime/errors can't be emitted via SocketIO)
+ # Exclude match_details β large, not needed for live status, saved to DB separately
result_dict = {
k: (v.isoformat() if hasattr(v, 'isoformat') else v)
for k, v in result.__dict__.items()
+ if k != 'match_details'
}
with sync_lock:
sync_states[playlist_id] = {
@@ -33268,17 +33308,36 @@ 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
+ # Record sync history completion with per-track data
try:
matched = getattr(result, 'matched_tracks', 0)
failed = getattr(result, 'failed_tracks', 0)
synced = getattr(result, 'synced_tracks', 0)
db = MusicDatabase()
+ target_batch_id = sync_batch_id
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)
+ # For re-sync, get the batch_id from the original entry
+ try:
+ entry = db.get_sync_history_entry(_resync_entry_id)
+ if entry:
+ target_batch_id = entry.get('batch_id', sync_batch_id)
+ except Exception:
+ pass
else:
db.update_sync_history_completion(sync_batch_id, matched, synced, failed)
+
+ # Save per-track match details from sync service
+ match_details = getattr(result, 'match_details', None)
+ if match_details:
+ try:
+ track_results_json = json.dumps(match_details, default=str)
+ saved = db.update_sync_history_track_results(target_batch_id, track_results_json)
+ print(f"π [Sync History] Saved {len(match_details)} track results for batch {target_batch_id} (saved={saved})")
+ except Exception as json_err:
+ print(f"β οΈ [Sync History] Failed to serialize track results: {json_err}")
+ else:
+ print(f"β οΈ [Sync History] No match_details on SyncResult for batch {target_batch_id}")
except Exception as e:
logger.warning(f"Failed to record sync history completion: {e}")
diff --git a/webui/index.html b/webui/index.html
index 076d64d7..bff0fa35 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -679,6 +679,14 @@
+
+
+
System Statistics
diff --git a/webui/static/helper.js b/webui/static/helper.js
index ca3f0aa3..b0376249 100644
--- a/webui/static/helper.js
+++ b/webui/static/helper.js
@@ -3403,6 +3403,7 @@ function closeHelperSearch() {
const WHATS_NEW = {
'2.1': [
// Newest features first
+ { title: 'Sync History Dashboard', desc: 'Dashboard shows recent syncs as cards β click for per-track match details with confidence scores and album art' },
{ title: 'Fix Japanese/CJK Soulseek Searches', desc: 'Japanese kanji no longer mangled into Chinese pinyin β searches now use original characters' },
{ title: 'Fix Partial Title Matching', desc: '"Believe" no longer falsely matches "Believe In Me" β length ratio penalty prevents prefix false positives' },
{ title: 'Fix Pipeline Blocking on Discovery Fail', desc: 'Playlist sync no longer drops tracks that failed metadata discovery β continues with original name/artist for download' },
diff --git a/webui/static/script.js b/webui/static/script.js
index c32382c7..7049c00c 100644
--- a/webui/static/script.js
+++ b/webui/static/script.js
@@ -2784,6 +2784,7 @@ async function loadPageData(pageId) {
switch (pageId) {
case 'dashboard':
await loadDashboardData();
+ loadDashboardSyncHistory();
break;
case 'sync':
initializeSyncPage();
@@ -9122,6 +9123,7 @@ async function loadInitialData() {
navigateToPage(homePage);
} else {
await loadDashboardData();
+ loadDashboardSyncHistory();
}
} catch (error) {
console.error('Error loading initial data:', error);
@@ -64510,3 +64512,279 @@ window.addEventListener('resize', () => {
clearTimeout(_explorer._resizeTimer);
_explorer._resizeTimer = setTimeout(() => _explorerRedrawAllConnections(), 150);
});
+
+
+// ==================================================================================
+// DASHBOARD β Recent Syncs Section
+// ==================================================================================
+
+// Auto-refresh sync cards every 30 seconds when on dashboard
+setInterval(() => {
+ if (typeof currentPage !== 'undefined' && currentPage === 'dashboard') {
+ loadDashboardSyncHistory();
+ }
+}, 30000);
+
+async function loadDashboardSyncHistory() {
+ const container = document.getElementById('sync-history-cards');
+ if (!container) return;
+
+ try {
+ const response = await fetch('/api/sync/history?limit=10');
+ if (!response.ok) return;
+
+ const data = await response.json();
+ const entries = data.entries || [];
+
+ if (entries.length === 0) {
+ container.innerHTML = '
No syncs yet
';
+ return;
+ }
+
+ container.innerHTML = entries.map((entry, cardIndex) => {
+ const found = entry.tracks_found || 0;
+ const total = entry.total_tracks || 0;
+ const downloaded = entry.tracks_downloaded || 0;
+ const failed = entry.tracks_failed || 0;
+ const pct = total > 0 ? Math.round((found / total) * 100) : 0;
+
+ // Health color
+ let healthClass = 'health-good';
+ if (pct < 50) healthClass = 'health-bad';
+ else if (pct < 80) healthClass = 'health-warn';
+
+ // Source badge
+ const sourceLabels = { spotify: 'Spotify', tidal: 'Tidal', deezer: 'Deezer', youtube: 'YouTube', beatport: 'Beatport', wishlist: 'Wishlist' };
+ const sourceLabel = sourceLabels[entry.source] || entry.source || 'Unknown';
+
+ // Time
+ const timeStr = entry.started_at ? _relativeTime(entry.started_at) : '';
+
+ // Name
+ const name = entry.artist_name
+ ? `${entry.artist_name} β ${entry.album_name || entry.playlist_name}`
+ : entry.playlist_name || 'Unknown';
+
+ return `
+
+
+
+ ${entry.thumb_url ? `

` : '
♫
'}
+
+
+
${typeof _esc === 'function' ? _esc(name) : name}
+
+ ${sourceLabel}
+ ${timeStr}
+
+
+
+
${pct}%
+
+
${found}/${total} matched${downloaded > 0 ? ` Β· ${downloaded} β¬` : ''}${failed > 0 ? ` Β· ${failed} β` : ''}
+
+
+ `;
+ }).join('');
+
+ } catch (e) {
+ console.warn('Failed to load sync history for dashboard:', e);
+ }
+}
+
+function _relativeTime(dateStr) {
+ try {
+ const d = new Date(dateStr);
+ const now = new Date();
+ const diffMs = now - d;
+ const mins = Math.floor(diffMs / 60000);
+ if (mins < 1) return 'just now';
+ if (mins < 60) return `${mins}m ago`;
+ const hrs = Math.floor(mins / 60);
+ if (hrs < 24) return `${hrs}h ago`;
+ const days = Math.floor(hrs / 24);
+ if (days < 7) return `${days}d ago`;
+ return d.toLocaleDateString();
+ } catch (e) { return ''; }
+}
+
+async function openSyncDetailModal(entryId) {
+ try {
+ showLoadingOverlay('Loading sync details...');
+ const response = await fetch(`/api/sync/history/${entryId}`);
+ const data = await response.json();
+ hideLoadingOverlay();
+
+ if (!data.success || !data.entry) {
+ showToast('Could not load sync details', 'error');
+ return;
+ }
+
+ const entry = data.entry;
+ const trackResults = entry.track_results || [];
+ const name = entry.artist_name
+ ? `${entry.artist_name} β ${entry.album_name || entry.playlist_name}`
+ : entry.playlist_name || 'Unknown';
+
+ // Build modal
+ const overlay = document.createElement('div');
+ overlay.className = 'discog-modal-overlay';
+ overlay.id = 'sync-detail-overlay';
+
+ const found = entry.tracks_found || 0;
+ const total = entry.total_tracks || 0;
+ const downloaded = entry.tracks_downloaded || 0;
+
+ let trackRowsHtml = '';
+ if (trackResults.length > 0) {
+ trackRowsHtml = trackResults.map((t, i) => {
+ const statusIcon = t.status === 'found' ? 'β
' : 'β';
+ const statusClass = t.status === 'found' ? 'matched' : 'unmatched';
+ const confPct = Math.round((t.confidence || 0) * 100);
+ const confClass = confPct >= 80 ? 'conf-high' : confPct >= 50 ? 'conf-mid' : 'conf-low';
+ let dlIcon = '';
+ if (t.download_status === 'completed') dlIcon = 'β
';
+ else if (t.download_status === 'failed') dlIcon = 'β';
+ else if (t.download_status === 'not_found') dlIcon = 'π';
+ else if (t.download_status === 'cancelled') dlIcon = 'π«';
+
+ let dlDisplay = dlIcon;
+ if (!dlDisplay && t.download_status === 'wishlist') dlDisplay = '
β Wishlist';
+
+ return `
+
+ | ${i + 1} |
+
+ ${t.image_url ? ` ` : ''}
+ |
+ ${_esc(t.name || '')} |
+ ${_esc(t.artist || '')} |
+ ${_esc(t.album || '')} |
+ ${statusIcon} |
+ ${confPct}% |
+ ${dlDisplay} |
+
+ `;
+ }).join('');
+ } else {
+ // Fallback to tracks_json if no track_results (old syncs before data caching)
+ const tracks = entry.tracks || [];
+ const esc = typeof _esc === 'function' ? _esc : s => s;
+ trackRowsHtml = `
+
|
+ Per-track match data not available for this sync. Re-sync this playlist to see detailed match results.
+ |
+ ` + tracks.map((t, i) => {
+ const artists = t.artists || [];
+ const artistName = artists.length > 0 ? (typeof artists[0] === 'string' ? artists[0] : artists[0]?.name || '') : '';
+ const albumName = typeof t.album === 'object' ? (t.album?.name || '') : (t.album || '');
+ return `
+
+ | ${i + 1} |
+ |
+ ${esc(t.name || '')} |
+ ${esc(artistName)} |
+ ${esc(albumName)} |
+ |
+
+ `;
+ }).join('');
+ }
+
+ // Count stats for filter bar
+ const matchedCount = trackResults.filter(t => t.status === 'found').length;
+ const unmatchedCount = trackResults.filter(t => t.status !== 'found').length;
+ const downloadedCount = trackResults.filter(t => t.download_status === 'completed').length;
+
+ overlay.innerHTML = `
+
+
+
+
+
Sync Details
+
${_esc(name)}
+
+
+
+
+
+
+
+
+ ${downloadedCount > 0 ? `` : ''}
+
+
+
+
+
+
+ | # |
+ |
+ Track |
+ Artist |
+ Album |
+ Match |
+ Conf. |
+ Status |
+
+
+
+ ${trackRowsHtml}
+
+
+
+
+
+ `;
+
+ document.body.appendChild(overlay);
+ requestAnimationFrame(() => overlay.classList.add('visible'));
+
+ } catch (e) {
+ hideLoadingOverlay();
+ showToast('Failed to load sync details', 'error');
+ }
+}
+
+async function deleteSyncHistoryCard(entryId, btnEl) {
+ try {
+ const card = btnEl.closest('.sync-history-card');
+ if (card) {
+ card.style.opacity = '0';
+ card.style.transform = 'scale(0.9)';
+ }
+ const resp = await fetch(`/api/sync/history/${entryId}`, { method: 'DELETE' });
+ if (resp.ok) {
+ setTimeout(() => { if (card) card.remove(); }, 200);
+ }
+ } catch (e) {
+ console.warn('Failed to delete sync entry:', e);
+ }
+}
+
+function _syncDetailFilter(btn, filter) {
+ // Update active button
+ btn.closest('.discog-filters').querySelectorAll('.discog-filter').forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+
+ // Filter rows
+ document.querySelectorAll('#sync-detail-tbody .sync-detail-row').forEach(row => {
+ if (filter === 'all') {
+ row.style.display = '';
+ } else if (filter === 'matched') {
+ row.style.display = row.classList.contains('matched') ? '' : 'none';
+ } else if (filter === 'unmatched') {
+ row.style.display = row.classList.contains('unmatched') ? '' : 'none';
+ } else if (filter === 'downloaded') {
+ const dlCell = row.querySelector('.sync-detail-dl');
+ row.style.display = dlCell && dlCell.textContent.trim() === 'β
' ? '' : 'none';
+ }
+ });
+}
diff --git a/webui/static/style.css b/webui/static/style.css
index 1079180d..4ab28cff 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -50506,3 +50506,346 @@ tr.tag-diff-same {
.explorer-connector-dot { display: none; }
.explorer-connector-line { display: none; }
}
+
+
+/* ==================================================================================
+ DASHBOARD β Recent Syncs
+ ================================================================================== */
+
+.sync-history-cards {
+ display: flex;
+ gap: 12px;
+ overflow-x: auto;
+ padding: 4px 2px 8px;
+ scrollbar-width: thin;
+ scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
+}
+
+.sync-history-cards::-webkit-scrollbar { height: 4px; }
+.sync-history-cards::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.1); border-radius: 4px; }
+
+.sync-history-empty {
+ font-size: 13px;
+ color: rgba(255, 255, 255, 0.25);
+ padding: 16px 0;
+}
+
+.sync-history-card {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 16px;
+ min-width: 280px;
+ max-width: 360px;
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: 14px;
+ cursor: pointer;
+ transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
+ flex-shrink: 0;
+ position: relative;
+ overflow: hidden;
+ animation: sync-card-enter 0.4s ease both;
+}
+
+@keyframes sync-card-enter {
+ from { opacity: 0; transform: translateY(8px) scale(0.97); }
+ to { opacity: 1; transform: none; }
+}
+
+.sync-card-delete {
+ position: absolute;
+ top: 6px;
+ right: 8px;
+ width: 20px;
+ height: 20px;
+ border: none;
+ background: rgba(255, 255, 255, 0.06);
+ color: rgba(255, 255, 255, 0.3);
+ border-radius: 50%;
+ font-size: 14px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ transition: all 0.2s;
+ z-index: 2;
+ line-height: 1;
+}
+
+.sync-history-card:hover .sync-card-delete { opacity: 1; }
+.sync-card-delete:hover { background: rgba(239, 83, 80, 0.3); color: #EF5350; }
+
+.sync-history-card::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: 3px;
+ border-radius: 14px 0 0 14px;
+ transition: all 0.2s;
+}
+
+.sync-history-card.health-good::before { background: #4CAF50; }
+.sync-history-card.health-warn::before { background: #FFB74D; }
+.sync-history-card.health-bad::before { background: #EF5350; }
+
+.sync-history-card:hover {
+ background: rgba(255, 255, 255, 0.06);
+ border-color: rgba(255, 255, 255, 0.12);
+ transform: translateY(-2px);
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25);
+}
+
+.sync-card-thumb {
+ width: 48px;
+ height: 48px;
+ border-radius: 10px;
+ overflow: hidden;
+ flex-shrink: 0;
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.sync-card-thumb img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.sync-card-thumb-placeholder {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: rgba(255, 255, 255, 0.15);
+ font-size: 18px;
+}
+
+.sync-card-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.sync-card-name {
+ font-size: 13px;
+ font-weight: 600;
+ color: rgba(255, 255, 255, 0.85);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.sync-card-meta {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ margin-top: 3px;
+}
+
+.sync-card-source {
+ font-size: 10px;
+ font-weight: 700;
+ color: rgba(255, 255, 255, 0.4);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.sync-card-time {
+ font-size: 10px;
+ color: rgba(255, 255, 255, 0.25);
+}
+
+.sync-card-stats {
+ flex-shrink: 0;
+ text-align: right;
+ min-width: 60px;
+}
+
+.sync-card-pct {
+ font-size: 18px;
+ font-weight: 800;
+ color: rgba(255, 255, 255, 0.9);
+ line-height: 1;
+}
+
+.sync-card-bar {
+ width: 60px;
+ height: 3px;
+ background: rgba(255, 255, 255, 0.06);
+ border-radius: 3px;
+ margin: 4px 0;
+ overflow: hidden;
+}
+
+.sync-card-bar-fill {
+ height: 100%;
+ border-radius: 3px;
+ transition: width 0.5s ease;
+}
+
+.health-good .sync-card-bar-fill { background: #4CAF50; }
+.health-warn .sync-card-bar-fill { background: #FFB74D; }
+.health-bad .sync-card-bar-fill { background: #EF5350; }
+
+.sync-card-counts {
+ font-size: 9px;
+ color: rgba(255, 255, 255, 0.3);
+ white-space: nowrap;
+}
+
+/* Sync Detail Modal β wider than default discog modal */
+#sync-detail-overlay .discog-modal {
+ max-width: 900px;
+ width: 95vw;
+}
+
+/* Sync Detail Modal Table */
+.sync-detail-table-wrap {
+ overflow: auto;
+ flex: 1;
+ min-height: 0;
+ padding: 0 16px 16px;
+}
+
+.sync-detail-notice {
+ text-align: center;
+ padding: 16px !important;
+}
+
+.sync-detail-notice-text {
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.3);
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px dashed rgba(255, 255, 255, 0.08);
+ border-radius: 8px;
+ padding: 12px 16px;
+ line-height: 1.5;
+}
+
+.sync-detail-row.no-data td {
+ color: rgba(255, 255, 255, 0.4);
+}
+
+.sync-detail-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+}
+
+.sync-detail-table thead th {
+ position: sticky;
+ top: 0;
+ background: rgba(18, 18, 22, 0.95);
+ padding: 10px 8px;
+ text-align: left;
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: rgba(255, 255, 255, 0.4);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+ z-index: 2;
+}
+
+.sync-detail-table tbody tr {
+ border-bottom: 1px solid rgba(255, 255, 255, 0.03);
+ transition: background 0.15s;
+}
+
+.sync-detail-table tbody tr:hover {
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.sync-detail-table tbody td {
+ padding: 8px 10px;
+ color: rgba(255, 255, 255, 0.7);
+}
+
+.sync-detail-num {
+ color: rgba(255, 255, 255, 0.25);
+ width: 32px;
+ white-space: nowrap;
+}
+
+.sync-detail-art {
+ width: 36px;
+ padding: 4px 6px !important;
+}
+
+.sync-detail-art img {
+ width: 32px;
+ height: 32px;
+ border-radius: 4px;
+ object-fit: cover;
+ display: block;
+}
+
+.sync-detail-art-empty {
+ width: 32px;
+ height: 32px;
+ border-radius: 4px;
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.sync-dl-wishlist {
+ font-size: 9px;
+ color: rgba(255, 183, 77, 0.8);
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.sync-detail-track {
+ font-weight: 500;
+ min-width: 150px;
+}
+
+.sync-detail-artist {
+ min-width: 100px;
+ color: rgba(255, 255, 255, 0.5);
+}
+
+.sync-detail-album {
+ min-width: 100px;
+ color: rgba(255, 255, 255, 0.4);
+ font-size: 11px;
+}
+
+.sync-detail-status { text-align: center; width: 45px; white-space: nowrap; }
+.sync-detail-conf { text-align: center; width: 55px; white-space: nowrap; }
+.sync-detail-dl { text-align: center; width: 35px; white-space: nowrap; }
+
+.sync-detail-row.unmatched .sync-detail-track {
+ color: rgba(255, 255, 255, 0.45);
+}
+
+.conf-badge {
+ display: inline-block;
+ padding: 1px 6px;
+ border-radius: 4px;
+ font-size: 10px;
+ font-weight: 700;
+}
+
+.conf-badge.conf-high {
+ background: rgba(76, 175, 80, 0.15);
+ color: rgba(76, 175, 80, 0.9);
+}
+
+.conf-badge.conf-mid {
+ background: rgba(255, 183, 77, 0.15);
+ color: rgba(255, 183, 77, 0.9);
+}
+
+.conf-badge.conf-low {
+ background: rgba(239, 83, 80, 0.15);
+ color: rgba(239, 83, 80, 0.9);
+}
+
+@media (max-width: 768px) {
+ .sync-history-card { min-width: 240px; }
+ .sync-detail-table { font-size: 11px; }
+ .sync-detail-table tbody td { max-width: 120px; }
+}