Add Sync History dashboard with per-track match caching and detail modal

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
This commit is contained in:
Broque Thomas 2026-03-31 15:26:46 -07:00
parent ad262822a4
commit de3fba3f37
6 changed files with 739 additions and 6 deletions

View file

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

View file

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

View file

@ -679,6 +679,14 @@
</div>
</div>
<!-- Recent Syncs Section -->
<div class="dashboard-section">
<h3 class="section-title">Recent Syncs</h3>
<div class="sync-history-cards" id="sync-history-cards">
<!-- Dynamically populated by JS -->
</div>
</div>
<div class="dashboard-section">
<h3 class="section-title">System Statistics</h3>
<div class="stats-grid-dashboard">

View file

@ -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' },

View file

@ -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 = '<div class="sync-history-empty">No syncs yet</div>';
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 `
<div class="sync-history-card ${healthClass}" onclick="openSyncDetailModal(${entry.id})" style="animation-delay: ${cardIndex * 0.05}s">
<button class="sync-card-delete" onclick="event.stopPropagation(); deleteSyncHistoryCard(${entry.id}, this)" title="Remove">&times;</button>
<div class="sync-card-thumb">
${entry.thumb_url ? `<img src="${entry.thumb_url}" alt="" loading="lazy">` : '<div class="sync-card-thumb-placeholder">&#9835;</div>'}
</div>
<div class="sync-card-info">
<div class="sync-card-name">${typeof _esc === 'function' ? _esc(name) : name}</div>
<div class="sync-card-meta">
<span class="sync-card-source">${sourceLabel}</span>
<span class="sync-card-time">${timeStr}</span>
</div>
</div>
<div class="sync-card-stats">
<div class="sync-card-pct">${pct}%</div>
<div class="sync-card-bar">
<div class="sync-card-bar-fill" style="width: ${pct}%"></div>
</div>
<div class="sync-card-counts">${found}/${total} matched${downloaded > 0 ? ` · ${downloaded}` : ''}${failed > 0 ? ` · ${failed}` : ''}</div>
</div>
</div>
`;
}).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 = '<span class="sync-dl-wishlist">→ Wishlist</span>';
return `
<tr class="sync-detail-row ${statusClass}">
<td class="sync-detail-num">${i + 1}</td>
<td class="sync-detail-art">
${t.image_url ? `<img src="${t.image_url}" alt="" loading="lazy">` : '<div class="sync-detail-art-empty"></div>'}
</td>
<td class="sync-detail-track">${_esc(t.name || '')}</td>
<td class="sync-detail-artist">${_esc(t.artist || '')}</td>
<td class="sync-detail-album">${_esc(t.album || '')}</td>
<td class="sync-detail-status">${statusIcon}</td>
<td class="sync-detail-conf"><span class="conf-badge ${confClass}">${confPct}%</span></td>
<td class="sync-detail-dl">${dlDisplay}</td>
</tr>
`;
}).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 = `
<tr><td colspan="8" class="sync-detail-notice">
<div class="sync-detail-notice-text">Per-track match data not available for this sync.<br>Re-sync this playlist to see detailed match results.</div>
</td></tr>
` + 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 `
<tr class="sync-detail-row no-data">
<td class="sync-detail-num">${i + 1}</td>
<td class="sync-detail-art"></td>
<td class="sync-detail-track">${esc(t.name || '')}</td>
<td class="sync-detail-artist">${esc(artistName)}</td>
<td class="sync-detail-album">${esc(albumName)}</td>
<td class="sync-detail-status" colspan="3"></td>
</tr>
`;
}).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 = `
<div class="discog-modal">
<div class="discog-modal-hero" ${entry.thumb_url ? `style="background-image:url('${entry.thumb_url}')"` : ''}>
<div class="discog-modal-hero-overlay"></div>
<div class="discog-modal-hero-content">
<h2 class="discog-modal-title">Sync Details</h2>
<p class="discog-modal-artist">${_esc(name)}</p>
</div>
<button class="discog-modal-close" onclick="document.getElementById('sync-detail-overlay')?.remove()">&times;</button>
</div>
<div class="discog-filter-bar">
<div class="discog-filters">
<button class="discog-filter active" data-filter="all" onclick="_syncDetailFilter(this, 'all')">All (${total})</button>
<button class="discog-filter" data-filter="matched" onclick="_syncDetailFilter(this, 'matched')">Matched (${matchedCount})</button>
<button class="discog-filter" data-filter="unmatched" onclick="_syncDetailFilter(this, 'unmatched')">Unmatched (${unmatchedCount})</button>
${downloadedCount > 0 ? `<button class="discog-filter" data-filter="downloaded" onclick="_syncDetailFilter(this, 'downloaded')">Downloaded (${downloadedCount})</button>` : ''}
</div>
</div>
<div class="sync-detail-table-wrap">
<table class="sync-detail-table">
<thead>
<tr>
<th>#</th>
<th></th>
<th>Track</th>
<th>Artist</th>
<th>Album</th>
<th>Match</th>
<th>Conf.</th>
<th>Status</th>
</tr>
</thead>
<tbody id="sync-detail-tbody">
${trackRowsHtml}
</tbody>
</table>
</div>
<div class="discog-footer">
<div class="discog-footer-info">${found} matched · ${downloaded} downloaded · ${total} total</div>
<div class="discog-footer-actions">
<button class="discog-cancel-btn" onclick="document.getElementById('sync-detail-overlay')?.remove()">Close</button>
</div>
</div>
</div>
`;
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';
}
});
}

View file

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