From d777301c2096094b4c354a9b5a60d82f6bfd6cf7 Mon Sep 17 00:00:00 2001 From: Honey Date: Thu, 4 Jun 2026 22:33:12 +0200 Subject: [PATCH] feat: batch fix artist sort tags + rename folder when override is added MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/novasonic-server.js | 126 +++++++++++++++++++++++++++++++++++- src/ui.js | 16 ++++- 2 files changed, 140 insertions(+), 2 deletions(-) diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index 1ea9705..aaee6ed 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -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) { diff --git a/src/ui.js b/src/ui.js index 2d6bb16..afbaaf1 100644 --- a/src/ui.js +++ b/src/ui.js @@ -89,7 +89,7 @@ function _renderRomajiOverrides() { ` ).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);