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
+239
-5
No files matched your search
+138
-5
@@ -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
|
||||
|
||||
Reference in new issue
Block a user