From a2714dddaa686d54fbf6cc0616e1c749bebc30f8 Mon Sep 17 00:00:00 2001 From: Honey Date: Fri, 29 May 2026 00:36:31 +0200 Subject: [PATCH] feat: delete song from disk via edit-metadata modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/delete-song endpoint: resolves path via same fallback chain as edit-metadata, validates file is inside music root + is an audio file, deletes it, then prunes the album folder if empty and the artist folder if that too becomes empty — no recursive rm, depth strictly limited to two levels above the file so no accidental mass-deletes - Safety: path.resolve() guard ensures no path-traversal outside music root; rmdirSync (non-recursive) fails if dir is non-empty as extra safety net - Frontend: delete button (red outline, trash icon) at bottom of edit mode with confirmation dialog showing title + folder-cleanup warning Co-Authored-By: Claude Sonnet 4.6 --- backend/novasonic-server.js | 84 +++++++++++++++++++++++++++++++++++++ index.html | 6 +++ src/main.js | 4 +- src/ui.js | 26 ++++++++++++ 4 files changed, 118 insertions(+), 2 deletions(-) diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index 4a87bda..1c5854f 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -1021,6 +1021,90 @@ app.post('/api/edit-metadata', async (req, res) => { } }); +// ─── SONG DELETE ───────────────────────────────────────────────────────────── +// Deletes a song file from disk, then prunes empty album/artist folders. +// Folder pruning is strictly depth-limited: only the direct album parent and its +// artist parent are ever touched — no recursive delete, no removing the music root. +app.post('/api/delete-song', async (req, res) => { + const { ndUrl, user, pass, songId } = req.body; + if (!ndUrl || !user || !pass || !songId) + return res.status(400).json({ error: 'ndUrl, user, pass, songId required' }); + + // Resolve the file path from Navidrome + let filePath; + try { + const salt = Math.random().toString(36).slice(2, 10); + const token = crypto.createHash('md5').update(pass + salt).digest('hex'); + const ndRes = await fetch( + `${ndUrl}/rest/getSong?id=${songId}&u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=novasonic&f=json`, + { signal: AbortSignal.timeout(8000) } + ); + const ndData = await ndRes.json(); + filePath = ndData?.['subsonic-response']?.song?.path; + if (!filePath) return res.status(404).json({ error: 'Song not found in Navidrome' }); + } catch (e) { + return res.status(502).json({ error: `Navidrome lookup failed: ${e.message}` }); + } + + if (!path.isAbsolute(filePath)) filePath = path.join(CONFIG.musicPath, filePath); + if (!fs.existsSync(filePath)) { + const fileName = path.basename(filePath); + const titleStem = fileName.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '').toLowerCase(); + const found = _rebasePath(filePath, CONFIG.musicPath) + || _findFileRecursive(CONFIG.musicPath, fileName) + || _findFileInDir(path.dirname(filePath), fileName) + || _findFileByStem(CONFIG.musicPath, titleStem); + if (found) filePath = found; + else return res.status(404).json({ error: 'File not found on disk' }); + } + + // Safety: file must be inside the music root and must be an audio file + const realFile = path.resolve(filePath); + const realRoot = path.resolve(CONFIG.musicPath); + const rootSlash = realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep; + if (!realFile.startsWith(rootSlash)) + return res.status(403).json({ error: 'Path outside music root — refusing to delete' }); + if (!AUDIO_EXT_RE.test(path.extname(realFile))) + return res.status(400).json({ error: 'Target is not a recognised audio file' }); + + const albumDir = path.dirname(realFile); + const artistDir = path.dirname(albumDir); + + // Delete the file + try { fs.unlinkSync(realFile); } + catch (e) { return res.status(500).json({ error: `Could not delete file: ${e.message}` }); } + + // Prune empty album folder — only if it is a direct child of the music root hierarchy + // (not the root itself) and only when truly empty (no hidden files either) + if (albumDir !== realRoot && albumDir.startsWith(rootSlash)) { + try { + const albumLeft = fs.readdirSync(albumDir).filter(f => !f.startsWith('.')); + if (albumLeft.length === 0) { + fs.rmdirSync(albumDir); // throws if not empty — extra safety net + // Prune empty artist folder only if it too is directly inside the music root + if (artistDir !== realRoot && artistDir.startsWith(rootSlash)) { + try { + const artistLeft = fs.readdirSync(artistDir).filter(f => !f.startsWith('.')); + if (artistLeft.length === 0) fs.rmdirSync(artistDir); + } catch {} + } + } + } catch {} + } + + // Trigger Navidrome rescan so the deleted song disappears from the UI + try { + const s2 = Math.random().toString(36).slice(2, 10); + const t2 = crypto.createHash('md5').update(pass + s2).digest('hex'); + await fetch( + `${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${t2}&s=${s2}&v=1.16.1&c=novasonic&f=json`, + { signal: AbortSignal.timeout(5000) } + ); + } catch {} + + res.json({ ok: true }); +}); + // ─── ABS PROXY ─────────────────────────────────────────────────────────────── // Forwards API calls to Audiobookshelf to avoid CORS restrictions in the browser. // The browser sends X-ABS-URL and X-ABS-Token headers; audio streaming uses diff --git a/index.html b/index.html index b7f9976..15989f0 100644 --- a/index.html +++ b/index.html @@ -961,6 +961,12 @@ +
+ +
diff --git a/src/main.js b/src/main.js index 57da774..b206661 100644 --- a/src/main.js +++ b/src/main.js @@ -45,7 +45,7 @@ import { toggleMobileSearch, closeMobileSearch, switchSetupTab, toggleNotifPanel, deleteNotif, clearNotifs, initNotifBell, _notifNavigate, _pickTheme, - toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, autofillItunes, saveMetadata, + toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, autofillItunes, saveMetadata, deleteSong, } from './ui.js'; // ─── CONNECT ─── @@ -316,7 +316,7 @@ Object.assign(window, { loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll, scDlSingle, scDlTrack, executeSpotifyDownload, resolveYouTubeLink, executeYouTubeDownload, openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist, createPlaylistPrompt, - showSongInfo, closeInfoModal, toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, autofillItunes, saveMetadata, + showSongInfo, closeInfoModal, toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, autofillItunes, saveMetadata, deleteSong, openDlModal, closeDlModal, selectDlType, executeDownload, openEditPlModal, closeEditPlModal, saveEditPlaylist, editPlUploadCover, editPlCoverSelected, editPlRemoveCover, deletePlaylist, _confirmOk, _confirmCancel, diff --git a/src/ui.js b/src/ui.js index 265df4d..6abaf79 100644 --- a/src/ui.js +++ b/src/ui.js @@ -515,6 +515,32 @@ export async function saveMetadata() { } } +export async function deleteSong() { + if (!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert'); return; } + const s = S.infoSong; + if (!s?.id) return; + const ok = await showConfirm( + 'Song löschen', + `"${s.title}" wird unwiderruflich von der Festplatte gelöscht. Leere Album- und Künstlerordner werden ebenfalls entfernt.`, + 'Löschen' + ); + if (!ok) return; + const status = document.getElementById('info-edit-status'); + status.textContent = '🗑 Löschen…'; + try { + const r = await fetch(`${S.cfg.backendUrl}/api/delete-song`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ndUrl: S.cfg.url, user: S.cfg.user, pass: S.cfg.pass, songId: s.id }), + }); + const d = await r.json(); + if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`); + toast(`🗑 "${s.title}" gelöscht`); + closeInfoModal(); + } catch (e) { + status.textContent = '❌ Fehler: ' + e.message; + } +} + // ─── DOWNLOAD MODAL ─── export function openDlModal(track) { if(!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert. Bitte in Einstellungen eintragen.'); return; }