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:
1 parent
24558497da
commit
8d25bf0404
4 files changed
+238
-4
No files matched your search
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
Reference in new issue
Block a user