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:
1 parent
0a2736e893
commit
d777301c20
2 files changed
+140
-2
No files matched your search
+125
-1
@@ -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) {
|
||||
|
||||
@@ -89,7 +89,7 @@ function _renderRomajiOverrides() {
|
||||
</div>`
|
||||
).join('');
|
||||
}
|
||||
export function addRomajiOverrideUI() {
|
||||
export async function addRomajiOverrideUI() {
|
||||
const k = (document.getElementById('ro-kanji')?.value || '').trim();
|
||||
const v = (document.getElementById('ro-romaji')?.value || '').trim();
|
||||
if (!k || !v) { toast('⚠ Kanji und Romaji eingeben'); return; }
|
||||
@@ -97,6 +97,20 @@ export function addRomajiOverrideUI() {
|
||||
document.getElementById('ro-kanji').value = '';
|
||||
document.getElementById('ro-romaji').value = '';
|
||||
_renderRomajiOverrides();
|
||||
// Batch-fix existing songs in the library
|
||||
if (!S.cfg.backendUrl || !S.cfg.url || !S.cfg.user || !S.cfg.pass) return;
|
||||
try {
|
||||
const jobId = 'job_' + Date.now();
|
||||
addJob(jobId, `Romaji-Fix: ${v}`, k, 'fix-sort');
|
||||
const res = await fetch(`${S.cfg.backendUrl}/api/fix-artist-sort`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ndUrl: S.cfg.url, user: S.cfg.user, pass: S.cfg.pass, artistKanji: k, artistSort: v, jobId }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
pollJob(jobId);
|
||||
} catch (e) {
|
||||
toast('⚠ Fix-Artist-Sort: ' + e.message);
|
||||
}
|
||||
}
|
||||
export function removeRomajiOverrideUI(kanji) {
|
||||
removeRomajiOverride(kanji);
|
||||
|
||||
Reference in new issue
Block a user