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:
parent
e8cde40d22
commit
9f12bdfef6
7 changed files with 792 additions and 26 deletions
|
|
@ -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.
|
||||
|
|
|
|||
68
tests/test_watchlist_scan_history.py
Normal file
68
tests/test_watchlist_scan_history.py
Normal 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'
|
||||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
149
webui/static/watchlist-history.js
Normal file
149
webui/static/watchlist-history.js
Normal 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;
|
||||
Loading…
Reference in a new issue