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:
HoneyandClaude Sonnet 4.6 committed 2026-05-29 00:36:31 +02:00
1 parent 0788c801fa
commit a2714dddaa
4 files changed
+118 -2

No files matched your search

+84
View File
@@ -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