From 8d6486bee3722a4cab48fbef74feface524da31b Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:29:07 -0700 Subject: [PATCH] Add Smart Delete with file removal and download blacklist (#234) Track delete in the enhanced library now shows three options: - Remove from Library: DB record only (existing behavior) - Delete File Too: DB + os.remove() the file from disk - Delete & Blacklist: DB + file removal + add source to blacklist New download_blacklist table stores rejected sources (username + filename) with CRUD methods. Blacklist will be checked by the download pipeline and the upcoming track redownload modal. Smart delete modal styled with the same glass/dark theme as other SoulSync modals, with color-coded destructive options. --- database/music_database.py | 75 ++++++++++++++++++++++++++ web_server.py | 49 ++++++++++++++++- webui/static/script.js | 74 ++++++++++++++++++++++++-- webui/static/style.css | 104 +++++++++++++++++++++++++++++++++++++ 4 files changed, 297 insertions(+), 5 deletions(-) diff --git a/database/music_database.py b/database/music_database.py index f8470d6..24057b9 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -1119,6 +1119,21 @@ class MusicDatabase: """) cursor.execute("CREATE INDEX IF NOT EXISTS idx_sync_cache_lookup ON sync_match_cache (spotify_track_id, server_source)") + # Download blacklist — tracks users have rejected as wrong matches + cursor.execute(""" + CREATE TABLE IF NOT EXISTS download_blacklist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + track_title TEXT, + track_artist TEXT, + blocked_filename TEXT, + blocked_username TEXT, + reason TEXT DEFAULT 'user_rejected', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(blocked_username, blocked_filename) + ) + """) + cursor.execute("CREATE INDEX IF NOT EXISTS idx_blacklist_user_file ON download_blacklist (blocked_username, blocked_filename)") + logger.info("Discovery tables created successfully") except Exception as e: @@ -8433,6 +8448,66 @@ class MusicDatabase: logger.error(f"Error invalidating sync match cache: {e}") return 0 + # ==================== Download Blacklist Methods ==================== + + def add_to_blacklist(self, track_title: str, track_artist: str, blocked_filename: str, blocked_username: str, reason: str = 'user_rejected') -> bool: + """Add a download source to the blacklist so it won't be used again.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute(""" + INSERT OR IGNORE INTO download_blacklist + (track_title, track_artist, blocked_filename, blocked_username, reason) + VALUES (?, ?, ?, ?, ?) + """, (track_title, track_artist, blocked_filename, blocked_username, reason)) + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + logger.error(f"Error adding to blacklist: {e}") + return False + + def is_blacklisted(self, username: str, filename: str) -> bool: + """Check if a download source is blacklisted.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT 1 FROM download_blacklist + WHERE blocked_username = ? AND blocked_filename = ? + LIMIT 1 + """, (username, filename)) + return cursor.fetchone() is not None + except Exception: + return False + + def get_blacklist(self, limit: int = 100, offset: int = 0) -> list: + """Get blacklist entries.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT id, track_title, track_artist, blocked_filename, blocked_username, reason, created_at + FROM download_blacklist + ORDER BY created_at DESC + LIMIT ? OFFSET ? + """, (limit, offset)) + return [dict(row) for row in cursor.fetchall()] + except Exception as e: + logger.error(f"Error getting blacklist: {e}") + return [] + + def remove_from_blacklist(self, blacklist_id: int) -> bool: + """Remove an entry from the blacklist.""" + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute("DELETE FROM download_blacklist WHERE id = ?", (blacklist_id,)) + conn.commit() + return cursor.rowcount > 0 + except Exception as e: + logger.error(f"Error removing from blacklist: {e}") + return False + # ==================== Discovery Pool Methods ==================== def get_discovery_pool_matched(self, limit: int = 500) -> list: diff --git a/web_server.py b/web_server.py index 3459b06..37d15e8 100644 --- a/web_server.py +++ b/web_server.py @@ -12791,16 +12791,61 @@ def library_manual_match(): @app.route('/api/library/track/', methods=['DELETE']) def library_delete_track(track_id): - """Delete a single track record from the database (does NOT delete the file on disk).""" + """Delete a track from the database, optionally deleting the file and blacklisting the source.""" try: + delete_file = request.args.get('delete_file', 'false').lower() == 'true' + add_blacklist = request.args.get('blacklist', 'false').lower() == 'true' + database = get_database() + file_deleted = False + blacklisted = False + with database._get_connection() as conn: cursor = conn.cursor() + + # Get track info before deleting (for file removal + blacklist) + track_info = None + if delete_file or add_blacklist: + cursor.execute(""" + SELECT t.file_path, t.title, ar.name AS artist_name + FROM tracks t + JOIN artists ar ON t.artist_id = ar.id + WHERE t.id = ? + """, (track_id,)) + track_info = cursor.fetchone() + + # Delete file from disk if requested + if delete_file and track_info and track_info['file_path']: + resolved = _resolve_library_file_path(track_info['file_path']) + if resolved and os.path.exists(resolved): + try: + os.remove(resolved) + file_deleted = True + logger.info(f"Deleted file from disk: {resolved}") + except Exception as e: + logger.warning(f"Failed to delete file: {e}") + + # Add to blacklist if requested + if add_blacklist and track_info and track_info['file_path']: + # Extract username and filename from the file path for blacklisting + # Soulseek paths are stored as: username/path/to/file.ext or just the local path + fp = track_info['file_path'].replace('\\', '/') + database.add_to_blacklist( + track_title=track_info['title'], + track_artist=track_info['artist_name'], + blocked_filename=os.path.basename(fp), + blocked_username='', # Local files don't have a username + reason='user_rejected' + ) + blacklisted = True + + # Delete DB record cursor.execute("DELETE FROM tracks WHERE id = ?", (track_id,)) conn.commit() if cursor.rowcount == 0: return jsonify({"success": False, "error": "Track not found"}), 404 - return jsonify({"success": True, "deleted_count": cursor.rowcount}) + + return jsonify({"success": True, "deleted_count": cursor.rowcount, "file_deleted": file_deleted, "blacklisted": blacklisted}) except Exception as e: print(f"❌ Error deleting track {track_id}: {e}") import traceback diff --git a/webui/static/script.js b/webui/static/script.js index 7f10988..0cbfd8f 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -42561,12 +42561,25 @@ function sortEnhancedTracks(album, field, ascending) { async function deleteLibraryTrack(trackId, albumId) { cancelInlineEdit(); - if (!await showConfirmDialog({ title: 'Delete Track', message: 'Delete this track from the library? (File on disk is not affected)', confirmText: 'Delete', destructive: true })) return; + + // Smart delete dialog — three options + const choice = await _showSmartDeleteDialog(); + if (!choice) return; + + const params = new URLSearchParams(); + if (choice === 'delete_file' || choice === 'delete_and_blacklist') params.set('delete_file', 'true'); + if (choice === 'delete_and_blacklist') params.set('blacklist', 'true'); + try { - const response = await fetch(`/api/library/track/${trackId}`, { method: 'DELETE' }); + const response = await fetch(`/api/library/track/${trackId}?${params}`, { method: 'DELETE' }); const result = await response.json(); if (!result.success) throw new Error(result.error); - showToast('Track deleted from library', 'success'); + + let msg = 'Track removed from library'; + if (result.file_deleted) msg = 'Track deleted from library and disk'; + if (result.blacklisted) msg += ' (source blacklisted)'; + showToast(msg, 'success'); + if (artistDetailPageState.enhancedData) { const albums = artistDetailPageState.enhancedData.albums || []; const album = albums.find(a => a.id === albumId); @@ -42581,6 +42594,61 @@ async function deleteLibraryTrack(trackId, albumId) { } } +function _showSmartDeleteDialog() { + return new Promise(resolve => { + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:10000;display:flex;align-items:center;justify-content:center;'; + + const close = (val) => { overlay.remove(); resolve(val); }; + overlay.onclick = e => { if (e.target === overlay) close(null); }; + + overlay.innerHTML = ` +
+
+

Delete Track

+ +
+

How should this track be deleted?

+
+ + + +
+
+ `; + + overlay.querySelectorAll('.smart-delete-option').forEach(btn => { + btn.addEventListener('click', () => close(btn.dataset.choice)); + }); + overlay.querySelector('.smart-delete-close').addEventListener('click', () => close(null)); + + // Escape to close + const escHandler = e => { if (e.key === 'Escape') { document.removeEventListener('keydown', escHandler); close(null); } }; + document.addEventListener('keydown', escHandler); + + document.body.appendChild(overlay); + }); +} + async function deleteLibraryAlbum(albumId) { if (!await showConfirmDialog({ title: 'Delete Album', message: 'Delete this album and all its tracks from the library? (Files on disk are not affected)', confirmText: 'Delete', destructive: true })) return; try { diff --git a/webui/static/style.css b/webui/static/style.css index 4f9a658..9544afc 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -51940,3 +51940,107 @@ tr.tag-diff-same { .sync-tab-server { flex: 1 !important; } .sync-tab-divider { display: none; } } + +/* ================================================================================== + SMART DELETE MODAL + ================================================================================== */ + +.smart-delete-modal { + background: rgba(20, 20, 28, 0.98); + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 20px; + padding: 24px; + max-width: 420px; + width: 90vw; + box-shadow: 0 32px 80px rgba(0, 0, 0, 0.6); +} + +.smart-delete-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.smart-delete-header h3 { + font-size: 16px; + font-weight: 700; + color: #fff; + margin: 0; +} + +.smart-delete-close { + width: 30px; + height: 30px; + border: none; + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.4); + border-radius: 50%; + font-size: 18px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} +.smart-delete-close:hover { background: rgba(255, 255, 255, 0.12); color: #fff; } + +.smart-delete-desc { + font-size: 12px; + color: rgba(255, 255, 255, 0.35); + margin: 0 0 16px; +} + +.smart-delete-options { + display: flex; + flex-direction: column; + gap: 8px; +} + +.smart-delete-option { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 16px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + cursor: pointer; + transition: all 0.2s; + text-align: left; + color: #fff; +} +.smart-delete-option:hover { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.12); +} +.smart-delete-option.destructive:hover { + background: rgba(239, 83, 80, 0.08); + border-color: rgba(239, 83, 80, 0.2); +} + +.smart-delete-option-icon { + font-size: 20px; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.smart-delete-option-info { flex: 1; min-width: 0; } + +.smart-delete-option-title { + font-size: 13px; + font-weight: 600; + margin-bottom: 2px; +} +.smart-delete-option.destructive .smart-delete-option-title { color: #ef5350; } + +.smart-delete-option-desc { + font-size: 11px; + color: rgba(255, 255, 255, 0.35); + line-height: 1.3; +}