From 7ba5b8ce9aa3d15d8c0d0f00c2c7c825f8d32d2d Mon Sep 17 00:00:00 2001 From: Honey Date: Sat, 23 May 2026 22:33:35 +0200 Subject: [PATCH] feat: artist in filename, hide album=title subtitle, remove title hover Naming convention (download + edit-metadata): Before: {01 - }Title.ext After: {01 - }Artist1, Artist2 - Title.ext - ytdlp.js: builds fileBase with artist prefix; findExistingTrack checks both new and old format so existing files aren't re-downloaded - metadata.js: findExistingTrack accepts optional safeArtist param - novasonic-server.js: file rename on title edit now includes artist (falls back to Navidrome's stored artist if not provided in body) Song list subtitle: hide album name when album === title (singles where the album is named after the track appear to duplicate the title) Player bar: removed .player-title:hover underline/colour effect Co-Authored-By: Claude Sonnet 4.6 --- backend/lib/metadata.js | 9 ++++++--- backend/lib/ytdlp.js | 14 +++++++++----- backend/novasonic-server.js | 15 +++++++++------ src/components.js | 3 ++- styles.css | 2 -- 5 files changed, 26 insertions(+), 17 deletions(-) diff --git a/backend/lib/metadata.js b/backend/lib/metadata.js index 8707c79..0a07a7d 100644 --- a/backend/lib/metadata.js +++ b/backend/lib/metadata.js @@ -47,9 +47,12 @@ async function downloadArtwork(url, dir) { const AUDIO_EXTS = ['mp3', 'flac', 'opus', 'm4a', 'ogg', 'wav']; -function findExistingTrack(dir, trackPrefix, safeTitle) { - return AUDIO_EXTS - .map(ext => path.join(dir, `${trackPrefix}${safeTitle}.${ext}`)) +function findExistingTrack(dir, trackPrefix, safeTitle, safeArtist) { + // Check new format "{prefix}{artist} - {title}.ext" first, then old format "{prefix}{title}.ext" + const names = safeArtist + ? [`${trackPrefix}${safeArtist} - ${safeTitle}`, `${trackPrefix}${safeTitle}`] + : [`${trackPrefix}${safeTitle}`]; + return AUDIO_EXTS.flatMap(ext => names.map(n => path.join(dir, `${n}.${ext}`))) .find(f => fs.existsSync(f)) || null; } diff --git a/backend/lib/ytdlp.js b/backend/lib/ytdlp.js index 92efe79..0d813d1 100644 --- a/backend/lib/ytdlp.js +++ b/backend/lib/ytdlp.js @@ -54,16 +54,20 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t const dir = path.join(outputDir || CONFIG.musicPath, safeArtist, safeAlbum); fs.mkdirSync(dir, { recursive: true }); - const safeTitle = sanitize(title); - const trackPrefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : ''; + const safeTitle = sanitize(title); + const trackPrefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : ''; + // Filename format: "{01 - }Artist1, Artist2 - Title.ext" + // Use track artist (not albumArtist) so all credited artists appear in the filename + const safeArtistForFile = sanitize(artist.replace(/;/g, ',')); + const fileBase = `${trackPrefix}${safeArtistForFile} - ${safeTitle}`; - const existing = findExistingTrack(dir, trackPrefix, safeTitle); + const existing = findExistingTrack(dir, trackPrefix, safeTitle, safeArtistForFile); if (existing) { log(jobId, `⏭ Bereits vorhanden: ${path.basename(existing)} — übersprungen`); return { success: true, dir, skipped: true }; } - const outputTemplate = path.join(dir, `${trackPrefix}${safeTitle}.%(ext)s`); + const outputTemplate = path.join(dir, `${fileBase}.%(ext)s`); // Strip only Unicode symbol chars (☆ ★ ♥ etc.) for ytsearch — YouTube search chokes on them // and returns 0 results. Keep ASCII punctuation (!&-) so e.g. "N!GHT" stays "N!GHT" rather // than collapsing to "NGHT" which YouTube misreads as the word "night". @@ -106,7 +110,7 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t // Use original artist name (with special chars) for YTM URL — better search ranking than stripped version const ytmQuery = `${mainArtist} - ${title}`; const ytmUrl = `https://music.youtube.com/search?q=${encodeURIComponent(ytmQuery)}¶ms=${YTM_SONGS_PARAM}`; - const expectedAudio = path.join(dir, `${trackPrefix}${safeTitle}.${CONFIG.audioFmt}`); + const expectedAudio = path.join(dir, `${fileBase}.${CONFIG.audioFmt}`); const hasAudio = () => fs.existsSync(expectedAudio); // Direct URL download (e.g. SoundCloud) — skip the ytsearch cascade entirely diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index 057d1f2..a04e46e 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -604,7 +604,8 @@ app.post('/api/edit-metadata', async (req, res) => { 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(); - filePath = ndData?.['subsonic-response']?.song?.path; + const ndSong = ndData?.['subsonic-response']?.song; + filePath = ndSong?.path; if (!filePath) return res.status(404).json({ error: 'Song not found in Navidrome' }); } catch (e) { return res.status(502).json({ error: `Navidrome lookup failed: ${e.message}` }); @@ -687,12 +688,14 @@ app.post('/api/edit-metadata', async (req, res) => { // ── Post-save: rename file and/or move to correct artist/album folder ── let currentFile = filePath; - // 1. Rename file if title changed — convention: {01 - }?{sanitized title}.ext + // 1. Rename file if title changed — convention: {01 - }?{Artist} - {Title}.ext if (title) { - const safeTitle = sanitize(title); - const prefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : ''; - const newName = `${prefix}${safeTitle}${fileExt}`; - const newPath = path.join(path.dirname(currentFile), newName); + const safeTitle = sanitize(title); + const fileArtist = (artist || ndSong?.artist || '').replace(/;/g, ','); + const safeArtistPart = fileArtist ? sanitize(fileArtist) + ' - ' : ''; + const prefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : ''; + const newName = `${prefix}${safeArtistPart}${safeTitle}${fileExt}`; + const newPath = path.join(path.dirname(currentFile), newName); if (newPath !== currentFile && !fs.existsSync(newPath)) { try { fs.renameSync(currentFile, newPath); currentFile = newPath; } catch (e) { console.error('[edit-metadata] file rename:', e.message); } diff --git a/src/components.js b/src/components.js index 0aafb19..9a39b32 100644 --- a/src/components.js +++ b/src/components.js @@ -40,7 +40,8 @@ export function makeSongItem(s, idx, list, { noArtistLink = false, noAlbumLink = : `${esc(a.name)}` ).join(' • ') : ''; - const albumHtml = !noAlbumLink && (s.albumId||s.album) + // Hide album in subtitle when it's identical to the track title (common for singles) + const albumHtml = !noAlbumLink && (s.albumId||s.album) && s.album !== s.title ? ` · ${esc(s.album||'')}` : ''; const playsHtml = S.cfg.showPlayCount diff --git a/styles.css b/styles.css index ea573cd..0e08ba4 100644 --- a/styles.css +++ b/styles.css @@ -267,8 +267,6 @@ button:focus{outline:none} @keyframes spin{to{transform:rotate(360deg)}} .player-info{min-width:0;display:flex;flex-direction:column;justify-content:center} .player-title{font-size:13px;font-weight:600;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:color .15s;margin-bottom:2px} -/* Player bar clickable title/artist */ -.player-title:hover{color:var(--accent);text-decoration:underline} #player-artist-name{color:var(--accent)} #player-artist-name span:hover{color:var(--accent)} #player-album-name{color:var(--text3)}