Add file deletion option to album delete on enhanced library page

Album delete now shows a smart delete dialog with two options:
- Remove from Library (DB only, files untouched)
- Delete Files Too (removes DB records AND deletes audio files from
  disk, cleans up empty album folder)

Backend /api/library/album/<id> DELETE now accepts ?delete_files=true
parameter, resolves each track's file path, and removes files before
deleting DB records. Reports files_deleted and files_failed counts.
This commit is contained in:
Broque Thomas 2026-04-17 15:26:25 -07:00
parent 619b7ab4be
commit eb2218ec8d
2 changed files with 115 additions and 6 deletions

View file

@ -15468,12 +15468,50 @@ def sync_artist_library(artist_id):
@app.route('/api/library/album/<album_id>', methods=['DELETE'])
def library_delete_album(album_id):
"""Delete an album and all its tracks from the database (does NOT delete files on disk)."""
"""Delete an album and all its tracks from the database, optionally deleting files on disk."""
try:
delete_files = request.args.get('delete_files', 'false').lower() == 'true'
database = get_database()
files_deleted = 0
files_failed = 0
with database._get_connection() as conn:
cursor = conn.cursor()
# Delete all tracks belonging to this album first
# If deleting files, resolve and remove each track's file first
if delete_files:
cursor.execute("SELECT id, file_path FROM tracks WHERE album_id = ?", (album_id,))
track_rows = cursor.fetchall()
for row in track_rows:
fp = row['file_path']
if not fp:
continue
resolved = _resolve_library_file_path(fp)
if resolved and os.path.exists(resolved):
try:
os.remove(resolved)
files_deleted += 1
except Exception as e:
logger.warning(f"Failed to delete track file: {e}")
files_failed += 1
else:
files_failed += 1
# Try to remove the album folder if it's now empty
if track_rows:
first_fp = track_rows[0]['file_path']
if first_fp:
resolved_first = _resolve_library_file_path(first_fp)
if resolved_first:
album_dir = os.path.dirname(resolved_first)
try:
if os.path.isdir(album_dir) and not os.listdir(album_dir):
os.rmdir(album_dir)
logger.info(f"Removed empty album directory: {album_dir}")
except Exception:
pass
# Delete all tracks belonging to this album
cursor.execute("DELETE FROM tracks WHERE album_id = ?", (album_id,))
tracks_deleted = cursor.rowcount
# Delete the album itself
@ -15482,7 +15520,13 @@ def library_delete_album(album_id):
conn.rollback()
return jsonify({"success": False, "error": "Album not found"}), 404
conn.commit()
return jsonify({"success": True, "deleted_count": 1, "tracks_deleted": tracks_deleted})
return jsonify({
"success": True,
"deleted_count": 1,
"tracks_deleted": tracks_deleted,
"files_deleted": files_deleted,
"files_failed": files_failed
})
except Exception as e:
print(f"Error deleting album {album_id}: {e}")
import traceback

View file

@ -47755,12 +47755,30 @@ function _pollRedownloadProgress(taskId, 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;
const choice = await _showAlbumDeleteDialog();
if (!choice) return;
const deleteFiles = choice === 'delete_files';
const params = deleteFiles ? '?delete_files=true' : '';
try {
const response = await fetch(`/api/library/album/${albumId}`, { method: 'DELETE' });
const response = await fetch(`/api/library/album/${albumId}${params}`, { method: 'DELETE' });
const result = await response.json();
if (!result.success) throw new Error(result.error);
showToast(`Album deleted (${result.tracks_deleted || 0} tracks removed)`, 'success');
let msg = `Album removed from library (${result.tracks_deleted || 0} tracks)`;
let toastType = 'success';
if (deleteFiles) {
if (result.files_deleted > 0) {
msg = `Album deleted — ${result.files_deleted} files removed from disk`;
}
if (result.files_failed > 0) {
msg += ` (${result.files_failed} files could not be deleted)`;
toastType = 'warning';
}
}
showToast(msg, toastType);
if (artistDetailPageState.enhancedData) {
const album = (artistDetailPageState.enhancedData.albums || []).find(a => a.id === albumId);
if (album && album.tracks) {
@ -47777,6 +47795,53 @@ async function deleteLibraryAlbum(albumId) {
}
}
function _showAlbumDeleteDialog() {
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 Album</h3>
<button class="smart-delete-close">&times;</button>
</div>
<p class="smart-delete-desc">How should this album 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 album and all tracks from the database. Files on disk are not affected.</div>
</div>
</button>
<button class="smart-delete-option destructive" data-choice="delete_files">
<div class="smart-delete-option-icon">🗑</div>
<div class="smart-delete-option-info">
<div class="smart-delete-option-title">Delete Files Too</div>
<div class="smart-delete-option-desc">Remove from library and delete all audio files from disk. Empty album folder will be cleaned up.</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));
const escHandler = e => { if (e.key === 'Escape') { document.removeEventListener('keydown', escHandler); close(null); } };
document.addEventListener('keydown', escHandler);
document.body.appendChild(overlay);
});
}
function extractFormat(filePath) {
if (!filePath) return '-';
const ext = filePath.split('.').pop().toLowerCase();