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.
This commit is contained in:
parent
660221d86a
commit
8d6486bee3
4 changed files with 297 additions and 5 deletions
|
|
@ -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)")
|
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")
|
logger.info("Discovery tables created successfully")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -8433,6 +8448,66 @@ class MusicDatabase:
|
||||||
logger.error(f"Error invalidating sync match cache: {e}")
|
logger.error(f"Error invalidating sync match cache: {e}")
|
||||||
return 0
|
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 ====================
|
# ==================== Discovery Pool Methods ====================
|
||||||
|
|
||||||
def get_discovery_pool_matched(self, limit: int = 500) -> list:
|
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'])
|
@app.route('/api/library/track/<int:track_id>', methods=['DELETE'])
|
||||||
def library_delete_track(track_id):
|
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:
|
try:
|
||||||
|
delete_file = request.args.get('delete_file', 'false').lower() == 'true'
|
||||||
|
add_blacklist = request.args.get('blacklist', 'false').lower() == 'true'
|
||||||
|
|
||||||
database = get_database()
|
database = get_database()
|
||||||
|
file_deleted = False
|
||||||
|
blacklisted = False
|
||||||
|
|
||||||
with database._get_connection() as conn:
|
with database._get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
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,))
|
cursor.execute("DELETE FROM tracks WHERE id = ?", (track_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
if cursor.rowcount == 0:
|
if cursor.rowcount == 0:
|
||||||
return jsonify({"success": False, "error": "Track not found"}), 404
|
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:
|
except Exception as e:
|
||||||
print(f"❌ Error deleting track {track_id}: {e}")
|
print(f"❌ Error deleting track {track_id}: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
|
|
|
||||||
|
|
@ -42561,12 +42561,25 @@ function sortEnhancedTracks(album, field, ascending) {
|
||||||
|
|
||||||
async function deleteLibraryTrack(trackId, albumId) {
|
async function deleteLibraryTrack(trackId, albumId) {
|
||||||
cancelInlineEdit();
|
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 {
|
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();
|
const result = await response.json();
|
||||||
if (!result.success) throw new Error(result.error);
|
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) {
|
if (artistDetailPageState.enhancedData) {
|
||||||
const albums = artistDetailPageState.enhancedData.albums || [];
|
const albums = artistDetailPageState.enhancedData.albums || [];
|
||||||
const album = albums.find(a => a.id === albumId);
|
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">×</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) {
|
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;
|
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 {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -51940,3 +51940,107 @@ tr.tag-diff-same {
|
||||||
.sync-tab-server { flex: 1 !important; }
|
.sync-tab-server { flex: 1 !important; }
|
||||||
.sync-tab-divider { display: none; }
|
.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…
Reference in a new issue