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;