Files
marcandClaude Opus 5 cb5ea26c77 fix: Maus-Zurück im PWA-Fenster, sprechende Player-Namen, Pfad-Etikett
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>
2026-09-12 14:34:17 +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-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 };