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:
parent
76c63b5bc4
commit
1f7834cc7b
10 changed files with 776 additions and 5 deletions
|
|
@ -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
71
core/downloads/origin.py
Normal 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, ""
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
116
tests/test_download_origins.py
Normal file
116
tests/test_download_origins.py
Normal 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': '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
|
||||||
|
|
@ -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)."""
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 ? `
|
||||||
|
|
|
||||||
187
webui/static/origin-history.js
Normal file
187
webui/static/origin-history.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue