diff --git a/backend/lib/metadata.js b/backend/lib/metadata.js index 4965b28..54cf30e 100644 --- a/backend/lib/metadata.js +++ b/backend/lib/metadata.js @@ -58,7 +58,10 @@ const _RELEASE_SUFFIX_RE = /\s*[-–]\s*(?:(?:official\s+)?(?:music\s+)?(?:singl function findExistingTrack(musicRoot, firstArtist, cleanTitle) { const norm = s => (s || '').toLowerCase() .replace(/[\[((][^\]))]*[\]))]/g, '') - .replace(/[,.'!?]/g, '') + // Filesystem-sanitized chars (/ \ : * ? " < > |) become "_" on disk — map those AND "_" + // to a space so a title tag "Kwik Flip | YTPMV" matches the file "Kwik Flip _ YTPMV". + .replace(/[/\\:*?"<>|_]/g, ' ') + .replace(/[,.'!]/g, '') .replace(/\s+/g, ' ') .trim(); const normArtist = norm(firstArtist); diff --git a/backend/lib/ytdlp.js b/backend/lib/ytdlp.js index ecffb5e..b49277f 100644 --- a/backend/lib/ytdlp.js +++ b/backend/lib/ytdlp.js @@ -359,7 +359,10 @@ async function embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, ra '-metadata', `artist=${tagArtist}`, '-metadata', `album_artist=${tagAlbumArtist}`, ); - if (album) ffArgs.push('-metadata', `album=${album}`); + // Always write an album tag, defaulting to "Singles" so it matches the on-disk folder + // (album || 'Singles'). Without this, no-album tracks land in a "Singles" folder but show + // as "[Unknown Album]" in Navidrome — a tag/folder mismatch that breaks the metadata editor. + ffArgs.push('-metadata', `album=${album || 'Singles'}`); // Sort tags — only write when they differ from main (no-op for non-Japanese tracks) if (sortTitle !== title) ffArgs.push('-metadata', `TITLESORT=${sortTitle}`); if (sortArtist !== tagArtist) ffArgs.push('-metadata', `ARTISTSORT=${sortArtist}`); diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index 223e9a0..6ea8c28 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -17,7 +17,7 @@ const crypto = require('crypto'); const CONFIG = require('./lib/config'); const { jobs, updateJob, log } = require('./lib/jobs'); -const { sanitize, lookupItunesTrackMeta, fetchItunesTracks, searchItunesAlbumTracks } = require('./lib/metadata'); +const { sanitize, lookupItunesTrackMeta, fetchItunesTracks, searchItunesAlbumTracks, findExistingTrack } = require('./lib/metadata'); const { downloadTrack, downloadAlbumTracks } = require('./lib/downloader'); const { buildRomajiIndex, clearRomajiCache, isTokenizerReady, fetchNdSongs, romajiForStorage } = require('./lib/romaji'); @@ -1178,7 +1178,11 @@ app.post('/api/edit-metadata', async (req, res) => { const found = rebased || _findFileRecursive(CONFIG.musicPath, fileName) || _findFileInDir(rebasedDir || originalDir, fileName) - || _findFileByStemOrRomaji(CONFIG.musicPath, titleStem); + || _findFileByStemOrRomaji(CONFIG.musicPath, titleStem) + // Last resort: Subsonic getSong returns a *virtual* (tag-built) path, so the folder + // can be "[Unknown Album]" while the file actually sits in "Singles" with an artist + // prefix. findExistingTrack matches by normalized artist+title across all album folders. + || findExistingTrack(CONFIG.musicPath, ndSong.albumArtist || ndSong.artist || '', ndSong.title || ''); if (found) { filePath = found; } else return res.status(404).json({ error: `File not found: ${filePath} (music root: ${CONFIG.musicPath})` }); }