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) {