feat: batch fix artist sort tags + rename folder when override is added

- edit-metadata: use albumArtistSort (when provided) for artist folder naming
  instead of auto-converted romajiAlbumArtist — ensures manual overrides sent
  by the download pipeline also place files in the correct folder
- Add POST /api/fix-artist-sort endpoint: searches Navidrome for all songs
  where artist or albumArtist matches the kanji, re-writes ARTISTSORT +
  ALBUMARTISTSORT via ffmpeg (-map_metadata 0 preserves all other existing
  tags), moves each file to the correct artist folder, cleans up empty old
  dirs, triggers Navidrome rescan — returns a job ID for progress tracking
- addRomajiOverrideUI: after saving a new override, calls fix-artist-sort and
  shows a job card with live progress

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-06-04 22:33:12 +02:00
1 parent 0a2736e893
commit d777301c20
2 files changed
+140 -2

No files matched your search

+125 -1
View File
@@ -602,6 +602,130 @@ app.post('/api/download', async (req, res) => {
});
});
// POST /api/fix-artist-sort — batch-update sort tags + rename artist folder for all songs by a kanji artist
app.post('/api/fix-artist-sort', async (req, res) => {
const { ndUrl, user, pass, artistKanji, artistSort, jobId: clientJobId } = req.body;
if (!ndUrl || !user || !pass || !artistKanji || !artistSort)
return res.status(400).json({ error: 'ndUrl, user, pass, artistKanji, artistSort required' });
const jobId = clientJobId || ('job_' + Date.now());
jobs.set(jobId, {
id: jobId, type: 'fix-sort',
artist: artistKanji, title: `Romaji-Fix: ${artistSort}`, album: '',
status: 'queued', progress: 0, message: 'Suche Songs…',
log: [], createdAt: Date.now(), updatedAt: Date.now(),
});
res.json({ jobId, status: 'queued' });
(async () => {
const mkTok = () => {
const s = Math.random().toString(36).slice(2, 10);
return { s, t: crypto.createHash('md5').update(pass + s).digest('hex') };
};
// Search Navidrome for all songs by this artist
let songs = [];
try {
const { s, t } = mkTok();
const r = await fetch(
`${ndUrl}/rest/search3?query=${encodeURIComponent(artistKanji)}&artistCount=0&albumCount=0&songCount=500&u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=novasonic&f=json`,
{ signal: AbortSignal.timeout(15000) }
);
const d = await r.json();
const all = d?.['subsonic-response']?.searchResult3?.song || [];
songs = all.filter(sng => sng.artist === artistKanji || sng.albumArtist === artistKanji);
} catch (e) {
updateJob(jobId, { status: 'error', message: `Navidrome-Suche fehlgeschlagen: ${e.message}` });
return;
}
if (!songs.length) {
updateJob(jobId, { status: 'done', progress: 100, message: 'Keine Songs gefunden' });
return;
}
log(jobId, `🔍 ${songs.length} Songs gefunden für "${artistKanji}"`);
updateJob(jobId, { status: 'running', progress: 10, message: `${songs.length} Songs gefunden…` });
let done = 0, errors = 0;
const safeArtistDir = sanitize(artistSort.split(/[,;&]/)[0].trim());
for (const ndSong of songs) {
try {
// Resolve file path
let filePath = ndSong.path;
if (!filePath) { errors++; continue; }
if (!path.isAbsolute(filePath)) filePath = path.join(CONFIG.musicPath, filePath);
if (!fs.existsSync(filePath)) {
const fileName = path.basename(filePath);
const titleStem = fileName.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '').toLowerCase();
const found = _rebasePath(filePath, CONFIG.musicPath)
|| _findFileRecursive(CONFIG.musicPath, fileName)
|| _findFileByStemOrRomaji(CONFIG.musicPath, titleStem);
if (found) filePath = found;
else { log(jobId, `⚠ Nicht gefunden: ${path.basename(ndSong.path || '')}`); errors++; continue; }
}
const fileExt = path.extname(filePath);
const tmpFile = filePath.slice(0, -fileExt.length) + '.sorttmp' + fileExt;
// -map_metadata 0 preserves all existing tags; individual -metadata flags override only sort ones
await new Promise((resolve, reject) => {
const ff = spawn('ffmpeg', [
'-i', filePath,
'-map', '0:a', '-map', '0:v?',
'-c', 'copy', '-map_metadata', '0',
'-metadata', `ARTISTSORT=${artistSort}`,
'-metadata', `ALBUMARTISTSORT=${artistSort}`,
tmpFile, '-y', '-loglevel', 'error',
]);
let ffErr = '';
ff.stderr.on('data', d => ffErr += d.toString());
ff.on('error', reject);
ff.on('close', code => code === 0 ? resolve() : reject(new Error(`ffmpeg exit ${code}: ${ffErr.slice(-200)}`)));
});
fs.renameSync(tmpFile, filePath);
// Move to correct artist/album folder: {musicPath}/{safeArtistDir}/{albumName}/
const oldAlbumDir = path.dirname(filePath);
const oldArtistDir = path.dirname(oldAlbumDir);
const albumName = path.basename(oldAlbumDir);
const newAlbumDir = path.join(CONFIG.musicPath, safeArtistDir, albumName);
if (newAlbumDir !== oldAlbumDir) {
fs.mkdirSync(newAlbumDir, { recursive: true });
const destFile = path.join(newAlbumDir, path.basename(filePath));
if (!fs.existsSync(destFile)) fs.renameSync(filePath, destFile);
// Clean up empty old dirs
try {
if (fs.readdirSync(oldAlbumDir).filter(f => !f.startsWith('.')).length === 0) {
fs.rmdirSync(oldAlbumDir);
if (fs.readdirSync(oldArtistDir).filter(f => !f.startsWith('.')).length === 0)
fs.rmdirSync(oldArtistDir);
}
} catch {}
}
done++;
updateJob(jobId, { progress: 10 + Math.round((done / songs.length) * 80), message: `${done}/${songs.length} Songs aktualisiert…` });
log(jobId, `✅ ${ndSong.title}`);
} catch (e) {
log(jobId, `❌ ${ndSong.title}: ${e.message}`);
errors++;
}
}
// Trigger Navidrome rescan
try {
const { s, t } = mkTok();
await fetch(`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=novasonic&f=json`, { signal: AbortSignal.timeout(5000) });
} catch {}
const msg = errors > 0 ? `${done} aktualisiert, ${errors} Fehler` : `${done} Songs aktualisiert`;
updateJob(jobId, { status: 'done', progress: 100, message: msg });
})().catch(e => updateJob(jobId, { status: 'error', message: e.message }));
});
app.get('/api/status/:jobId', (req, res) => {
const job = jobs.get(req.params.jobId);
if (!job) return res.status(404).json({ error: 'Job not found' });
@@ -978,7 +1102,7 @@ app.post('/api/edit-metadata', async (req, res) => {
const oldAlbumDir = path.dirname(currentFile);
const oldArtistDir = path.dirname(oldAlbumDir);
const newAlbumName = romajiAlbum ? sanitize(romajiAlbum) : path.basename(oldAlbumDir);
const newArtistName = romajiAlbumArtist ? sanitize(romajiAlbumArtist.split(/[,;&]/)[0].trim()) : path.basename(oldArtistDir);
const newArtistName = (albumArtistSort || romajiAlbumArtist) ? sanitize((albumArtistSort || romajiAlbumArtist).split(/[,;&]/)[0].trim()) : path.basename(oldArtistDir);
const newDir = path.join(CONFIG.musicPath, newArtistName, newAlbumName);
if (newDir !== oldAlbumDir) {