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.
This commit is contained in:
parent
bcd69c8baa
commit
e8cde40d22
6 changed files with 277 additions and 12 deletions
|
|
@ -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"> • </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…
Reference in a new issue