diff --git a/core/downloads/master.py b/core/downloads/master.py index dfcb4eab..a3e50083 100644 --- a/core/downloads/master.py +++ b/core/downloads/master.py @@ -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, diff --git a/core/downloads/origin.py b/core/downloads/origin.py new file mode 100644 index 00000000..078da681 --- /dev/null +++ b/core/downloads/origin.py @@ -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, "" diff --git a/core/imports/side_effects.py b/core/imports/side_effects.py index 7d9d92c9..1c84f287 100644 --- a/core/imports/side_effects.py +++ b/core/imports/side_effects.py @@ -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) diff --git a/database/music_database.py b/database/music_database.py index 71c7aa23..f9ba7144 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -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. diff --git a/tests/test_download_origins.py b/tests/test_download_origins.py new file mode 100644 index 00000000..0b893746 --- /dev/null +++ b/tests/test_download_origins.py @@ -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': 'Today’s Top Hits'}} + assert derive_download_origin(ctx) == ('playlist', 'Today’s 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 diff --git a/web_server.py b/web_server.py index bd9a667b..39b6f19e 100644 --- a/web_server.py +++ b/web_server.py @@ -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).""" diff --git a/webui/index.html b/webui/index.html index 8c3aeafc..10b921f9 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1018,6 +1018,7 @@ + @@ -8066,6 +8067,7 @@ + diff --git a/webui/static/api-monitor.js b/webui/static/api-monitor.js index ffda8f91..cd39fbd1 100644 --- a/webui/static/api-monitor.js +++ b/webui/static/api-monitor.js @@ -2040,6 +2040,13 @@ async function showWatchlistModal() { ${globalOverrideActive ? 'Global Override ON' : 'Global Settings'} + ${globalOverrideActive ? ` diff --git a/webui/static/origin-history.js b/webui/static/origin-history.js new file mode 100644 index 00000000..090e2980 --- /dev/null +++ b/webui/static/origin-history.js @@ -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 = ` +
+
+
+

Download Origins

+

What your watchlist and playlist syncs have downloaded.

+
+ +
+
+ + +
+ + +
+
+
+
`; + _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 = '
Loading…
'; + 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 = `
Couldn't load: ${escapeHtml(err.message)}
`; + } +} + +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 = `
${what}
`; + 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 + ? `` + : '
🎵
'; + const fname = (e.file_path || '').split(/[\\/]/).pop(); + return `
+ + ${thumb} +
+
+
+
${escapeHtml(e.title || 'Unknown')}
+ +
+ ${escapeHtml(e.origin_context || '—')} + ${e.quality ? `${escapeHtml(e.quality)}` : ''} +
${escapeHtml(_originFormatTime(e.created_at))}
+ +
+ ${fname ? `
File: ${escapeHtml(fname)}
` : ''} +
+
`; + }).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; + } +} diff --git a/webui/static/style.css b/webui/static/style.css index 85072d48..c6d7abf8 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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; +}