Files
NovaSonic/src/api.js
T

225 lines
9.1 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; }
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 []; }
}