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'])
|
@app.route('/api/library/album/<album_id>', methods=['DELETE'])
|
||||||
def library_delete_album(album_id):
|
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:
|
try:
|
||||||
|
delete_files = request.args.get('delete_files', 'false').lower() == 'true'
|
||||||
database = get_database()
|
database = get_database()
|
||||||
|
files_deleted = 0
|
||||||
|
files_failed = 0
|
||||||
|
|
||||||
with database._get_connection() as conn:
|
with database._get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
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,))
|
cursor.execute("DELETE FROM tracks WHERE album_id = ?", (album_id,))
|
||||||
tracks_deleted = cursor.rowcount
|
tracks_deleted = cursor.rowcount
|
||||||
# Delete the album itself
|
# Delete the album itself
|
||||||
|
|
@ -15482,7 +15520,13 @@ def library_delete_album(album_id):
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
return jsonify({"success": False, "error": "Album not found"}), 404
|
return jsonify({"success": False, "error": "Album not found"}), 404
|
||||||
conn.commit()
|
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:
|
except Exception as e:
|
||||||
print(f"Error deleting album {album_id}: {e}")
|
print(f"Error deleting album {album_id}: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
|
|
|
||||||
|
|
@ -47755,12 +47755,30 @@ function _pollRedownloadProgress(taskId, 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;
|
const choice = await _showAlbumDeleteDialog();
|
||||||
|
if (!choice) return;
|
||||||
|
|
||||||
|
const deleteFiles = choice === 'delete_files';
|
||||||
|
const params = deleteFiles ? '?delete_files=true' : '';
|
||||||
|
|
||||||
try {
|
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();
|
const result = await response.json();
|
||||||
if (!result.success) throw new Error(result.error);
|
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) {
|
if (artistDetailPageState.enhancedData) {
|
||||||
const album = (artistDetailPageState.enhancedData.albums || []).find(a => a.id === albumId);
|
const album = (artistDetailPageState.enhancedData.albums || []).find(a => a.id === albumId);
|
||||||
if (album && album.tracks) {
|
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) {
|
function extractFormat(filePath) {
|
||||||
if (!filePath) return '-';
|
if (!filePath) return '-';
|
||||||
const ext = filePath.split('.').pop().toLowerCase();
|
const ext = filePath.split('.').pop().toLowerCase();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue