From 9f12bdfef6532ef9f10633864a9d09bd023d94fe Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Tue, 9 Jun 2026 20:35:16 -0700 Subject: [PATCH] Watchlist: bespoke live scan deck + persistent per-run Scan History (#831 round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boulder: the live display was a cramped ~600px box showing a fraction of the data the scan already tracks, with no animation and no history. Live scan deck (replaces the three-column box, full width): - Header: pulsing live dot, "x / y artists" progress text, and two live counter chips (found / added) that pop when they change. - Animated progress bar (artist index / total) with a shimmer sweep. - Stage: artist avatar with accent glow + name + readable phase line ("Checking album 2 of 5"), album art + album + current track. - "Added to wishlist this run" feed: taller, bigger art, slide-in animation that plays once per new track (feed re-renders only when it changes). - All data was already in scan_state (current_artist_index, total_artists, tracks_found/added_this_scan, current_phase) — just never displayed. The legacy fullscreen-modal markup shares element ids and lacks the new ones, so it keeps working untouched. Scan History (persistent): - New watchlist_scan_runs table — one row per run (status, timestamps, artists/found/added counts) + the full track ledger JSON. Saved at scan completion AND cancellation; idempotent on run_id; pruned to the last 100 runs. Wishlist rows erode as tracks download, so this is the durable record. - GET /api/watchlist/scan/history (runs) + /history//tracks (ledger). - New History button on the Watchlist page → modal in the origins/blocklist house style: run cards (date, cancelled chip, artists/found/added stats) expanding into the Added / Skipped track lists with art and badges. Tests: save+fetch with ledger, idempotent re-save, prune keeps newest, unknown-run empty, cancelled runs recorded. 398 watchlist/wishlist/history tests pass; JS syntax-checked; all rendered strings escaped. --- database/music_database.py | 90 ++++++++ tests/test_watchlist_scan_history.py | 68 ++++++ web_server.py | 46 ++++ webui/index.html | 56 +++-- webui/static/api-monitor.js | 78 +++++-- webui/static/style.css | 331 +++++++++++++++++++++++++++ webui/static/watchlist-history.js | 149 ++++++++++++ 7 files changed, 792 insertions(+), 26 deletions(-) create mode 100644 tests/test_watchlist_scan_history.py create mode 100644 webui/static/watchlist-history.js diff --git a/database/music_database.py b/database/music_database.py index 0fc68c4c..d87e92de 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -649,6 +649,27 @@ class MusicDatabase: logger.info(f"Added {_col} column to library_history") cursor.execute("CREATE INDEX IF NOT EXISTS idx_lh_origin ON library_history (origin, created_at DESC)") + # Watchlist scan history (#831 round 2) — one row per scan run with + # its full track ledger (added/skipped), so the Watchlist page can + # show what every past run did. Wishlist rows erode as tracks + # download, so this is the durable record. + cursor.execute(""" + CREATE TABLE IF NOT EXISTS watchlist_scan_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL UNIQUE, + profile_id INTEGER DEFAULT 1, + status TEXT NOT NULL, + started_at TIMESTAMP, + completed_at TIMESTAMP, + total_artists INTEGER DEFAULT 0, + artists_scanned INTEGER DEFAULT 0, + tracks_found INTEGER DEFAULT 0, + tracks_added INTEGER DEFAULT 0, + track_events TEXT + ) + """) + cursor.execute("CREATE INDEX IF NOT EXISTS idx_wsr_completed ON watchlist_scan_runs (completed_at DESC)") + # Auto-import history — tracks auto-import scan results and processing status cursor.execute(""" CREATE TABLE IF NOT EXISTS auto_import_history ( @@ -12544,6 +12565,75 @@ class MusicDatabase: logger.debug(f"Error adding library history entry: {e}") return False + def save_watchlist_scan_run(self, run_id, profile_id=1, status='completed', + started_at=None, completed_at=None, + total_artists=0, artists_scanned=0, + tracks_found=0, tracks_added=0, + track_events=None, keep_last=100) -> bool: + """Persist one watchlist scan run + its track ledger (#831 round 2). + + Idempotent on run_id (re-saving a run replaces it). Prunes the table to + the most recent ``keep_last`` runs so history can't grow unbounded.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute(""" + INSERT OR REPLACE INTO watchlist_scan_runs + (run_id, profile_id, status, started_at, completed_at, + total_artists, artists_scanned, tracks_found, tracks_added, + track_events) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (run_id, profile_id, status, started_at, completed_at, + total_artists, artists_scanned, tracks_found, tracks_added, + json.dumps(track_events or []))) + cursor.execute(""" + DELETE FROM watchlist_scan_runs WHERE id NOT IN ( + SELECT id FROM watchlist_scan_runs + ORDER BY completed_at DESC, id DESC LIMIT ? + ) + """, (keep_last,)) + conn.commit() + return True + except Exception as e: + logger.error(f"Error saving watchlist scan run {run_id}: {e}") + return False + + def get_watchlist_scan_runs(self, limit=30, profile_id=None): + """Recent watchlist scan runs, newest first — WITHOUT track ledgers + (fetch those per-run via get_watchlist_scan_run_events).""" + try: + conn = self._get_connection() + cursor = conn.cursor() + where = "WHERE profile_id = ?" if profile_id is not None else "" + params = ([profile_id] if profile_id is not None else []) + [limit] + cursor.execute(f""" + SELECT run_id, profile_id, status, started_at, completed_at, + total_artists, artists_scanned, tracks_found, tracks_added + FROM watchlist_scan_runs {where} + ORDER BY completed_at DESC, id DESC LIMIT ? + """, params) + return [dict(row) for row in cursor.fetchall()] + except Exception as e: + logger.error(f"Error getting watchlist scan runs: {e}") + return [] + + def get_watchlist_scan_run_events(self, run_id): + """The track ledger (added/skipped events) for one scan run.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute( + "SELECT track_events FROM watchlist_scan_runs WHERE run_id = ?", + (run_id,)) + row = cursor.fetchone() + if not row or not row['track_events']: + return [] + events = json.loads(row['track_events']) + return events if isinstance(events, list) else [] + except Exception as e: + logger.error(f"Error getting watchlist scan run events for {run_id}: {e}") + return [] + def get_origin_cleanup_candidates(self): """Origin-tracked downloads (watchlist/playlist) annotated with the matching library track's play_count, for the Expired Download Cleaner. diff --git a/tests/test_watchlist_scan_history.py b/tests/test_watchlist_scan_history.py new file mode 100644 index 00000000..3b6f4924 --- /dev/null +++ b/tests/test_watchlist_scan_history.py @@ -0,0 +1,68 @@ +"""Watchlist scan history persistence (#831 round 2). + +Every scan run is saved to watchlist_scan_runs with its track ledger so the +Watchlist History modal can show what each past run added — wishlist rows +erode as tracks download, so this table is the durable record. +""" + +from __future__ import annotations + +import pytest + +from database.music_database import MusicDatabase + + +@pytest.fixture() +def db(tmp_path): + return MusicDatabase(str(tmp_path / 'm.db')) + + +def _events(n_added=2, n_skipped=1): + evs = [{'track_name': f'Added {i}', 'artist_name': 'A', 'album_name': 'Al', + 'album_image_url': '', 'status': 'added'} for i in range(n_added)] + evs += [{'track_name': f'Skipped {i}', 'artist_name': 'A', 'album_name': 'Al', + 'album_image_url': '', 'status': 'skipped'} for i in range(n_skipped)] + return evs + + +def test_save_and_fetch_run_with_ledger(db): + assert db.save_watchlist_scan_run( + 'run-1', status='completed', + started_at='2026-06-09T20:00:00', completed_at='2026-06-09T20:05:00', + total_artists=63, artists_scanned=63, tracks_found=19, tracks_added=10, + track_events=_events()) + runs = db.get_watchlist_scan_runs() + assert len(runs) == 1 + r = runs[0] + assert (r['run_id'], r['status'], r['tracks_found'], r['tracks_added']) == \ + ('run-1', 'completed', 19, 10) + events = db.get_watchlist_scan_run_events('run-1') + assert [e['status'] for e in events] == ['added', 'added', 'skipped'] + + +def test_resave_is_idempotent_on_run_id(db): + db.save_watchlist_scan_run('run-1', tracks_added=10) + db.save_watchlist_scan_run('run-1', tracks_added=11) + runs = db.get_watchlist_scan_runs() + assert len(runs) == 1 and runs[0]['tracks_added'] == 11 + + +def test_prune_keeps_most_recent(db): + for i in range(1, 8): + db.save_watchlist_scan_run( + f'run-{i}', completed_at=f'2026-06-09T20:0{i}:00', keep_last=5) + runs = db.get_watchlist_scan_runs() + assert len(runs) == 5 + assert runs[0]['run_id'] == 'run-7' # newest first + assert all(r['run_id'] != 'run-1' for r in runs) # oldest pruned + + +def test_events_for_unknown_run_empty(db): + assert db.get_watchlist_scan_run_events('nope') == [] + + +def test_cancelled_run_recorded(db): + db.save_watchlist_scan_run('run-c', status='cancelled', tracks_added=3, + track_events=_events(1, 0)) + r = db.get_watchlist_scan_runs()[0] + assert r['status'] == 'cancelled' diff --git a/web_server.py b/web_server.py index 4f4aba3b..922bb85a 100644 --- a/web_server.py +++ b/web_server.py @@ -25996,6 +25996,29 @@ def start_watchlist_scan(): else: logger.warning("Watchlist scan cancelled — skipping post-scan steps") + # #831 round 2: persist this run + its track ledger so the + # Watchlist History modal can show what every past scan did. + try: + _state = watchlist_scan_state + get_database().save_watchlist_scan_run( + run_id=_state.get('scan_run_id') or datetime.now().strftime('%Y%m%d-%H%M%S'), + profile_id=scan_profile_id, + status='cancelled' if was_cancelled else 'completed', + started_at=(_state.get('started_at').isoformat() + if _state.get('started_at') else None), + completed_at=(_state.get('completed_at') or datetime.now()).isoformat() + if not isinstance(_state.get('completed_at'), str) + else _state.get('completed_at'), + total_artists=(_state.get('summary') or {}).get('total_artists', + _state.get('total_artists', 0)), + artists_scanned=(_state.get('summary') or {}).get('successful_scans', 0), + tracks_found=_state.get('tracks_found_this_scan', 0), + tracks_added=_state.get('tracks_added_this_scan', 0), + track_events=_state.get('scan_track_events') or [], + ) + except Exception as _hist_err: + logger.error(f"Failed to persist watchlist scan run: {_hist_err}") + # Post-scan steps — skip if cancelled if not was_cancelled: # Populate discovery pool from similar artists @@ -26152,6 +26175,29 @@ def get_watchlist_scan_status(): logger.error(f"Error getting watchlist scan status: {e}") return jsonify({"success": False, "error": str(e)}), 500 +@app.route('/api/watchlist/scan/history', methods=['GET']) +def get_watchlist_scan_history(): + """Recent watchlist scan runs (counts only — ledgers fetched per run).""" + try: + limit = min(int(request.args.get('limit', 30) or 30), 100) + runs = get_database().get_watchlist_scan_runs(limit=limit) + return jsonify({"success": True, "runs": runs}) + except Exception as e: + logger.error(f"Error getting watchlist scan history: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/watchlist/scan/history//tracks', methods=['GET']) +def get_watchlist_scan_history_tracks(run_id): + """The track ledger (added/skipped) for one past scan run.""" + try: + events = get_database().get_watchlist_scan_run_events(run_id) + return jsonify({"success": True, "events": events}) + except Exception as e: + logger.error(f"Error getting watchlist scan history tracks: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + @app.route('/api/watchlist/scan/cancel', methods=['POST']) def cancel_watchlist_scan(): """Cancel a running watchlist scan""" diff --git a/webui/index.html b/webui/index.html index a0c5722b..2ebf113d 100644 --- a/webui/index.html +++ b/webui/index.html @@ -6975,20 +6975,45 @@