feat: artist separator normalization + separator hints in metadata editor
- 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
7ba5b8ce9a
commit
4842c89c4e
5 files changed
+33
-12
No files matched your search
@@ -13,7 +13,7 @@ function navidromeArtist(name) {
|
|||||||
.replace(/\s+ft\.?\s+/gi, ';')
|
.replace(/\s+ft\.?\s+/gi, ';')
|
||||||
.replace(/\s+featuring\s+/gi, ';')
|
.replace(/\s+featuring\s+/gi, ';')
|
||||||
.replace(/\s+&\s+/g, ';')
|
.replace(/\s+&\s+/g, ';')
|
||||||
.replace(/,\s+/g, ';');
|
.replace(/,\s*/g, ';');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download hi-res iTunes artwork (1000x1000) to cover.jpg, return path or null
|
// Download hi-res iTunes artwork (1000x1000) to cover.jpg, return path or null
|
||||||
|
|||||||
@@ -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
|
// Strip " - Topic" suffix YouTube Music appends to artist channel names
|
||||||
function _cleanYtArtist(name) {
|
function _cleanYtArtist(name) {
|
||||||
return (name || '').replace(/\s*-\s*Topic$/i, '').trim();
|
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;
|
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' });
|
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
|
// Resolve file path from Navidrome
|
||||||
let filePath;
|
let filePath;
|
||||||
|
let ndSong;
|
||||||
try {
|
try {
|
||||||
const salt = Math.random().toString(36).slice(2, 10);
|
const salt = Math.random().toString(36).slice(2, 10);
|
||||||
const token = crypto.createHash('md5').update(pass + salt).digest('hex');
|
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 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 ndRes = await fetch(ndApi, { signal: AbortSignal.timeout(8000) });
|
||||||
const ndData = await ndRes.json();
|
const ndData = await ndRes.json();
|
||||||
const ndSong = ndData?.['subsonic-response']?.song;
|
ndSong = ndData?.['subsonic-response']?.song;
|
||||||
filePath = ndSong?.path;
|
filePath = ndSong?.path;
|
||||||
if (!filePath) return res.status(404).json({ error: 'Song not found in Navidrome' });
|
if (!filePath) return res.status(404).json({ error: 'Song not found in Navidrome' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -660,8 +672,8 @@ app.post('/api/edit-metadata', async (req, res) => {
|
|||||||
}
|
}
|
||||||
ffArgs.push('-c', 'copy', '-map_metadata', '-1', '-id3v2_version', '3');
|
ffArgs.push('-c', 'copy', '-map_metadata', '-1', '-id3v2_version', '3');
|
||||||
if (title) ffArgs.push('-metadata', `title=${title}`);
|
if (title) ffArgs.push('-metadata', `title=${title}`);
|
||||||
if (artist) ffArgs.push('-metadata', `artist=${artist}`);
|
if (normalArtist) ffArgs.push('-metadata', `artist=${normalArtist}`);
|
||||||
const resolvedAlbumArtist = albumArtist || (artist ? artist.split(';')[0].trim() : null);
|
const resolvedAlbumArtist = normalAlbumArtist || (normalArtist ? normalArtist.split(';')[0].trim() : null);
|
||||||
if (resolvedAlbumArtist) ffArgs.push('-metadata', `album_artist=${resolvedAlbumArtist}`);
|
if (resolvedAlbumArtist) ffArgs.push('-metadata', `album_artist=${resolvedAlbumArtist}`);
|
||||||
if (album) ffArgs.push('-metadata', `album=${album}`);
|
if (album) ffArgs.push('-metadata', `album=${album}`);
|
||||||
if (year) ffArgs.push('-metadata', `date=${year}`);
|
if (year) ffArgs.push('-metadata', `date=${year}`);
|
||||||
@@ -691,7 +703,7 @@ app.post('/api/edit-metadata', async (req, res) => {
|
|||||||
// 1. Rename file if title changed — convention: {01 - }?{Artist} - {Title}.ext
|
// 1. Rename file if title changed — convention: {01 - }?{Artist} - {Title}.ext
|
||||||
if (title) {
|
if (title) {
|
||||||
const safeTitle = sanitize(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 safeArtistPart = fileArtist ? sanitize(fileArtist) + ' - ' : '';
|
||||||
const prefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : '';
|
const prefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : '';
|
||||||
const newName = `${prefix}${safeArtistPart}${safeTitle}${fileExt}`;
|
const newName = `${prefix}${safeArtistPart}${safeTitle}${fileExt}`;
|
||||||
@@ -706,7 +718,7 @@ app.post('/api/edit-metadata', async (req, res) => {
|
|||||||
const oldAlbumDir = path.dirname(currentFile);
|
const oldAlbumDir = path.dirname(currentFile);
|
||||||
const oldArtistDir = path.dirname(oldAlbumDir);
|
const oldArtistDir = path.dirname(oldAlbumDir);
|
||||||
const newAlbumName = album ? sanitize(album) : path.basename(oldAlbumDir);
|
const newAlbumName = album ? sanitize(album) : path.basename(oldAlbumDir);
|
||||||
const newArtistName = albumArtist ? sanitize(albumArtist.split(/[,;]/)[0].trim()) : path.basename(oldArtistDir);
|
const newArtistName = normalAlbumArtist ? sanitize(normalAlbumArtist.split(/[,;]/)[0].trim()) : path.basename(oldArtistDir);
|
||||||
const newDir = path.join(CONFIG.musicPath, newArtistName, newAlbumName);
|
const newDir = path.join(CONFIG.musicPath, newArtistName, newAlbumName);
|
||||||
|
|
||||||
if (newDir !== oldAlbumDir) {
|
if (newDir !== oldAlbumDir) {
|
||||||
|
|||||||
+2
-2
@@ -928,8 +928,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="info-edit-fields">
|
<div class="info-edit-fields">
|
||||||
<label class="info-edit-label">Titel<input class="info-edit-input" id="ie-title" type="text" placeholder="Titel"></label>
|
<label class="info-edit-label">Titel<input class="info-edit-input" id="ie-title" type="text" placeholder="Titel"></label>
|
||||||
<label class="info-edit-label">Künstler<input class="info-edit-input" id="ie-artist" type="text" placeholder="Künstler (mit ; trennen)"></label>
|
<label class="info-edit-label">Künstler<span class="info-edit-hint">Mehrere Künstler mit <code>;</code> trennen — z.B. <code>Künstler A; Künstler B</code></span><input class="info-edit-input" id="ie-artist" type="text" placeholder="Künstler"></label>
|
||||||
<label class="info-edit-label">Album-Künstler<input class="info-edit-input" id="ie-albumartist" type="text" placeholder="Album-Künstler (mit , trennen)"></label>
|
<label class="info-edit-label">Album-Künstler<span class="info-edit-hint">Mehrere mit <code>,</code> trennen — z.B. <code>Künstler A, Künstler B</code></span><input class="info-edit-input" id="ie-albumartist" type="text" placeholder="Album-Künstler"></label>
|
||||||
<label class="info-edit-label">Album<input class="info-edit-input" id="ie-album" type="text" placeholder="Album"></label>
|
<label class="info-edit-label">Album<input class="info-edit-input" id="ie-album" type="text" placeholder="Album"></label>
|
||||||
<label class="info-edit-label">Genre<input class="info-edit-input" id="ie-genre" type="text" placeholder="Genre"></label>
|
<label class="info-edit-label">Genre<input class="info-edit-input" id="ie-genre" type="text" placeholder="Genre"></label>
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
||||||
|
|||||||
@@ -235,6 +235,13 @@ document.addEventListener('click', e => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─── SONG INFO MODAL ───
|
// ─── 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) {
|
function _renderInfoModal(s) {
|
||||||
const cv = coverUrl(s.coverArt, 200);
|
const cv = coverUrl(s.coverArt, 200);
|
||||||
document.getElementById('info-cover').innerHTML = cv
|
document.getElementById('info-cover').innerHTML = cv
|
||||||
@@ -279,8 +286,8 @@ export async function showSongInfo(s) {
|
|||||||
// so the form shows the authoritative data (albumArtist, etc.) from Navidrome
|
// so the form shows the authoritative data (albumArtist, etc.) from Navidrome
|
||||||
if (document.getElementById('info-edit-mode').style.display !== 'none') {
|
if (document.getElementById('info-edit-mode').style.display !== 'none') {
|
||||||
document.getElementById('ie-title').value = full.title || '';
|
document.getElementById('ie-title').value = full.title || '';
|
||||||
document.getElementById('ie-artist').value = full.artist || '';
|
document.getElementById('ie-artist').value = _fmtArtists(full.artist, '; ') || '';
|
||||||
document.getElementById('ie-albumartist').value = full.albumArtist || '';
|
document.getElementById('ie-albumartist').value = _fmtArtists(full.albumArtist, ', ') || '';
|
||||||
document.getElementById('ie-album').value = full.album || '';
|
document.getElementById('ie-album').value = full.album || '';
|
||||||
document.getElementById('ie-genre').value = full.genre || '';
|
document.getElementById('ie-genre').value = full.genre || '';
|
||||||
document.getElementById('ie-year').value = full.year || '';
|
document.getElementById('ie-year').value = full.year || '';
|
||||||
@@ -320,8 +327,8 @@ export function toggleInfoEdit() {
|
|||||||
|
|
||||||
// Populate fields from current song
|
// Populate fields from current song
|
||||||
document.getElementById('ie-title').value = s.title || '';
|
document.getElementById('ie-title').value = s.title || '';
|
||||||
document.getElementById('ie-artist').value = s.artist || '';
|
document.getElementById('ie-artist').value = _fmtArtists(s.artist, '; ') || '';
|
||||||
document.getElementById('ie-albumartist').value = s.albumArtist || '';
|
document.getElementById('ie-albumartist').value = _fmtArtists(s.albumArtist, ', ') || '';
|
||||||
document.getElementById('ie-album').value = s.album || '';
|
document.getElementById('ie-album').value = s.album || '';
|
||||||
document.getElementById('ie-genre').value = s.genre || '';
|
document.getElementById('ie-genre').value = s.genre || '';
|
||||||
document.getElementById('ie-year').value = s.year || '';
|
document.getElementById('ie-year').value = s.year || '';
|
||||||
|
|||||||
@@ -485,6 +485,8 @@ button:focus{outline:none}
|
|||||||
.info-edit-art-btn:hover{color:var(--text)}
|
.info-edit-art-btn:hover{color:var(--text)}
|
||||||
.info-edit-fields{display:flex;flex-direction:column;gap:8px;padding:0 20px 4px}
|
.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-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{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)}
|
.info-edit-input:focus{border-color:var(--accent)}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user