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.
pull/253/head
Broque Thomas 1 week ago
parent 660221d86a
commit 8d6486bee3

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

@ -12791,16 +12791,61 @@ def library_manual_match():
@app.route('/api/library/track/<int:track_id>', 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

@ -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 = `
<div class="smart-delete-modal">
<div class="smart-delete-header">
<h3>Delete Track</h3>
<button class="smart-delete-close">&times;</button>
</div>
<p class="smart-delete-desc">How should this track be deleted?</p>
<div class="smart-delete-options">
<button class="smart-delete-option" data-choice="db_only">
<div class="smart-delete-option-icon">📋</div>
<div class="smart-delete-option-info">
<div class="smart-delete-option-title">Remove from Library</div>
<div class="smart-delete-option-desc">Remove the database entry only. File stays on disk.</div>
</div>
</button>
<button class="smart-delete-option destructive" data-choice="delete_file">
<div class="smart-delete-option-icon">🗑</div>
<div class="smart-delete-option-info">
<div class="smart-delete-option-title">Delete File Too</div>
<div class="smart-delete-option-desc">Remove from library and delete the audio file from disk.</div>
</div>
</button>
<button class="smart-delete-option destructive" data-choice="delete_and_blacklist">
<div class="smart-delete-option-icon"></div>
<div class="smart-delete-option-info">
<div class="smart-delete-option-title">Delete & Blacklist</div>
<div class="smart-delete-option-desc">Delete file and blacklist the source so it won't be downloaded again.</div>
</div>
</button>
</div>
</div>
`;
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 {

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

Loading…
Cancel
Save