Watchlist: bespoke live scan deck + persistent per-run Scan History (#831 round 2)

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/<run_id>/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.
This commit is contained in:
BoulderBadgeDad 2026-06-09 20:35:16 -07:00
parent e8cde40d22
commit 9f12bdfef6
7 changed files with 792 additions and 26 deletions

View file

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

View file

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

View file

@ -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/<run_id>/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"""

View file

@ -6975,20 +6975,45 @@
<!-- Scan status / live activity (IDs match old modal for compatibility) -->
<div id="watchlist-scan-status" class="watchlist-page-scan-status" style="display: none;">
<div id="watchlist-live-activity" class="watchlist-live-activity" style="display: none;">
<div class="watchlist-live-activity-col">
<img id="watchlist-artist-img" class="watchlist-live-activity-artist-img" src="" alt="Artist" onerror="this.style.display='none';" />
<div id="watchlist-artist-name" class="watchlist-live-activity-label">Waiting...</div>
<!-- #831 round 2: full-width live "scan deck" -->
<div id="watchlist-live-activity" class="wl-scan-deck" style="display: none;">
<div class="wl-scan-deck-head">
<span class="wl-scan-live-dot"></span>
<span class="wl-scan-live-label">Scanning</span>
<span id="wl-scan-progress-text" class="wl-scan-progress-text"></span>
<div class="wl-scan-counters">
<div class="wl-scan-counter">
<span id="wl-scan-found" class="wl-scan-counter-num">0</span>
<span class="wl-scan-counter-label">found</span>
</div>
<div class="wl-scan-counter added">
<span id="wl-scan-added" class="wl-scan-counter-num">0</span>
<span class="wl-scan-counter-label">added</span>
</div>
</div>
</div>
<div class="watchlist-live-activity-col">
<img id="watchlist-album-img" class="watchlist-live-activity-album-img" src="" alt="Album" onerror="this.style.display='none';" />
<div id="watchlist-album-name" class="watchlist-live-activity-label">Waiting...</div>
</div>
<div class="watchlist-live-activity-feed">
<div class="watchlist-live-activity-feed-label">Current Track:</div>
<div id="watchlist-track-name" class="watchlist-live-activity-track">Waiting...</div>
<div class="watchlist-live-activity-feed-label-orange">Recently Added:</div>
<div id="watchlist-additions-feed" style="max-height: 80px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; font-size: 10px;"></div>
<div class="wl-scan-progress"><div id="wl-scan-progress-bar" class="wl-scan-progress-bar" style="width: 0%;"></div></div>
<div class="wl-scan-deck-body">
<div class="wl-scan-stage">
<div class="wl-scan-stage-row">
<img id="watchlist-artist-img" class="wl-scan-artist-img" src="" alt="" onerror="this.style.display='none';" />
<div class="wl-scan-stage-text">
<div id="watchlist-artist-name" class="wl-scan-artist-name">Waiting…</div>
<div id="wl-scan-phase" class="wl-scan-phase">starting…</div>
</div>
</div>
<div class="wl-scan-stage-row">
<img id="watchlist-album-img" class="wl-scan-album-img" src="" alt="" onerror="this.style.display='none';" />
<div class="wl-scan-stage-text">
<div id="watchlist-album-name" class="wl-scan-album-name"></div>
<div id="watchlist-track-name" class="wl-scan-track-name"></div>
</div>
</div>
</div>
<div class="wl-scan-feed">
<div class="wl-scan-feed-label">Added to wishlist this run</div>
<div id="watchlist-additions-feed" class="wl-scan-feed-list"></div>
</div>
</div>
</div>
<div id="watchlist-page-scan-summary" class="scan-status-summary" style="display: none;"></div>
@ -7016,6 +7041,10 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Download Origins
</button>
<button class="btn btn--secondary" id="watchlist-page-history-btn" onclick="openWatchlistHistoryModal()" title="Every past scan and the tracks it added to the wishlist">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v5h5"/><path d="M3.05 13A9 9 0 1 0 6 5.3L3 8"/><polyline points="12 7 12 12 15 15"/></svg>
History
</button>
<button class="btn btn--secondary" id="watchlist-page-blocklist-btn" onclick="openBlocklistModal('artist')" title="Block artists, albums or tracks from ever being downloaded">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="4.9" y1="4.9" x2="19.1" y2="19.1"/></svg>
Blocklist
@ -8163,6 +8192,7 @@
<script src="{{ url_for('static', filename='track-detail.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='wishlist-tools.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='origin-history.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='watchlist-history.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='blocklist.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-services.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-listenbrainz.js', v=static_v) }}"></script>

View file

@ -3224,6 +3224,35 @@ function renderWatchlistScanTrackLedger(events) {
</div>`;
}
// Human-readable phase line for the scan deck ("checking_album_2_of_5" →
// "Checking album 2 of 5").
function _wlPrettyPhase(data) {
const phase = data.current_phase || '';
if (!phase) return 'Working…';
const m = phase.match(/^checking_album_(\d+)_of_(\d+)$/);
if (m) return `Checking album ${m[1]} of ${m[2]}`;
const map = {
starting: 'Starting…',
fetching_discography: 'Fetching releases…',
populating_discovery_pool: 'Populating discovery…',
updating_listenbrainz: 'Updating ListenBrainz…',
};
return map[phase] || phase.replace(/_/g, ' ');
}
// Update a live counter and replay its pop animation when the value changes.
function _wlBumpCounter(id, value) {
const el = document.getElementById(id);
if (!el) return;
const text = String(value);
if (el.textContent !== text) {
el.textContent = text;
el.classList.remove('pop');
void el.offsetWidth; // restart the CSS animation
el.classList.add('pop');
}
}
function toggleWatchlistScanTracks(btn) {
const list = btn.parentElement.querySelector('.watchlist-scan-tracks');
if (!list) return;
@ -3245,7 +3274,7 @@ function handleWatchlistScanData(data) {
// Update live visual activity display
if (liveActivity && data.status === 'scanning') {
liveActivity.style.display = 'flex';
liveActivity.style.display = liveActivity.classList.contains('wl-scan-deck') ? 'block' : 'flex';
// Update artist image and name
const artistImg = document.getElementById('watchlist-artist-img');
@ -3277,21 +3306,44 @@ function handleWatchlistScanData(data) {
trackName.textContent = data.current_track_name || (data.current_phase === 'fetching_discography' ? 'Fetching releases...' : 'Processing...');
}
// Update wishlist additions feed
// #831 round 2: scan-deck extras — artist progress bar, live counters,
// readable phase. All optional elements, so the legacy modal markup
// (which lacks them) keeps working untouched.
const total = data.total_artists || 0;
const idx = total ? Math.min((data.current_artist_index || 0) + 1, total) : 0;
const progText = document.getElementById('wl-scan-progress-text');
if (progText) progText.textContent = total ? `${idx} / ${total} artists` : '';
const progBar = document.getElementById('wl-scan-progress-bar');
if (progBar && total) progBar.style.width = `${Math.round((100 * idx) / total)}%`;
const phaseEl = document.getElementById('wl-scan-phase');
if (phaseEl) phaseEl.textContent = _wlPrettyPhase(data);
_wlBumpCounter('wl-scan-found', data.tracks_found_this_scan || 0);
_wlBumpCounter('wl-scan-added', data.tracks_added_this_scan || 0);
// Update wishlist additions feed — only re-render when it actually
// changed, so the slide-in animation plays once per new track instead
// of replaying on every poll.
const additionsFeed = document.getElementById('watchlist-additions-feed');
if (additionsFeed) {
if (data.recent_wishlist_additions && data.recent_wishlist_additions.length > 0) {
additionsFeed.innerHTML = data.recent_wishlist_additions.map(item => `
<div class="watchlist-live-addition-item">
<img src="${item.album_image_url || ''}" alt="" onerror="this.style.display='none';" />
<div class="watchlist-live-addition-item-info">
<div class="watchlist-live-addition-item-track">${item.track_name}</div>
<div class="watchlist-live-addition-item-artist">${item.artist_name}</div>
const additions = data.recent_wishlist_additions || [];
const feedKey = additions.map(a => a.track_name).join('');
if (additionsFeed.dataset.feedKey !== feedKey) {
const grew = additions.length > 0 && additionsFeed.dataset.feedKey !== undefined
&& feedKey.length > (additionsFeed.dataset.feedKey || '').length;
additionsFeed.dataset.feedKey = feedKey;
if (additions.length > 0) {
additionsFeed.innerHTML = additions.map((item, i) => `
<div class="watchlist-live-addition-item${grew && i === 0 ? ' is-new' : ''}">
<img src="${escapeHtml(item.album_image_url || '')}" alt="" onerror="this.style.display='none';" />
<div class="watchlist-live-addition-item-info">
<div class="watchlist-live-addition-item-track">${escapeHtml(item.track_name || '')}</div>
<div class="watchlist-live-addition-item-artist">${escapeHtml(item.artist_name || '')}</div>
</div>
</div>
</div>
`).join('');
} else {
additionsFeed.innerHTML = '<div class="watchlist-live-addition-empty">No tracks added yet...</div>';
`).join('');
} else {
additionsFeed.innerHTML = '<div class="watchlist-live-addition-empty">No tracks added yet…</div>';
}
}
}
} else if (liveActivity && data.status !== 'scanning') {

View file

@ -19950,6 +19950,337 @@ body.helper-mode-active #dashboard-activity-feed:hover {
color: #999;
}
/* ── #831 round 2: full-width live scan deck ───────────────────────────── */
.wl-scan-deck {
width: 100%;
margin-top: 15px;
padding: 18px 20px 16px;
background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.07), rgba(255, 255, 255, 0.02) 55%);
border: 1px solid rgba(var(--accent-rgb), 0.22);
border-radius: 14px;
backdrop-filter: blur(8px);
text-align: left;
}
.wl-scan-deck-head {
display: flex;
align-items: center;
gap: 10px;
}
.wl-scan-live-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: rgb(var(--accent-rgb));
box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0.55);
animation: wl-live-pulse 1.6s ease-out infinite;
}
@keyframes wl-live-pulse {
0% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0.55); }
70% { box-shadow: 0 0 0 9px rgba(var(--accent-rgb), 0); }
100% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0); }
}
.wl-scan-live-label {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1.2px;
color: rgb(var(--accent-light-rgb));
}
.wl-scan-progress-text {
font-size: 12px;
color: rgba(255, 255, 255, 0.55);
}
.wl-scan-counters {
margin-left: auto;
display: flex;
gap: 8px;
}
.wl-scan-counter {
display: flex;
align-items: baseline;
gap: 5px;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.wl-scan-counter.added {
background: rgba(80, 200, 120, 0.1);
border-color: rgba(80, 200, 120, 0.25);
}
.wl-scan-counter-num {
font-size: 16px;
font-weight: 700;
color: #fff;
display: inline-block;
}
.wl-scan-counter.added .wl-scan-counter-num {
color: #6fd99a;
}
.wl-scan-counter-num.pop {
animation: wl-counter-pop 0.35s ease;
}
@keyframes wl-counter-pop {
0% { transform: scale(1); }
40% { transform: scale(1.35); }
100% { transform: scale(1); }
}
.wl-scan-counter-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(255, 255, 255, 0.45);
}
.wl-scan-progress {
margin: 12px 0 14px;
height: 5px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.07);
overflow: hidden;
}
.wl-scan-progress-bar {
height: 100%;
border-radius: 3px;
background: linear-gradient(90deg, rgb(var(--accent-rgb)), rgb(var(--accent-light-rgb)));
position: relative;
transition: width 0.6s ease;
}
.wl-scan-progress-bar::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.35), transparent);
animation: wl-progress-shimmer 1.8s linear infinite;
}
@keyframes wl-progress-shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.wl-scan-deck-body {
display: grid;
grid-template-columns: minmax(260px, 1fr) minmax(280px, 1.2fr);
gap: 20px;
}
@media (max-width: 760px) {
.wl-scan-deck-body { grid-template-columns: 1fr; }
}
.wl-scan-stage {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
.wl-scan-stage-row {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.wl-scan-artist-img {
width: 64px;
height: 64px;
border-radius: 50%;
border: 2px solid rgb(var(--accent-rgb));
box-shadow: 0 0 18px rgba(var(--accent-rgb), 0.3);
object-fit: cover;
background: #1a1a1a;
flex-shrink: 0;
}
.wl-scan-album-img {
width: 64px;
height: 64px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.15);
object-fit: cover;
background: #1a1a1a;
flex-shrink: 0;
transition: opacity 0.3s ease;
}
.wl-scan-stage-text {
min-width: 0;
}
.wl-scan-artist-name {
font-size: 16px;
font-weight: 700;
color: #fff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wl-scan-phase {
font-size: 12px;
color: rgba(var(--accent-light-rgb), 0.85);
margin-top: 2px;
}
.wl-scan-album-name {
font-size: 13px;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wl-scan-track-name {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wl-scan-feed {
min-width: 0;
}
.wl-scan-feed-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #6fd99a;
margin-bottom: 6px;
}
.wl-scan-feed-list {
max-height: 176px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 5px;
}
.wl-scan-feed-list .watchlist-live-addition-item {
padding: 5px 8px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
}
.wl-scan-feed-list .watchlist-live-addition-item img {
width: 34px;
height: 34px;
border-radius: 5px;
}
.wl-scan-feed-list .watchlist-live-addition-item.is-new {
animation: wl-feed-slide-in 0.45s cubic-bezier(0.2, 0.9, 0.3, 1.2);
}
@keyframes wl-feed-slide-in {
0% { opacity: 0; transform: translateY(-10px) scale(0.96); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
/* ── #831 round 2: scan history modal run cards ────────────────────────── */
.wlh-run {
margin-bottom: 6px;
}
.wlh-run-header {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 14px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
cursor: pointer;
text-align: left;
transition: background 0.15s ease;
}
.wlh-run-header:hover {
background: rgba(255, 255, 255, 0.08);
}
.wlh-run-when {
font-size: 13px;
font-weight: 600;
color: #fff;
}
.wlh-run-status.cancelled {
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
background: rgba(248, 113, 113, 0.12);
color: #f87171;
}
.wlh-run-stats {
margin-left: auto;
display: flex;
gap: 14px;
}
.wlh-run-stat {
display: flex;
align-items: baseline;
gap: 4px;
font-size: 14px;
font-weight: 700;
color: rgba(255, 255, 255, 0.85);
}
.wlh-run-stat.added {
color: #6fd99a;
}
.wlh-run-stat i {
font-style: normal;
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.4px;
color: rgba(255, 255, 255, 0.4);
}
.wlh-run-body {
padding: 8px 6px 4px 14px;
}
.wlh-run-body .origin-modal-loading,
.wlh-empty {
padding: 16px;
}
.wlh-run-body .watchlist-live-addition-item {
margin-bottom: 4px;
}
/* Watchlist Search */
.watchlist-search-input {

View file

@ -0,0 +1,149 @@
// Watchlist Scan History modal (#831 round 2).
//
// Every scan run is persisted server-side (watchlist_scan_runs) with its full
// track ledger — the tracks the run ADDED to the wishlist plus the found-but-
// skipped ones. This modal lists past runs (newest first) and expands each
// into its ledger. Note: this is what the watchlist PUT IN THE WISHLIST — the
// watchlist never downloads; downloaded tracks live in Download Origins.
let _wlhModalEl = null;
let _wlhRuns = [];
const _wlhEventsCache = new Map();
function openWatchlistHistoryModal() {
if (!_wlhModalEl) {
_wlhModalEl = document.createElement('div');
_wlhModalEl.className = 'modal-overlay origin-modal-overlay';
_wlhModalEl.innerHTML = `
<div class="origin-modal">
<div class="origin-modal-head">
<div>
<h2 class="origin-modal-title">Scan History</h2>
<p class="origin-modal-sub">Every watchlist scan and the tracks it added to your wishlist.</p>
</div>
<button class="origin-modal-close" onclick="closeWatchlistHistoryModal()" aria-label="Close"></button>
</div>
<div class="origin-modal-body" id="wlh-modal-body"></div>
</div>`;
_wlhModalEl.addEventListener('click', (e) => {
if (e.target === _wlhModalEl) closeWatchlistHistoryModal();
});
document.body.appendChild(_wlhModalEl);
}
_wlhModalEl.classList.remove('hidden');
_wlhLoadRuns();
}
function closeWatchlistHistoryModal() {
if (_wlhModalEl) _wlhModalEl.classList.add('hidden');
}
async function _wlhLoadRuns() {
const body = document.getElementById('wlh-modal-body');
body.innerHTML = '<div class="origin-modal-loading">Loading…</div>';
try {
const resp = await fetch('/api/watchlist/scan/history?limit=50');
const data = await resp.json();
if (!data.success) throw new Error(data.error || 'Failed to load');
_wlhRuns = data.runs || [];
_wlhRenderRuns();
} catch (err) {
body.innerHTML = `<div class="origin-modal-empty">Couldn't load: ${escapeHtml(err.message)}</div>`;
}
}
function _wlhRenderRuns() {
const body = document.getElementById('wlh-modal-body');
if (!_wlhRuns.length) {
body.innerHTML = '<div class="origin-modal-empty">No scans recorded yet. Run a watchlist scan and it will appear here.</div>';
return;
}
body.innerHTML = _wlhRuns.map(r => {
const when = _wlhFormatDate(r.completed_at || r.started_at);
const cancelled = r.status === 'cancelled';
return `<div class="wlh-run" data-run="${escapeHtml(r.run_id)}">
<button type="button" class="wlh-run-header" onclick="toggleWatchlistHistoryRun('${escapeHtml(r.run_id)}', this)">
<span class="origin-group-caret"></span>
<span class="wlh-run-when">${escapeHtml(when)}</span>
${cancelled ? '<span class="wlh-run-status cancelled">cancelled</span>' : ''}
<span class="wlh-run-stats">
<span class="wlh-run-stat">${r.artists_scanned || 0}<i>artists</i></span>
<span class="wlh-run-stat">${r.tracks_found || 0}<i>found</i></span>
<span class="wlh-run-stat added">${r.tracks_added || 0}<i>added</i></span>
</span>
</button>
<div class="wlh-run-body" style="display: none;"></div>
</div>`;
}).join('');
}
async function toggleWatchlistHistoryRun(runId, btn) {
const runEl = btn.closest('.wlh-run');
const bodyEl = runEl.querySelector('.wlh-run-body');
const caret = btn.querySelector('.origin-group-caret');
const open = bodyEl.style.display !== 'none';
if (open) {
bodyEl.style.display = 'none';
if (caret) caret.textContent = '▸';
return;
}
bodyEl.style.display = '';
if (caret) caret.textContent = '▾';
if (!_wlhEventsCache.has(runId)) {
bodyEl.innerHTML = '<div class="origin-modal-loading">Loading…</div>';
try {
const resp = await fetch(`/api/watchlist/scan/history/${encodeURIComponent(runId)}/tracks`);
const data = await resp.json();
_wlhEventsCache.set(runId, data.success ? (data.events || []) : []);
} catch (e) {
_wlhEventsCache.set(runId, []);
}
}
bodyEl.innerHTML = _wlhRenderEvents(_wlhEventsCache.get(runId));
}
function _wlhRenderEvents(events) {
if (!events.length) {
return '<div class="origin-modal-empty wlh-empty">No new tracks were found by this scan.</div>';
}
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 wlh-track">
<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 section('Added to wishlist', added)
+ section('Found but skipped — already queued or blocklisted', skipped);
}
function _wlhFormatDate(ts) {
if (!ts) return 'Unknown time';
try {
const d = new Date(ts);
if (isNaN(d.getTime())) return ts;
return d.toLocaleString(undefined, {
month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit',
});
} catch (e) {
return ts;
}
}
window.openWatchlistHistoryModal = openWatchlistHistoryModal;
window.closeWatchlistHistoryModal = closeWatchlistHistoryModal;
window.toggleWatchlistHistoryRun = toggleWatchlistHistoryRun;