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 let _artistRomajiMap = null; // Map 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 []; } }