Im installierten Fenster ("display": standalone) gibt es keine Zurück-/
Vorwärts-Knöpfe, und die Maus-Seitentasten werden nicht auf den Verlauf
umgelenkt — im Tab ging es, im Fenster nicht. Die App übernimmt X1/X2
jetzt selbst, aber nur im Standalone-Fenster (im Tab würde das doppelt
navigieren; beide Fälle im Test abgedeckt). Zurück läuft über _navGoBack,
schließt also erst ein offenes Now-Playing.
Backend meldet sich jetzt als NovaSonic-Backend, der Scrobble-Dienst als
NovaSonic-Scrobble. Vorher novasonic vs. NovaSonic — in Navidromes
Player-Liste (Name = "client [User-Agent]") zwei Zeilen "[node]", die
sich nur an einem Großbuchstaben unterschieden.
Pfad-Etikett wieder ohne "(Navidrome)": der Wert kommt ohnehin aus der
Subsonic-API, die Erklärung gehört in den Code und nicht ins Fenster.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
162 lines
7.0 KiB
JavaScript
162 lines
7.0 KiB
JavaScript
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-Backend&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 };
|