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:
parent
619b7ab4be
commit
eb2218ec8d
2 changed files with 115 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">×</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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue