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 = ` +
What your watchlist and playlist syncs have downloaded.
+