feat: delete song from disk via edit-metadata modal
- /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 <noreply@anthropic.com>
This commit is contained in:
1 parent
0788c801fa
commit
a2714dddaa
4 files changed
+118
-2
No files matched your search
@@ -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 ───────────────────────────────────────────────────────────────
|
// ─── ABS PROXY ───────────────────────────────────────────────────────────────
|
||||||
// Forwards API calls to Audiobookshelf to avoid CORS restrictions in the browser.
|
// 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
|
// The browser sends X-ABS-URL and X-ABS-Token headers; audio streaming uses
|
||||||
|
|||||||
@@ -961,6 +961,12 @@
|
|||||||
<button class="info-close" style="flex:1;margin:0" onclick="toggleInfoEdit()">Abbrechen</button>
|
<button class="info-close" style="flex:1;margin:0" onclick="toggleInfoEdit()">Abbrechen</button>
|
||||||
<button class="info-close" style="flex:1;margin:0;background:var(--accent);color:#fff" onclick="saveMetadata()">Speichern</button>
|
<button class="info-close" style="flex:1;margin:0;background:var(--accent);color:#fff" onclick="saveMetadata()">Speichern</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="padding:0 20px 20px">
|
||||||
|
<button class="info-close" style="width:100%;margin:0;background:none;border:1px solid #c0392b;color:#c0392b;display:flex;align-items:center;justify-content:center;gap:6px" onclick="deleteSong()">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
|
||||||
|
Song löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-2
@@ -45,7 +45,7 @@ import {
|
|||||||
toggleMobileSearch, closeMobileSearch, switchSetupTab,
|
toggleMobileSearch, closeMobileSearch, switchSetupTab,
|
||||||
toggleNotifPanel, deleteNotif, clearNotifs, initNotifBell, _notifNavigate,
|
toggleNotifPanel, deleteNotif, clearNotifs, initNotifBell, _notifNavigate,
|
||||||
_pickTheme,
|
_pickTheme,
|
||||||
toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, autofillItunes, saveMetadata,
|
toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, autofillItunes, saveMetadata, deleteSong,
|
||||||
} from './ui.js';
|
} from './ui.js';
|
||||||
|
|
||||||
// ─── CONNECT ───
|
// ─── CONNECT ───
|
||||||
@@ -316,7 +316,7 @@ Object.assign(window, {
|
|||||||
loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll, scDlSingle, scDlTrack,
|
loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll, scDlSingle, scDlTrack,
|
||||||
executeSpotifyDownload, resolveYouTubeLink, executeYouTubeDownload,
|
executeSpotifyDownload, resolveYouTubeLink, executeYouTubeDownload,
|
||||||
openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist, createPlaylistPrompt,
|
openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist, createPlaylistPrompt,
|
||||||
showSongInfo, closeInfoModal, toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, autofillItunes, saveMetadata,
|
showSongInfo, closeInfoModal, toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, autofillItunes, saveMetadata, deleteSong,
|
||||||
openDlModal, closeDlModal, selectDlType, executeDownload,
|
openDlModal, closeDlModal, selectDlType, executeDownload,
|
||||||
openEditPlModal, closeEditPlModal, saveEditPlaylist,
|
openEditPlModal, closeEditPlModal, saveEditPlaylist,
|
||||||
editPlUploadCover, editPlCoverSelected, editPlRemoveCover, deletePlaylist, _confirmOk, _confirmCancel,
|
editPlUploadCover, editPlCoverSelected, editPlRemoveCover, deletePlaylist, _confirmOk, _confirmCancel,
|
||||||
|
|||||||
@@ -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 ───
|
// ─── DOWNLOAD MODAL ───
|
||||||
export function openDlModal(track) {
|
export function openDlModal(track) {
|
||||||
if(!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert. Bitte in Einstellungen eintragen.'); return; }
|
if(!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert. Bitte in Einstellungen eintragen.'); return; }
|
||||||
|
|||||||
Reference in new issue
Block a user