diff --git a/CHANGELOG.md b/CHANGELOG.md index 49518ba..2ec64a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -159,6 +159,33 @@ nur zu zeigen, was drinsteht. vier Anfragen gleichzeitig; bei einem Deezer-Fehler wird der abgelaufene Stand ausgeliefert statt einer leeren Liste. +### Fixed +- **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: + - **Der gemeldete Pfad kann es gar nicht geben.** Navidrome baut ihn aus den Tags + (`fakePath()`): `{AlbumArtist}/{Album}/{DD}-{TT} - {Titel}`. Steht ein Schrägstrich **im** + Namen, wird daraus eine zusätzliche Verzeichnisebene — „K/DA/ALL OUT/…" statt des Ordners + `K_DA`, den unser `sanitize()` beim Herunterladen angelegt hat. Dasselbe bei „POP/STARS" → + `POP_STARS`. Der Pfad zeigt also ins Leere, und die Suche fällt auf Ähnlichkeit zurück. + - **Die Ähnlichkeitssuche war zu großzügig.** Sie nahm jede Datei, deren Stamm auf den Titel + **endet** — und „bad omens - like a villain" endet nun einmal auf „villain". Getroffen wird + jetzt nur noch ein vollständiges Feld des Dateinamens: der ganze Stamm, oder der Teil hinter + einem „ - ". Die Endsilben-Regel ist ersatzlos weg. + - Neu davor: der Ordner wird **aus den Tags nachgebaut**, genau so wie der Downloader ihn + angelegt hätte (Schrägstrich → `_`, erster Künstler), und zuerst dort gesucht. Das ist gezielt + statt geraten und macht die Ähnlichkeitssuche für diesen Fall überflüssig. + - Dass der Fehler überhaupt sichtbar wurde, lag am Tag-Abgleich aus 2.3: er hat den Fehlgriff + erkannt und abgebrochen, statt die falsche Datei zu bearbeiten. +- **Das Löschen hatte diesen Abgleich nicht** — es nahm den Treffer der Ähnlichkeitssuche ohne + Gegenprobe und löschte. Bei „VILLAIN" hätte es die Datei von Bad Omens erwischt, unwiderruflich + und ohne Hinweis. Jetzt prüft auch `/api/delete-song` die Tags der gefundenen Datei gegen das, + was Navidrome für diesen Song meldet, und bricht im Zweifel mit 409 ab. Als letzten Suchschritt + bekommt es zusätzlich `findExistingTrack` — der Weg, der bei „K/DA" von sich aus richtig liegt, + weil er `/` und `_` beim Vergleich gleich behandelt. +- Die Suchhelfer liegen jetzt in [metadata.js](backend/lib/metadata.js) statt im Server, damit sie + geprüft werden können: sieben neue Fälle in `backend/test/metadata.test.js` (49 Prüfungen grün). + ### Changed - **Künstlerbilder laden endlich zügig (Web)** — im Künstlerraster lief **pro Künstler ein eigener `getArtistInfo2`-Aufruf**, nur um die Bildadresse zu erfahren. Bei 200 Künstlern sind das 200 diff --git a/backend/lib/metadata.js b/backend/lib/metadata.js index b52f9d0..8ab7f1a 100644 --- a/backend/lib/metadata.js +++ b/backend/lib/metadata.js @@ -118,6 +118,70 @@ function findExistingTrack(musicRoot, firstArtist, cleanTitle) { return null; } +const AUDIO_EXT_RE = /\.(mp3|flac|opus|m4a|ogg|wav)$/i; + +// Vereinheitlicht einen Dateinamen-Stamm fuer den lockeren Vergleich: +// 1. fuehrende Track-Praefixe wie "01-15 – " oder "01 - " abschneiden +// 2. alle Strichvarianten (en/em dash) auf " - " bringen +function normStem(s) { + return s + .replace(/^\d[\d\-–—]*\s*[-–—]\s*/, '') + .replace(/\s*[–—]\s*/g, ' - '); +} + +// Tiefensuche nach einer Audiodatei, deren Stamm zum Titel passt. Letzter Ausweg, wenn der von +// Navidrome gemeldete Pfad nicht existiert (Ordner umbenannt, Tag-Pfad statt echtem Pfad). +// +// WICHTIG: der Titel muss ein VOLLSTAENDIGES Feld des Dateinamens sein, nie nur dessen Ende. +// Ein blosses endsWith(titel) liess "Bad Omens - Like A Villain" als Treffer fuer den Titel +// "VILLAIN" durchgehen — ein voellig anderer Song derselben Endsilbe. Genau solche Fehlgriffe +// landen sonst im Metadaten-Editor oder, schlimmer, im Loeschen-Endpunkt. +function findFileByStem(dir, titleStem, depth = 0) { + if (depth > 4) return null; + const normTitle = normStem(titleStem); + try { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + const found = findFileByStem(path.join(dir, entry.name), titleStem, depth + 1); + if (found) return found; + } else if (AUDIO_EXT_RE.test(entry.name)) { + const bare = entry.name.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, ''); + const bareStem = normStem(bare.toLowerCase()); + // Treffer: exakt, "Kuenstler - Titel", oder Release-Suffix-Versatz ("… - Single"). + if (bareStem === normTitle || + bareStem.endsWith(' - ' + normTitle) || + bareStem.startsWith(normTitle + ' - ') || + normTitle.startsWith(bareStem + ' - ')) { + return path.join(dir, entry.name); + } + } + } + } catch {} + return null; +} + +// Navidrome gibt ueber die Subsonic-API standardmaessig keinen echten Pfad heraus, sondern baut +// einen aus den Tags: {AlbumArtist}/{Album}/{DD}-{TT} - {Titel}.{Endung}. Steht ein "/" IM Namen +// — "K/DA", "POP/STARS" —, wird daraus eine zusaetzliche Verzeichnisebene, waehrend auf der Platte +// sanitize() ein "_" geschrieben hat ("K_DA/POP_STARS"). Der gemeldete Pfad existiert dann nicht, +// und die Suche faellt auf Aehnlichkeit zurueck. Diese Funktion baut den Ordner stattdessen genau +// so zusammen, wie der Downloader ihn angelegt haette. +function albumDirFromTags(musicRoot, albumArtist, album) { + if (!musicRoot || !albumArtist) return null; + // Der Downloader legt den Ordner unter dem ERSTEN Kuenstler an (mainArtist.split(/[,;&]/)[0]). + const ersterKuenstler = String(albumArtist).split(/[,;&]/)[0].trim(); + const kandidaten = []; + for (const k of new Set([ersterKuenstler, String(albumArtist).trim()])) { + if (!k) continue; + if (album) kandidaten.push(path.join(musicRoot, sanitize(k), sanitize(album))); + kandidaten.push(path.join(musicRoot, sanitize(k))); + } + for (const c of kandidaten) { + try { if (fs.statSync(c).isDirectory()) return c; } catch {} + } + return null; +} + // Fetch plain + synced lyrics from LRCLIB async function fetchLyrics(artist, title, album) { try { @@ -140,4 +204,5 @@ async function fetchLyrics(artist, title, album) { module.exports = { sanitize, navidromeArtist, downloadArtwork, findExistingTrack, + normStem, findFileByStem, albumDirFromTags, }; diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index c95e4fe..5e3d003 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -18,7 +18,7 @@ const crypto = require('crypto'); const CONFIG = require('./lib/config'); const { jobs, updateJob, log } = require('./lib/jobs'); -const { sanitize, findExistingTrack } = require('./lib/metadata'); +const { sanitize, findExistingTrack, findFileByStem, albumDirFromTags } = require('./lib/metadata'); const dz = require('./lib/deezer'); const tm = require('./lib/trackmeta'); // Deezer → MusicBrainz → clean fallback (single tracks) const { downloadTrack, downloadAlbumTracks } = require('./lib/downloader'); @@ -94,57 +94,17 @@ function _findFileInDir(dir, basename) { return null; } -// Last-resort: deep recursive search by title stem (handles folder renames after metadata edit). -// Convention: MusicRoot/AlbumArtist/Album/{01 - }Artist - Title.ext -// The title stem is everything after the optional "NN - Artist - " prefix. -// We match any audio file whose stem ends with the same title words. -// Normalize a file stem for loose matching: -// 1. Strip compound track prefixes like "01-15 – " or "01 - " (handles en-dash, em-dash, hyphen) -// 2. Normalize all dash variants (en-dash, em-dash) to " - " -function _normStem(s) { - return s - .replace(/^\d[\d\-–—]*\s*[-–—]\s*/, '') // strip leading "01-15 – " or "01 - " - .replace(/\s*[–—]\s*/g, ' - '); // normalize remaining en/em-dash to " - " -} - -function _findFileByStem(dir, titleStem, depth = 0) { - if (depth > 4) return null; - const normTitle = _normStem(titleStem); - try { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - if (entry.isDirectory()) { - const found = _findFileByStem(path.join(dir, entry.name), titleStem, depth + 1); - if (found) return found; - } else if (AUDIO_EXT_RE.test(entry.name)) { - // Strip extension + leading track prefix, then normalize dashes - const bare = entry.name.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, ''); - const bareStem = _normStem(bare.toLowerCase()); - // Match: exact, or "Artist - Title" ending with our title stem - if (bareStem === normTitle || - bareStem.endsWith(' - ' + normTitle) || - bareStem.endsWith(normTitle) || - // Release-suffix desync: Navidrome's title-derived stem ("… - recognize") - // may lack the "- Single"/"- EP" suffix the on-disk filename still carries - // ("… - recognize - single"), or vice-versa. Match on the structured prefix. - bareStem.startsWith(normTitle + ' - ') || - normTitle.startsWith(bareStem + ' - ')) { - return path.join(dir, entry.name); - } - } - } - } catch {} - return null; -} - -// Like _findFileByStem but also tries a romaji-converted stem. +// Die Tiefensuche nach Titelstamm liegt jetzt in lib/metadata.js (dort getestet) — sie ist der +// letzte Ausweg, wenn Navidromes Pfad nicht existiert, und darf deshalb nicht raten. +// Wie findFileByStem, versucht zusaetzlich einen Romaji-Stamm. // Handles the case where Navidrome has a Japanese path in its DB but the file // on disk was stored with a romaji filename by the download pipeline. function _findFileByStemOrRomaji(dir, stem) { - const direct = _findFileByStem(dir, stem); + const direct = findFileByStem(dir, stem); if (direct) return direct; try { const rt = (romajiForStorage(stem) || '').toLowerCase().trim(); - if (rt && rt !== stem) return _findFileByStem(dir, rt); + if (rt && rt !== stem) return findFileByStem(dir, rt); } catch {} return null; } @@ -1273,13 +1233,14 @@ app.get('/api/musicbrainz/lookup', async (req, res) => { app.get('/api/song-sorttags', async (req, res) => { const { ndUrl, user, pass, songId } = req.query; if (!ndUrl || !user || !pass || !songId) return res.status(400).json({ error: 'ndUrl, user, pass, songId required' }); - let filePath; + let filePath, ndSong; try { const salt = Math.random().toString(36).slice(2, 10); const token = crypto.createHash('md5').update(pass + salt).digest('hex'); const ndRes = await fetch(`${ndUrl}/rest/getSong?id=${songId}&u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=NovaSonic-Backend&f=json`, { signal: AbortSignal.timeout(8000) }); const nd = await ndRes.json(); - filePath = nd?.['subsonic-response']?.song?.path; + ndSong = nd?.['subsonic-response']?.song; + filePath = ndSong?.path; if (!filePath) return res.status(404).json({ error: 'Song not found' }); } catch (e) { return res.status(502).json({ error: e.message }); } if (!path.isAbsolute(filePath)) filePath = path.join(CONFIG.musicPath, filePath); @@ -1288,9 +1249,11 @@ app.get('/api/song-sorttags', async (req, res) => { const rebased = _rebasePath(filePath, CONFIG.musicPath); const rebasedDir = rebased ? path.dirname(rebased) : null; const titleStem = fileName.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '').toLowerCase(); + const tagDir = albumDirFromTags(CONFIG.musicPath, ndSong?.albumArtist || ndSong?.artist, ndSong?.album); const found = rebased || _findFileRecursive(CONFIG.musicPath, fileName) || _findFileInDir(rebasedDir || path.dirname(filePath), fileName) + || (tagDir && (_findFileInDir(tagDir, fileName) || _findFileByStemOrRomaji(tagDir, titleStem))) || _findFileByStemOrRomaji(CONFIG.musicPath, titleStem); if (found) filePath = found; else return res.status(404).json({ error: 'File not found' }); } @@ -1436,9 +1399,14 @@ app.post('/api/edit-metadata', async (req, res) => { // but Navidrome's index is stale and still points to the old name. const originalDir = path.dirname(filePath); const titleStem = fileName.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '').toLowerCase(); + // Schraegstrich im Kuenstler-/Albumnamen ("K/DA", "POP/STARS"): Navidromes Tag-Pfad macht + // daraus eine Verzeichnisebene, auf der Platte steht "_". Den Ordner deshalb aus den Tags + // nachbauen und DORT suchen — das ist gezielt und geht jeder Aehnlichkeitssuche vor. + const tagDir = albumDirFromTags(CONFIG.musicPath, ndSong?.albumArtist || ndSong?.artist, ndSong?.album); const found = rebased || _findFileRecursive(CONFIG.musicPath, fileName) || _findFileInDir(rebasedDir || originalDir, fileName) + || (tagDir && (_findFileInDir(tagDir, fileName) || _findFileByStemOrRomaji(tagDir, titleStem))) || _findFileByStemOrRomaji(CONFIG.musicPath, titleStem) // Last resort: Subsonic getSong returns a *virtual* (tag-built) path, so the folder // can be "[Unknown Album]" while the file actually sits in "Singles" with an artist @@ -1649,7 +1617,7 @@ app.post('/api/delete-song', async (req, res) => { return res.status(400).json({ error: 'ndUrl, user, pass, songId required' }); // Resolve the file path from Navidrome - let filePath; + let filePath, ndSong; try { const salt = Math.random().toString(36).slice(2, 10); const token = crypto.createHash('md5').update(pass + salt).digest('hex'); @@ -1658,7 +1626,8 @@ app.post('/api/delete-song', async (req, res) => { { signal: AbortSignal.timeout(8000) } ); const ndData = await ndRes.json(); - filePath = ndData?.['subsonic-response']?.song?.path; + ndSong = ndData?.['subsonic-response']?.song; + filePath = ndSong?.path; if (!filePath) return res.status(404).json({ error: 'Song not found in Navidrome' }); } catch (e) { return res.status(502).json({ error: `Navidrome lookup failed: ${e.message}` }); @@ -1669,14 +1638,36 @@ app.post('/api/delete-song', async (req, res) => { if (!fs.existsSync(filePath)) { const fileName = path.basename(filePath); const titleStem = fileName.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '').toLowerCase(); + const tagDir = albumDirFromTags(CONFIG.musicPath, ndSong?.albumArtist || ndSong?.artist, ndSong?.album); const found = _rebasePath(filePath, CONFIG.musicPath) || _findFileRecursive(CONFIG.musicPath, fileName) || _findFileInDir(path.dirname(filePath), fileName) - || _findFileByStemOrRomaji(CONFIG.musicPath, titleStem); - if (found) filePath = found; - else return res.status(404).json({ + || (tagDir && (_findFileInDir(tagDir, fileName) || _findFileByStemOrRomaji(tagDir, titleStem))) + || _findFileByStemOrRomaji(CONFIG.musicPath, titleStem) + || findExistingTrack(CONFIG.musicPath, ndSong?.albumArtist || ndSong?.artist || '', ndSong?.title || ''); + if (!found) return res.status(404).json({ error: `File not found on disk. Navidrome path: "${ndPath}" — music root: "${CONFIG.musicPath}" — searched for: "${fileName}"`, }); + // Derselbe Abgleich wie im Metadaten-Editor, und hier WICHTIGER: der Treffer stammt aus einer + // Aehnlichkeitssuche, und geloescht wird unwiderruflich. Passen die Tags der gefundenen Datei + // nicht zu dem, was Navidrome fuer diesen Song meldet, wird abgebrochen statt geraten. + const delTags = await _readTags(found); + const istTitel = _tagNorm(delTags.title || delTags.TITLE); + const istArtist = _tagNorm(delTags.artist || delTags.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 "` + + `${delTags.artist || '?'} – ${delTags.title || '?'}" (${found}). ` + + `Es wird nichts geloescht — bitte die Dubletten aufraeumen.`, + }); + } + filePath = found; } // Safety: file must be inside the music root and must be an audio file diff --git a/backend/test/metadata.test.js b/backend/test/metadata.test.js index 6d67bdd..228e72f 100644 --- a/backend/test/metadata.test.js +++ b/backend/test/metadata.test.js @@ -113,3 +113,60 @@ test('die Zweitpruefung schliesst die Luecke, ohne Fremdes zu treffen', () => { // Remix bleibt auch ueber zwei Durchlaeufe hinweg eigenstaendig. assert.equal(pruefe('Arcane', 'Bea Miller', 'Playground (Remix)'), null); }); + +// ─── Dateisuche: Titelstamm und Schraegstrich im Namen ─────────────────────── +// Regression zu "K/DA – VILLAIN": Navidromes Tag-Pfad wurde zu "K/DA/ALL OUT/…", auf der Platte +// steht "K_DA/ALL OUT/…". Der Pfad fand nichts, und die Aehnlichkeitssuche lieferte +// "Bad Omens - Like A Villain", weil deren Stamm auf "villain" ENDET. +const { findFileByStem, albumDirFromTags } = require('../lib/metadata'); + +let SLASH_ROOT; +before(() => { + SLASH_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'ns-slash-')); + const put = (...segs) => { + const file = segs.pop(); + const dir = path.join(SLASH_ROOT, ...segs); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, file), ''); + }; + put('K_DA', 'ALL OUT', '03 - K_DA - VILLAIN.mp3'); + put('K_DA', 'POP_STARS', '01 - K_DA - POP_STARS.mp3'); + put('Bad Omens', 'THE DEATH OF PEACE OF MIND', '06 - Bad Omens - Like A Villain.mp3'); +}); +after(() => { try { fs.rmSync(SLASH_ROOT, { recursive: true, force: true }); } catch {} }); + +test('Titelstamm trifft nicht auf blosse Endsilbe', () => { + // "Like A Villain" darf NICHT als "VILLAIN" durchgehen + const t = findFileByStem(path.join(SLASH_ROOT, 'Bad Omens'), 'villain'); + assert.equal(t, null); +}); + +test('Titelstamm trifft "Kuenstler - Titel" weiterhin', () => { + const t = findFileByStem(path.join(SLASH_ROOT, 'Bad Omens'), 'like a villain'); + assert.ok(t && t.endsWith('06 - Bad Omens - Like A Villain.mp3')); +}); + +test('Ganze Bibliothek: VILLAIN findet K_DA, nicht Bad Omens', () => { + const t = findFileByStem(SLASH_ROOT, 'villain'); + assert.ok(t && t.includes(path.join('K_DA', 'ALL OUT')), `unerwartet: ${t}`); +}); + +test('Schraegstrich im Kuenstlernamen fuehrt zum richtigen Ordner', () => { + assert.equal(albumDirFromTags(SLASH_ROOT, 'K/DA', 'ALL OUT'), path.join(SLASH_ROOT, 'K_DA', 'ALL OUT')); + assert.equal(albumDirFromTags(SLASH_ROOT, 'K/DA', 'POP/STARS'), path.join(SLASH_ROOT, 'K_DA', 'POP_STARS')); +}); + +test('Unbekanntes Album faellt auf den Kuenstlerordner zurueck', () => { + assert.equal(albumDirFromTags(SLASH_ROOT, 'K/DA', 'Gibt Es Nicht'), path.join(SLASH_ROOT, 'K_DA')); +}); + +test('Mehrere Kuenstler: Ordner haengt am ersten', () => { + assert.equal( + albumDirFromTags(SLASH_ROOT, 'K/DA, Madison Beer, Kim Petras', 'ALL OUT'), + path.join(SLASH_ROOT, 'K_DA', 'ALL OUT'), + ); +}); + +test('Kein Treffer bleibt null', () => { + assert.equal(albumDirFromTags(SLASH_ROOT, 'Niemand', 'Nichts'), null); +});