diff --git a/backend/lib/ytdlp.js b/backend/lib/ytdlp.js index c8c7e5c..f224c34 100644 --- a/backend/lib/ytdlp.js +++ b/backend/lib/ytdlp.js @@ -53,8 +53,9 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t const mainArtistRomaji = romajiForStorage(mainArtist) || mainArtist; const artistRomaji = romajiForStorage(artist) || artist; const titleRomaji = romajiForStorage(title) || title; + const albumRomaji = album ? (romajiForStorage(album) || album) : null; const safeArtist = sanitize(mainArtistRomaji.split(/[,;&]/)[0].trim()); - const safeAlbum = sanitize(album || 'Singles'); + const safeAlbum = sanitize(albumRomaji || 'Singles'); const dir = path.join(outputDir || CONFIG.musicPath, safeArtist, safeAlbum); fs.mkdirSync(dir, { recursive: true }); @@ -306,6 +307,7 @@ async function embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, ar const storedTitle = romajiForStorage(title) || title; const storedArtist = romajiForStorage(navidromeArtist(artist)) || navidromeArtist(artist); const storedAlbumArtist = romajiForStorage(mainArtist.replace(/\s+&\s+/g, ', ')) || mainArtist.replace(/\s+&\s+/g, ', '); + const storedAlbum = album ? (romajiForStorage(album) || album) : null; ffArgs.push( '-c', 'copy', '-map_metadata', '-1', @@ -314,7 +316,7 @@ async function embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, ar '-metadata', `artist=${storedArtist}`, '-metadata', `album_artist=${storedAlbumArtist}`, ); - if (album) ffArgs.push('-metadata', `album=${album}`); + if (storedAlbum) ffArgs.push('-metadata', `album=${storedAlbum}`); if (year) ffArgs.push('-metadata', `date=${String(year)}`); if (trackNum) ffArgs.push('-metadata', `track=${totalTracks ? `${trackNum}/${totalTracks}` : String(trackNum)}`); if (totalTracks) ffArgs.push('-metadata', `totaltracks=${String(totalTracks)}`); diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index 6f5a2fa..ef19e11 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -613,6 +613,7 @@ app.post('/api/edit-metadata', async (req, res) => { const romajiArtist = normalArtist ? romajiForStorage(normalArtist) || normalArtist : normalArtist; const romajiAlbumArtist = normalAlbumArtist ? romajiForStorage(normalAlbumArtist) || normalAlbumArtist : normalAlbumArtist; const romajiTitle = title ? romajiForStorage(title) || title : title; + const romajiAlbum = album ? romajiForStorage(album) || album : album; // Resolve file path from Navidrome let filePath; @@ -682,7 +683,7 @@ app.post('/api/edit-metadata', async (req, res) => { if (romajiArtist) ffArgs.push('-metadata', `artist=${romajiArtist}`); const resolvedAlbumArtist = romajiAlbumArtist || (romajiArtist ? romajiArtist.split(';')[0].trim() : null); if (resolvedAlbumArtist) ffArgs.push('-metadata', `album_artist=${resolvedAlbumArtist}`); - if (album) ffArgs.push('-metadata', `album=${album}`); + if (romajiAlbum) ffArgs.push('-metadata', `album=${romajiAlbum}`); if (year) ffArgs.push('-metadata', `date=${year}`); if (trackNum) ffArgs.push('-metadata', `track=${totalTracks ? `${trackNum}/${totalTracks}` : String(trackNum)}`); if (totalTracks) ffArgs.push('-metadata', `totaltracks=${String(totalTracks)}`); @@ -724,7 +725,7 @@ 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 newAlbumName = romajiAlbum ? sanitize(romajiAlbum) : path.basename(oldAlbumDir); const newArtistName = romajiAlbumArtist ? sanitize(romajiAlbumArtist.split(/[,;&]/)[0].trim()) : path.basename(oldArtistDir); const newDir = path.join(CONFIG.musicPath, newArtistName, newAlbumName); @@ -746,9 +747,9 @@ app.post('/api/edit-metadata', async (req, res) => { } catch {} } } catch (e) { console.error('[edit-metadata] move:', e.message); } - } else if (album && path.basename(oldAlbumDir) !== sanitize(album)) { + } else if (romajiAlbum && path.basename(oldAlbumDir) !== sanitize(romajiAlbum)) { // Only album name changed (no artist move) — rename album folder in place - const renamedDir = path.join(oldArtistDir, sanitize(album)); + const renamedDir = path.join(oldArtistDir, sanitize(romajiAlbum)); if (!fs.existsSync(renamedDir)) { try { fs.renameSync(oldAlbumDir, renamedDir); } catch (e) { console.error('[edit-metadata] folder rename:', e.message); } diff --git a/src/ui.js b/src/ui.js index 8f426d1..d7d86d7 100644 --- a/src/ui.js +++ b/src/ui.js @@ -1,6 +1,6 @@ import { S } from './state.js'; import { esc, formatDur, toast } from './utils.js'; -import { api, coverUrl, ndAuth } from './api.js'; +import { api, coverUrl, ndAuth, fetchRomajiIndex } from './api.js'; import { THEMES, applyTheme, getCurrentThemeId } from './themes.js'; // Circular imports — safe (only used inside function bodies) import { playQueue, dlSongLocal, toggleStar, togglePlay, renderQueue, syncNpStar, updateStarBtn, npToggleStar, playCurrent, _syncNpDirect, setPlayIcon, nativeInsertAt } from './player.js'; @@ -560,6 +560,11 @@ async function _createNdPlaylist(tracks, name) { const norm = s => s?.toLowerCase().trim() || ''; // Strip non-word, non-space chars for loose artist first-word comparison const normA = s => norm(s).replace(/[^\w\s]/g, ''); + const JP_RE = /[぀-ヿ一-鿿]/; + // Pre-fetch romaji index once if any track has Japanese chars — Navidrome search3 + // doesn't handle Japanese text, so we need the index as a fallback. + const hasJp = tracks.some(t => JP_RE.test(t.trackName) || JP_RE.test(t.artistName)); + const romajiIdx = hasJp ? await fetchRomajiIndex().catch(() => null) : null; for (const t of tracks) { if (!t.trackName) continue; const missingLabel = t.artistName ? t.artistName + ' – ' + t.trackName : t.trackName; @@ -579,8 +584,14 @@ async function _createNdPlaylist(tracks, name) { const firstWord = s => normA(s).split(/\s+/)[0]; const titleMatch = s => norm(s.title) === norm(t.trackName); const artistMatch = s => firstWord(s.artist) === firstWord(firstArtist || t.artistName); - const match = songs.find(s => titleMatch(s) && artistMatch(s)) - || songs.find(s => titleMatch(s)); + let match = songs.find(s => titleMatch(s) && artistMatch(s)) + || songs.find(s => titleMatch(s)); + // Fallback: search romaji index by original Japanese title + // (Navidrome search3 doesn't index Japanese text properly) + if (!match && romajiIdx && JP_RE.test(t.trackName)) { + const jpEntry = romajiIdx.find(e => norm(e.title) === norm(t.trackName)); + if (jpEntry) match = { id: jpEntry.id }; + } if (match) songIds.push(match.id); else missing.push(missingLabel); } catch { missing.push(missingLabel); }