From d30273985fb2f030ccbadc64f38f29c9488f8456 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sat, 27 Jun 2026 23:37:06 -0700 Subject: [PATCH] downloads: restore Clear Completed for persisted history (clears the whole completed list) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit since 9a0e3b40 persisted completed downloads in the Downloads view, the Clear Completed button was hidden for those rows and clear-completed only pruned live session tasks. after a restart the page filled with persisted completed downloads with no way to clear them. now Clear Completed clears BOTH: - live session completed/failed tasks (clear_completed_local, unchanged), AND - the persisted download-history tail: new clear_completed_download_history() deletes every library_history event_type='download' row, so the list actually empties and stays empty. this includes unverified rows (the verification review queue) by design: on a library where verification never confirmed the imports, ALL completed downloads are 'unverified', so preserving them made the button a no-op. it only removes HISTORY rows — the actual files and their tracks entries are untouched, so nothing in the library is lost, only the 'needs verification' flags. the action confirms first (showConfirmDialog, destructive) and the button now shows whenever any completed/failed row is present. 3 seam tests (clears all incl unverified; leaves non-download history; empty=0); reconcile + orphan + JS integrity suites green. --- database/music_database.py | 22 ++++ .../test_clear_completed_download_history.py | 103 ++++++++++++++++++ web_server.py | 11 +- webui/static/pages-extra.js | 20 +++- 4 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 tests/test_clear_completed_download_history.py diff --git a/database/music_database.py b/database/music_database.py index c928b5cc..b8702752 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -13799,6 +13799,28 @@ class MusicDatabase: logger.debug(f"Error deleting history rows: {e}") return 0 + def clear_completed_download_history(self) -> int: + """Delete the persisted completed-download history shown on the Downloads + page (every event_type='download' row). This also clears the verification + review queue, since those unverified/force_imported rows ARE download-history + rows — that's intended: 'Clear Completed' empties the list. It only removes + HISTORY rows; the actual files and their `tracks` entries are untouched, so + nothing in the library is lost — only the 'needs verification' review flags. + Returns the number of rows removed.""" + conn = None + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute("DELETE FROM library_history WHERE event_type = 'download'") + conn.commit() + return cursor.rowcount + except Exception as e: + logger.error("Error clearing completed download history: %s", e) + return 0 + finally: + if conn: + conn.close() + def delete_track_by_file_path(self, file_path): """Delete a library track row whose stored path matches. Returns count.""" if not file_path: diff --git a/tests/test_clear_completed_download_history.py b/tests/test_clear_completed_download_history.py new file mode 100644 index 00000000..e84e1cae --- /dev/null +++ b/tests/test_clear_completed_download_history.py @@ -0,0 +1,103 @@ +"""'Clear Completed' on the Downloads page deletes ALL persisted completed-download +history (every event_type='download' row), including the unverified review-queue rows +— the user wants the list emptied, and those unverified rows ARE download-history rows. +It only removes HISTORY rows; the actual files / `tracks` entries are untouched, so the +library is never affected — only the 'needs verification' flags. (Clear-button restoration.)""" + +import sqlite3 +import sys +import types + +if "spotipy" not in sys.modules: # match the suite's lightweight stubs + spotipy = types.ModuleType("spotipy") + spotipy.Spotify = object + oauth2 = types.ModuleType("spotipy.oauth2") + oauth2.SpotifyOAuth = object + oauth2.SpotifyClientCredentials = object + spotipy.oauth2 = oauth2 + sys.modules["spotipy"] = spotipy + sys.modules["spotipy.oauth2"] = oauth2 + +if "config.settings" not in sys.modules: + config_pkg = types.ModuleType("config") + settings_mod = types.ModuleType("config.settings") + + class _DummyConfigManager: + def get(self, key, default=None): + return default + + def get_active_media_server(self): + return "primary" + + settings_mod.config_manager = _DummyConfigManager() + config_pkg.settings = settings_mod + sys.modules["config"] = config_pkg + sys.modules["config.settings"] = settings_mod + +from database.music_database import MusicDatabase # noqa: E402 + + +class _NonClosingConn: + def __init__(self, real): + self._real = real + + def cursor(self): + return self._real.cursor() + + def commit(self): + return self._real.commit() + + def close(self): + pass + + +class _InMemoryDB(MusicDatabase): + def __init__(self): + self._conn = sqlite3.connect(":memory:") + self._conn.row_factory = sqlite3.Row + self._conn.execute( + "CREATE TABLE library_history (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, event_type TEXT NOT NULL, " + "title TEXT, file_path TEXT, verification_status TEXT)") + + def _get_connection(self): + return _NonClosingConn(self._conn) + + def _add(self, event_type, status, title="Song"): + self._conn.execute( + "INSERT INTO library_history (event_type, title, verification_status) " + "VALUES (?, ?, ?)", (event_type, title, status)) + self._conn.commit() + + def _statuses(self): + return [r[0] for r in self._conn.execute( + "SELECT verification_status FROM library_history ORDER BY id").fetchall()] + + +def test_clears_all_completed_download_rows_including_unverified(): + db = _InMemoryDB() + db._add("download", "verified") + db._add("download", "human_verified") + db._add("download", None) # legacy / unscored completed + db._add("download", "unverified") # review queue — also cleared (user chose clear-all) + db._add("download", "force_imported") + removed = db.clear_completed_download_history() + assert removed == 5 + assert db._statuses() == [] + + +def test_does_not_touch_non_download_history(): + """Only event_type='download' rows are the Downloads-page tail; imports etc. stay.""" + db = _InMemoryDB() + db._add("download", "verified") + db._add("import", "verified") + removed = db.clear_completed_download_history() + assert removed == 1 + # the import row survives + rows = db._conn.execute("SELECT event_type FROM library_history").fetchall() + assert [r[0] for r in rows] == ["import"] + + +def test_empty_history_returns_zero(): + db = _InMemoryDB() + assert db.clear_completed_download_history() == 0 diff --git a/web_server.py b/web_server.py index d3b19db7..a9b69d1b 100644 --- a/web_server.py +++ b/web_server.py @@ -19102,10 +19102,17 @@ def get_batch_history(): @app.route('/api/downloads/clear-completed', methods=['POST']) def clear_completed_downloads(): - """Remove completed/failed/cancelled tasks from the download tracker.""" + """Clear completed/failed downloads from the Downloads page: the live + session tasks AND the persisted download-history tail (so the list actually + empties and stays empty across restart). Rows still awaiting verification + (unverified / force_imported) are preserved — they belong to the review + queue, not this cleanup.""" try: cleared = _downloads_cancel.clear_completed_local() - return jsonify({'success': True, 'cleared': cleared}) + history_cleared = get_database().clear_completed_download_history() + return jsonify({'success': True, 'cleared': cleared, + 'history_cleared': history_cleared, + 'total_cleared': cleared + history_cleared}) except Exception as e: logger.error(f"Error clearing completed downloads: {e}") return jsonify({'success': False, 'error': str(e)}), 500 diff --git a/webui/static/pages-extra.js b/webui/static/pages-extra.js index b66404d9..3d212f51 100644 --- a/webui/static/pages-extra.js +++ b/webui/static/pages-extra.js @@ -3307,8 +3307,12 @@ function _adlRender() { // (review banner injected below when this filter is active) else if (_adlFilter === 'failed') filtered = filtered.filter(d => failedStatuses.includes(d.status)); + // Clear-completed clears live completed tasks AND the persisted download-history + // tail (including unverified review-queue rows), so count every completed/failed + // row — otherwise the button vanishes after a restart when the list is all + // persisted completed rows. const completedN = _adlData.filter(d => - [...completedStatuses, ...failedStatuses].includes(d.status) && !d.is_persistent_history + [...completedStatuses, ...failedStatuses].includes(d.status) ).length; if (countEl) { @@ -3611,11 +3615,23 @@ function _adlBundleProgressText(bundle) { } async function adlClearCompleted() { + // This now also deletes the persisted completed-download history, so confirm. + if (typeof showConfirmDialog === 'function') { + const ok = await showConfirmDialog({ + title: 'Clear Completed', + message: 'Remove ALL completed and failed downloads from the list and history? ' + + 'This also clears unverified items from the verification queue. ' + + 'Your files stay in the library — only the download-history rows are removed.', + confirmText: 'Clear', destructive: true, + }); + if (!ok) return; + } try { const resp = await fetch('/api/downloads/clear-completed', { method: 'POST' }); const data = await resp.json(); if (data.success) { - if (typeof showToast === 'function') showToast(`Cleared ${data.cleared} downloads`, 'success'); + const n = data.total_cleared != null ? data.total_cleared : data.cleared; + if (typeof showToast === 'function') showToast(`Cleared ${n} downloads`, 'success'); _adlFetch(); } } catch (e) {