diff --git a/backend/lib/romaji.js b/backend/lib/romaji.js new file mode 100644 index 0000000..02f04ee --- /dev/null +++ b/backend/lib/romaji.js @@ -0,0 +1,126 @@ +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 }; diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index 31049d1..615b304 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -10,7 +10,6 @@ const express = require('express'); const { execSync } = require('child_process'); -const { createHash } = require('crypto'); const path = require('path'); const fs = require('fs'); const https = require('https'); @@ -19,100 +18,7 @@ const CONFIG = require('./lib/config'); const { jobs, updateJob, log } = require('./lib/jobs'); const { lookupItunesTrackMeta, fetchItunesTracks, searchItunesAlbumTracks } = require('./lib/metadata'); const { downloadTrack, downloadAlbumTracks } = require('./lib/downloader'); - -// ─── ROMAJI INDEX ───────────────────────────────────────────────────────────── -// Converts kanji/kana → romaji so users can search Japanese songs phonetically. - -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'); } - -// katakana→hiragana shift (kuromoji readings come back in katakana) -function _kataToHira(s) { return s.replace(/[ァ-ヶ]/g, c => String.fromCharCode(c.charCodeAt(0) - 0x60)); } - -// Full hiragana → romaji table (includes digraphs + っ doubling) -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(); -} - -// Cache: key = "ndUrl|user" → { ts, index } -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; -} +const { buildRomajiIndex, clearRomajiCache, isTokenizerReady } = require('./lib/romaji'); // ─── INIT ───────────────────────────────────────────────────────────────────── const app = express(); @@ -169,29 +75,10 @@ function parseSpotifyUrl(url) { app.get('/api/romaji-index', async (req, res) => { const { ndUrl, user, pass } = req.query; if (!ndUrl || !user || !pass) return res.status(400).json({ error: 'missing params' }); - if (!_tokenizer) return res.status(503).json({ error: 'tokenizer not ready — try again in a few seconds' }); - - const key = `${ndUrl}|${user}`; - const cached = _romajiCache.get(key); - if (cached && Date.now() - cached.ts < ROMAJI_TTL) return res.json(cached.index); - + if (!isTokenizerReady()) return res.status(503).json({ error: 'tokenizer not ready — try again in a few seconds' }); try { - 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 }); - console.log(`✅ Romaji index built: ${index.length} JP songs (${songs.length} total)`); + const { fromCache, index } = await buildRomajiIndex(ndUrl, user, pass); + if (!fromCache) console.log(`✅ Romaji index built: ${index.length} JP songs`); res.json(index); } catch (e) { console.error('Romaji index error:', e); @@ -201,7 +88,7 @@ app.get('/api/romaji-index', async (req, res) => { // DELETE /api/romaji-index — force rebuild on next request app.delete('/api/romaji-index', (req, res) => { - _romajiCache.clear(); + clearRomajiCache(); res.json({ ok: true }); }); diff --git a/src/player.js b/src/player.js index 97174af..5702f63 100644 --- a/src/player.js +++ b/src/player.js @@ -46,9 +46,9 @@ function _nsBridge(method, ...args) { try { window.NSBridge[method](...args); } catch(_) {} } -// Build the NSBridge Track JSON array from S.queue. -function buildTrackList() { - return S.queue.map(s => ({ +// Convert a single Navidrome song to the NSBridge Track schema. +function _trackToNative(s) { + return { id: s.id, url: streamUrl(s.id), title: s.title || '', @@ -56,9 +56,12 @@ function buildTrackList() { album: s.album || null, artworkUrl: s.coverArt ? coverUrl(s.coverArt, 1024) : null, durationMs: Math.floor((s.duration || 0) * 1000), - })); + }; } +// Build the NSBridge Track JSON array from S.queue. +const buildTrackList = () => S.queue.map(_trackToNative); + // Push the current JS queue to native whenever S.queue changes (radio top-ups, etc.). function _pushQueueIfBridge() { if (!HAS_BRIDGE) return; @@ -73,19 +76,6 @@ export function nativeInsertAt(song, position) { try { window.NSBridge.addToQueue(JSON.stringify(_trackToNative(song)), position); } catch(_) {} } -// Convert a single Navidrome song to the NSBridge Track schema (used for addToQueue). -function _trackToNative(s) { - return { - id: s.id, - url: streamUrl(s.id), - title: s.title || '', - artist: s.artist || '', - album: s.album || null, - artworkUrl: s.coverArt ? coverUrl(s.coverArt, 1024) : null, - durationMs: Math.floor((s.duration || 0) * 1000), - }; -} - // ─── NATIVE → JS CALLBACKS ──────────────────────────────────────────────── // window.NSPlayer is the surface that MusicService calls back into. // Installed here so it exists before onPageFinished fires. diff --git a/src/views.js b/src/views.js index 7e25107..0317bf5 100644 --- a/src/views.js +++ b/src/views.js @@ -310,6 +310,28 @@ export async function loadAlbums(type = _albumSort) { } catch(e) { g.innerHTML = `
${e.message}
`; } } +// ─── SHARED TOPSONG ROW ─── +// Used by loadArtistDetail (top songs) and loadArtistAllSongs. +function _makeTopsongRow(s, num, subtitle, list, idx) { + const cv = coverUrl(s.coverArt, 128); + if (s.starred && !s._starred) s._starred = true; + const row = document.createElement('div'); row.className = 'topsong-item'; + row.innerHTML = `