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.
This commit is contained in:
BoulderBadgeDad 2026-06-07 00:15:31 -07:00
parent 76c63b5bc4
commit 1f7834cc7b
10 changed files with 776 additions and 5 deletions

View file

@ -1089,6 +1089,26 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
f"{track_info.get('name')}" 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] = { download_tasks[task_id] = {
'status': 'pending', 'track_info': track_info, 'status': 'pending', 'track_info': track_info,
'playlist_id': playlist_id, 'batch_id': batch_id, 'playlist_id': playlist_id, 'batch_id': batch_id,

71
core/downloads/origin.py Normal file
View file

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

View file

@ -246,6 +246,11 @@ def record_library_history_download(context: Dict[str, Any]) -> None:
acoustid_result = context.get("_acoustid_result", "") 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 = get_database()
db.add_library_history_entry( db.add_library_history_entry(
event_type="download", event_type="download",
@ -261,6 +266,8 @@ def record_library_history_download(context: Dict[str, Any]) -> None:
source_filename=source_filename, source_filename=source_filename,
acoustid_result=acoustid_result, acoustid_result=acoustid_result,
source_artist=source_artist, source_artist=source_artist,
origin=origin,
origin_context=origin_context,
) )
except Exception as e: except Exception as e:
logger.debug("library history record failed: %s", e) logger.debug("library history record failed: %s", e)

View file

@ -639,6 +639,15 @@ class MusicDatabase:
cursor.execute(f"ALTER TABLE library_history ADD COLUMN {_col} TEXT") cursor.execute(f"ALTER TABLE library_history ADD COLUMN {_col} TEXT")
logger.info(f"Added {_col} column to library_history") 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 # Auto-import history — tracks auto-import scan results and processing status
cursor.execute(""" cursor.execute("""
CREATE TABLE IF NOT EXISTS auto_import_history ( 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, 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, quality=None, server_source=None, file_path=None, thumb_url=None,
download_source=None, source_track_id=None, source_track_title=None, download_source=None, source_track_id=None, source_track_title=None,
source_filename=None, acoustid_result=None, source_artist=None): source_filename=None, acoustid_result=None, source_artist=None,
"""Record a download or import event to the library history table.""" 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: try:
conn = self._get_connection() conn = self._get_connection()
cursor = conn.cursor() cursor = conn.cursor()
@ -12138,17 +12152,89 @@ class MusicDatabase:
INSERT INTO library_history (event_type, title, artist_name, album_name, INSERT INTO library_history (event_type, title, artist_name, album_name,
quality, server_source, file_path, thumb_url, download_source, quality, server_source, file_path, thumb_url, download_source,
source_track_id, source_track_title, source_filename, source_track_id, source_track_title, source_filename,
acoustid_result, source_artist) acoustid_result, source_artist, origin, origin_context)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (event_type, title, artist_name, album_name, quality, server_source, file_path, thumb_url, """, (event_type, title, artist_name, album_name, quality, server_source, file_path, thumb_url,
download_source, source_track_id, source_track_title, source_filename, download_source, source_track_id, source_track_title, source_filename,
acoustid_result, source_artist)) acoustid_result, source_artist, origin, origin_context))
conn.commit() conn.commit()
return True return True
except Exception as e: except Exception as e:
logger.debug(f"Error adding library history entry: {e}") logger.debug(f"Error adding library history entry: {e}")
return False 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): def get_library_history(self, event_type=None, page=1, limit=50):
"""Query library history with optional type filter and pagination. """Query library history with optional type filter and pagination.

View file

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

View file

@ -7561,6 +7561,73 @@ def maintain_search_history():
except Exception as e: except Exception as e:
logger.error(f"Error maintaining search history: {e}") logger.error(f"Error maintaining search history: {e}")
return jsonify({"success": False, "error": str(e)}), 500 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') @app.route('/api/library/history')
def get_library_history(): def get_library_history():
"""Get persistent library history (downloads and server imports).""" """Get persistent library history (downloads and server imports)."""

View file

@ -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 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="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="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> </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='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='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='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-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-listenbrainz.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-lastfm.js', v=static_v) }}"></script> <script src="{{ url_for('static', filename='sync-lastfm.js', v=static_v) }}"></script>

View file

@ -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> <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'} ${globalOverrideActive ? 'Global Override ON' : 'Global Settings'}
</button> </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> </div>
${globalOverrideActive ? ` ${globalOverrideActive ? `

View file

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

View file

@ -66451,3 +66451,211 @@ body.em-scroll-lock { overflow: hidden; }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.track-download-status[data-state] { animation: none !important; } .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;
}