From 4842c89c4e01413635587324c736c9366dfe724d Mon Sep 17 00:00:00 2001 From: Honey Date: Sat, 23 May 2026 22:43:51 +0200 Subject: [PATCH] feat: artist separator normalization + separator hints in metadata editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Normalize •, ·, ;, , separators on save (both edit and download paths) - Fix ndSong scope bug (was inaccessible outside try block in edit-metadata) - Apply _normalizeArtists to artist/albumArtist before ffmpeg tags and file rename - Storage format: Artist A;Artist B (no space, Navidrome/MusicBrainz style) - Display format: Artist A; Artist B (space, via _fmtArtists in frontend) - Show separator hint below label in metadata editor fields - Remove hover effect on player bar song title Co-Authored-By: Claude Sonnet 4.6 --- backend/lib/metadata.js | 2 +- backend/novasonic-server.js | 28 ++++++++++++++++++++-------- index.html | 4 ++-- src/ui.js | 23 +++++++++++++++-------- styles.css | 2 ++ 5 files changed, 40 insertions(+), 19 deletions(-) diff --git a/backend/lib/metadata.js b/backend/lib/metadata.js index 0a07a7d..76989c7 100644 --- a/backend/lib/metadata.js +++ b/backend/lib/metadata.js @@ -13,7 +13,7 @@ function navidromeArtist(name) { .replace(/\s+ft\.?\s+/gi, ';') .replace(/\s+featuring\s+/gi, ';') .replace(/\s+&\s+/g, ';') - .replace(/,\s+/g, ';'); + .replace(/,\s*/g, ';'); } // Download hi-res iTunes artwork (1000x1000) to cover.jpg, return path or null diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index a04e46e..13f3eca 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -240,6 +240,13 @@ app.post('/api/spotify/resolve', async (req, res) => { } }); +// Normalize user-supplied artist strings — accepts •, ·, ;, or , as separators +// and rejoins with the canonical sep ("; " for track artist, ", " for album artist). +function _normalizeArtists(str, sep) { + if (!str) return str; + return str.split(/\s*[•·;,]\s*/).map(s => s.trim()).filter(Boolean).join(sep); +} + // Strip " - Topic" suffix YouTube Music appends to artist channel names function _cleanYtArtist(name) { return (name || '').replace(/\s*-\s*Topic$/i, '').trim(); @@ -596,15 +603,20 @@ app.post('/api/edit-metadata', async (req, res) => { discNum, discTotal, genre, artworkBase64, artworkMime, artworkUrl: newArtworkUrl } = req.body; if (!ndUrl || !user || !pass || !songId) return res.status(400).json({ error: 'ndUrl, user, pass, songId required' }); + // Normalize artist separators — accept •, ·, ;, , and rejoin canonically + const normalArtist = _normalizeArtists(artist, ';'); + const normalAlbumArtist = _normalizeArtists(albumArtist, ', '); + // Resolve file path from Navidrome let filePath; + let ndSong; try { const salt = Math.random().toString(36).slice(2, 10); const token = crypto.createHash('md5').update(pass + salt).digest('hex'); const ndApi = `${ndUrl}/rest/getSong?id=${songId}&u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=novasonic&f=json`; const ndRes = await fetch(ndApi, { signal: AbortSignal.timeout(8000) }); const ndData = await ndRes.json(); - const ndSong = ndData?.['subsonic-response']?.song; + ndSong = ndData?.['subsonic-response']?.song; filePath = ndSong?.path; if (!filePath) return res.status(404).json({ error: 'Song not found in Navidrome' }); } catch (e) { @@ -659,10 +671,10 @@ app.post('/api/edit-metadata', async (req, res) => { ffArgs.push('-map', '0:v?'); // '?' = optional, no error if file has no cover } ffArgs.push('-c', 'copy', '-map_metadata', '-1', '-id3v2_version', '3'); - if (title) ffArgs.push('-metadata', `title=${title}`); - if (artist) ffArgs.push('-metadata', `artist=${artist}`); - const resolvedAlbumArtist = albumArtist || (artist ? artist.split(';')[0].trim() : null); - if (resolvedAlbumArtist) ffArgs.push('-metadata', `album_artist=${resolvedAlbumArtist}`); + if (title) ffArgs.push('-metadata', `title=${title}`); + if (normalArtist) ffArgs.push('-metadata', `artist=${normalArtist}`); + const resolvedAlbumArtist = normalAlbumArtist || (normalArtist ? normalArtist.split(';')[0].trim() : null); + if (resolvedAlbumArtist) ffArgs.push('-metadata', `album_artist=${resolvedAlbumArtist}`); if (album) ffArgs.push('-metadata', `album=${album}`); if (year) ffArgs.push('-metadata', `date=${year}`); if (trackNum) ffArgs.push('-metadata', `track=${totalTracks ? `${trackNum}/${totalTracks}` : String(trackNum)}`); @@ -691,7 +703,7 @@ app.post('/api/edit-metadata', async (req, res) => { // 1. Rename file if title changed — convention: {01 - }?{Artist} - {Title}.ext if (title) { const safeTitle = sanitize(title); - const fileArtist = (artist || ndSong?.artist || '').replace(/;/g, ','); + const fileArtist = (normalArtist || ndSong?.artist || '').replace(/;/g, ','); const safeArtistPart = fileArtist ? sanitize(fileArtist) + ' - ' : ''; const prefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : ''; const newName = `${prefix}${safeArtistPart}${safeTitle}${fileExt}`; @@ -705,8 +717,8 @@ app.post('/api/edit-metadata', async (req, res) => { // 2. Move to new artist/album folder when albumArtist or album changed const oldAlbumDir = path.dirname(currentFile); const oldArtistDir = path.dirname(oldAlbumDir); - const newAlbumName = album ? sanitize(album) : path.basename(oldAlbumDir); - const newArtistName = albumArtist ? sanitize(albumArtist.split(/[,;]/)[0].trim()) : path.basename(oldArtistDir); + const newAlbumName = album ? sanitize(album) : path.basename(oldAlbumDir); + const newArtistName = normalAlbumArtist ? sanitize(normalAlbumArtist.split(/[,;]/)[0].trim()) : path.basename(oldArtistDir); const newDir = path.join(CONFIG.musicPath, newArtistName, newAlbumName); if (newDir !== oldAlbumDir) { diff --git a/index.html b/index.html index 1e0bf7e..77cf4ce 100644 --- a/index.html +++ b/index.html @@ -928,8 +928,8 @@
- - + +
diff --git a/src/ui.js b/src/ui.js index 4fefb00..b7499d7 100644 --- a/src/ui.js +++ b/src/ui.js @@ -235,6 +235,13 @@ document.addEventListener('click', e => { }); // ─── SONG INFO MODAL ─── +// Normalize various artist separators (•, ;, ,) for display in the edit form. +// Track artist → "Serph; Zun" | Album artist → "Serph, Zun" +function _fmtArtists(str, sep) { + if (!str) return ''; + return str.split(/\s*[•·;,]\s*/).map(s => s.trim()).filter(Boolean).join(sep); +} + function _renderInfoModal(s) { const cv = coverUrl(s.coverArt, 200); document.getElementById('info-cover').innerHTML = cv @@ -278,10 +285,10 @@ export async function showSongInfo(s) { // If edit mode was already opened before getSong returned, refresh the fields // so the form shows the authoritative data (albumArtist, etc.) from Navidrome if (document.getElementById('info-edit-mode').style.display !== 'none') { - document.getElementById('ie-title').value = full.title || ''; - document.getElementById('ie-artist').value = full.artist || ''; - document.getElementById('ie-albumartist').value = full.albumArtist || ''; - document.getElementById('ie-album').value = full.album || ''; + document.getElementById('ie-title').value = full.title || ''; + document.getElementById('ie-artist').value = _fmtArtists(full.artist, '; ') || ''; + document.getElementById('ie-albumartist').value = _fmtArtists(full.albumArtist, ', ') || ''; + document.getElementById('ie-album').value = full.album || ''; document.getElementById('ie-genre').value = full.genre || ''; document.getElementById('ie-year').value = full.year || ''; document.getElementById('ie-track').value = full.track || ''; @@ -319,10 +326,10 @@ export function toggleInfoEdit() { btn.classList.add('active'); // Populate fields from current song - document.getElementById('ie-title').value = s.title || ''; - document.getElementById('ie-artist').value = s.artist || ''; - document.getElementById('ie-albumartist').value = s.albumArtist || ''; - document.getElementById('ie-album').value = s.album || ''; + document.getElementById('ie-title').value = s.title || ''; + document.getElementById('ie-artist').value = _fmtArtists(s.artist, '; ') || ''; + document.getElementById('ie-albumartist').value = _fmtArtists(s.albumArtist, ', ') || ''; + document.getElementById('ie-album').value = s.album || ''; document.getElementById('ie-genre').value = s.genre || ''; document.getElementById('ie-year').value = s.year || ''; document.getElementById('ie-track').value = s.track || ''; diff --git a/styles.css b/styles.css index 0e08ba4..38c20fd 100644 --- a/styles.css +++ b/styles.css @@ -485,6 +485,8 @@ button:focus{outline:none} .info-edit-art-btn:hover{color:var(--text)} .info-edit-fields{display:flex;flex-direction:column;gap:8px;padding:0 20px 4px} .info-edit-label{display:flex;flex-direction:column;font-size:11px;color:var(--text3);gap:3px;font-weight:500} +.info-edit-hint{font-size:10px;color:var(--text3);font-weight:400;opacity:.7;margin-top:-1px} +.info-edit-hint code{background:var(--surface3,var(--surface2));border-radius:3px;padding:0 3px;font-size:10px;font-family:monospace;opacity:.9} .info-edit-input{background:var(--surface2);border:1px solid var(--border2);border-radius:7px;color:var(--text);font-family:var(--font);font-size:13px;padding:7px 10px;outline:none;width:100%;box-sizing:border-box} .info-edit-input:focus{border-color:var(--accent)}