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)}