fix(backend): Metadaten-Edit trifft nicht mehr die falsche Datei

Findet das Backend die von Navidrome gemeldete Datei nicht am angegebenen Pfad
(Subsonic liefert oft einen aus Tags gebauten virtuellen Pfad), sucht es ueber
Dateiname bzw. Kuenstler+Titel weiter. Liegt derselbe Song mehrfach auf der
Platte, konnte das die falsche Kopie treffen: die wurde getaggt, der bearbeitete
Song blieb unveraendert — die Aenderung "sprang zurueck". Das erklaert, warum es
bei Songs ohne Dublette immer ging und bei anderen nie.

Der ueber Aehnlichkeit gefundene Kandidat wird jetzt per ffprobe gegen die Tags
geprueft, die Navidrome fuer diesen Song meldet, und bei Widerspruch mit 409 und
erklaerender Meldung abgebrochen statt geraten. Zusaetzlich melden uebersprungene
Umbenennungen und Verschiebungen sich als Warnung, statt stumm zu bleiben; die
Antwort nennt den tatsaechlich bearbeiteten Pfad, der Editor zeigt beides an.

Ausserdem: -dontwarn org.slf4j.** — der Release-APK-Build brach seit laengerem an
R8 ab (Ktors Logging referenziert den slf4j-1.x-Binder, den es auf Android nicht
gibt). Vorbestehend, mit HEAD~1 verifiziert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-04 23:46:44 +02:00
1 parent aae358c4c7
commit f62b248bb6
4 files changed
+86 -8

No files matched your search

+12
View File
@@ -108,6 +108,18 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
`scrollHeight` kleiner als das Ziel, und der Browser kappte den Wert stillschweigend: man landete
wieder oben. Der Wert wird jetzt über bis zu ~0,5 s erneut gesetzt, bis er sitzt.
- **Metadaten-Änderungen „sprangen zurück" — bei manchen Songs immer, bei anderen nie.** Findet das
Backend die von Navidrome gemeldete Datei nicht am angegebenen Pfad (Navidrome liefert oft einen
aus Tags gebauten *virtuellen* Pfad), sucht es über Dateiname bzw. Künstler+Titel weiter. Liegt
derselbe Song mehrfach auf der Platte — etwa ein Fehlgriff des Downloaders neben der richtigen
Datei —, konnte das die **falsche Kopie** treffen: die wurde getaggt, der bearbeitete Song blieb
unverändert. Genau deshalb ging es bei Songs ohne Dublette immer und bei anderen nie. Der über
Ähnlichkeit gefundene Kandidat wird jetzt gegen die Tags geprüft, die Navidrome für den Song
meldet, und bei Widerspruch mit einer erklärenden Meldung abgebrochen, statt zu raten.
- **Stumm übersprungene Umbenennungen** — lag am Zielnamen schon eine Datei, blieb der Song unter
altem Namen bzw. im alten Ordner liegen, ohne Hinweis. Solche Fälle kommen jetzt als Warnung
zurück; der Editor zeigt sie samt bearbeitetem Dateipfad an und bleibt offen.
### Added
- **Android: Songs von der Festplatte löschen** — im Song-Kontextmenü, unten abgesetzt und rot, mit
derselben Sicherheitsabfrage wie im Web. Nutzt denselben Backend-Endpunkt (`/api/delete-song`) und
+6
View File
@@ -6,3 +6,9 @@
# Keep Media3 session / exoplayer classes used by reflection / service binding.
-keep class androidx.media3.** { *; }
-keep class com.google.android.exoplayer2.** { *; }
# Ktors Logging referenziert ueber slf4j den Binder aus slf4j 1.x. Auf Android existiert der nicht
# (slf4j 2.x sucht Implementierungen per ServiceLoader, und wir binden ohnehin keine), deshalb bricht
# R8 den Release-Build mit "Missing class org.slf4j.impl.StaticLoggerBinder" ab. Die Referenz ist zur
# Laufzeit unerreichbar — slf4j faengt das Fehlen selbst ab und schaltet auf einen No-op-Logger.
-dontwarn org.slf4j.**
+58 -6
View File
@@ -1342,6 +1342,19 @@ async function _migratePlaylists(ndUrl, user, pass, plIds, oldId, newId) {
}
// ─── METADATA EDIT ───────────────────────────────────────────────────────────
// Liest die vorhandenen Tags einer Datei. Wird gebraucht, um einen ueber Namensaehnlichkeit
// gefundenen Kandidaten zu PRUEFEN, bevor er ueberschrieben wird.
async function _readTags(file) {
return new Promise((resolve) => {
const proc = spawn('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_entries', 'format_tags', file]);
let buf = '';
proc.stdout.on('data', d => buf += d);
proc.on('error', () => resolve({}));
proc.on('close', () => { try { resolve(JSON.parse(buf)?.format?.tags || {}); } catch { resolve({}); } });
});
}
const _tagNorm = (v) => (v || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
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,
@@ -1401,8 +1414,32 @@ app.post('/api/edit-metadata', async (req, res) => {
// can be "[Unknown Album]" while the file actually sits in "Singles" with an artist
// prefix. findExistingTrack matches by normalized artist+title across all album folders.
|| findExistingTrack(CONFIG.musicPath, ndSong.albumArtist || ndSong.artist || '', ndSong.title || '');
if (found) { filePath = found; }
else return res.status(404).json({ error: `File not found: ${filePath} (music root: ${CONFIG.musicPath})` });
if (!found) {
return res.status(404).json({ error: `File not found: ${filePath} (music root: ${CONFIG.musicPath})` });
}
// Der Treffer stammt aus einer Aehnlichkeitssuche ueber Dateiname bzw. Kuenstler+Titel. Liegt
// derselbe Song mehrfach auf der Platte — etwa ein Fehlgriff des Downloaders neben der richtigen
// Datei —, kann das die FALSCHE Kopie sein. Die wurde dann getaggt, waehrend der bearbeitete Song
// unveraendert blieb: die Aenderung "sprang zurueck". Deshalb wird der Kandidat gegen die Tags
// geprueft, die Navidrome fuer diesen Song meldet, und im Zweifel abgebrochen statt geraten.
const kandidatTags = await _readTags(found);
const istTitel = _tagNorm(kandidatTags.title || kandidatTags.TITLE);
const istArtist = _tagNorm(kandidatTags.artist || kandidatTags.ARTIST);
const sollTitel = _tagNorm(ndSong?.title);
const sollArtist = _tagNorm(ndSong?.artist);
const titelPasst = !sollTitel || !istTitel || istTitel === sollTitel;
const artistPasst = !sollArtist || !istArtist ||
istArtist.includes(sollArtist) || sollArtist.includes(istArtist);
if (!titelPasst || !artistPasst) {
return res.status(409).json({
error: `Datei nicht eindeutig zuzuordnen. Navidrome meldet "${ndSong?.artist || '?'} – ` +
`${ndSong?.title || '?'}", die gefundene Datei enthaelt aber "` +
`${kandidatTags.artist || '?'} – ${kandidatTags.title || '?'}" (${found}). ` +
`Vermutlich liegt derselbe Song mehrfach in der Bibliothek — bitte die Dubletten ` +
`aufraeumen, sonst wuerde die falsche Datei bearbeitet.`,
});
}
filePath = found;
}
// Resolve artwork: base64 > URL
@@ -1481,6 +1518,10 @@ app.post('/api/edit-metadata', async (req, res) => {
// ── Post-save: rename file and/or move to correct artist/album folder ──
let currentFile = filePath;
// Umbenennen und Verschieben werden uebersprungen, wenn am Ziel schon eine Datei liegt. Das war
// 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 = [];
// 1. Rename file if title changed — convention: {01 - }?{Artist} - {Title}.ext
// Use original names (may be Japanese); romaji goes only into sort tags.
@@ -1491,9 +1532,15 @@ app.post('/api/edit-metadata', async (req, res) => {
const prefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : '';
const newName = `${prefix}${safeArtistPart}${safeTitle}${fileExt}`;
const newPath = path.join(path.dirname(currentFile), newName);
if (newPath !== currentFile && !fs.existsSync(newPath)) {
if (newPath !== currentFile && fs.existsSync(newPath)) {
warnungen.push(`Datei nicht umbenannt: "${path.basename(newPath)}" existiert bereits im ` +
`selben Ordner. Die Tags wurden geschrieben, der Dateiname bleibt alt.`);
} else if (newPath !== currentFile) {
try { fs.renameSync(currentFile, newPath); currentFile = newPath; }
catch (e) { console.error('[edit-metadata] file rename:', e.message); }
catch (e) {
console.error('[edit-metadata] file rename:', e.message);
warnungen.push(`Umbenennen fehlgeschlagen: ${e.message}`);
}
}
}
@@ -1508,7 +1555,10 @@ app.post('/api/edit-metadata', async (req, res) => {
try {
fs.mkdirSync(newDir, { recursive: true });
const destFile = path.join(newDir, path.basename(currentFile));
if (!fs.existsSync(destFile)) {
if (fs.existsSync(destFile)) {
warnungen.push(`Nicht verschoben: "${destFile}" existiert bereits. Die Datei bleibt in ` +
`"${oldAlbumDir}" — vermutlich eine Dublette.`);
} else {
fs.renameSync(currentFile, destFile);
currentFile = destFile;
}
@@ -1548,7 +1598,9 @@ app.post('/api/edit-metadata', async (req, res) => {
}
}
res.json({ ok: true });
// 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 });
} catch (e) {
try { if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); } catch {}
if (artFile) try { fs.unlinkSync(artFile); } catch {}
+10 -2
View File
@@ -281,10 +281,18 @@ export async function saveMetadata() {
});
const d = await r.json();
if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`);
status.textContent = '✅ Gespeichert! Navidrome scannt…';
toast('✅ Metadaten gespeichert');
invalidateRomajiIndex();
invalidateApiCache('getArtists');
// Warnungen des Backends (Dublette am Zielnamen, nicht verschoben, …) sichtbar machen und das
// Fenster offen lassen — sonst sieht es nach "gespeichert" aus, obwohl die Datei liegen blieb.
if (d.warnings?.length) {
status.innerHTML = '⚠ Gespeichert, aber: ' + d.warnings.map(esc).join('<br>⚠ ') +
'<br><span style="color:var(--text3)">Datei: ' + esc(d.file || '?') + '</span>';
toast('⚠ Gespeichert — mit Hinweisen, siehe Fenster');
return;
}
status.textContent = '✅ Gespeichert! Navidrome scannt…';
toast('✅ Metadaten gespeichert');
setTimeout(() => { _leaveEditMode(); closeInfoModal(); }, 1500);
} catch (e) {
status.textContent = '❌ Fehler: ' + e.message;