Download Origins: see (and delete) exactly what watchlist + playlist syncs downloaded

User ask: "a modal that lists the tracks downloaded via watchlist" — extended,
as discussed, to playlists too. One modal, two tabs, opened from the Watchlist
page (watchlist tab preselected) and the Sync page (playlists tab) — same
shared-modal-different-entry-points UX as the rest of the app.

The data: library_history recorded which SERVICE a file came from but never
what TRIGGERED it. New origin/origin_context columns (migration + index) are
written once at the import chokepoint via core/downloads/origin.py, a pure
tested deriver that reads, in priority: an explicit _dl_origin stamp (set at
batch-task creation for direct playlist batches, where the playlist context
otherwise only survived in folder mode), the wishlist provenance already
riding in track_info.source_info (watchlist_artist_name / playlist_name —
watchlist_scanner has stamped these for ages), and the folder-mode playlist
thread. Manual downloads stay unclassified by design. History starts from
now — provenance can't be conjured retroactively.

API: GET /api/download-origins?origin=watchlist|playlist (paged) and POST
/api/download-origins/delete — deletes the file on disk (resolved through the
shared container/host path resolver), the matching library track row, and the
history entries; a file that refuses deletion keeps its row and reports the
error instead of lying.

UI: webui/static/origin-history.js — tabbed modal in the revamp design
language (accent light-edge, pill tabs, entry rows reusing the
library-history-entry components), per-row delete + select-all bulk delete
with honest result toasts, empty/loading states, per-tab totals.

Tests: 8 — deriver priority/shapes (incl. the exact watchlist_scanner
source_info shape and JSON-string survival), origin filtering + counts,
row fetch/delete isolation between origins, delete-track-by-path.
pull/812/head
BoulderBadgeDad 2 weeks ago
parent 76c63b5bc4
commit 1f7834cc7b

@ -1089,6 +1089,26 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
f"{track_info.get('name')}"
)
# Download-origin provenance: stamp what TRIGGERED this download
# so the history chokepoint can record it (origin-history modal).
# Wishlist rows already ride their source_info in track_info
# (watchlist_artist_name / playlist_name — the deriver reads
# those directly); this stamp covers DIRECT playlist batches,
# where the playlist context otherwise only survives in
# folder mode.
if '_dl_origin' not in track_info and batch_source_playlist_ref and batch_playlist_name:
_prov_si = track_info.get('source_info') or {}
if isinstance(_prov_si, str):
try:
_prov_si = json.loads(_prov_si)
except (json.JSONDecodeError, TypeError):
_prov_si = {}
if not _prov_si.get('watchlist_artist_name'):
track_info['_dl_origin'] = 'playlist'
track_info['_dl_origin_context'] = (
_prov_si.get('playlist_name') or batch_playlist_name
)
download_tasks[task_id] = {
'status': 'pending', 'track_info': track_info,
'playlist_id': playlist_id, 'batch_id': batch_id,

@ -0,0 +1,71 @@
"""Download-origin provenance: what TRIGGERED a download.
The library history records which SERVICE a file came from (Soulseek,
YouTube, ...) but not WHY it was downloaded a watchlist scan, a playlist
sync, or a manual click. The origin-history modal (watchlist page / sync
page) answers that, so the trigger must be derived once, at the history
chokepoint (``record_library_history_download``), from the post-process
context.
Signals, in priority order:
1. explicit ``track_info._dl_origin`` / ``_dl_origin_context`` stamps
(set at batch-task creation in core/downloads/master.py)
2. wishlist provenance riding in ``track_info.source_info`` watchlist
items carry ``watchlist_artist_name``, playlist items ``playlist_name``
3. the playlist-folder-mode ``_playlist_name`` thread
Anything unmatched derives ``(None, '')`` manual/other downloads are
intentionally not classified.
"""
from __future__ import annotations
import json
from typing import Any, Dict, Optional, Tuple
ORIGIN_WATCHLIST = "watchlist"
ORIGIN_PLAYLIST = "playlist"
VALID_ORIGINS = (ORIGIN_WATCHLIST, ORIGIN_PLAYLIST)
def _parse_source_info(raw: Any) -> Dict[str, Any]:
if isinstance(raw, dict):
return raw
if isinstance(raw, str) and raw:
try:
parsed = json.loads(raw)
return parsed if isinstance(parsed, dict) else {}
except (json.JSONDecodeError, TypeError):
return {}
return {}
def derive_download_origin(context: Dict[str, Any]) -> Tuple[Optional[str], str]:
"""Return ``(origin, origin_context)`` for a completed download.
``origin`` is 'watchlist' / 'playlist' / None; ``origin_context`` is the
human label (watchlist artist name / playlist name). Never raises."""
try:
ti = context.get("track_info") or {}
if not isinstance(ti, dict):
return None, ""
si = _parse_source_info(ti.get("source_info"))
# 1. Explicit stamp wins.
origin = ti.get("_dl_origin")
if origin in VALID_ORIGINS:
return origin, str(ti.get("_dl_origin_context") or "")
# 2. Wishlist provenance riding in source_info.
if si.get("watchlist_artist_name"):
return ORIGIN_WATCHLIST, str(si["watchlist_artist_name"])
if si.get("playlist_name"):
return ORIGIN_PLAYLIST, str(si["playlist_name"])
# 3. Playlist-folder-mode thread.
if ti.get("_playlist_name"):
return ORIGIN_PLAYLIST, str(ti["_playlist_name"])
return None, ""
except Exception:
return None, ""

@ -246,6 +246,11 @@ def record_library_history_download(context: Dict[str, Any]) -> None:
acoustid_result = context.get("_acoustid_result", "")
# What TRIGGERED this download (watchlist scan / playlist sync) —
# feeds the origin-history modal. None for manual/unclassified.
from core.downloads.origin import derive_download_origin
origin, origin_context = derive_download_origin(context)
db = get_database()
db.add_library_history_entry(
event_type="download",
@ -261,6 +266,8 @@ def record_library_history_download(context: Dict[str, Any]) -> None:
source_filename=source_filename,
acoustid_result=acoustid_result,
source_artist=source_artist,
origin=origin,
origin_context=origin_context,
)
except Exception as e:
logger.debug("library history record failed: %s", e)

@ -639,6 +639,15 @@ class MusicDatabase:
cursor.execute(f"ALTER TABLE library_history ADD COLUMN {_col} TEXT")
logger.info(f"Added {_col} column to library_history")
# Migration: download-origin provenance — what TRIGGERED a download
# ('watchlist' + artist / 'playlist' + playlist name). Read by the
# origin-history modal on the watchlist + sync pages.
for _col in ['origin', 'origin_context']:
if _col not in lh_cols:
cursor.execute(f"ALTER TABLE library_history ADD COLUMN {_col} TEXT")
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)")
# Auto-import history — tracks auto-import scan results and processing status
cursor.execute("""
CREATE TABLE IF NOT EXISTS auto_import_history (
@ -12129,8 +12138,13 @@ class MusicDatabase:
def add_library_history_entry(self, event_type, title, artist_name=None, album_name=None,
quality=None, server_source=None, file_path=None, thumb_url=None,
download_source=None, source_track_id=None, source_track_title=None,
source_filename=None, acoustid_result=None, source_artist=None):
"""Record a download or import event to the library history table."""
source_filename=None, acoustid_result=None, source_artist=None,
origin=None, origin_context=None):
"""Record a download or import event to the library history table.
``origin``/``origin_context`` record what TRIGGERED the download
('watchlist' + artist name, 'playlist' + playlist name) the
origin-history modal reads them. None for manual/unclassified."""
try:
conn = self._get_connection()
cursor = conn.cursor()
@ -12138,17 +12152,89 @@ class MusicDatabase:
INSERT INTO library_history (event_type, title, artist_name, album_name,
quality, server_source, file_path, thumb_url, download_source,
source_track_id, source_track_title, source_filename,
acoustid_result, source_artist)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
acoustid_result, source_artist, origin, origin_context)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (event_type, title, artist_name, album_name, quality, server_source, file_path, thumb_url,
download_source, source_track_id, source_track_title, source_filename,
acoustid_result, source_artist))
acoustid_result, source_artist, origin, origin_context))
conn.commit()
return True
except Exception as e:
logger.debug(f"Error adding library history entry: {e}")
return False
def get_download_origin_entries(self, origin, limit=200, offset=0):
"""Downloads triggered by ``origin`` ('watchlist' / 'playlist'),
newest first. Returns (entries, total_count)."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute(
"SELECT COUNT(*) FROM library_history WHERE event_type = 'download' AND origin = ?",
(origin,))
total = cursor.fetchone()[0]
cursor.execute("""
SELECT id, title, artist_name, album_name, quality, file_path,
thumb_url, download_source, origin, origin_context, created_at
FROM library_history
WHERE event_type = 'download' AND origin = ?
ORDER BY created_at DESC, id DESC
LIMIT ? OFFSET ?
""", (origin, int(limit), int(offset)))
cols = ['id', 'title', 'artist_name', 'album_name', 'quality', 'file_path',
'thumb_url', 'download_source', 'origin', 'origin_context', 'created_at']
return [dict(zip(cols, row, strict=True)) for row in cursor.fetchall()], total
except Exception as e:
logger.debug(f"Error querying download origins: {e}")
return [], 0
def get_library_history_rows_by_ids(self, ids):
"""Fetch history rows (id, file_path, title) for a list of ids."""
if not ids:
return []
try:
conn = self._get_connection()
cursor = conn.cursor()
placeholders = ','.join('?' * len(ids))
cursor.execute(
f"SELECT id, file_path, title FROM library_history WHERE id IN ({placeholders})",
[int(i) for i in ids])
return [{'id': r[0], 'file_path': r[1], 'title': r[2]} for r in cursor.fetchall()]
except Exception as e:
logger.debug(f"Error fetching history rows: {e}")
return []
def delete_library_history_rows(self, ids):
"""Delete history rows by id. Returns the number removed."""
if not ids:
return 0
try:
conn = self._get_connection()
cursor = conn.cursor()
placeholders = ','.join('?' * len(ids))
cursor.execute(
f"DELETE FROM library_history WHERE id IN ({placeholders})",
[int(i) for i in ids])
conn.commit()
return cursor.rowcount
except Exception as e:
logger.debug(f"Error deleting history rows: {e}")
return 0
def delete_track_by_file_path(self, file_path):
"""Delete a library track row whose stored path matches. Returns count."""
if not file_path:
return 0
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM tracks WHERE file_path = ?", (file_path,))
conn.commit()
return cursor.rowcount
except Exception as e:
logger.debug(f"Error deleting track by path: {e}")
return 0
def get_library_history(self, event_type=None, page=1, limit=50):
"""Query library history with optional type filter and pagination.

@ -0,0 +1,116 @@
"""Download-origin provenance: the deriver + the library_history persistence.
Feature: the origin-history modal (watchlist page / sync page) lists which
downloads were triggered by a watchlist scan vs a playlist sync, and lets the
user delete them. The trigger is derived once at the import chokepoint and
stored on the library_history row.
"""
from __future__ import annotations
import json
from core.downloads.origin import derive_download_origin
from database.music_database import MusicDatabase
# ── deriver ──────────────────────────────────────────────────────────────────
def test_explicit_stamp_wins():
ctx = {'track_info': {
'_dl_origin': 'playlist', '_dl_origin_context': 'Discover Weekly',
'source_info': {'watchlist_artist_name': 'Drake'}, # would say watchlist
}}
assert derive_download_origin(ctx) == ('playlist', 'Discover Weekly')
def test_watchlist_provenance_from_wishlist_source_info():
# The exact shape watchlist_scanner writes into the wishlist row, which
# rides into track_info when the wishlist worker downloads the item.
ctx = {'track_info': {'source_info': {
'watchlist_artist_name': 'Kendrick Lamar',
'watchlist_artist_id': 'spot123',
'album_name': 'GNX',
}}}
assert derive_download_origin(ctx) == ('watchlist', 'Kendrick Lamar')
def test_playlist_provenance_from_source_info_and_json_string():
ctx = {'track_info': {'source_info': {'playlist_name': 'Release Radar'}}}
assert derive_download_origin(ctx) == ('playlist', 'Release Radar')
# source_info sometimes survives as a JSON string — parse it.
ctx2 = {'track_info': {'source_info': json.dumps({'playlist_name': 'RapCaviar'})}}
assert derive_download_origin(ctx2) == ('playlist', 'RapCaviar')
def test_playlist_folder_mode_thread():
ctx = {'track_info': {'_playlist_name': 'Todays Top Hits'}}
assert derive_download_origin(ctx) == ('playlist', 'Todays Top Hits')
def test_manual_and_garbage_derive_none():
assert derive_download_origin({'track_info': {'name': 'Song'}}) == (None, '')
assert derive_download_origin({}) == (None, '')
assert derive_download_origin({'track_info': 'not-a-dict'}) == (None, '')
# invalid explicit origin is ignored, not trusted
assert derive_download_origin({'track_info': {'_dl_origin': 'aliens'}}) == (None, '')
# ── persistence ──────────────────────────────────────────────────────────────
def _seed(db):
db.add_library_history_entry(
event_type='download', title='Squabble Up', artist_name='Kendrick Lamar',
album_name='GNX', file_path='/music/k/squabble.flac',
origin='watchlist', origin_context='Kendrick Lamar')
db.add_library_history_entry(
event_type='download', title='Opalite', artist_name='Taylor Swift',
album_name='Showgirl', file_path='/music/t/opalite.flac',
origin='playlist', origin_context='Release Radar')
db.add_library_history_entry( # manual download — no origin
event_type='download', title='Random', artist_name='Someone',
file_path='/music/r/random.flac')
def test_origin_entries_filtered_and_counted(tmp_path):
db = MusicDatabase(str(tmp_path / 'm.db'))
_seed(db)
wl, wl_total = db.get_download_origin_entries('watchlist')
pl, pl_total = db.get_download_origin_entries('playlist')
assert wl_total == 1 and wl[0]['title'] == 'Squabble Up'
assert wl[0]['origin_context'] == 'Kendrick Lamar'
assert pl_total == 1 and pl[0]['title'] == 'Opalite'
assert pl[0]['origin_context'] == 'Release Radar'
def test_history_rows_fetch_and_delete(tmp_path):
db = MusicDatabase(str(tmp_path / 'm.db'))
_seed(db)
entries, _ = db.get_download_origin_entries('watchlist')
ids = [e['id'] for e in entries]
rows = db.get_library_history_rows_by_ids(ids)
assert rows and rows[0]['file_path'] == '/music/k/squabble.flac'
assert db.delete_library_history_rows(ids) == 1
assert db.get_download_origin_entries('watchlist')[1] == 0
# the other origin untouched
assert db.get_download_origin_entries('playlist')[1] == 1
def test_delete_track_by_file_path(tmp_path):
db = MusicDatabase(str(tmp_path / 'm.db'))
conn = db._get_connection()
cur = conn.cursor()
cur.execute("INSERT INTO artists (id, name) VALUES ('a1', 'A')")
cur.execute("INSERT INTO albums (id, title, artist_id) VALUES ('al1', 'Al', 'a1')")
cur.execute("""INSERT INTO tracks (id, album_id, artist_id, title, file_path)
VALUES ('t1', 'al1', 'a1', 'Song', '/music/k/squabble.flac')""")
conn.commit()
conn.close()
assert db.delete_track_by_file_path('/music/k/squabble.flac') == 1
assert db.delete_track_by_file_path('/music/k/squabble.flac') == 0
assert db.delete_track_by_file_path('') == 0

@ -7561,6 +7561,73 @@ def maintain_search_history():
except Exception as e:
logger.error(f"Error maintaining search history: {e}")
return jsonify({"success": False, "error": str(e)}), 500
# ── Download-origin history (origin modal: watchlist page / sync page) ──
# Lists downloads by what TRIGGERED them ('watchlist' / 'playlist'), recorded
# at the import chokepoint via core.downloads.origin. Delete removes the file
# on disk (resolved through the same container/host path resolver everything
# else uses), the matching library track row, and the history entries.
@app.route('/api/download-origins')
def get_download_origins():
try:
origin = request.args.get('origin', 'watchlist')
if origin not in ('watchlist', 'playlist'):
return jsonify({'success': False, 'error': 'origin must be watchlist or playlist'}), 400
limit = min(500, max(1, int(request.args.get('limit', 200))))
offset = max(0, int(request.args.get('offset', 0)))
entries, total = get_database().get_download_origin_entries(origin, limit=limit, offset=offset)
return jsonify({'success': True, 'origin': origin, 'entries': entries, 'total': total})
except Exception as e:
logger.error(f"Error listing download origins: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/download-origins/delete', methods=['POST'])
def delete_download_origins():
"""Delete origin-history entries; optionally (default) also delete the
files on disk and their library track rows."""
try:
data = request.get_json(silent=True) or {}
ids = [int(i) for i in (data.get('ids') or []) if str(i).strip()]
if not ids:
return jsonify({'success': False, 'error': 'No ids given'}), 400
delete_files = bool(data.get('delete_files', True))
from core.library.path_resolver import resolve_library_file_path
db = get_database()
rows = db.get_library_history_rows_by_ids(ids)
files_deleted, files_missing, file_errors = 0, 0, []
failed_ids = set()
for row in rows:
raw_path = row.get('file_path') or ''
if not delete_files or not raw_path:
continue
resolved = resolve_library_file_path(raw_path, config_manager=config_manager)
if resolved and os.path.isfile(resolved):
try:
os.remove(resolved)
files_deleted += 1
except OSError as e:
file_errors.append(f"{row.get('title') or raw_path}: {e}")
failed_ids.add(row['id']) # keep the row when the file refuses to go
continue
else:
files_missing += 1 # already gone — still clean up the rows
db.delete_track_by_file_path(raw_path)
removed = db.delete_library_history_rows(
[r['id'] for r in rows if r['id'] not in failed_ids])
return jsonify({
'success': True,
'removed': removed,
'files_deleted': files_deleted,
'files_missing': files_missing,
'errors': file_errors,
})
except Exception as e:
logger.error(f"Error deleting download origins: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/library/history')
def get_library_history():
"""Get persistent library history (downloads and server imports)."""

@ -1018,6 +1018,7 @@
<button class="btn btn--sm btn--secondary sync-history-btn auto-sync-manager-btn" onclick="openAutoSyncScheduleModal()" title="Schedule mirrored playlists to refresh, discover, sync, and queue missing tracks">Auto-Sync</button>
<button class="btn btn--sm btn--secondary sync-history-btn" onclick="openManualLibraryMatchTool()" title="Manually link source tracks to library tracks">Library Match</button>
<button class="btn btn--sm btn--secondary sync-history-btn" onclick="openSyncHistoryModal()" title="View sync history">Sync History</button>
<button class="btn btn--sm btn--secondary sync-history-btn" onclick="openDownloadOriginsModal('playlist')" title="See every track your playlist syncs downloaded">Download Origins</button>
</div>
</div>
</div>
@ -8066,6 +8067,7 @@
<script src="{{ url_for('static', filename='downloads.js', v=static_v) }}"></script>
<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='sync-services.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-listenbrainz.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-lastfm.js', v=static_v) }}"></script>

@ -2040,6 +2040,13 @@ async function showWatchlistModal() {
<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="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
${globalOverrideActive ? 'Global Override ON' : 'Global Settings'}
</button>
<button class="playlist-modal-btn playlist-modal-btn-secondary watchlist-btn-origins"
id="watchlist-download-origins-btn"
onclick="openDownloadOriginsModal('watchlist')"
title="See every track your watchlist downloaded">
<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>
</div>
${globalOverrideActive ? `

@ -0,0 +1,187 @@
// ── Download Origins modal ──
// "What did the watchlist / playlist syncs download?" One modal, two tabs,
// opened from the Watchlist page (watchlist tab) and the Sync page (playlists
// tab). Entries come from library_history rows stamped with origin provenance
// at the import chokepoint (core/downloads/origin.py). Delete removes the
// file on disk, the library track row, and the history entries.
let _originModalEl = null;
let _originActiveTab = 'watchlist';
let _originEntries = [];
let _originSelected = new Set();
function openDownloadOriginsModal(tab) {
_originActiveTab = tab === 'playlist' ? 'playlist' : 'watchlist';
_originSelected = new Set();
if (!_originModalEl) {
_originModalEl = document.createElement('div');
_originModalEl.className = 'modal-overlay origin-modal-overlay';
_originModalEl.innerHTML = `
<div class="origin-modal">
<div class="origin-modal-head">
<div>
<h2 class="origin-modal-title">Download Origins</h2>
<p class="origin-modal-sub">What your watchlist and playlist syncs have downloaded.</p>
</div>
<button class="origin-modal-close" onclick="closeDownloadOriginsModal()" aria-label="Close"></button>
</div>
<div class="origin-modal-tabs">
<button class="origin-tab" data-tab="watchlist" onclick="switchDownloadOriginTab('watchlist')">
Watchlist <span class="origin-tab-count" id="origin-count-watchlist"></span>
</button>
<button class="origin-tab" data-tab="playlist" onclick="switchDownloadOriginTab('playlist')">
Playlists <span class="origin-tab-count" id="origin-count-playlist"></span>
</button>
<div class="origin-toolbar">
<label class="origin-select-all">
<input type="checkbox" id="origin-select-all" onchange="toggleAllOriginEntries(this.checked)"> All
</label>
<button class="origin-delete-btn" id="origin-delete-selected"
onclick="deleteSelectedOriginEntries()" disabled>Delete Selected</button>
</div>
</div>
<div class="origin-modal-body" id="origin-modal-body"></div>
</div>`;
_originModalEl.addEventListener('click', (e) => {
if (e.target === _originModalEl) closeDownloadOriginsModal();
});
document.body.appendChild(_originModalEl);
}
_originModalEl.classList.remove('hidden');
_refreshOriginTabs();
_loadOriginEntries();
}
function closeDownloadOriginsModal() {
if (_originModalEl) _originModalEl.classList.add('hidden');
}
function switchDownloadOriginTab(tab) {
if (tab === _originActiveTab) return;
_originActiveTab = tab;
_originSelected = new Set();
_refreshOriginTabs();
_loadOriginEntries();
}
function _refreshOriginTabs() {
_originModalEl.querySelectorAll('.origin-tab').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === _originActiveTab);
});
const selAll = document.getElementById('origin-select-all');
if (selAll) selAll.checked = false;
_updateOriginDeleteButton();
}
async function _loadOriginEntries() {
const body = document.getElementById('origin-modal-body');
body.innerHTML = '<div class="origin-modal-loading">Loading…</div>';
try {
const resp = await fetch(`/api/download-origins?origin=${_originActiveTab}&limit=500`);
const data = await resp.json();
if (!data.success) throw new Error(data.error || 'Failed to load');
_originEntries = data.entries || [];
const countEl = document.getElementById(`origin-count-${_originActiveTab}`);
if (countEl) countEl.textContent = data.total ? `(${data.total})` : '';
_renderOriginEntries();
} catch (err) {
body.innerHTML = `<div class="origin-modal-empty">Couldn't load: ${escapeHtml(err.message)}</div>`;
}
}
function _renderOriginEntries() {
const body = document.getElementById('origin-modal-body');
if (!_originEntries.length) {
const what = _originActiveTab === 'watchlist'
? 'No watchlist-triggered downloads recorded yet. New watchlist downloads will appear here.'
: 'No playlist-triggered downloads recorded yet. New playlist sync downloads will appear here.';
body.innerHTML = `<div class="origin-modal-empty">${what}</div>`;
return;
}
const ctxLabel = _originActiveTab === 'watchlist' ? 'Watchlist artist' : 'Playlist';
body.innerHTML = _originEntries.map(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"
onerror="this.outerHTML='<div class=\\'library-history-thumb-placeholder\\'>🎵</div>'">`
: '<div class="library-history-thumb-placeholder">🎵</div>';
const fname = (e.file_path || '').split(/[\\/]/).pop();
return `<div class="library-history-entry origin-entry" data-id="${e.id}">
<input type="checkbox" class="origin-entry-check" ${checked}
onchange="toggleOriginEntry(${e.id}, this.checked)">
${thumb}
<div class="library-history-entry-content">
<div class="library-history-entry-row1">
<div class="library-history-entry-text">
<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"
onclick="deleteSelectedOriginEntries(${e.id})">Delete</button>
</div>
${fname ? `<div class="library-history-entry-source"><span class="lh-prov-label">File:</span> ${escapeHtml(fname)}</div>` : ''}
</div>
</div>`;
}).join('');
_updateOriginDeleteButton();
}
function toggleOriginEntry(id, on) {
if (on) _originSelected.add(id); else _originSelected.delete(id);
_updateOriginDeleteButton();
}
function toggleAllOriginEntries(on) {
_originSelected = on ? new Set(_originEntries.map(e => e.id)) : new Set();
_originModalEl.querySelectorAll('.origin-entry-check').forEach(cb => { cb.checked = on; });
_updateOriginDeleteButton();
}
function _updateOriginDeleteButton() {
const btn = document.getElementById('origin-delete-selected');
if (!btn) return;
btn.disabled = _originSelected.size === 0;
btn.textContent = _originSelected.size ? `Delete Selected (${_originSelected.size})` : 'Delete Selected';
}
async function deleteSelectedOriginEntries(singleId) {
const ids = singleId !== undefined ? [singleId] : [..._originSelected];
if (!ids.length) return;
const what = ids.length === 1 ? 'this track' : `these ${ids.length} tracks`;
if (!confirm(`Delete ${what}? This removes the audio file(s) from disk and the library entry.`)) return;
try {
const resp = await fetch('/api/download-origins/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids, delete_files: true }),
});
const data = await resp.json();
if (!data.success) throw new Error(data.error || 'Delete failed');
let msg = `Removed ${data.removed} entr${data.removed === 1 ? 'y' : 'ies'}`;
if (data.files_deleted) msg += `, deleted ${data.files_deleted} file(s)`;
if (data.files_missing) msg += ` (${data.files_missing} already gone)`;
showToast(msg, data.errors && data.errors.length ? 'warning' : 'success');
if (data.errors && data.errors.length) console.warn('Origin delete errors:', data.errors);
_originSelected = new Set();
_loadOriginEntries();
} catch (err) {
showToast(`Delete failed: ${err.message}`, 'error');
}
}
function _originFormatTime(ts) {
if (!ts) return '';
try {
// SQLite CURRENT_TIMESTAMP is UTC without a zone marker.
const d = new Date(String(ts).includes('T') ? ts : ts.replace(' ', 'T') + 'Z');
if (isNaN(d.getTime())) return ts;
return d.toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
});
} catch (e) {
return ts;
}
}

@ -66451,3 +66451,211 @@ body.em-scroll-lock { overflow: hidden; }
@media (prefers-reduced-motion: reduce) {
.track-download-status[data-state] { animation: none !important; }
}
/* ── Download Origins modal (watchlist / playlist provenance) ── */
.origin-modal {
position: relative;
width: 860px;
max-width: 94vw;
max-height: 86vh;
display: flex;
flex-direction: column;
background:
linear-gradient(165deg, rgba(24, 24, 32, 0.97) 0%, rgba(13, 13, 18, 0.985) 60%, rgba(10, 10, 14, 0.99) 100%);
backdrop-filter: blur(24px) saturate(1.35);
-webkit-backdrop-filter: blur(24px) saturate(1.35);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 24px;
box-shadow:
0 32px 80px rgba(0, 0, 0, 0.65),
0 0 60px rgba(var(--accent-rgb), 0.07),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
animation: modal-revamp-enter 0.38s cubic-bezier(0.22, 1.2, 0.36, 1);
overflow: hidden;
}
.origin-modal::before {
content: '';
position: absolute;
top: 0;
left: 8%;
right: 8%;
height: 1.5px;
background: linear-gradient(90deg,
transparent,
rgba(var(--accent-rgb), 0.65) 30%,
rgba(var(--accent-light-rgb), 0.85) 50%,
rgba(var(--accent-rgb), 0.65) 70%,
transparent);
pointer-events: none;
z-index: 3;
}
.origin-modal-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 20px 24px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.origin-modal-title {
margin: 0;
font-size: 19px;
font-weight: 700;
letter-spacing: -0.01em;
}
.origin-modal-sub {
margin: 3px 0 0;
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
}
.origin-modal-close {
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
transition: background 0.2s, transform 0.2s, color 0.2s;
}
.origin-modal-close:hover {
background: rgba(239, 68, 68, 0.18);
color: #fff;
transform: rotate(90deg);
}
.origin-modal-tabs {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.origin-tab {
padding: 7px 16px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.09);
background: rgba(255, 255, 255, 0.03);
color: rgba(255, 255, 255, 0.6);
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s, color 0.2s, border-color 0.2s;
}
.origin-tab:hover {
background: rgba(255, 255, 255, 0.07);
}
.origin-tab.active {
background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.9), rgba(var(--accent-rgb), 0.65));
border-color: rgba(var(--accent-light-rgb), 0.4);
color: #fff;
box-shadow: 0 3px 14px rgba(var(--accent-rgb), 0.3);
}
.origin-tab-count {
opacity: 0.75;
font-weight: 500;
}
.origin-toolbar {
margin-left: auto;
display: flex;
align-items: center;
gap: 12px;
}
.origin-select-all {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: rgba(255, 255, 255, 0.55);
cursor: pointer;
}
.origin-select-all input,
.origin-entry-check {
accent-color: rgb(var(--accent-rgb));
}
.origin-delete-btn {
padding: 7px 16px;
border-radius: 999px;
border: 1px solid rgba(239, 68, 68, 0.35);
background: rgba(239, 68, 68, 0.12);
color: #fca5a5;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s, opacity 0.2s;
}
.origin-delete-btn:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.22);
}
.origin-delete-btn:disabled {
opacity: 0.35;
cursor: default;
}
.origin-modal-body {
overflow-y: auto;
padding: 10px 16px 16px;
flex: 1;
min-height: 220px;
}
.origin-modal-body::-webkit-scrollbar { width: 8px; }
.origin-modal-body::-webkit-scrollbar-thumb {
background: rgba(var(--accent-rgb), 0.28);
border-radius: 999px;
}
.origin-modal-body::-webkit-scrollbar-track { background: transparent; }
.origin-entry {
display: flex;
align-items: center;
gap: 12px;
}
.origin-entry .origin-entry-check {
flex: 0 0 auto;
}
.origin-context-badge {
flex: 0 0 auto;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 3px 11px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
border: 1px solid rgba(var(--accent-rgb), 0.3);
background: rgba(var(--accent-rgb), 0.1);
color: rgb(var(--accent-light-rgb));
}
.origin-modal-loading,
.origin-modal-empty {
padding: 48px 24px;
text-align: center;
color: rgba(255, 255, 255, 0.4);
font-size: 13px;
}
.origin-row-delete {
border-color: rgba(248, 113, 113, 0.4) !important;
color: #f87171 !important;
}

Loading…
Cancel
Save