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

+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