diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index 239cfba0..c99841eb 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -1376,14 +1376,34 @@ class WatchlistScanner: if scan_state is not None: scan_state['tracks_found_this_scan'] += 1 - if self.add_track_to_wishlist(track, album_data, artist): + added = self.add_track_to_wishlist( + track, album_data, artist, + scan_run_id=(scan_state or {}).get('scan_run_id', ''), + ) + + track_artists = track.get('artists', []) + track_artist_name = track_artists[0].get('name', 'Unknown Artist') if track_artists else 'Unknown Artist' + + # #831: per-run ledger so the completed-scan + # summary can list WHICH tracks the counts mean. + # 'skipped' = found-new but add_to_wishlist + # declined (already queued in the wishlist, or + # the artist is blocklisted). Capped for sanity. + if scan_state is not None: + events = scan_state.setdefault('scan_track_events', []) + if len(events) < 500: + events.append({ + 'track_name': track_name, + 'artist_name': track_artist_name, + 'album_name': album_name, + 'album_image_url': album_image_url, + 'status': 'added' if added else 'skipped', + }) + + if added: artist_added_tracks += 1 if scan_state is not None: scan_state['tracks_added_this_scan'] += 1 - - track_artists = track.get('artists', []) - track_artist_name = track_artists[0].get('name', 'Unknown Artist') if track_artists else 'Unknown Artist' - if scan_state is not None: scan_state['recent_wishlist_additions'].insert(0, { 'track_name': track_name, 'artist_name': track_artist_name, @@ -2224,7 +2244,8 @@ class WatchlistScanner: logger.warning(f"Error checking if track exists: {track_name}: {e}") return True # Assume missing if we can't check - def add_track_to_wishlist(self, track, album, watchlist_artist: WatchlistArtist) -> bool: + def add_track_to_wishlist(self, track, album, watchlist_artist: WatchlistArtist, + scan_run_id: str = '') -> bool: """Add a missing track to the wishlist""" try: # Handle both dict and object track/album formats @@ -2306,7 +2327,9 @@ class WatchlistScanner: 'watchlist_artist_name': watchlist_artist.artist_name, 'watchlist_artist_id': watchlist_artist.spotify_artist_id, 'album_name': album_name, - 'scan_timestamp': datetime.now().isoformat() + 'scan_timestamp': datetime.now().isoformat(), + # #831: groups wishlist rows by the scan run that added them. + 'scan_run_id': scan_run_id or '', }, profile_id=getattr(watchlist_artist, 'profile_id', 1) ) diff --git a/tests/test_watchlist_scanner_scan.py b/tests/test_watchlist_scanner_scan.py index 6c08efa0..f02eab45 100644 --- a/tests/test_watchlist_scanner_scan.py +++ b/tests/test_watchlist_scanner_scan.py @@ -1177,3 +1177,57 @@ def test_match_to_spotify_uses_strict_lookup(): assert result is None assert spotify_client.search_calls == [("Artist One", 5, False)] + + +def test_scan_records_per_run_track_ledger(monkeypatch): + """#831: the scan keeps a full per-run ledger (scan_track_events) of found + tracks — 'added' vs 'skipped' (wishlist dup / blocklisted) — so the UI can + list WHICH tracks the 'New tracks / Added to wishlist' counts refer to.""" + monkeypatch.setattr(watchlist_scanner_module, "DELAY_BETWEEN_ARTISTS", 0) + monkeypatch.setattr(watchlist_scanner_module, "DELAY_BETWEEN_ALBUMS", 0) + + artist = _build_artist() + album = types.SimpleNamespace(id="album-1", name="Album One") + album_data = { + "name": "Album One", + "images": [{"url": "https://example.com/album.jpg"}], + "tracks": { + "items": [ + {"id": "t1", "name": "Added Track", "track_number": 1, + "disc_number": 1, "artists": [{"name": "Artist One"}]}, + {"id": "t2", "name": "Skipped Track", "track_number": 2, + "disc_number": 1, "artists": [{"name": "Artist One"}]}, + ] + }, + } + scanner = _build_scanner(album_data, [artist]) + scanner._database.has_fresh_similar_artists = lambda *args, **kwargs: False + + monkeypatch.setattr(scanner, "_backfill_missing_ids", lambda *args, **kwargs: None) + monkeypatch.setattr(scanner, "get_artist_image_url", lambda *_a, **_k: "") + monkeypatch.setattr(scanner, "get_artist_discography_for_watchlist", lambda *_a, **_k: [album]) + monkeypatch.setattr(scanner, "_get_lookback_period_setting", lambda: "30") + monkeypatch.setattr(scanner, "_get_rescan_cutoff", lambda: None) + monkeypatch.setattr(scanner, "_should_include_release", lambda *_a, **_k: True) + monkeypatch.setattr(scanner, "_should_include_track", lambda *_a, **_k: True) + monkeypatch.setattr(scanner, "is_track_missing_from_library", lambda *_a, **_k: True) + # First add succeeds, second is declined (already queued / blocklisted). + _add_results = iter([True, False]) + monkeypatch.setattr(scanner, "add_track_to_wishlist", + lambda *_a, **_k: next(_add_results)) + monkeypatch.setattr(scanner, "update_artist_scan_timestamp", lambda *_a, **_k: True) + monkeypatch.setattr(scanner, "update_similar_artists", lambda *_a, **_k: True) + monkeypatch.setattr(scanner, "_backfill_similar_artists_fallback_ids", lambda *_a, **_k: 0) + + scan_state = {"scan_run_id": "20260609-1"} + scanner.scan_watchlist_artists([artist], scan_state=scan_state) + + events = scan_state["scan_track_events"] + assert [(e["track_name"], e["status"]) for e in events] == [ + ("Added Track", "added"), + ("Skipped Track", "skipped"), + ] + assert events[0]["album_name"] == "Album One" + assert events[0]["artist_name"] == "Artist One" + # The 10-item live FIFO only carries the ADDED one, as before. + assert [a["track_name"] for a in scan_state["recent_wishlist_additions"]] == ["Added Track"] diff --git a/web_server.py b/web_server.py index bd396d6c..4f4aba3b 100644 --- a/web_server.py +++ b/web_server.py @@ -25952,9 +25952,14 @@ def start_watchlist_scan(): 'current_track_name': '', 'tracks_found_this_scan': 0, 'tracks_added_this_scan': 0, - 'recent_wishlist_additions': [] + 'recent_wishlist_additions': [], + # #831: full per-run ledger of found tracks (added vs + # skipped) so the completed-scan summary can list WHICH + # tracks the "New tracks / Added to wishlist" counts mean. + 'scan_track_events': [], + 'scan_run_id': datetime.now().strftime('%Y%m%d-%H%M%S'), }) - + scan_results = [] # Pause enrichment workers during scan to reduce API contention diff --git a/webui/static/api-monitor.js b/webui/static/api-monitor.js index 0d51d85b..c5b4d2a5 100644 --- a/webui/static/api-monitor.js +++ b/webui/static/api-monitor.js @@ -3189,6 +3189,50 @@ async function startWatchlistScan() { /** * Poll watchlist scan status */ +// #831 (Tacobell444): the scan summary said "New tracks: 19 • Added to +// wishlist: 10" with no way to see WHICH tracks. The scan now ships a per-run +// ledger (scan_track_events: track/artist/album/thumb + added|skipped) and +// this renders it as an expandable list under the completion summary. +function renderWatchlistScanTrackLedger(events) { + if (!Array.isArray(events) || events.length === 0) return ''; + const added = events.filter(e => e.status === 'added'); + const skipped = events.filter(e => e.status !== 'added'); + + const row = (e) => ` +
+ +
+
${escapeHtml(e.track_name || '')}
+
${escapeHtml(e.artist_name || '')}${e.album_name ? ' — ' + escapeHtml(e.album_name) : ''}
+
+ ${e.status === 'added' + ? 'added' + : 'skipped'} +
`; + + const section = (label, list) => list.length + ? `
${label} (${list.length})
${list.map(row).join('')}` + : ''; + + return ` + + `; +} + +function toggleWatchlistScanTracks(btn) { + const list = btn.parentElement.querySelector('.watchlist-scan-tracks'); + if (!list) return; + const open = list.style.display !== 'none'; + list.style.display = open ? 'none' : 'block'; + btn.innerHTML = (open ? 'Show tracks ' : 'Hide tracks ') + + `${open ? '▾' : '▴'}`; +} + function handleWatchlistScanData(data) { const button = document.getElementById('scan-watchlist-btn'); const liveActivity = document.getElementById('watchlist-live-activity'); @@ -3295,6 +3339,7 @@ function handleWatchlistScanData(data) { Added to wishlist: ${addedTracks} + ${renderWatchlistScanTrackLedger(data.scan_track_events)} `; } @@ -3341,6 +3386,7 @@ function handleWatchlistScanData(data) { Added to wishlist: ${addedTracks} + ${renderWatchlistScanTrackLedger(data.scan_track_events)} `; } diff --git a/webui/static/origin-history.js b/webui/static/origin-history.js index 090e2980..c525588d 100644 --- a/webui/static/origin-history.js +++ b/webui/static/origin-history.js @@ -99,7 +99,8 @@ function _renderOriginEntries() { return; } const ctxLabel = _originActiveTab === 'watchlist' ? 'Watchlist artist' : 'Playlist'; - body.innerHTML = _originEntries.map(e => { + + const entryRow = (e) => { const checked = _originSelected.has(e.id) ? 'checked' : ''; const thumb = e.thumb_url ? `${escapeHtml(e.title || 'Unknown')}
${escapeHtml(e.artist_name || '')}${e.album_name ? ' — ' + escapeHtml(e.album_name) : ''}
- ${escapeHtml(e.origin_context || '—')} ${e.quality ? `${escapeHtml(e.quality)}` : ''}
${escapeHtml(_originFormatTime(e.created_at))}
+
${entries.map(entryRow).join('')}
+ `).join(''); _updateOriginDeleteButton(); } +function toggleOriginGroup(btn) { + const bodyEl = btn.parentElement.querySelector('.origin-group-body'); + const caret = btn.querySelector('.origin-group-caret'); + if (!bodyEl) return; + const open = bodyEl.style.display !== 'none'; + bodyEl.style.display = open ? 'none' : ''; + if (caret) caret.textContent = open ? '▸' : '▾'; +} + function toggleOriginEntry(id, on) { if (on) _originSelected.add(id); else _originSelected.delete(id); _updateOriginDeleteButton(); diff --git a/webui/static/style.css b/webui/static/style.css index cc22d3b2..28942dda 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -19891,6 +19891,65 @@ body.helper-mode-active #dashboard-activity-feed:hover { margin-bottom: 10px; } +/* #831: expandable per-run track ledger under the scan summary */ +.watchlist-scan-tracks-toggle { + margin-top: 10px; + padding: 5px 14px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 14px; + color: #ccc; + font-size: 12px; + cursor: pointer; + transition: background 0.15s ease; +} + +.watchlist-scan-tracks-toggle:hover { + background: rgba(255, 255, 255, 0.12); + color: #fff; +} + +.watchlist-scan-tracks { + margin-top: 10px; + max-height: 280px; + overflow-y: auto; + text-align: left; +} + +.watchlist-scan-tracks-section { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.4px; + color: #888; + margin: 10px 0 4px; +} + +.watchlist-scan-tracks .watchlist-live-addition-item { + position: relative; +} + +.watchlist-scan-track-badge { + margin-left: auto; + flex-shrink: 0; + padding: 2px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.watchlist-scan-track-badge.added { + background: rgba(80, 200, 120, 0.15); + color: #6fd99a; +} + +.watchlist-scan-track-badge.skipped { + background: rgba(255, 255, 255, 0.08); + color: #999; +} + /* Watchlist Search */ .watchlist-search-input { @@ -66708,6 +66767,55 @@ body.em-scroll-lock { overflow: hidden; } color: #f87171 !important; } +/* #831: origins grouped by what triggered them (artist / playlist) */ +.origin-group { + margin-bottom: 6px; +} + +.origin-group-header { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 12px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + cursor: pointer; + text-align: left; + transition: background 0.15s ease; +} + +.origin-group-header:hover { + background: rgba(255, 255, 255, 0.08); +} + +.origin-group-caret { + color: rgba(255, 255, 255, 0.45); + font-size: 11px; + width: 12px; +} + +.origin-group-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-weight: 600; + color: rgb(var(--accent-light-rgb)); +} + +.origin-group-count { + flex: 0 0 auto; + font-size: 11px; + color: rgba(255, 255, 255, 0.45); +} + +.origin-group-body { + padding: 4px 0 2px 6px; +} + /* ── Blocklist modal (artist/album/track bans) ── */ .blocklist-modal { position: relative;