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 = /[぀-ヿ㐀-䶿一-鿿＀-￯]/; 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; return (r && r !== '*') ? _kataToHira(r) : t.surface_form; }).join(''); return _hiraToRomaji(hira).toLowerCase(); } // ─── 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 || '', 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 };