feat(web+backend): Dubletten im Editor aufloesen, Favoriten ueberleben das Umbenennen

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-20 21:41:42 +02:00
1 parent 24558497da
commit 8d25bf0404
4 files changed
+239 -5

No files matched your search

+138 -5
View File
@@ -1348,6 +1348,30 @@ async function _readTags(file) {
}
const _tagNorm = (v) => (v || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
// Steckbrief einer Datei fuer den Dublettenvergleich: Groesse, Laenge, Titel/Kuenstler.
// Zwei Kopien desselben Songs unterscheiden sich fast immer nur in der Groesse (andere Bitrate),
// nie nennenswert in der Laenge — deshalb ist die Laenge das aussagekraeftige Feld.
async function _dateiSteckbrief(file) {
let groesse = null;
try { groesse = fs.statSync(file).size; } catch {}
const dauer = await new Promise((resolve) => {
const proc = spawn('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_entries', 'format=duration', file]);
let buf = '';
proc.stdout.on('data', d => buf += d);
proc.on('error', () => resolve(null));
proc.on('close', () => {
try { resolve(Math.round(parseFloat(JSON.parse(buf)?.format?.duration)) || null); } catch { resolve(null); }
});
});
const tags = await _readTags(file);
return {
pfad: file, groesse, dauer,
titel: tags.title || tags.TITLE || null,
artist: tags.artist || tags.ARTIST || null,
album: tags.album || tags.ALBUM || null,
};
}
app.post('/api/edit-metadata', async (req, res) => {
const { ndUrl, user, pass, songId, title, artist, albumArtist, album, year, trackNum, totalTracks,
discNum, discTotal, genre, artworkBase64, artworkMime, artworkUrl: newArtworkUrl,
@@ -1520,6 +1544,10 @@ app.post('/api/edit-metadata', async (req, res) => {
// bisher stumm — der Song blieb dann unter dem alten Namen bzw. im alten Ordner liegen, ohne dass
// es jemand erfuhr. Solche Faelle kommen jetzt als Warnung mit in die Antwort.
const warnungen = [];
// Faellt beim Verschieben eine gleichnamige Datei am Ziel auf, ist das bereits der
// Dublettenfall. Statt nur zu melden, werden beide Dateien verglichen und die Angaben
// mitgeschickt, damit die Oberflaeche das Aufraeumen direkt anbieten kann.
let dublette = null;
// 1. Rename file if title changed — convention: {01 - }?{Artist} - {Title}.ext
// Use original names (may be Japanese); romaji goes only into sort tags.
@@ -1554,8 +1582,21 @@ app.post('/api/edit-metadata', async (req, res) => {
fs.mkdirSync(newDir, { recursive: true });
const destFile = path.join(newDir, path.basename(currentFile));
if (fs.existsSync(destFile)) {
const [ziel, hier] = await Promise.all([
_dateiSteckbrief(destFile), _dateiSteckbrief(currentFile),
]);
// Gleiche Laenge (± 2 s) UND gleicher Titel: dann sind es zwei Kopien desselben Songs
// und die ueberzaehlige kann weg. Sonst nur melden — Loeschen ist unwiderruflich.
const laengeGleich = ziel.dauer != null && hier.dauer != null &&
Math.abs(ziel.dauer - hier.dauer) <= 2;
const titelGleich = _tagNorm(ziel.titel) === _tagNorm(hier.titel);
dublette = {
behalten: ziel, // liegt schon am richtigen Platz
ueberzaehlig: hier, // die gerade bearbeitete Datei im alten Ordner
gleich: !!(laengeGleich && titelGleich),
};
warnungen.push(`Nicht verschoben: "${destFile}" existiert bereits. Die Datei bleibt in ` +
`"${oldAlbumDir}" — vermutlich eine Dublette.`);
`"${oldAlbumDir}" — dieselbe Aufnahme liegt doppelt in der Bibliothek.`);
} else {
fs.renameSync(currentFile, destFile);
currentFile = destFile;
@@ -1586,19 +1627,39 @@ app.post('/api/edit-metadata', async (req, res) => {
await fetch(`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${tok2}&s=${salt2}&v=1.16.1&c=NovaSonic-Backend&f=json`, { signal: AbortSignal.timeout(5000) });
} catch {}
// Restore playlist membership after rename/move — the rescan gives the renamed song a new ID
if (affectedPlaylists.length > 0) {
// Umbenennen und Verschieben geben dem Song beim naechsten Scan eine NEUE Navidrome-ID. Alles,
// was an der alten ID hing, waere sonst weg: die Playlist-Zugehoerigkeit — und der Stern, der
// bisher stillschweigend verlorenging. Beides wird auf die neue ID uebertragen.
const warStarred = !!ndSong?.starred;
if (affectedPlaylists.length > 0 || warStarred) {
const effectiveTitle = title || ndSong?.title || '';
const effectiveArtist = (artist || ndSong?.artist || '').split(';')[0].trim();
const newSong = await _findSongAfterRename(ndUrl, user, pass, effectiveTitle, effectiveArtist, songId);
if (newSong && newSong.id !== songId) {
await _migratePlaylists(ndUrl, user, pass, affectedPlaylists, songId, newSong.id);
if (affectedPlaylists.length > 0) {
await _migratePlaylists(ndUrl, user, pass, affectedPlaylists, songId, newSong.id);
}
if (warStarred) {
// Der Stern ist in Navidrome pro Nutzer gespeichert; uebertragen laesst sich nur der des
// bearbeitenden Nutzers — fremde Markierungen sind ueber die Subsonic-API weder lesbar
// noch setzbar. Schlaegt es fehl, bleibt es bei einer Warnung statt eines Abbruchs.
try {
const st = await _ndApi(ndUrl, user, pass, 'star', `&id=${encodeURIComponent(newSong.id)}`);
if (st?.['subsonic-response']?.status !== 'ok') throw new Error('star abgelehnt');
} catch (e) {
warnungen.push(`Favorit konnte nicht uebertragen werden (${e.message}) — der Song muss ` +
`erneut markiert werden.`);
}
}
} else if (warStarred && !newSong) {
warnungen.push('Der Song wurde nach dem Umbenennen nicht wiedergefunden — die Markierung ' +
'als Favorit konnte nicht uebertragen werden.');
}
}
// Den tatsaechlich bearbeiteten Pfad mitgeben: wer eine Aenderung "zurueckspringen" sieht, kann
// damit sofort erkennen, ob die richtige Datei erwischt wurde.
res.json({ ok: true, file: currentFile, warnings: warnungen });
res.json({ ok: true, file: currentFile, warnings: warnungen, duplicate: dublette });
} catch (e) {
try { if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); } catch {}
if (artFile) try { fs.unlinkSync(artFile); } catch {}
@@ -1717,6 +1778,78 @@ app.post('/api/delete-song', async (req, res) => {
res.json({ ok: true });
});
// ─── DUBLETTE AUFLOESEN ──────────────────────────────────────────────────────
// Nach einem abgelehnten Verschieben ("am Ziel liegt schon eine Datei") liegt derselbe Song
// zweimal auf der Platte. Dieser Endpunkt loescht die ueberzaehlige Kopie — aber nur, wenn er
// selbst nachgeprueft hat, dass es wirklich eine Dublette ist. Er nimmt Pfade entgegen, deshalb
// prueft er strenger als das Loeschen per songId:
// 1. beide Pfade liegen im Musikordner und sind Audiodateien
// 2. beide existieren
// 3. gleiche Laenge (± 2 s) UND gleicher Titel-Tag — sonst sind es zwei verschiedene Aufnahmen
// Erst dann faellt die zu loeschende Datei.
app.post('/api/resolve-duplicate', async (req, res) => {
const { ndUrl, user, pass, behalten, ueberzaehlig } = req.body;
if (!ndUrl || !user || !pass || !behalten || !ueberzaehlig)
return res.status(400).json({ error: 'ndUrl, user, pass, behalten, ueberzaehlig required' });
if (behalten === ueberzaehlig)
return res.status(400).json({ error: 'Beide Pfade sind identisch' });
const realRoot = path.resolve(CONFIG.musicPath);
const rootSlash = realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep;
const pruefe = (pfad, name) => {
const abs = path.resolve(pfad);
if (!abs.startsWith(rootSlash)) return `${name}: Pfad liegt ausserhalb des Musikordners`;
if (!AUDIO_EXT_RE.test(path.extname(abs))) return `${name}: keine Audiodatei`;
if (!fs.existsSync(abs)) return `${name}: existiert nicht mehr`;
return null;
};
const fehler = pruefe(behalten, 'behalten') || pruefe(ueberzaehlig, 'ueberzaehlig');
if (fehler) return res.status(400).json({ error: fehler });
const [a, b] = await Promise.all([
_dateiSteckbrief(path.resolve(behalten)), _dateiSteckbrief(path.resolve(ueberzaehlig)),
]);
const laengeGleich = a.dauer != null && b.dauer != null && Math.abs(a.dauer - b.dauer) <= 2;
const titelGleich = _tagNorm(a.titel) === _tagNorm(b.titel);
if (!laengeGleich || !titelGleich) {
return res.status(409).json({
error: `Das sind keine zwei Kopien desselben Songs — "${a.titel || '?'}" (${a.dauer ?? '?'} s) ` +
`gegen "${b.titel || '?'}" (${b.dauer ?? '?'} s). Es wird nichts geloescht.`,
});
}
const ziel = path.resolve(ueberzaehlig);
try { fs.unlinkSync(ziel); }
catch (e) { return res.status(500).json({ error: `Loeschen fehlgeschlagen: ${e.message}` }); }
// Leere Album- und Kuenstlerordner aufraeumen — gleiche Tiefenbegrenzung wie beim Song-Loeschen.
const albumDir = path.dirname(ziel);
const artistDir = path.dirname(albumDir);
if (albumDir !== realRoot && albumDir.startsWith(rootSlash)) {
try {
if (fs.readdirSync(albumDir).filter(f => !f.startsWith('.')).length === 0) {
fs.rmdirSync(albumDir);
if (artistDir !== realRoot && artistDir.startsWith(rootSlash)) {
try {
if (fs.readdirSync(artistDir).filter(f => !f.startsWith('.')).length === 0) fs.rmdirSync(artistDir);
} catch {}
}
}
} catch {}
}
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-Backend&f=json`,
{ signal: AbortSignal.timeout(5000) }
);
} catch {}
res.json({ ok: true, geloescht: ziel, verbleibt: path.resolve(behalten) });
});
// ─── 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