Files
NovaSonic/src/api.js
T
HoneyandClaude Sonnet 4.6 8c3a23ad14 feat: romaji override list — manual kanji→romaji corrections for sort tags
Adds a maintained list in Settings where you can map kanji artist/album/title
strings to their correct romaji reading (e.g. 照井順政 → Terui Yoshimasa).
Overrides are applied last (highest priority) when the edit-metadata modal
opens, so saving the song writes the correct sort tags permanently to the file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 20:17:13 +02:00

241 lines
9.8 KiB
JavaScript

import { S } from './state.js';
import { md5 } from './utils.js';
// ─── SUBSONIC ───
export function apiUrl(method, extra='') {
const salt = Math.random().toString(36).slice(2,10);
const token = md5(S.cfg.pass + salt);
return `${S.cfg.url}/rest/${method}?u=${encodeURIComponent(S.cfg.user)}&t=${token}&s=${salt}&v=1.16.1&c=NovaSonic&f=json${extra}`;
}
export async function api(method, extra='') {
const res = await fetch(apiUrl(method, extra));
if(!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const r = data['subsonic-response'];
if(r.status !== 'ok') throw new Error(r.error?.message || 'API Error');
return r;
}
export function streamUrl(id) { return apiUrl('stream', `&id=${id}&format=raw`); }
// Stable per-session cache so the same id+size always produces the same URL.
// Consistent URLs let WebView's HTTP cache work — without this every render
// generates a new token (random salt) and the image is re-fetched every time.
const _coverUrlCache = new Map();
export async function getLyricsBySongId(songId) {
try {
const r = await api('getLyricsBySongId', `&id=${songId}`);
return r.lyricsList?.structuredLyrics ?? [];
} catch { return []; }
}
export function downloadUrl(id) { return apiUrl('download', `&id=${id}`); }
// Stable salt derived from credentials so the browser HTTP cache survives page reloads.
// Random salt (apiUrl default) changes every session → cache miss every time → art re-fetches.
let _coverSalt = null, _coverToken = null;
function _coverAuth() {
if (!_coverSalt && S.cfg.user && S.cfg.pass) {
_coverSalt = md5(S.cfg.user + S.cfg.pass).slice(0, 8);
_coverToken = md5(S.cfg.pass + _coverSalt);
}
return _coverSalt ? `u=${encodeURIComponent(S.cfg.user)}&t=${_coverToken}&s=${_coverSalt}` : null;
}
export function coverUrl(id, size=300) {
if (!id) return null;
const key = `${id}:${size}`;
if (!_coverUrlCache.has(key)) {
const auth = _coverAuth();
const url = auth
? `${S.cfg.url}/rest/getCoverArt?${auth}&v=1.16.1&c=NovaSonic&f=json&id=${id}&size=${size}`
: apiUrl('getCoverArt', `&id=${id}&size=${size}`);
_coverUrlCache.set(key, url);
}
return _coverUrlCache.get(key);
}
export function invalidateCoverCache() { _coverUrlCache.clear(); _coverSalt = null; _coverToken = null; }
// ─── NAVIDROME REST AUTH ───
export async function ndAuth() {
if(S._ndToken) return S._ndToken;
const res = await fetch(`${S.cfg.url}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: S.cfg.user, password: S.cfg.pass }),
});
if(!res.ok) throw new Error('Navidrome login fehlgeschlagen');
const d = await res.json();
S._ndToken = d.token;
return S._ndToken;
}
// ─── ROMAJI INDEX ───
// Fetched once per session from the backend, cached in memory.
let _romajiIndex = null;
let _romajiMap = null; // Map<songId, { titleRomaji, artistRomaji, albumRomaji }>
let _artistRomajiMap = null; // Map<artistId, artistRomaji>
let _romajiIndexFetch = null; // in-flight promise dedup
function _buildRomajiMap(idx) {
const m = new Map();
const am = new Map();
if (Array.isArray(idx)) idx.forEach(e => {
m.set(String(e.id), e);
if (e.artistId && e.artistRomaji) am.set(String(e.artistId), e.artistRomaji);
});
_artistRomajiMap = am;
return m;
}
export async function fetchRomajiIndex() {
if (!S.cfg.backendUrl || !S.cfg.url || !S.cfg.user || !S.cfg.pass) return null;
if (_romajiIndex) return _romajiIndex;
if (_romajiIndexFetch) return _romajiIndexFetch;
_romajiIndexFetch = fetch(
`${S.cfg.backendUrl}/api/romaji-index?ndUrl=${encodeURIComponent(S.cfg.url)}&user=${encodeURIComponent(S.cfg.user)}&pass=${encodeURIComponent(S.cfg.pass)}`
).then(r => r.ok ? r.json() : null)
.then(idx => { _romajiIndex = idx; _romajiMap = _buildRomajiMap(idx); _romajiIndexFetch = null; return idx; })
.catch(() => { _romajiIndexFetch = null; return null; });
return _romajiIndexFetch;
}
export function invalidateRomajiIndex() { _romajiIndex = null; _romajiMap = null; _artistRomajiMap = null; }
// ─── ROMAJI OVERRIDES ───
// Manual kanji → romaji corrections stored in localStorage. Always take priority over auto-conversion.
export function getRomajiOverrides() {
try { return JSON.parse(localStorage.getItem('ns_romaji_overrides') || '{}'); } catch { return {}; }
}
export function setRomajiOverride(kanji, romaji) {
const o = getRomajiOverrides();
if (romaji?.trim()) o[kanji.trim()] = romaji.trim(); else delete o[kanji.trim()];
localStorage.setItem('ns_romaji_overrides', JSON.stringify(o));
}
export function removeRomajiOverride(kanji) {
const o = getRomajiOverrides();
delete o[kanji];
localStorage.setItem('ns_romaji_overrides', JSON.stringify(o));
}
export function dispArtistById(id, name) {
if (!S.cfg.showRomaji || !_artistRomajiMap) return name || '';
return _artistRomajiMap.get(String(id)) || name || '';
}
// Synchronous lookup — returns index entry for a song ID, or null if not in index.
export function romajiLookup(id) { return _romajiMap?.get(String(id)) ?? null; }
// Display helpers — return romaji when showRomaji is on and the song is in the index,
// otherwise fall back to the original Navidrome values.
export function dispTitle(s) {
if (!S.cfg.showRomaji) return s.title || '';
return romajiLookup(s.id)?.titleRomaji || s.title || '';
}
export function dispArtist(s) {
if (!S.cfg.showRomaji) return s.artist || '';
return romajiLookup(s.id)?.artistRomaji || s.artist || '';
}
export function dispAlbum(s) {
if (!S.cfg.showRomaji) return s.album || '';
return romajiLookup(s.id)?.albumRomaji || s.album || '';
}
// Returns [{name, id}] like parseArtists but with romaji display names when showRomaji is on.
// Multi-artist romaji is collapsed into one entry since we can't reliably split the converted string.
export function dispArtistParts(s, parseArtistsFn) {
const parts = parseArtistsFn(s);
if (!S.cfg.showRomaji) return parts;
const info = romajiLookup(s.id);
if (!info?.artistRomaji) return parts;
return [{ name: info.artistRomaji, id: parts[0]?.id || '' }];
}
export async function romajiConvert(texts) {
if (!S.cfg.backendUrl || !texts.length) return texts;
try {
const r = await fetch(`${S.cfg.backendUrl}/api/romaji-convert`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ texts }),
});
if (!r.ok) return texts;
const { results } = await r.json();
return results;
} catch { return texts; }
}
// ─── ITUNES ───
// Route through backend proxy to avoid CORS; fall back to direct if no backend configured.
function _itunesBase() {
return S.cfg.backendUrl ? `${S.cfg.backendUrl}/api/itunes` : 'https://itunes.apple.com';
}
export async function itunesSearch(query, entity='musicTrack', limit=25) {
try {
const base = `${_itunesBase()}/search?term=${encodeURIComponent(query)}&media=music&entity=${entity}&limit=${limit}&explicit=Yes`;
const rJp = await fetch(`${base}&country=jp`);
const jpResults = JSON.parse(await rJp.text()).results || [];
if (jpResults.length) return jpResults;
const rUs = await fetch(`${base}&country=us`);
return JSON.parse(await rUs.text()).results || [];
} catch { return []; }
}
export async function itunesLookupAlbum(collectionId) {
const r = await fetch(`${_itunesBase()}/lookup?id=${collectionId}&entity=song&explicit=Yes`);
const d = await r.json();
return (d.results || []).filter(x => x.wrapperType === 'track');
}
// Returns actual explicit tracks for an artist via lookup (search endpoint only returns cleaned versions)
export async function itunesLookupArtistSongs(artistId, limit=15) {
const r = await fetch(`${_itunesBase()}/lookup?id=${artistId}&entity=song&limit=${limit}&country=us&explicit=Yes`);
const d = await r.json();
return (d.results || []).filter(x => x.wrapperType === 'track');
}
export async function itunesLookupArtistAlbums(artistId, limit=200) {
const r = await fetch(`${_itunesBase()}/lookup?id=${artistId}&entity=album&limit=${limit}&country=us`);
const d = await r.json();
return (d.results || []).filter(x => x.wrapperType === 'collection' && x.collectionType === 'Album');
}
export async function itunesTopCharts() {
const urls = [
'https://rss.applemarketingtools.com/api/v2/de/music/most-played/50/albums.json',
'https://rss.applemarketingtools.com/api/v2/us/music/most-played/50/albums.json',
];
for(const url of urls) {
try {
const r = await fetch(url, { signal: AbortSignal.timeout(6000) });
if(!r.ok) continue;
const d = await r.json();
if(d.feed?.results?.length) return d.feed.results;
} catch {}
}
try {
const r = await fetch(`${_itunesBase()}/search?term=new+music&media=music&entity=album&limit=50&country=de`);
const d = await r.json();
return (d.results||[]).map(a => ({
name: a.collectionName, artistName: a.artistName,
artworkUrl: a.artworkUrl100?.replace('100x100','300x300'), artworkUrl100: a.artworkUrl100,
}));
} catch { return []; }
}
export async function itunesTopSongs() {
const urls = [
'https://rss.applemarketingtools.com/api/v2/de/music/most-played/50/songs.json',
'https://rss.applemarketingtools.com/api/v2/us/music/most-played/50/songs.json',
];
for(const url of urls) {
try {
const r = await fetch(url, { signal: AbortSignal.timeout(6000) });
if(!r.ok) continue;
const d = await r.json();
if(d.feed?.results?.length) return d.feed.results;
} catch {}
}
try {
const r = await fetch(`${_itunesBase()}/search?term=top+hits&media=music&entity=musicTrack&limit=50&country=de`);
const d = await r.json();
return (d.results||[]).map(t => ({ name: t.trackName, artistName: t.artistName, artworkUrl: t.artworkUrl100 }));
} catch { return []; }
}