diff --git a/backend/lib/romaji.js b/backend/lib/romaji.js index dff9dbc..7b6efef 100644 --- a/backend/lib/romaji.js +++ b/backend/lib/romaji.js @@ -75,6 +75,25 @@ function toRomaji(text) { return _hiraToRomaji(hira).toLowerCase(); } +// Human-readable romaji for storage: spaces between words, Title Case, ASCII preserved as-is. +function romajiForStorage(text) { + if (!text || !_tokenizer || !JP_RE.test(text)) return text || ''; + const tokens = _tokenizer.tokenize(text); + const parts = tokens.map(t => { + const r = t.reading; + if (!r || r === '*') return t.surface_form; // ASCII — keep original + return _hiraToRomaji(_kataToHira(r)); // Japanese — convert (lowercase) + }); + // Add space between adjacent alphabetic segments (word separation for names/titles) + const joined = parts.reduce((acc, part, i) => { + if (i === 0) return part; + const needsSpace = /[a-zA-Z0-9]$/.test(parts[i - 1]) && /^[a-zA-Z]/.test(part); + return acc + (needsSpace ? ' ' : '') + part; + }, ''); + // Title-case: capitalize first lowercase letter of each word, preserve existing uppercase + return joined.replace(/\b([a-z])/g, c => c.toUpperCase()).replace(/\s+/g, ' ').trim(); +} + // ─── INDEX BUILDER ──────────────────────────────────────────────────────────── const romajiCache = new Map(); @@ -123,4 +142,4 @@ function clearRomajiCache() { romajiCache.clear(); } function isTokenizerReady() { return !!_tokenizer; } -module.exports = { buildRomajiIndex, clearRomajiCache, isTokenizerReady, fetchNdSongs: _fetchNdSongs }; +module.exports = { buildRomajiIndex, clearRomajiCache, isTokenizerReady, fetchNdSongs: _fetchNdSongs, romajiForStorage }; diff --git a/backend/lib/ytdlp.js b/backend/lib/ytdlp.js index 286750d..c8c7e5c 100644 --- a/backend/lib/ytdlp.js +++ b/backend/lib/ytdlp.js @@ -7,6 +7,7 @@ const { sanitize, navidromeArtist, downloadArtwork, findExistingTrack, } = require('./metadata'); +const { romajiForStorage } = require('./romaji'); function buildTitleKeywords(title) { const VARIANT_WORDS = new Set(['sped','speedup','slowed','reverb','nightcore','karaoke','parody','remix','cover','acoustic','instrumental']); @@ -49,16 +50,19 @@ function buildVariantFilter(title) { async function downloadTrack({ jobId, artist, albumArtist, title, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, outputDir, durationMs, sourceUrl }) { const mainArtist = albumArtist || artist; - const safeArtist = sanitize(mainArtist.split(/[,;&]/)[0].trim()); + const mainArtistRomaji = romajiForStorage(mainArtist) || mainArtist; + const artistRomaji = romajiForStorage(artist) || artist; + const titleRomaji = romajiForStorage(title) || title; + const safeArtist = sanitize(mainArtistRomaji.split(/[,;&]/)[0].trim()); const safeAlbum = sanitize(album || 'Singles'); const dir = path.join(outputDir || CONFIG.musicPath, safeArtist, safeAlbum); fs.mkdirSync(dir, { recursive: true }); - const safeTitle = sanitize(title); + const safeTitle = sanitize(titleRomaji); 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 safeArtistForFile = sanitize(artistRomaji.replace(/;/g, ',')); const fileBase = `${trackPrefix}${safeArtistForFile} - ${safeTitle}`; const existing = findExistingTrack(dir, trackPrefix, safeTitle, safeArtistForFile); @@ -299,13 +303,16 @@ async function embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, ar if (hasCover) ffArgs.push('-i', coverPath); ffArgs.push('-map', '0:a'); if (hasCover) ffArgs.push('-map', '1:v'); + 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, ', '); ffArgs.push( '-c', 'copy', '-map_metadata', '-1', '-id3v2_version', '3', - '-metadata', `title=${title}`, - '-metadata', `artist=${navidromeArtist(artist)}`, - '-metadata', `album_artist=${mainArtist.replace(/\s+&\s+/g, ', ')}`, + '-metadata', `title=${storedTitle}`, + '-metadata', `artist=${storedArtist}`, + '-metadata', `album_artist=${storedAlbumArtist}`, ); if (album) ffArgs.push('-metadata', `album=${album}`); if (year) ffArgs.push('-metadata', `date=${String(year)}`); diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index 53fbbcc..cb79fee 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -19,7 +19,7 @@ const CONFIG = require('./lib/config'); const { jobs, updateJob, log } = require('./lib/jobs'); const { sanitize, lookupItunesTrackMeta, fetchItunesTracks, searchItunesAlbumTracks } = require('./lib/metadata'); const { downloadTrack, downloadAlbumTracks } = require('./lib/downloader'); -const { buildRomajiIndex, clearRomajiCache, isTokenizerReady, fetchNdSongs } = require('./lib/romaji'); +const { buildRomajiIndex, clearRomajiCache, isTokenizerReady, fetchNdSongs, romajiForStorage } = require('./lib/romaji'); // ─── INIT ───────────────────────────────────────────────────────────────────── const app = express(); @@ -225,6 +225,13 @@ app.post('/api/spotify/resolve', async (req, res) => { setTimeout(() => reject(new Error('Zeitüberschreitung — Spotify API antwortet nicht (>120s)')), 120000) ); const data = await Promise.race([_resolveSpotifyViaSpotdl(url), timeout]); + // Normalize Japanese artist/title names to romaji before sending to frontend + if (data.tracks) { + for (const t of data.tracks) { + if (t.artistName) t.artistName = romajiForStorage(t.artistName) || t.artistName; + if (t.trackName) t.trackName = romajiForStorage(t.trackName) || t.trackName; + } + } res.json(data); } catch(e) { console.error('[spotify/resolve]', e.message); @@ -609,6 +616,11 @@ app.post('/api/edit-metadata', async (req, res) => { const normalArtist = _normalizeArtists(artist, ';'); const normalAlbumArtist = _normalizeArtists(albumArtist, ', '); + // Convert Japanese names to romaji for storage + const romajiArtist = normalArtist ? romajiForStorage(normalArtist) || normalArtist : normalArtist; + const romajiAlbumArtist = normalAlbumArtist ? romajiForStorage(normalAlbumArtist) || normalAlbumArtist : normalAlbumArtist; + const romajiTitle = title ? romajiForStorage(title) || title : title; + // Resolve file path from Navidrome let filePath; let ndSong; @@ -673,10 +685,10 @@ app.post('/api/edit-metadata', async (req, res) => { ffArgs.push('-map', '0:v?'); // '?' = optional, no error if file has no cover } ffArgs.push('-c', 'copy', '-map_metadata', '-1', '-id3v2_version', '3'); - if (title) ffArgs.push('-metadata', `title=${title}`); - if (normalArtist) ffArgs.push('-metadata', `artist=${normalArtist}`); - const resolvedAlbumArtist = normalAlbumArtist || (normalArtist ? normalArtist.split(';')[0].trim() : null); - if (resolvedAlbumArtist) ffArgs.push('-metadata', `album_artist=${resolvedAlbumArtist}`); + if (romajiTitle) ffArgs.push('-metadata', `title=${romajiTitle}`); + 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 (year) ffArgs.push('-metadata', `date=${year}`); if (trackNum) ffArgs.push('-metadata', `track=${totalTracks ? `${trackNum}/${totalTracks}` : String(trackNum)}`); @@ -704,8 +716,8 @@ app.post('/api/edit-metadata', async (req, res) => { // 1. Rename file if title changed — convention: {01 - }?{Artist} - {Title}.ext if (title) { - const safeTitle = sanitize(title); - const fileArtist = (normalArtist || ndSong?.artist || '').replace(/;/g, ','); + const safeTitle = sanitize(romajiTitle || title); + const fileArtist = (romajiArtist || ndSong?.artist || '').replace(/;/g, ','); const safeArtistPart = fileArtist ? sanitize(fileArtist) + ' - ' : ''; const prefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : ''; const newName = `${prefix}${safeArtistPart}${safeTitle}${fileExt}`; @@ -720,7 +732,7 @@ app.post('/api/edit-metadata', async (req, res) => { const oldAlbumDir = path.dirname(currentFile); const oldArtistDir = path.dirname(oldAlbumDir); const newAlbumName = album ? sanitize(album) : path.basename(oldAlbumDir); - const newArtistName = normalAlbumArtist ? sanitize(normalAlbumArtist.split(/[,;&]/)[0].trim()) : path.basename(oldArtistDir); + const newArtistName = romajiAlbumArtist ? sanitize(romajiAlbumArtist.split(/[,;&]/)[0].trim()) : path.basename(oldArtistDir); const newDir = path.join(CONFIG.musicPath, newArtistName, newAlbumName); if (newDir !== oldAlbumDir) { diff --git a/src/spotify.js b/src/spotify.js index 1b92a21..37b3113 100644 --- a/src/spotify.js +++ b/src/spotify.js @@ -295,6 +295,10 @@ async function _loadSpotifyTrackArtwork(tracks, listEl) { if (match.trackExplicitness) t.trackExplicitness = match.trackExplicitness; if (match.previewUrl) t.previewUrl = match.previewUrl; if (match.trackTimeMillis) t.durationMs = match.trackTimeMillis; + // Use iTunes canonical English/romaji names — more accurate than Spotify (which may use Japanese) + if (match.artistName) t.artistName = match.artistName; + const JP_RE = /[぀-ヿ一-鿿]/; + if (JP_RE.test(t.trackName) && match.trackName) t.trackName = match.trackName; if (window._spotifyResolved?.tracks?.[idx]) Object.assign(window._spotifyResolved.tracks[idx], t); const url = match.artworkUrl100 || match.artworkUrl60 || ''; if (url) _setArt(artEls[idx], url);