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:
1 parent
eb72e5c27d
commit
24558497da
4 files changed
+193
-53
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user