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

Recent Syncs

+
+ +
+
+

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 ? `` : ''} +
+
+
+ + + + + + + + + + + + + + + ${trackRowsHtml} + +
#TrackArtistAlbumMatchConf.Status
+
+ +
+ `; + + 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; } +}