Watchlist: show WHICH tracks a scan found/added + group Download Origins (#831)

Tacobell444 (#707 follow-up): the scan summary said "New tracks: 19 • Added to
wishlist: 10" with no way to see which tracks those were — you had to scan your
wishlist and guess what was new.

Scan ledger: the scanner now records a per-run scan_track_events list (track,
artist, album, thumb, status added|skipped — skipped = found-new but declined
by add_to_wishlist: already queued or blocklisted; capped at 500). The status
endpoint already serializes scan_state, so the payload flows free. The
completed (and cancelled) scan summary on the Watchlist page gets a
"Show tracks" toggle expanding a styled list — Added section + Skipped section
with badges, reusing the live-feed row styling.

Download Origins grouping: the modal now groups entries by what triggered them
(watchlist artist / playlist name) with collapsible headers + counts instead of
a flat list with a per-row badge. Entries arrive newest-first so groups order
themselves by their newest download. Same row markup, checkboxes/delete intact.

Provenance: watchlist adds now stamp scan_run_id into wishlist source_info, so
per-run grouping is queryable later (future "what did run X add" views).

Tests: per-run ledger seam test (added + skipped statuses, album/artist fields,
FIFO unchanged). 316 watchlist/wishlist tests pass; JS syntax-checked.
pull/834/head
BoulderBadgeDad 3 days ago
parent bcd69c8baa
commit e8cde40d22

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

@ -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"]

@ -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

@ -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) => `
<div class="watchlist-live-addition-item">
<img src="${escapeHtml(e.album_image_url || '')}" alt="" onerror="this.style.display='none';" />
<div class="watchlist-live-addition-item-info">
<div class="watchlist-live-addition-item-track">${escapeHtml(e.track_name || '')}</div>
<div class="watchlist-live-addition-item-artist">${escapeHtml(e.artist_name || '')}${e.album_name ? ' — ' + escapeHtml(e.album_name) : ''}</div>
</div>
${e.status === 'added'
? '<span class="watchlist-scan-track-badge added">added</span>'
: '<span class="watchlist-scan-track-badge skipped">skipped</span>'}
</div>`;
const section = (label, list) => list.length
? `<div class="watchlist-scan-tracks-section">${label} (${list.length})</div>${list.map(row).join('')}`
: '';
return `
<button type="button" class="watchlist-scan-tracks-toggle" onclick="toggleWatchlistScanTracks(this)">
Show tracks <span class="watchlist-scan-tracks-caret"></span>
</button>
<div class="watchlist-scan-tracks" style="display: none;">
${section('Added to wishlist', added)}
${section('Found but skipped — already queued or blocklisted', skipped)}
</div>`;
}
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 ')
+ `<span class="watchlist-scan-tracks-caret">${open ? '▾' : '▴'}</span>`;
}
function handleWatchlistScanData(data) {
const button = document.getElementById('scan-watchlist-btn');
const liveActivity = document.getElementById('watchlist-live-activity');
@ -3295,6 +3339,7 @@ function handleWatchlistScanData(data) {
<span class="sync-separator"> </span>
<span class="sync-stat">Added to wishlist: ${addedTracks}</span>
</div>
${renderWatchlistScanTrackLedger(data.scan_track_events)}
</div>
`;
}
@ -3341,6 +3386,7 @@ function handleWatchlistScanData(data) {
<span class="sync-separator"> &bull; </span>
<span class="sync-stat">Added to wishlist: ${addedTracks}</span>
</div>
${renderWatchlistScanTrackLedger(data.scan_track_events)}
</div>
`;
}

@ -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
? `<img class="library-history-thumb" src="${escapeHtml(e.thumb_url)}" alt="" loading="lazy"
@ -116,7 +117,6 @@ function _renderOriginEntries() {
<div class="library-history-entry-title">${escapeHtml(e.title || 'Unknown')}</div>
<div class="library-history-entry-meta">${escapeHtml(e.artist_name || '')}${e.album_name ? ' — ' + escapeHtml(e.album_name) : ''}</div>
</div>
<span class="origin-context-badge" title="${ctxLabel}">${escapeHtml(e.origin_context || '—')}</span>
${e.quality ? `<span class="library-history-badge">${escapeHtml(e.quality)}</span>` : ''}
<div class="library-history-entry-time">${escapeHtml(_originFormatTime(e.created_at))}</div>
<button class="lh-audit-btn origin-row-delete" title="Delete this file + entry"
@ -125,10 +125,39 @@ function _renderOriginEntries() {
${fname ? `<div class="library-history-entry-source"><span class="lh-prov-label">File:</span> ${escapeHtml(fname)}</div>` : ''}
</div>
</div>`;
}).join('');
};
// #831: group entries by what triggered them (watchlist artist / playlist
// name) instead of a flat list with a per-row badge. Entries arrive
// newest-first, so groups order themselves by their newest download.
const groups = new Map();
for (const e of _originEntries) {
const key = e.origin_context || '—';
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(e);
}
body.innerHTML = Array.from(groups.entries()).map(([ctx, entries]) => `
<div class="origin-group">
<button type="button" class="origin-group-header" onclick="toggleOriginGroup(this)" title="${ctxLabel}">
<span class="origin-group-caret"></span>
<span class="origin-group-name">${escapeHtml(ctx)}</span>
<span class="origin-group-count">${entries.length} track${entries.length !== 1 ? 's' : ''}</span>
</button>
<div class="origin-group-body">${entries.map(entryRow).join('')}</div>
</div>`).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();

@ -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;

Loading…
Cancel
Save