Restyle Auto-Sync manager and fix loading regressions
Three problems wrapped into one pass on the Playlist Auto-Sync surface: 1. Visual: the manager modal had its own vibe (radial gradient, pill tabs, sky-blue chrome) that didn't line up with the rest of the app. Reworked the modal shell, KPI summary, live pipeline monitor, tab bar, schedule board sidebar, and column cards to use the standard SoulSync patterns — gradient `#1a1a1a → #121212`, accent-tinted 1px border, 20px radius, underline tabs, dense dark card pattern that Automations + Library pages already use. Modal now uses near-full screen so there's room for the schedule board without horizontal scroll pain. Run history cards followed the same path: slim horizontal row mirroring `.automation-card` plus an expanded detail that mirrors the Automations run-history modal (stats-grid + facts row + result pills + log section). 2. Hang: the previous SQL fix for the run-history "in library" count added `COLLATE NOCASE` on the join columns of `tracks` and `artists`. SQLite can't use `idx_artists_name` or `idx_tracks_title` when the comparison collation doesn't match the column collation, so the join did a full table scan per mirrored playlist track. ~18s per playlist × 30 playlists = `/api/mirrored-playlists` hung indefinitely and the modal stayed at "Loading schedule…" forever. Switched the join back to case-sensitive equality (~6ms per playlist, 3000× faster). Spotify names canonicalize to the same form as library imports so the recall loss is in the rounding error of pure case-only mismatches. 3. Slowness: even after the hang fix, each modal open spent ~1.5s gathering per-playlist status counts. The endpoint looped `get_mirrored_playlist_status_counts(playlist_id)` per row, which opened a fresh SQLite connection + PRAGMA setup each time. Added `get_all_mirrored_playlist_status_counts(profile_id)` which returns counts for every mirrored playlist owned by the active profile in 4 batched `GROUP BY` queries over a single connection. Modal load dropped to ~280ms. Also fixed: `tracks.artist` reference in `get_mirrored_playlist_status_counts` that never worked since the schema went relational — the query threw "no such column", got swallowed by the try/except, and the in-library count silently defaulted to 0 on every playlist. Rewired to join through `artists`. `get_mirrored_playlist_status_counts` (single-playlist) kept for callers that still want it, but the modal endpoint uses the batched version.
This commit is contained in:
parent
8f72cc3113
commit
dfdc6c6277
5 changed files with 726 additions and 551 deletions
|
|
@ -12136,6 +12136,82 @@ class MusicDatabase:
|
|||
logger.error(f"Error getting mirrored playlist discovery counts: {e}")
|
||||
return (0, 0)
|
||||
|
||||
def get_all_mirrored_playlist_status_counts(self, profile_id: int = 1) -> dict:
|
||||
"""Return status counts for every mirrored playlist owned by the profile
|
||||
in a single round-trip. Replaces N×4-query per-playlist loop on the
|
||||
Auto-Sync modal load path. Result is `{playlist_id: {total, discovered,
|
||||
wishlisted, in_library}}`."""
|
||||
result: dict = {}
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT mp.id as playlist_id
|
||||
FROM mirrored_playlists mp
|
||||
WHERE mp.profile_id = ?
|
||||
""", (profile_id,))
|
||||
for row in cursor.fetchall():
|
||||
result[row['playlist_id']] = {'total': 0, 'discovered': 0, 'wishlisted': 0, 'in_library': 0}
|
||||
|
||||
# Core counts: total + discovered, grouped per playlist
|
||||
cursor.execute("""
|
||||
SELECT mpt.playlist_id,
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN mpt.extra_data LIKE '%"discovered": true%' THEN 1 ELSE 0 END) as discovered
|
||||
FROM mirrored_playlist_tracks mpt
|
||||
JOIN mirrored_playlists mp ON mp.id = mpt.playlist_id
|
||||
WHERE mp.profile_id = ?
|
||||
GROUP BY mpt.playlist_id
|
||||
""", (profile_id,))
|
||||
for row in cursor.fetchall():
|
||||
pid = row['playlist_id']
|
||||
if pid not in result:
|
||||
result[pid] = {'total': 0, 'discovered': 0, 'wishlisted': 0, 'in_library': 0}
|
||||
result[pid]['total'] = row['total'] or 0
|
||||
result[pid]['discovered'] = row['discovered'] or 0
|
||||
|
||||
# Wishlist counts in one shot
|
||||
try:
|
||||
cursor.execute("""
|
||||
SELECT mpt.playlist_id, COUNT(*) as wishlisted
|
||||
FROM mirrored_playlist_tracks mpt
|
||||
JOIN mirrored_playlists mp ON mp.id = mpt.playlist_id
|
||||
WHERE mp.profile_id = ?
|
||||
AND mpt.source_track_id IS NOT NULL AND mpt.source_track_id != ''
|
||||
AND EXISTS (SELECT 1 FROM wishlist_tracks wt
|
||||
WHERE wt.spotify_track_id = mpt.source_track_id)
|
||||
GROUP BY mpt.playlist_id
|
||||
""", (profile_id,))
|
||||
for row in cursor.fetchall():
|
||||
pid = row['playlist_id']
|
||||
if pid in result:
|
||||
result[pid]['wishlisted'] = row['wishlisted'] or 0
|
||||
except Exception as e:
|
||||
logger.debug(f"Batch wishlist counts failed: {e}")
|
||||
|
||||
# In-library counts in one shot. Case-sensitive join so
|
||||
# idx_artists_name + idx_tracks_title kick in.
|
||||
try:
|
||||
cursor.execute("""
|
||||
SELECT mpt.playlist_id, COUNT(DISTINCT mpt.id) as in_library
|
||||
FROM mirrored_playlist_tracks mpt
|
||||
JOIN mirrored_playlists mp ON mp.id = mpt.playlist_id
|
||||
JOIN artists a ON a.name = mpt.artist_name
|
||||
JOIN tracks t ON t.artist_id = a.id
|
||||
AND t.title = mpt.track_name
|
||||
WHERE mp.profile_id = ?
|
||||
GROUP BY mpt.playlist_id
|
||||
""", (profile_id,))
|
||||
for row in cursor.fetchall():
|
||||
pid = row['playlist_id']
|
||||
if pid in result:
|
||||
result[pid]['in_library'] = row['in_library'] or 0
|
||||
except Exception as e:
|
||||
logger.debug(f"Batch library counts failed: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting batch mirrored playlist status counts: {e}")
|
||||
return result
|
||||
|
||||
def get_mirrored_playlist_status_counts(self, playlist_id: int) -> dict:
|
||||
"""Return discovery, wishlisted, and downloaded counts for a mirrored playlist.
|
||||
Discovery counts are critical (same as old method). Library/wishlist counts are
|
||||
|
|
@ -12157,26 +12233,39 @@ class MusicDatabase:
|
|||
)
|
||||
result['discovered'] = cursor.fetchone()['discovered']
|
||||
|
||||
# Best-effort extras — won't break if tracks table has issues
|
||||
# Best-effort extras — won't break if tracks table has issues.
|
||||
# Wishlisted: indexed via wishlist_tracks.spotify_track_id.
|
||||
try:
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
SUM(CASE WHEN mpt.source_track_id IS NOT NULL AND mpt.source_track_id != ''
|
||||
AND EXISTS (SELECT 1 FROM wishlist_tracks wt
|
||||
WHERE wt.spotify_track_id = mpt.source_track_id)
|
||||
THEN 1 ELSE 0 END) as wishlisted,
|
||||
SUM(CASE WHEN EXISTS (SELECT 1 FROM tracks t
|
||||
WHERE t.title = mpt.track_name COLLATE NOCASE
|
||||
AND t.artist = mpt.artist_name COLLATE NOCASE)
|
||||
THEN 1 ELSE 0 END) as in_library
|
||||
SELECT COUNT(*) as wishlisted
|
||||
FROM mirrored_playlist_tracks mpt
|
||||
WHERE mpt.playlist_id = ?
|
||||
AND mpt.source_track_id IS NOT NULL AND mpt.source_track_id != ''
|
||||
AND EXISTS (SELECT 1 FROM wishlist_tracks wt
|
||||
WHERE wt.spotify_track_id = mpt.source_track_id)
|
||||
""", (playlist_id,))
|
||||
row = cursor.fetchone()
|
||||
result['wishlisted'] = row['wishlisted'] or 0
|
||||
result['in_library'] = row['in_library'] or 0
|
||||
result['wishlisted'] = cursor.fetchone()['wishlisted'] or 0
|
||||
except Exception as extra_err:
|
||||
logger.debug(f"Optional status counts failed for playlist {playlist_id}: {extra_err}")
|
||||
logger.debug(f"Wishlist count failed for playlist {playlist_id}: {extra_err}")
|
||||
|
||||
# In-library: case-sensitive equality so SQLite can use
|
||||
# `idx_artists_name` and `idx_tracks_title`. COLLATE NOCASE on
|
||||
# the join columns prevents index usage and takes ~18s per
|
||||
# playlist on a 300k-track library; the case-sensitive variant
|
||||
# is ~6ms. Misses purely-case-different matches (rare — Spotify
|
||||
# canonicalizes artist/track names that match library imports).
|
||||
try:
|
||||
cursor.execute("""
|
||||
SELECT COUNT(DISTINCT mpt.id) as in_library
|
||||
FROM mirrored_playlist_tracks mpt
|
||||
JOIN artists a ON a.name = mpt.artist_name
|
||||
JOIN tracks t ON t.artist_id = a.id
|
||||
AND t.title = mpt.track_name
|
||||
WHERE mpt.playlist_id = ?
|
||||
""", (playlist_id,))
|
||||
result['in_library'] = cursor.fetchone()['in_library'] or 0
|
||||
except Exception as extra_err:
|
||||
logger.debug(f"Library count failed for playlist {playlist_id}: {extra_err}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting mirrored playlist status counts: {e}")
|
||||
|
|
|
|||
|
|
@ -305,7 +305,20 @@ _STATIC_CACHE_BUST = str(int(_cache_bust_time.time()))
|
|||
|
||||
@app.context_processor
|
||||
def _inject_static_cache_bust():
|
||||
return {'static_v': _STATIC_CACHE_BUST}
|
||||
static_v = _STATIC_CACHE_BUST
|
||||
if DEV_STATIC_NO_CACHE:
|
||||
try:
|
||||
static_dir = Path(app.static_folder)
|
||||
mtimes = [
|
||||
p.stat().st_mtime_ns
|
||||
for p in static_dir.rglob('*')
|
||||
if p.is_file() and p.suffix.lower() in {'.css', '.js'}
|
||||
]
|
||||
if mtimes:
|
||||
static_v = str(max(mtimes))
|
||||
except Exception:
|
||||
static_v = _STATIC_CACHE_BUST
|
||||
return {'static_v': static_v}
|
||||
|
||||
|
||||
@app.context_processor
|
||||
|
|
@ -32195,8 +32208,12 @@ def get_mirrored_playlists_endpoint():
|
|||
database = get_database()
|
||||
profile_id = get_current_profile_id()
|
||||
playlists = database.get_mirrored_playlists(profile_id=profile_id)
|
||||
# Single batched query instead of N per-playlist round-trips. Used to
|
||||
# take ~50ms per playlist (new connection + 4 sub-queries) — at 30
|
||||
# playlists that's 1.5s of modal load time just for status counts.
|
||||
batch_counts = database.get_all_mirrored_playlist_status_counts(profile_id=profile_id)
|
||||
for pl in playlists:
|
||||
counts = database.get_mirrored_playlist_status_counts(pl['id'])
|
||||
counts = batch_counts.get(pl['id'], {'total': 0, 'discovered': 0, 'wishlisted': 0, 'in_library': 0})
|
||||
pl['discovered_count'] = counts['discovered']
|
||||
pl['total_count'] = counts['total']
|
||||
pl['wishlisted_count'] = counts['wishlisted']
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ async function refreshAutoSyncScheduleModal() {
|
|||
<div><h3>Auto-Sync Schedule</h3><p>Could not load schedule data.</p></div>
|
||||
<button class="auto-sync-close" onclick="closeAutoSyncScheduleModal()">×</button>
|
||||
</div>
|
||||
<div class="auto-sync-error">${_esc(err.message)}</div>
|
||||
<div class="auto-sync-error">${_esc((err && err.message) || String(err))}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
@ -439,8 +439,8 @@ function renderAutoSyncHistoryPanel(history, total) {
|
|||
</div>
|
||||
<button onclick="refreshAutoSyncScheduleModal()">Refresh</button>
|
||||
</div>
|
||||
<div class="auto-sync-history-list">
|
||||
<div class="auto-sync-history-loading">Loading run history...</div>
|
||||
<div class="auto-sync-history-list" data-renderer="pending">
|
||||
<div class="auto-sync-history-loading">Preparing run history...</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
@ -448,9 +448,11 @@ function renderAutoSyncHistoryPanel(history, total) {
|
|||
function populateAutoSyncHistoryList(root = document) {
|
||||
const list = root.querySelector('.auto-sync-history-list');
|
||||
if (!list) return;
|
||||
const history = _autoSyncScheduleState.runHistory || [];
|
||||
const history = Array.isArray(_autoSyncScheduleState.runHistory) ? _autoSyncScheduleState.runHistory : [];
|
||||
const total = _autoSyncScheduleState.runHistoryTotal || 0;
|
||||
list.innerHTML = '';
|
||||
list.dataset.renderer = 'dom-cards';
|
||||
list.dataset.renderedCount = '0';
|
||||
history.forEach((entry, index) => {
|
||||
try {
|
||||
list.appendChild(createAutoSyncHistoryEntryElement(entry, index));
|
||||
|
|
@ -458,6 +460,11 @@ function populateAutoSyncHistoryList(root = document) {
|
|||
list.appendChild(createAutoSyncHistoryErrorElement(entry, index, err));
|
||||
}
|
||||
});
|
||||
const renderedCount = list.querySelectorAll('.auto-sync-history-entry').length;
|
||||
list.dataset.renderedCount = String(renderedCount);
|
||||
if (history.length && !renderedCount) {
|
||||
list.appendChild(createAutoSyncHistoryListFallback(history.length));
|
||||
}
|
||||
if (total > history.length) {
|
||||
const totalEl = document.createElement('div');
|
||||
totalEl.className = 'auto-sync-history-total';
|
||||
|
|
@ -466,6 +473,17 @@ function populateAutoSyncHistoryList(root = document) {
|
|||
}
|
||||
}
|
||||
|
||||
function createAutoSyncHistoryListFallback(count) {
|
||||
const fallback = document.createElement('div');
|
||||
fallback.className = 'auto-sync-history-empty';
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = 'Run history could not render';
|
||||
const detail = document.createElement('span');
|
||||
detail.textContent = `${count} playlist pipeline run${count === 1 ? '' : 's'} loaded, but the card renderer did not complete. Refresh the page to reload the latest Auto-Sync assets.`;
|
||||
fallback.append(title, detail);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function createAutoSyncHistoryErrorElement(entry, index, err) {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'auto-sync-history-entry auto-sync-history-entry-error';
|
||||
|
|
@ -508,10 +526,10 @@ function createAutoSyncHistoryEntryElement(entry, index = 0) {
|
|||
const libraryDelta = autoSyncDelta(after.in_library_count, before.in_library_count);
|
||||
const entryId = `auto-sync-history-${entry.id}`;
|
||||
const playlistName = entry.playlist_name || after.name || before.name || `Playlist #${entry.playlist_id || 'unknown'}`;
|
||||
const summary = entry.summary || autoSyncHistoryFallbackSummary(before, after, status);
|
||||
const triggerSource = entry.trigger_source || 'pipeline';
|
||||
|
||||
const card = document.createElement('article');
|
||||
card.className = 'auto-sync-history-entry';
|
||||
card.className = `auto-sync-history-entry ${status === 'completed' || status === 'finished' ? '' : 'auto-sync-history-entry-' + status}`.trim();
|
||||
card.id = `${entryId}-card`;
|
||||
card.dataset.historyEntry = entryId;
|
||||
|
||||
|
|
@ -523,42 +541,19 @@ function createAutoSyncHistoryEntryElement(entry, index = 0) {
|
|||
row.setAttribute('aria-controls', entryId);
|
||||
row.dataset.historyToggle = entryId;
|
||||
|
||||
const head = document.createElement('div');
|
||||
head.className = 'auto-sync-history-card-head';
|
||||
const titleBlock = document.createElement('div');
|
||||
titleBlock.className = 'auto-sync-history-title-block';
|
||||
const titleRow = document.createElement('div');
|
||||
titleRow.className = 'auto-sync-history-title-row';
|
||||
const dot = document.createElement('span');
|
||||
dot.className = `auto-sync-card-status-dot ${autoSyncHistoryStatusClass(status)}`;
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = playlistName;
|
||||
const statusBadge = document.createElement('span');
|
||||
statusBadge.className = `auto-sync-history-status ${status}`;
|
||||
statusBadge.textContent = autoSyncHistoryStatusLabel(status);
|
||||
titleRow.append(dot, title, statusBadge);
|
||||
const summaryEl = document.createElement('small');
|
||||
summaryEl.textContent = summary;
|
||||
titleBlock.append(titleRow, summaryEl);
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'auto-sync-history-meta';
|
||||
[started, duration, entry.trigger_source || 'pipeline'].filter(Boolean).forEach(value => {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = value;
|
||||
meta.appendChild(span);
|
||||
});
|
||||
const expand = document.createElement('button');
|
||||
expand.type = 'button';
|
||||
expand.className = 'auto-sync-history-expand-label';
|
||||
expand.dataset.historyToggleButton = entryId;
|
||||
expand.textContent = 'View details';
|
||||
meta.appendChild(expand);
|
||||
head.append(titleBlock, meta);
|
||||
const info = document.createElement('div');
|
||||
info.className = 'auto-sync-history-info';
|
||||
|
||||
const name = document.createElement('div');
|
||||
name.className = 'auto-sync-history-name';
|
||||
name.textContent = playlistName;
|
||||
|
||||
const flow = document.createElement('div');
|
||||
flow.className = 'auto-sync-card-flow';
|
||||
autoSyncAppendFlowChip(flow, entry.trigger_source || 'pipeline', 'flow-trigger');
|
||||
flow.className = 'auto-sync-history-flow';
|
||||
autoSyncAppendFlowChip(flow, triggerSource, 'flow-trigger');
|
||||
autoSyncAppendFlowArrow(flow);
|
||||
autoSyncAppendFlowChip(flow, 'Refresh', 'flow-action');
|
||||
autoSyncAppendFlowArrow(flow);
|
||||
|
|
@ -566,29 +561,60 @@ function createAutoSyncHistoryEntryElement(entry, index = 0) {
|
|||
autoSyncAppendFlowArrow(flow);
|
||||
autoSyncAppendFlowChip(flow, 'Sync + wishlist', 'flow-notify');
|
||||
|
||||
const preview = document.createElement('div');
|
||||
preview.className = 'auto-sync-history-preview';
|
||||
[
|
||||
['Tracks', before.track_count, after.track_count, trackDelta],
|
||||
['Discovered', before.discovered_count, after.discovered_count, discoveredDelta],
|
||||
['Wishlisted', before.wishlisted_count, after.wishlisted_count, wishlistDelta],
|
||||
['Library', before.in_library_count, after.in_library_count, libraryDelta],
|
||||
].forEach(([label, beforeValue, afterValue, delta]) => {
|
||||
const pill = document.createElement('span');
|
||||
pill.textContent = autoSyncHistoryPreviewText(label, beforeValue, afterValue, delta);
|
||||
preview.appendChild(pill);
|
||||
});
|
||||
const metaRow = document.createElement('div');
|
||||
metaRow.className = 'auto-sync-history-meta-inline';
|
||||
const statusBadge = document.createElement('span');
|
||||
statusBadge.className = `auto-sync-history-status ${status}`;
|
||||
statusBadge.textContent = autoSyncHistoryStatusLabel(status);
|
||||
metaRow.appendChild(statusBadge);
|
||||
if (started) {
|
||||
const t = document.createElement('span');
|
||||
t.className = 'auto-sync-history-time';
|
||||
t.textContent = started;
|
||||
metaRow.appendChild(t);
|
||||
}
|
||||
if (duration) {
|
||||
const d = document.createElement('span');
|
||||
d.className = 'auto-sync-history-duration';
|
||||
d.textContent = duration;
|
||||
metaRow.appendChild(d);
|
||||
}
|
||||
const trackChip = document.createElement('span');
|
||||
const trackClass = trackDelta > 0 ? 'pos' : trackDelta < 0 ? 'neg' : 'zero';
|
||||
trackChip.className = `auto-sync-history-delta ${trackClass}`;
|
||||
trackChip.textContent = autoSyncDeltaLabel(after.track_count, trackDelta, 'tracks');
|
||||
metaRow.appendChild(trackChip);
|
||||
|
||||
info.append(name, flow, metaRow);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auto-sync-history-actions';
|
||||
const expand = document.createElement('button');
|
||||
expand.type = 'button';
|
||||
expand.className = 'auto-sync-history-expand-btn';
|
||||
expand.dataset.historyToggleButton = entryId;
|
||||
expand.setAttribute('aria-label', 'Toggle details');
|
||||
expand.innerHTML = '<span class="auto-sync-history-expand-icon">▾</span>';
|
||||
actions.appendChild(expand);
|
||||
|
||||
row.append(dot, info, actions);
|
||||
|
||||
const detail = document.createElement('div');
|
||||
detail.id = entryId;
|
||||
detail.className = 'auto-sync-history-detail';
|
||||
detail.innerHTML = autoSyncHistoryDetailHtml(entry, before, after, result, { trackDelta, discoveredDelta, wishlistDelta, libraryDelta });
|
||||
|
||||
row.append(head, flow, preview);
|
||||
card.append(row, detail);
|
||||
return card;
|
||||
}
|
||||
|
||||
function autoSyncDeltaLabel(after, delta, unit) {
|
||||
const a = parseInt(after, 10) || 0;
|
||||
if (!delta) return `${a} ${unit}`;
|
||||
const sign = delta > 0 ? '+' : '';
|
||||
return `${a} ${unit} (${sign}${delta})`;
|
||||
}
|
||||
|
||||
function autoSyncAppendFlowChip(parent, text, className) {
|
||||
const span = document.createElement('span');
|
||||
span.className = className;
|
||||
|
|
@ -730,53 +756,66 @@ function autoSyncHistoryResultPill(label, value) {
|
|||
}
|
||||
|
||||
function autoSyncHistoryDetailHtml(entry, before, after, result, deltas) {
|
||||
const resultPills = [
|
||||
autoSyncHistoryResultPill('Refreshed', result.playlists_refreshed),
|
||||
autoSyncHistoryResultPill('Discovered', result.tracks_discovered),
|
||||
autoSyncHistoryResultPill('Synced', result.tracks_synced),
|
||||
autoSyncHistoryResultPill('Skipped', result.sync_skipped),
|
||||
autoSyncHistoryResultPill('Wishlisted', result.wishlist_queued),
|
||||
autoSyncHistoryResultPill('Duration', result.duration_seconds ? autoSyncDurationLabel(result.duration_seconds) : ''),
|
||||
result.error ? `<span class="error">${_esc(result.error)}</span>` : '',
|
||||
].filter(Boolean).join('');
|
||||
const timeline = [
|
||||
const stats = [
|
||||
['Tracks', before.track_count, after.track_count, deltas.trackDelta],
|
||||
['Discovered', before.discovered_count, after.discovered_count, deltas.discoveredDelta],
|
||||
['Wishlisted', before.wishlisted_count, after.wishlisted_count, deltas.wishlistDelta],
|
||||
['In library', before.in_library_count, after.in_library_count, deltas.libraryDelta],
|
||||
];
|
||||
const statsHtml = stats.map(([label, b, a, d]) => autoSyncHistoryStatCardHtml(label, b, a, d)).join('');
|
||||
const factsHtml = [
|
||||
['Started', autoSyncFormatDateTime(entry.started_at)],
|
||||
['Finished', autoSyncFormatDateTime(entry.finished_at)],
|
||||
['Duration', entry.duration_seconds ? autoSyncDurationLabel(entry.duration_seconds) : 'Not recorded'],
|
||||
['Trigger', entry.trigger_source || 'pipeline'],
|
||||
['Source', entry.source || after.source || before.source || 'Unknown'],
|
||||
['Playlist ID', entry.playlist_id || after.playlist_id || before.playlist_id || 'Unknown'],
|
||||
];
|
||||
['Duration', entry.duration_seconds ? autoSyncDurationLabel(entry.duration_seconds) : '—'],
|
||||
['Source', entry.source || after.source || before.source || '—'],
|
||||
].map(([k, v]) => `<div class="auto-sync-history-fact"><span>${_esc(k)}</span><strong>${_esc(autoSyncValueLabel(v))}</strong></div>`).join('');
|
||||
const resultPills = [
|
||||
['Refreshed', result.playlists_refreshed],
|
||||
['Discovered', result.tracks_discovered],
|
||||
['Synced', result.tracks_synced],
|
||||
['Skipped', result.sync_skipped],
|
||||
['Wishlisted', result.wishlist_queued],
|
||||
].filter(([, v]) => v !== undefined && v !== null && v !== '')
|
||||
.map(([k, v]) => `<span class="auto-sync-history-result-pill"><em>${_esc(k)}</em>${_esc(String(v))}</span>`).join('');
|
||||
const errorBlock = result.error ? `<div class="auto-sync-history-error">${_esc(result.error)}</div>` : '';
|
||||
const logsHtml = autoSyncHistoryLogsCompactHtml(entry.log_lines);
|
||||
return `
|
||||
<div class="auto-sync-history-detail-grid">
|
||||
<section class="auto-sync-history-section">
|
||||
<div class="auto-sync-history-section-title">Run Summary</div>
|
||||
<div class="auto-sync-history-stats">
|
||||
${autoSyncHistoryStatHtml('Tracks', before.track_count, after.track_count, deltas.trackDelta)}
|
||||
${autoSyncHistoryStatHtml('Discovered', before.discovered_count, after.discovered_count, deltas.discoveredDelta)}
|
||||
${autoSyncHistoryStatHtml('Wishlisted', before.wishlisted_count, after.wishlisted_count, deltas.wishlistDelta)}
|
||||
${autoSyncHistoryStatHtml('In library', before.in_library_count, after.in_library_count, deltas.libraryDelta)}
|
||||
</div>
|
||||
<div class="auto-sync-history-result">
|
||||
${resultPills || '<span class="muted">No detailed result payload recorded for this run.</span>'}
|
||||
</div>
|
||||
</section>
|
||||
<section class="auto-sync-history-section">
|
||||
<div class="auto-sync-history-section-title">Timeline</div>
|
||||
<div class="auto-sync-history-facts">
|
||||
${timeline.map(([label, value]) => autoSyncHistoryFactHtml(label, value)).join('')}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="auto-sync-history-snapshots">
|
||||
${autoSyncHistorySnapshotHtml('Before refresh', before)}
|
||||
${autoSyncHistorySnapshotHtml('After pipeline', after)}
|
||||
</div>
|
||||
${autoSyncHistoryObjectHtml('Result payload', result, { skipPrivate: true })}
|
||||
${autoSyncHistoryLogsHtml(entry.log_lines)}
|
||||
<div class="auto-sync-history-stats-grid">${statsHtml}</div>
|
||||
<div class="auto-sync-history-facts-row">${factsHtml}</div>
|
||||
${resultPills ? `<div class="auto-sync-history-result-row">${resultPills}</div>` : ''}
|
||||
${errorBlock}
|
||||
${logsHtml}
|
||||
`;
|
||||
}
|
||||
|
||||
function autoSyncHistoryStatCardHtml(label, before, after, delta) {
|
||||
const a = parseInt(after, 10) || 0;
|
||||
const b = parseInt(before, 10) || 0;
|
||||
const deltaClass = delta > 0 ? 'pos' : delta < 0 ? 'neg' : 'zero';
|
||||
const deltaText = delta ? `${delta > 0 ? '+' : ''}${delta}` : '';
|
||||
return `
|
||||
<div class="auto-sync-history-stat">
|
||||
<div class="auto-sync-history-stat-label">${_esc(label)}</div>
|
||||
<div class="auto-sync-history-stat-value">
|
||||
<span class="stat-before">${b}</span>
|
||||
<span class="stat-arrow">→</span>
|
||||
<span class="stat-after">${a}</span>
|
||||
${deltaText ? `<span class="stat-delta ${deltaClass}">${deltaText}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function autoSyncHistoryLogsCompactHtml(logLines) {
|
||||
if (!Array.isArray(logLines) || !logLines.length) return '';
|
||||
const lines = logLines.slice(-20).map(line => {
|
||||
const text = typeof line === 'string' ? line : (line.message || line.log_line || JSON.stringify(line));
|
||||
const type = typeof line === 'object' ? (line.type || line.log_type || 'info') : 'info';
|
||||
return `<div class="auto-sync-history-log-line auto-sync-history-log-${_escAttr(type)}">${_esc(text)}</div>`;
|
||||
}).join('');
|
||||
return `<div class="auto-sync-history-log-section">${lines}</div>`;
|
||||
}
|
||||
|
||||
function autoSyncHistoryFactHtml(label, value) {
|
||||
return `
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -3415,6 +3415,10 @@ function closeHelperSearch() {
|
|||
const WHATS_NEW = {
|
||||
'2.6.2': [
|
||||
{ date: 'May 24, 2026 — 2.6.2 release' },
|
||||
{ title: 'Auto-Sync manager redesigned to match the rest of the app', desc: 'the Auto-Sync modal got a top-to-bottom restyle so it stops feeling like it lives in a different app. modal shell now uses the standard SoulSync gradient + accent-tinted border + 20px radius, the KPI summary became inset stat tiles, the live pipeline monitor is auto-fill instead of a fixed 4-col grid, tabs are now underline-style instead of pill buttons, and the schedule board sidebar/columns use the dense dark-card pattern that Automations + Library pages already use. modal also fills more of the screen since there was a lot of unused real estate. run history cards got the same treatment — slim horizontal row matching `.automation-card` (status dot + name + flow chips + meta row), and the expanded panel uses the same stats-grid / log-section structure as the Automations run-history modal.' },
|
||||
{ title: 'Fix: Auto-Sync manager hung forever on "Loading schedule..."', desc: 'a regression while wiring up the new "in library" count: the join used `COLLATE NOCASE` on `tracks.title` and `artists.name`, which can\'t use the indexes those columns have. on a 300k-track library each playlist took ~18s. with 30 mirrored playlists the modal\'s `/api/mirrored-playlists` call would never come back. switched to case-sensitive equality so SQLite uses `idx_artists_name` + `idx_tracks_title` (6ms per playlist). misses pure-case-different matches but Spotify names are canonical against library imports, so it\'s a rounding-error tradeoff for the 3000× speedup.' },
|
||||
{ title: 'Fix: Auto-Sync run history `in library` always showed 0', desc: 'the SQL query was referencing `tracks.artist` — a column that hasn\'t existed since the schema went relational. `tracks` has `artist_id` (FK to `artists`), so the query threw "no such column", got swallowed by the surrounding try/except, and the count silently defaulted to 0 on every playlist. rewired the join through `artists` so the count actually reflects how many mirrored tracks already live in your library.' },
|
||||
{ title: 'Auto-Sync modal opens faster (1.5s → ~280ms)', desc: 'opening the Auto-Sync manager used to spend ~1.5s in `/api/mirrored-playlists` because each mirrored playlist row triggered its own `get_mirrored_playlist_status_counts` call, and each of those opened a fresh SQLite connection with PRAGMA setup. new `get_all_mirrored_playlist_status_counts(profile_id)` does the totals / discovered / wishlisted / in-library counts across every playlist for the active profile in 4 batched `GROUP BY` queries over one connection. about 5× faster on a 30-playlist account.' },
|
||||
{ title: 'Playlist sync is way faster on partial-match playlists', desc: 'syncing a playlist where most tracks weren\'t in the library used to take forever — a 30-track playlist with only 9 matches was burning 4+ minutes because every unmatched track ran the full title-variation × artist-variation SQL grid against the tracks table (~30 SQL queries per missed track). cache only covered matches, so misses re-paid the cost every sync. sync now uses a per-artist track pool that fills in lazily — only tracks that miss the sync match cache trigger a one-time fetch of their artist\'s library tracks, and later misses for the same artist reuse the in-memory list. playlists where every track is already cached pay zero pool cost (no upfront delay). benefits every sync entry point — manual, auto-sync, the playlist_pipeline automation action, and the Sync All button.' },
|
||||
{ title: 'Fix: Auto-Sync "next in 8h" timezone bug', desc: 'scheduled Auto-Sync playlists were all showing "next in 8h" regardless of the interval — Every 1 hour, Every 1 day, anything. backend stores next-run as a naive UTC string and the frontend was parsing it as local time, which on Pacific time offset the displayed countdown by ~8 hours. Auto-Sync now uses the existing UTC-aware parser the rest of the Automations page already uses. as a separate correctness fix, the automation update endpoint also now blanks the stored next-run whenever the trigger type or trigger config changes, so the engine recomputes from scratch instead of preserving a leftover timestamp from the previous schedule.' },
|
||||
{ title: 'Auto-Sync modal restyled to your accent color', desc: 'the Playlist Auto-Sync manager now picks up your chosen accent (the same one used everywhere else in the app) instead of the hardcoded sky-blue palette it shipped with. tabs, drop zones, scheduled-playlist chips, scrollbars, and the modal glow all follow the accent theme. drop targets also light up clearly while dragging.' },
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue