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
+238 -4

No files matched your search

+21
View File
@@ -160,6 +160,27 @@ nur zu zeigen, was drinsteht.
statt einer leeren Liste. statt einer leeren Liste.
### Fixed ### 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** — - **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 beim Bearbeiten von „K/DA – VILLAIN" meldete das Backend, die gefundene Datei enthalte
„Bad Omens – Like A Villain". Zwei Fehler trafen aufeinander: „Bad Omens – Like A Villain". Zwei Fehler trafen aufeinander:
+137 -4
View File
@@ -1348,6 +1348,30 @@ async function _readTags(file) {
} }
const _tagNorm = (v) => (v || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ''); 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) => { app.post('/api/edit-metadata', async (req, res) => {
const { ndUrl, user, pass, songId, title, artist, albumArtist, album, year, trackNum, totalTracks, const { ndUrl, user, pass, songId, title, artist, albumArtist, album, year, trackNum, totalTracks,
discNum, discTotal, genre, artworkBase64, artworkMime, artworkUrl: newArtworkUrl, 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 // 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. // es jemand erfuhr. Solche Faelle kommen jetzt als Warnung mit in die Antwort.
const warnungen = []; 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 // 1. Rename file if title changed — convention: {01 - }?{Artist} - {Title}.ext
// Use original names (may be Japanese); romaji goes only into sort tags. // 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 }); fs.mkdirSync(newDir, { recursive: true });
const destFile = path.join(newDir, path.basename(currentFile)); const destFile = path.join(newDir, path.basename(currentFile));
if (fs.existsSync(destFile)) { 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 ` + warnungen.push(`Nicht verschoben: "${destFile}" existiert bereits. Die Datei bleibt in ` +
`"${oldAlbumDir}" — vermutlich eine Dublette.`); `"${oldAlbumDir}" — dieselbe Aufnahme liegt doppelt in der Bibliothek.`);
} else { } else {
fs.renameSync(currentFile, destFile); fs.renameSync(currentFile, destFile);
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) }); 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 {} } catch {}
// Restore playlist membership after rename/move — the rescan gives the renamed song a new ID // Umbenennen und Verschieben geben dem Song beim naechsten Scan eine NEUE Navidrome-ID. Alles,
if (affectedPlaylists.length > 0) { // 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 effectiveTitle = title || ndSong?.title || '';
const effectiveArtist = (artist || ndSong?.artist || '').split(';')[0].trim(); const effectiveArtist = (artist || ndSong?.artist || '').split(';')[0].trim();
const newSong = await _findSongAfterRename(ndUrl, user, pass, effectiveTitle, effectiveArtist, songId); const newSong = await _findSongAfterRename(ndUrl, user, pass, effectiveTitle, effectiveArtist, songId);
if (newSong && newSong.id !== songId) { if (newSong && newSong.id !== songId) {
if (affectedPlaylists.length > 0) {
await _migratePlaylists(ndUrl, user, pass, affectedPlaylists, songId, newSong.id); 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 // Den tatsaechlich bearbeiteten Pfad mitgeben: wer eine Aenderung "zurueckspringen" sieht, kann
// damit sofort erkennen, ob die richtige Datei erwischt wurde. // 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) { } catch (e) {
try { if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); } catch {} try { if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); } catch {}
if (artFile) try { fs.unlinkSync(artFile); } catch {} if (artFile) try { fs.unlinkSync(artFile); } catch {}
@@ -1717,6 +1778,78 @@ app.post('/api/delete-song', async (req, res) => {
res.json({ ok: true }); 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 ─────────────────────────────────────────────────────────────── // ─── 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
+67
View File
@@ -291,6 +291,8 @@ export async function saveMetadata() {
if (d.warnings?.length) { if (d.warnings?.length) {
status.innerHTML = '⚠ Gespeichert, aber: ' + d.warnings.map(esc).join('<br>⚠ ') + status.innerHTML = '⚠ Gespeichert, aber: ' + d.warnings.map(esc).join('<br>⚠ ') +
'<br><span style="color:var(--text3)">Datei: ' + esc(d.file || '?') + '</span>'; '<br><span style="color:var(--text3)">Datei: ' + esc(d.file || '?') + '</span>';
// 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'); toast('⚠ Gespeichert — mit Hinweisen, siehe Fenster');
return; 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) =>
`<div class="dublette-datei"><b>${esc(titel)}</b>` +
`<div class="dublette-pfad">${esc(f.pfad || '?')}</div>` +
`<div class="dublette-meta">${esc(f.artist || '?')} — ${esc(f.titel || '?')} · ` +
`${_sek(f.dauer)} · ${_mb(f.groesse)}</div></div>`;
box.innerHTML =
zeile('Bleibt liegen (richtiger Ordner)', dub.behalten) +
zeile('Überzählig (gerade bearbeitet)', dub.ueberzaehlig) +
(dub.gleich
? '<div class="dublette-urteil ok">Gleiche Länge und gleicher Titel — zwei Kopien desselben Songs.</div>'
: '<div class="dublette-urteil warn">Länge oder Titel weichen ab. Das Backend lehnt das Löschen ab, ' +
'solange das so ist — bitte erst selbst nachsehen.</div>');
const btn = document.createElement('button');
btn.className = 'btn-danger';
btn.textContent = '🗑 Überzählige Kopie löschen';
btn.onclick = () => _loescheDublette(btn, status, dub);
box.appendChild(btn);
status.appendChild(box);
}
async function _loescheDublette(btn, status, dub) {
const ok = await showConfirm(
'Dublette löschen',
`"${dub.ueberzaehlig.pfad}" wird unwiderruflich von der Festplatte gelöscht. ` +
`Die Kopie im richtigen Ordner bleibt bestehen.`,
'Löschen'
);
if (!ok) return;
btn.disabled = true;
btn.textContent = '🗑 Löschen…';
try {
const r = await fetch(`${S.cfg.backendUrl}/api/resolve-duplicate`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ndUrl: S.cfg.url, user: S.cfg.user, pass: S.cfg.pass,
behalten: dub.behalten.pfad, ueberzaehlig: dub.ueberzaehlig.pfad,
}),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`);
invalidateApiCache('getArtists');
invalidateRomajiIndex();
btn.remove();
status.insertAdjacentHTML('beforeend',
'<div class="dublette-urteil ok">✅ Gelöscht. Navidrome scannt — danach steht der Song nur noch einmal da.</div>');
toast('✅ Dublette gelöscht');
} catch (e) {
btn.disabled = false;
btn.textContent = '🗑 Überzählige Kopie löschen';
toast('❌ ' + e.message);
status.insertAdjacentHTML('beforeend',
'<div class="dublette-urteil warn">' + esc(e.message) + '</div>');
}
}
export async function deleteSong() { export async function deleteSong() {
if (!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert'); return; } if (!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert'); return; }
const s = S.infoSong; const s = S.infoSong;
+13
View File
@@ -1198,3 +1198,16 @@ button:focus{outline:none}
.artist-news-art{width:46px;height:46px} .artist-news-art{width:46px;height:46px}
.artist-news-row{gap:10px;padding:8px 6px} .artist-news-row{gap:10px;padding:8px 6px}
} }
/* Dublettenauflösung im Metadaten-Editor: zwei Dateien gegenübergestellt, darunter der Löschknopf */
.dublette-box{margin-top:12px;padding:12px;background:var(--bg2);border:1px solid var(--border2);border-radius:var(--radius-sm);text-align:left}
.dublette-datei{margin-bottom:10px;font-size:12px}
.dublette-datei b{display:block;margin-bottom:2px;color:var(--text2);font-size:11px;text-transform:uppercase;letter-spacing:.04em}
.dublette-pfad{color:var(--text3);word-break:break-all;line-height:1.35}
.dublette-meta{color:var(--text2);margin-top:2px}
.dublette-urteil{margin:8px 0;font-size:12px;line-height:1.4}
.dublette-urteil.ok{color:var(--text2)}
.dublette-urteil.warn{color:var(--danger)}
.dublette-box .btn-danger{width:100%;padding:9px 14px;background:transparent;border:1px solid var(--danger);border-radius:var(--radius-sm);color:var(--danger);font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;transition:background .2s}
.dublette-box .btn-danger:hover:not(:disabled){background:rgba(224,85,85,.12)}
.dublette-box .btn-danger:disabled{opacity:.55;cursor:default}