From 7ff9261000cdf7a5e90358802b518bd1507a6054 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Thu, 2 Jul 2026 06:57:50 -0500 Subject: [PATCH] feat(v3): show "N added / M removed" after a library scan (#686) Completes the Refresh feature: the scanner now reports a delta so the toast can say what changed instead of a generic confirmation. Server: delete_missing returns both deltas from its one query -- rows pruned (removed) and current files not yet in the DB (added) -- and the scan retains added/removed on the terminal scan-status (previously wiped to 0). Client: the completion toast shows "N songs added / M removed" (or "up to date"), and a scan we merely attached to (background / Settings) toasts only when it actually changed something, so a periodic no-op pass stays silent. library:changed now carries the delta too. Verified end to end: empty rescan -> added 0; add a song -> added 1; remove it -> removed 1. Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF Co-authored-by: Claude Opus 4.8 --- server.py | 21 +++++++++++++-------- static/v3/songs.js | 23 +++++++++++++++++------ 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/server.py b/server.py index 2c6ffb5..c6ae4a6 100644 --- a/server.py +++ b/server.py @@ -2586,7 +2586,10 @@ class MetadataDB: self.conn.executemany("DELETE FROM songs WHERE filename = ?", [(f,) for f in stale]) self.conn.commit() self._work_display_dirty = True # membership changed → regroup - return len(stale) + # Report both deltas from the one query we already ran: rows pruned, + # and how many current files are genuinely new (not yet in the DB), + # so a scan can surface an "N added / M removed" summary. + return {"removed": len(stale), "added": len(current_filenames - db_files)} # ── Metadata enrichment (P7 — plumbing; the matcher itself is the next # slice) ───────────────────────────────────────────────────────────────── @@ -5034,7 +5037,7 @@ def _stat_for_cache(f: Path) -> tuple[float, int]: return st.st_mtime, st.st_size -_SCAN_STATUS_INIT = {"running": False, "stage": "idle", "total": 0, "done": 0, "current": "", "error": None, "is_first_scan": False} +_SCAN_STATUS_INIT = {"running": False, "stage": "idle", "total": 0, "done": 0, "current": "", "error": None, "is_first_scan": False, "added": 0, "removed": 0} _scan_status = dict(_SCAN_STATUS_INIT) _STARTUP_STATUS_INIT = { @@ -5364,10 +5367,12 @@ def _background_scan(): current_files = {_relpath(f, dlc) for f in all_songs} - # Clean up stale DB entries - stale = meta_db.delete_missing(current_files) - if stale: - log.info("Removed %d stale DB entries", stale) + # Clean up stale DB entries. delete_missing reports both deltas (rows pruned + # + genuinely-new files) so the scan can surface an added/removed summary. + _delta = meta_db.delete_missing(current_files) + removed, added = _delta["removed"], _delta["added"] + if removed: + log.info("Removed %d stale DB entries", removed) # Figure out which need scanning to_scan = [] @@ -5405,7 +5410,7 @@ def _background_scan(): to_scan.append((f, mtime, size, dlc)) if not to_scan: - _scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete"} + _scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete", "added": added, "removed": removed} log.info("Scan: nothing new to scan (%d songs, all cached)", len(all_songs)) return @@ -5430,7 +5435,7 @@ def _background_scan(): _scan_status["current"] = fname log.info("Scan complete: %d songs cached", len(to_scan)) - _scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete"} + _scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete", "added": added, "removed": removed} _scan_kick_lock = threading.Lock() diff --git a/static/v3/songs.js b/static/v3/songs.js index 5f6b999..b1fa291 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -2795,13 +2795,20 @@ } } // Show a completion toast (reuses the shared fbNotify surface), suppressed - // while in a song. Honest + never-punishing copy; the precise "N added" count - // arrives with the background-scan delta work — until then this is a generic, - // truthful confirmation. + // while in a song. Honest + never-punishing copy: shows the "N added / M + // removed" delta from the scan when there is one, else "up to date". function _scanCompleteToast(sd) { if (document.querySelector('.screen.active') && document.querySelector('.screen.active').id === 'player') return; if (!window.fbNotify) return; - const msg = (sd && sd.error) ? 'Scan finished with an error' : 'Your library is up to date'; + const added = (sd && sd.added) || 0, removed = (sd && sd.removed) || 0; + let msg; + if (sd && sd.error) msg = 'Scan finished with an error'; + else if (added || removed) { + const parts = []; + if (added) parts.push(added + ' song' + (added === 1 ? '' : 's') + ' added'); + if (removed) parts.push(removed + ' removed'); + msg = parts.join(' · '); + } else msg = 'Your library is up to date'; try { window.fbNotify.show({ title: 'Library scan complete', message: msg, icon: '🔄', accent: '#22C55E' }); } catch (e) { /* */ } } // Poll scan-status until the scan finishes, driving the button state. On @@ -2824,8 +2831,12 @@ if ((sawRunning && sd && !sd.running) || noopDone || ticks >= 180) { clearInterval(_refreshPoll); _refreshPoll = null; _setRefreshState(null); - if (sawRunning && window.feedBack) { try { window.feedBack.emit('library:changed', { reason: 'rescan' }); } catch (e) { /* */ } } - if (announce) _scanCompleteToast(sd); + const hasDelta = sd && (((sd.added || 0) > 0) || ((sd.removed || 0) > 0)); + if (sawRunning && window.feedBack) { try { window.feedBack.emit('library:changed', { reason: 'rescan', added: (sd && sd.added) || 0, removed: (sd && sd.removed) || 0 }); } catch (e) { /* */ } } + // A user-initiated refresh always confirms; a scan we only attached + // to (background / Settings) toasts just when it changed something, + // so a periodic no-op pass stays silent. + if (announce || hasDelta) _scanCompleteToast(sd); } }, 1000); }