diff --git a/CHANGELOG.md b/CHANGELOG.md
index 42e1e62..c583afe 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
index 1a74ddb..8e1b925 100644
--- a/android/app/proguard-rules.pro
+++ b/android/app/proguard-rules.pro
@@ -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.**
diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js
index 1c65add..e4b27b3 100644
--- a/backend/novasonic-server.js
+++ b/backend/novasonic-server.js
@@ -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 {}
diff --git a/src/ui/metadata.js b/src/ui/metadata.js
index 097201c..09996e5 100644
--- a/src/ui/metadata.js
+++ b/src/ui/metadata.js
@@ -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('
⚠ ') +
+ '
Datei: ' + esc(d.file || '?') + '';
+ 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;