diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2ec64a5..3de9c9a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -160,6 +160,27 @@ nur zu zeigen, was drinsteht.
statt einer leeren Liste.
### Fixed
+- **Favoriten überleben ein Umbenennen (Web)** — Umbenennen und Verschieben geben dem Song beim
+ nächsten Scan eine **neue** Navidrome-ID. Für Playlists wurde die Zugehörigkeit längst umgehängt,
+ für den Stern nicht: wer einen markierten Song bearbeitete, verlor die Markierung stillschweigend.
+ Der Zustand wird jetzt vor dem Schreiben gelesen (`starred` aus `getSong`) und nach dem Wiederfinden
+ auf die neue ID gesetzt. Schlägt das fehl, steht es als Warnung im Fenster statt unbemerkt zu
+ verschwinden.
+ **Grenze:** der Stern liegt in Navidrome **pro Nutzer**. Übertragen lässt sich nur der des
+ bearbeitenden Nutzers — fremde Markierungen sind über die Subsonic-API weder lesbar noch setzbar.
+- **Dubletten lassen sich im Editor selbst auflösen (Web)** — wollte das Speichern die Datei in den
+ Ordner der neuen Tags verschieben und lag dort schon eine gleichnamige, blieb sie liegen und der
+ Hinweis schickte einen auf die Konsole. Derselbe Song lag danach doppelt in der Bibliothek — genau
+ das, was die ganze Dateizuordnung durcheinanderbringt.
+ - Das Fenster stellt jetzt **beide Kopien gegenüber** (Pfad, Künstler, Titel, Länge, Größe) und
+ sagt, ob es dieselbe Aufnahme ist: gleiche Länge (± 2 s) **und** gleicher Titel-Tag. Darunter
+ steht ein Knopf, der die überzählige löscht — mit derselben Rückfrage wie beim Song-Löschen.
+ - **Neuer Endpunkt `POST /api/resolve-duplicate`.** Er nimmt Pfade entgegen und prüft deshalb
+ strenger als das Löschen per ID: beide müssen im Musikordner liegen, Audiodateien sein und
+ existieren, und dieselbe Länge und denselben Titel tragen. Sonst 409 und es wird nichts gelöscht.
+ Geprüft mit echten Dateien: sechs Fälle, von „fehlende Felder" über einen Pfad außerhalb des
+ Musikordners bis zum anderen Song mit ähnlichem Namen — nur die echte Dublette fiel.
+
- **Ein „/" im Künstler- oder Albumnamen führte den Metadaten-Editor auf die falsche Datei** —
beim Bearbeiten von „K/DA – VILLAIN" meldete das Backend, die gefundene Datei enthalte
„Bad Omens – Like A Villain". Zwei Fehler trafen aufeinander:
diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js
index 5e3d003..821fde3 100644
--- a/backend/novasonic-server.js
+++ b/backend/novasonic-server.js
@@ -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
diff --git a/src/ui/metadata.js b/src/ui/metadata.js
index d13be6d..fdaa580 100644
--- a/src/ui/metadata.js
+++ b/src/ui/metadata.js
@@ -291,6 +291,8 @@ export async function saveMetadata() {
if (d.warnings?.length) {
status.innerHTML = '⚠ Gespeichert, aber: ' + d.warnings.map(esc).join('
⚠ ') +
'
Datei: ' + esc(d.file || '?') + '';
+ // Liegt derselbe Song doppelt, kann das Aufraeumen hier passieren statt auf der Konsole.
+ if (d.duplicate) _zeigeDublette(status, d.duplicate);
toast('⚠ Gespeichert — mit Hinweisen, siehe Fenster');
return;
}
@@ -302,6 +304,71 @@ export async function saveMetadata() {
}
}
+// ─── DUBLETTE AUFLOESEN ───
+// Das Verschieben wurde abgelehnt, weil am Ziel schon eine Datei liegt — derselbe Song liegt also
+// zweimal auf der Platte. Statt den Nutzer zum Dateimanager zu schicken, werden beide Kopien
+// gegenuebergestellt und die ueberzaehlige zum Loeschen angeboten.
+const _mb = (n) => (n == null ? '?' : (n / 1048576).toFixed(1) + ' MB');
+const _sek = (n) => (n == null ? '?' : formatDur(n));
+
+function _zeigeDublette(status, dub) {
+ const box = document.createElement('div');
+ box.className = 'dublette-box';
+ const zeile = (titel, f) =>
+ `