fix(backend): Schraegstrich im Namen und zu lockere Titelsuche fuehrten auf die falsche Datei

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-20 20:35:36 +02:00
1 parent eb72e5c27d
commit 24558497da
4 files changed
+193 -53

No files matched your search

+27
View File
@@ -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
+65
View File
@@ -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,
};
+44 -53
View File
@@ -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
+57
View File
@@ -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);
});