feat: normalize Japanese artist/title names to romaji at storage time
- Add romajiForStorage() to romaji.js — smart word spacing + Title Case - ytdlp.js: apply to folder names, filenames, and embedded tags - novasonic-server.js: apply in edit-metadata endpoint and Spotify resolve - spotify.js: update track artist/name from iTunes canonical English names Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
c03463b66b
commit
446c583f7f
4 files changed
+56
-14
No files matched your search
+20
-1
@@ -75,6 +75,25 @@ function toRomaji(text) {
|
|||||||
return _hiraToRomaji(hira).toLowerCase();
|
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 ────────────────────────────────────────────────────────────
|
// ─── INDEX BUILDER ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const romajiCache = new Map();
|
const romajiCache = new Map();
|
||||||
@@ -123,4 +142,4 @@ function clearRomajiCache() { romajiCache.clear(); }
|
|||||||
|
|
||||||
function isTokenizerReady() { return !!_tokenizer; }
|
function isTokenizerReady() { return !!_tokenizer; }
|
||||||
|
|
||||||
module.exports = { buildRomajiIndex, clearRomajiCache, isTokenizerReady, fetchNdSongs: _fetchNdSongs };
|
module.exports = { buildRomajiIndex, clearRomajiCache, isTokenizerReady, fetchNdSongs: _fetchNdSongs, romajiForStorage };
|
||||||
+13
-6
@@ -7,6 +7,7 @@ const {
|
|||||||
sanitize, navidromeArtist,
|
sanitize, navidromeArtist,
|
||||||
downloadArtwork, findExistingTrack,
|
downloadArtwork, findExistingTrack,
|
||||||
} = require('./metadata');
|
} = require('./metadata');
|
||||||
|
const { romajiForStorage } = require('./romaji');
|
||||||
|
|
||||||
function buildTitleKeywords(title) {
|
function buildTitleKeywords(title) {
|
||||||
const VARIANT_WORDS = new Set(['sped','speedup','slowed','reverb','nightcore','karaoke','parody','remix','cover','acoustic','instrumental']);
|
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 }) {
|
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 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 safeAlbum = sanitize(album || 'Singles');
|
||||||
const dir = path.join(outputDir || CONFIG.musicPath, safeArtist, safeAlbum);
|
const dir = path.join(outputDir || CONFIG.musicPath, safeArtist, safeAlbum);
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
|
||||||
const safeTitle = sanitize(title);
|
const safeTitle = sanitize(titleRomaji);
|
||||||
const trackPrefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : '';
|
const trackPrefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : '';
|
||||||
// Filename format: "{01 - }Artist1, Artist2 - Title.ext"
|
// Filename format: "{01 - }Artist1, Artist2 - Title.ext"
|
||||||
// Use track artist (not albumArtist) so all credited artists appear in the filename
|
// 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 fileBase = `${trackPrefix}${safeArtistForFile} - ${safeTitle}`;
|
||||||
|
|
||||||
const existing = findExistingTrack(dir, trackPrefix, safeTitle, safeArtistForFile);
|
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);
|
if (hasCover) ffArgs.push('-i', coverPath);
|
||||||
ffArgs.push('-map', '0:a');
|
ffArgs.push('-map', '0:a');
|
||||||
if (hasCover) ffArgs.push('-map', '1:v');
|
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(
|
ffArgs.push(
|
||||||
'-c', 'copy',
|
'-c', 'copy',
|
||||||
'-map_metadata', '-1',
|
'-map_metadata', '-1',
|
||||||
'-id3v2_version', '3',
|
'-id3v2_version', '3',
|
||||||
'-metadata', `title=${title}`,
|
'-metadata', `title=${storedTitle}`,
|
||||||
'-metadata', `artist=${navidromeArtist(artist)}`,
|
'-metadata', `artist=${storedArtist}`,
|
||||||
'-metadata', `album_artist=${mainArtist.replace(/\s+&\s+/g, ', ')}`,
|
'-metadata', `album_artist=${storedAlbumArtist}`,
|
||||||
);
|
);
|
||||||
if (album) ffArgs.push('-metadata', `album=${album}`);
|
if (album) ffArgs.push('-metadata', `album=${album}`);
|
||||||
if (year) ffArgs.push('-metadata', `date=${String(year)}`);
|
if (year) ffArgs.push('-metadata', `date=${String(year)}`);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const CONFIG = require('./lib/config');
|
|||||||
const { jobs, updateJob, log } = require('./lib/jobs');
|
const { jobs, updateJob, log } = require('./lib/jobs');
|
||||||
const { sanitize, lookupItunesTrackMeta, fetchItunesTracks, searchItunesAlbumTracks } = require('./lib/metadata');
|
const { sanitize, lookupItunesTrackMeta, fetchItunesTracks, searchItunesAlbumTracks } = require('./lib/metadata');
|
||||||
const { downloadTrack, downloadAlbumTracks } = require('./lib/downloader');
|
const { downloadTrack, downloadAlbumTracks } = require('./lib/downloader');
|
||||||
const { buildRomajiIndex, clearRomajiCache, isTokenizerReady, fetchNdSongs } = require('./lib/romaji');
|
const { buildRomajiIndex, clearRomajiCache, isTokenizerReady, fetchNdSongs, romajiForStorage } = require('./lib/romaji');
|
||||||
|
|
||||||
// ─── INIT ─────────────────────────────────────────────────────────────────────
|
// ─── INIT ─────────────────────────────────────────────────────────────────────
|
||||||
const app = express();
|
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)
|
setTimeout(() => reject(new Error('Zeitüberschreitung — Spotify API antwortet nicht (>120s)')), 120000)
|
||||||
);
|
);
|
||||||
const data = await Promise.race([_resolveSpotifyViaSpotdl(url), timeout]);
|
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);
|
res.json(data);
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error('[spotify/resolve]', e.message);
|
console.error('[spotify/resolve]', e.message);
|
||||||
@@ -609,6 +616,11 @@ app.post('/api/edit-metadata', async (req, res) => {
|
|||||||
const normalArtist = _normalizeArtists(artist, ';');
|
const normalArtist = _normalizeArtists(artist, ';');
|
||||||
const normalAlbumArtist = _normalizeArtists(albumArtist, ', ');
|
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
|
// Resolve file path from Navidrome
|
||||||
let filePath;
|
let filePath;
|
||||||
let ndSong;
|
let ndSong;
|
||||||
@@ -673,9 +685,9 @@ app.post('/api/edit-metadata', async (req, res) => {
|
|||||||
ffArgs.push('-map', '0:v?'); // '?' = optional, no error if file has no cover
|
ffArgs.push('-map', '0:v?'); // '?' = optional, no error if file has no cover
|
||||||
}
|
}
|
||||||
ffArgs.push('-c', 'copy', '-map_metadata', '-1', '-id3v2_version', '3');
|
ffArgs.push('-c', 'copy', '-map_metadata', '-1', '-id3v2_version', '3');
|
||||||
if (title) ffArgs.push('-metadata', `title=${title}`);
|
if (romajiTitle) ffArgs.push('-metadata', `title=${romajiTitle}`);
|
||||||
if (normalArtist) ffArgs.push('-metadata', `artist=${normalArtist}`);
|
if (romajiArtist) ffArgs.push('-metadata', `artist=${romajiArtist}`);
|
||||||
const resolvedAlbumArtist = normalAlbumArtist || (normalArtist ? normalArtist.split(';')[0].trim() : null);
|
const resolvedAlbumArtist = romajiAlbumArtist || (romajiArtist ? romajiArtist.split(';')[0].trim() : null);
|
||||||
if (resolvedAlbumArtist) ffArgs.push('-metadata', `album_artist=${resolvedAlbumArtist}`);
|
if (resolvedAlbumArtist) ffArgs.push('-metadata', `album_artist=${resolvedAlbumArtist}`);
|
||||||
if (album) ffArgs.push('-metadata', `album=${album}`);
|
if (album) ffArgs.push('-metadata', `album=${album}`);
|
||||||
if (year) ffArgs.push('-metadata', `date=${year}`);
|
if (year) ffArgs.push('-metadata', `date=${year}`);
|
||||||
@@ -704,8 +716,8 @@ app.post('/api/edit-metadata', async (req, res) => {
|
|||||||
|
|
||||||
// 1. Rename file if title changed — convention: {01 - }?{Artist} - {Title}.ext
|
// 1. Rename file if title changed — convention: {01 - }?{Artist} - {Title}.ext
|
||||||
if (title) {
|
if (title) {
|
||||||
const safeTitle = sanitize(title);
|
const safeTitle = sanitize(romajiTitle || title);
|
||||||
const fileArtist = (normalArtist || ndSong?.artist || '').replace(/;/g, ',');
|
const fileArtist = (romajiArtist || ndSong?.artist || '').replace(/;/g, ',');
|
||||||
const safeArtistPart = fileArtist ? sanitize(fileArtist) + ' - ' : '';
|
const safeArtistPart = fileArtist ? sanitize(fileArtist) + ' - ' : '';
|
||||||
const prefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : '';
|
const prefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : '';
|
||||||
const newName = `${prefix}${safeArtistPart}${safeTitle}${fileExt}`;
|
const newName = `${prefix}${safeArtistPart}${safeTitle}${fileExt}`;
|
||||||
@@ -720,7 +732,7 @@ app.post('/api/edit-metadata', async (req, res) => {
|
|||||||
const oldAlbumDir = path.dirname(currentFile);
|
const oldAlbumDir = path.dirname(currentFile);
|
||||||
const oldArtistDir = path.dirname(oldAlbumDir);
|
const oldArtistDir = path.dirname(oldAlbumDir);
|
||||||
const newAlbumName = album ? sanitize(album) : path.basename(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);
|
const newDir = path.join(CONFIG.musicPath, newArtistName, newAlbumName);
|
||||||
|
|
||||||
if (newDir !== oldAlbumDir) {
|
if (newDir !== oldAlbumDir) {
|
||||||
|
|||||||
@@ -295,6 +295,10 @@ async function _loadSpotifyTrackArtwork(tracks, listEl) {
|
|||||||
if (match.trackExplicitness) t.trackExplicitness = match.trackExplicitness;
|
if (match.trackExplicitness) t.trackExplicitness = match.trackExplicitness;
|
||||||
if (match.previewUrl) t.previewUrl = match.previewUrl;
|
if (match.previewUrl) t.previewUrl = match.previewUrl;
|
||||||
if (match.trackTimeMillis) t.durationMs = match.trackTimeMillis;
|
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);
|
if (window._spotifyResolved?.tracks?.[idx]) Object.assign(window._spotifyResolved.tracks[idx], t);
|
||||||
const url = match.artworkUrl100 || match.artworkUrl60 || '';
|
const url = match.artworkUrl100 || match.artworkUrl60 || '';
|
||||||
if (url) _setArt(artEls[idx], url);
|
if (url) _setArt(artEls[idx], url);
|
||||||
|
|||||||
Reference in new issue
Block a user