diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ec64a5..3de9c9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -160,6 +160,27 @@ nur zu zeigen, was drinsteht. statt einer leeren Liste. ### 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** — beim Bearbeiten von „K/DA – VILLAIN" meldete das Backend, die gefundene Datei enthalte „Bad Omens – Like A Villain". Zwei Fehler trafen aufeinander: diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index 5e3d003..821fde3 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -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 diff --git a/src/ui/metadata.js b/src/ui/metadata.js index d13be6d..fdaa580 100644 --- a/src/ui/metadata.js +++ b/src/ui/metadata.js @@ -291,6 +291,8 @@ export async function saveMetadata() { if (d.warnings?.length) { status.innerHTML = '⚠ Gespeichert, aber: ' + d.warnings.map(esc).join('
⚠ ') + '
Datei: ' + esc(d.file || '?') + ''; + // 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'); 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) => + `
${esc(titel)}` + + `
${esc(f.pfad || '?')}
` + + `
${esc(f.artist || '?')} — ${esc(f.titel || '?')} · ` + + `${_sek(f.dauer)} · ${_mb(f.groesse)}
`; + box.innerHTML = + zeile('Bleibt liegen (richtiger Ordner)', dub.behalten) + + zeile('Überzählig (gerade bearbeitet)', dub.ueberzaehlig) + + (dub.gleich + ? '
Gleiche Länge und gleicher Titel — zwei Kopien desselben Songs.
' + : '
Länge oder Titel weichen ab. Das Backend lehnt das Löschen ab, ' + + 'solange das so ist — bitte erst selbst nachsehen.
'); + 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', + '
✅ Gelöscht. Navidrome scannt — danach steht der Song nur noch einmal da.
'); + toast('✅ Dublette gelöscht'); + } catch (e) { + btn.disabled = false; + btn.textContent = '🗑 Überzählige Kopie löschen'; + toast('❌ ' + e.message); + status.insertAdjacentHTML('beforeend', + '
' + esc(e.message) + '
'); + } +} + export async function deleteSong() { if (!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert'); return; } const s = S.infoSong; diff --git a/styles.css b/styles.css index 65b0887..3a6cc3c 100644 --- a/styles.css +++ b/styles.css @@ -1198,3 +1198,16 @@ button:focus{outline:none} .artist-news-art{width:46px;height:46px} .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}