Files
NovaSonic/backend/lib/romaji.js
T
HoneyandClaude Sonnet 4.6 212491aa89 fix: convert katakana proper nouns (e.g. ヨルシカ) to romaji in sort tags
When kuromoji marks a katakana token as reading '*' (unknown word — common
for band names like ヨルシカ), the code fell back to the surface_form which
is still katakana. _hiraToRomaji can't handle katakana so it passed through
unchanged, causing sort tags to show ヨルシカ instead of Yorushika.

Fix: if the surface_form contains katakana and reading is *, convert it
mechanically via _kataToHira + _hiraToRomaji — katakana-to-romaji is always
unambiguous and needs no dictionary.

Also add a Japanese-char guard in the frontend when applying romaji index
values, so stale cache entries can never overwrite correct values with Japanese.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 02:52:35 +02:00

162 lines
7.0 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const path = require('path');
const { createHash } = require('crypto');
// ─── KUROMOJI TOKENIZER ───────────────────────────────────────────────────────
let _tokenizer = null;
try {
const kuromoji = require('kuromoji');
const dicPath = path.join(path.dirname(require.resolve('kuromoji')), '..', 'dict');
kuromoji.builder({ dicPath })
.build((err, t) => {
if (err) console.warn('⚠️ kuromoji failed to load:', err.message);
else { _tokenizer = t; console.log('✅ kuromoji tokenizer ready'); }
});
} catch { console.warn('⚠️ kuromoji not installed — run: cd backend && npm install'); }
// ─── CONVERSION HELPERS ───────────────────────────────────────────────────────
function _kataToHira(s) { return s.replace(/[ァ-ヶ]/g, c => String.fromCharCode(c.charCodeAt(0) - 0x60)); }
const _H2R = {
'あ':'a','い':'i','う':'u','え':'e','お':'o',
'か':'ka','き':'ki','く':'ku','け':'ke','こ':'ko',
'さ':'sa','し':'shi','す':'su','せ':'se','そ':'so',
'た':'ta','ち':'chi','つ':'tsu','て':'te','と':'to',
'な':'na','に':'ni','ぬ':'nu','ね':'ne','の':'no',
'は':'ha','ひ':'hi','ふ':'fu','へ':'he','ほ':'ho',
'ま':'ma','み':'mi','む':'mu','め':'me','も':'mo',
'や':'ya','ゆ':'yu','よ':'yo',
'ら':'ra','り':'ri','る':'ru','れ':'re','ろ':'ro',
'わ':'wa','を':'wo','ん':'n',
'が':'ga','ぎ':'gi','ぐ':'gu','げ':'ge','ご':'go',
'ざ':'za','じ':'ji','ず':'zu','ぜ':'ze','ぞ':'zo',
'だ':'da','ぢ':'di','づ':'du','で':'de','ど':'do',
'ば':'ba','び':'bi','ぶ':'bu','べ':'be','ぼ':'bo',
'ぱ':'pa','ぴ':'pi','ぷ':'pu','ぺ':'pe','ぽ':'po',
'きゃ':'kya','きゅ':'kyu','きょ':'kyo',
'しゃ':'sha','しゅ':'shu','しょ':'sho',
'ちゃ':'cha','ちゅ':'chu','ちょ':'cho',
'にゃ':'nya','にゅ':'nyu','にょ':'nyo',
'ひゃ':'hya','ひゅ':'hyu','ひょ':'hyo',
'みゃ':'mya','みゅ':'myu','みょ':'myo',
'りゃ':'rya','りゅ':'ryu','りょ':'ryo',
'ぎゃ':'gya','ぎゅ':'gyu','ぎょ':'gyo',
'じゃ':'ja','じゅ':'ju','じょ':'jo',
'びゃ':'bya','びゅ':'byu','びょ':'byo',
'ぴゃ':'pya','ぴゅ':'pyu','ぴょ':'pyo',
'ふぁ':'fa','ふぃ':'fi','ふぇ':'fe','ふぉ':'fo',
'てぃ':'ti','でぃ':'di','とぅ':'tu','どぅ':'du',
'ゐ':'i','ゑ':'e','ヴ':'vu',
};
function _hiraToRomaji(str) {
let out = '', i = 0;
while (i < str.length) {
if (str[i] === 'っ') {
const nxt = _H2R[str.slice(i+1,i+3)] || _H2R[str[i+1]] || '';
out += nxt[0] || ''; i++; continue;
}
const two = _H2R[str.slice(i,i+2)];
if (two) { out += two; i += 2; continue; }
out += _H2R[str[i]] ?? str[i]; i++;
}
return out;
}
const JP_RE = /[぀-ヿ㐀-䶿一-鿿＀-￯]/;
const KATA_RE = /[ァ-ヶ]/;
function toRomaji(text) {
if (!text || !_tokenizer || !JP_RE.test(text)) return text || '';
const tokens = _tokenizer.tokenize(text);
const hira = tokens.map(t => {
const r = t.reading;
if (r && r !== '*') return _kataToHira(r);
if (KATA_RE.test(t.surface_form)) return _kataToHira(t.surface_form); // katakana proper noun
return t.surface_form;
}).join('');
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 === '*') {
if (KATA_RE.test(t.surface_form)) return _hiraToRomaji(_kataToHira(t.surface_form)); // katakana proper noun
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;
}, '');
// Clean up Japanese punctuation that passes through as-is
const cleaned = joined
.replace(/[、。]/g, ' ') // Japanese comma/period → space
.replace(/[・・]/g, ' ') // middle dot → space
.replace(/[「」『』【】〔〕〈〉《》]/g, '') // Japanese brackets → remove
.replace(/(/g, '(').replace(/)/g, ')'); // fullwidth parens → ASCII
// Title-case: capitalize first lowercase letter of each word, preserve existing uppercase
return cleaned.replace(/\b([a-z])/g, c => c.toUpperCase()).replace(/\s+/g, ' ').trim();
}
// ─── INDEX BUILDER ────────────────────────────────────────────────────────────
const romajiCache = new Map();
const ROMAJI_TTL = 20 * 60 * 1000; // 20 min
async function _fetchNdSongs(ndUrl, user, pass) {
const salt = Math.random().toString(36).slice(2, 10);
const token = createHash('md5').update(pass + salt).digest('hex');
const base = `${ndUrl}/rest/search3.view?u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=novasonic&f=json&albumCount=0&artistCount=0`;
const songs = [];
let offset = 0;
while (true) {
const r = await fetch(`${base}&query=&songCount=500&songOffset=${offset}`).then(r => r.json());
const page = r['subsonic-response']?.searchResult3?.song || [];
songs.push(...page);
if (page.length < 500) break;
offset += 500;
}
return songs;
}
async function buildRomajiIndex(ndUrl, user, pass) {
const key = `${ndUrl}|${user}`;
const cached = romajiCache.get(key);
if (cached && Date.now() - cached.ts < ROMAJI_TTL) return { fromCache: true, index: cached.index };
const songs = await _fetchNdSongs(ndUrl, user, pass);
const index = songs
.filter(s => JP_RE.test(s.title) || JP_RE.test(s.artist) || JP_RE.test(s.album))
.map(s => ({
id: s.id,
r: [toRomaji(s.title), toRomaji(s.artist), toRomaji(s.album)].join(' '),
title: s.title,
artist: s.artist || '',
album: s.album || '',
titleRomaji: romajiForStorage(s.title) || s.title,
artistRomaji: romajiForStorage(s.artist) || s.artist || '',
albumRomaji: romajiForStorage(s.album) || s.album || '',
coverArt: s.coverArt || '',
duration: s.duration || 0,
albumId: s.albumId || '',
artistId: s.artistId || '',
}));
romajiCache.set(key, { ts: Date.now(), index });
return { fromCache: false, index };
}
function clearRomajiCache() { romajiCache.clear(); }
function isTokenizerReady() { return !!_tokenizer; }
module.exports = { buildRomajiIndex, clearRomajiCache, isTokenizerReady, fetchNdSongs: _fetchNdSongs, romajiForStorage };