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}`; } // Bare Subsonic token-auth params (u=..&t=..&s=..) for talking to non-Navidrome NovaSonic // services (e.g. the scrobble play-log), which verify the same creds via ping.view. export function subsonicAuthParams() { if (!S.cfg.pass || !S.cfg.user) return ''; const salt = Math.random().toString(36).slice(2, 10); const token = md5(S.cfg.pass + salt); return `u=${encodeURIComponent(S.cfg.user)}&t=${token}&s=${salt}`; } 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; } // ─── API CACHE ─── const _apiCache = new Map(); // key → { data, ts } const _CACHE_TTL = 5 * 60 * 1000; // 5 min default export function apiIsCached(method, extra = '') { const hit = _apiCache.get(method + extra); return !!(hit && Date.now() - hit.ts < _CACHE_TTL); } export async function apiCached(method, extra = '', ttl = _CACHE_TTL) { const key = method + extra; const hit = _apiCache.get(key); if (hit && Date.now() - hit.ts < ttl) return hit.data; const data = await api(method, extra); _apiCache.set(key, { data, ts: Date.now() }); return data; } export function invalidateApiCache(method, extra) { if (method) _apiCache.delete(method + (extra || '')); else _apiCache.clear(); } 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 — kuromoji backend let _artistSortMap = null; // Map — Navidrome sort tag (preferred) let _albumRomajiMap = null; // Map — for album cards without song ID let _romajiIndexFetch = null; // in-flight promise dedup let _artistSortFetch = null; // in-flight dedup for ensureArtistSortMap function _buildRomajiMap(idx) { const m = new Map(); const am = new Map(); const bm = 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); if (e.album && e.albumRomaji && !bm.has(e.album)) bm.set(e.album, e.albumRomaji); }); _artistRomajiMap = am; _albumRomajiMap = bm; 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; _artistSortMap = null; _albumRomajiMap = null; _artistSortFetch = null; } // Ensures _artistSortMap is populated from getArtists (cached — one network call per session). // Safe to call from anywhere; deduplicates concurrent calls automatically. export async function ensureArtistSortMap() { if (_artistSortMap) return; if (_artistSortFetch) return _artistSortFetch; _artistSortFetch = apiCached('getArtists') .then(r => { const all = (r?.artists?.index || []).flatMap(i => i.artist || []); cacheArtistSortNames(all); _artistSortFetch = null; }) .catch(e => { console.warn('ensureArtistSortMap error:', e); _artistSortFetch = null; }); return _artistSortFetch; } // Populate _artistSortMap from Navidrome artist objects (OpenSubsonic sortName field). // Called whenever getArtists or getArtist data is loaded so dispArtistById can use it. export function cacheArtistSortNames(artists) { if (!_artistSortMap) _artistSortMap = new Map(); for (const a of artists) { if (a.id && a.sortName && !_isJp(a.sortName)) _artistSortMap.set(String(a.id), a.sortName); } } // ─── ROMAJI OVERRIDES ─── // Stored server-side (shared across all clients). Cached in memory per session. let _overridesCache = null; // null = not loaded yet export function getRomajiOverrides() { return _overridesCache || {}; } export async function loadRomajiOverrides() { if (!S.cfg?.backendUrl) return; try { const r = await fetch(`${S.cfg.backendUrl}/api/romaji-overrides`); _overridesCache = r.ok ? await r.json() : {}; } catch { _overridesCache = {}; } } export async function setRomajiOverride(kanji, romaji) { if (!S.cfg?.backendUrl || !kanji?.trim()) return; try { await fetch(`${S.cfg.backendUrl}/api/romaji-overrides`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kanji: kanji.trim(), romaji: romaji?.trim() || '' }), }); if (!_overridesCache) _overridesCache = {}; if (romaji?.trim()) _overridesCache[kanji.trim()] = romaji.trim(); else delete _overridesCache[kanji.trim()]; } catch (e) { console.warn('setRomajiOverride failed:', e); } } export async function removeRomajiOverride(kanji) { if (!S.cfg?.backendUrl || !kanji) return; try { await fetch(`${S.cfg.backendUrl}/api/romaji-overrides/${encodeURIComponent(kanji)}`, { method: 'DELETE' }); if (_overridesCache) delete _overridesCache[kanji]; } catch (e) { console.warn('removeRomajiOverride failed:', e); } } // ─── ARTIST OVERRIDES (pin a local artist → a specific Deezer artist; shared) ─── // Fixes same-name collisions on the artist page (fans / top songs / albums / image). let _artistOverridesCache = null; export function getArtistOverrides() { return _artistOverridesCache || {}; } export function getArtistOverride(ndArtistId) { return (_artistOverridesCache || {})[String(ndArtistId)] || null; } export async function loadArtistOverrides() { if (!S.cfg?.backendUrl) { _artistOverridesCache = {}; return; } try { const r = await fetch(`${S.cfg.backendUrl}/api/artist-overrides`); _artistOverridesCache = r.ok ? await r.json() : {}; } catch { _artistOverridesCache = {}; } } export async function setArtistOverride(ndArtistId, deezer) { if (!S.cfg?.backendUrl || !ndArtistId || !deezer?.deezerArtistId) return; const entry = { deezerArtistId: String(deezer.deezerArtistId), name: deezer.name || '', image: deezer.image || '', fanCount: deezer.fanCount ?? null, }; try { await fetch(`${S.cfg.backendUrl}/api/artist-overrides`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ndArtistId: String(ndArtistId), ...entry }), }); if (!_artistOverridesCache) _artistOverridesCache = {}; _artistOverridesCache[String(ndArtistId)] = entry; } catch (e) { console.warn('setArtistOverride failed:', e); } } export async function removeArtistOverride(ndArtistId) { if (!S.cfg?.backendUrl || !ndArtistId) return; try { await fetch(`${S.cfg.backendUrl}/api/artist-overrides/${encodeURIComponent(ndArtistId)}`, { method: 'DELETE' }); if (_artistOverridesCache) delete _artistOverridesCache[String(ndArtistId)]; } catch (e) { console.warn('removeArtistOverride failed:', e); } } export function dispArtistById(id, name) { if (!S.cfg.showRomaji) return name || ''; const ov = getRomajiOverrides(); if (name && ov[name]) return ov[name]; // Only romanize Japanese names — for non-JP names the sort tag is a sort key (symbols/articles // stripped, e.g. "☆NoAki☆" → "NoAki"), not a romaji, so it must not replace the display name. if (!_isJp(name || '')) return name || ''; const sort = _artistSortMap?.get(String(id)); if (sort) return sort; return _artistRomajiMap?.get(String(id)) || name || ''; } // Takes a full album object so it can read the Navidrome sortName tag. export function dispAlbumName(album) { const name = album?.name || ''; if (!S.cfg.showRomaji) return name; const ov = getRomajiOverrides(); if (name && ov[name]) return ov[name]; const sort = album?.sortName; // Only use sortName as romaji when the album title itself is Japanese — otherwise sortName is just a sort key (e.g. lowercase "ultra blue") if (sort && _isJp(name) && !_isJp(sort)) return sort; return 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. // Priority: override list > file sort tag (OpenSubsonic) > auto-romaji index > original const _isJp = (s) => /[ぁ-んァ-ン一-龯]/.test(s); export function dispTitle(s) { if (!S.cfg.showRomaji) return s.title || ''; const ov = getRomajiOverrides(); if (s.title && ov[s.title]) return ov[s.title]; if (!_isJp(s.title || '')) return s.title || ''; // non-JP title → sort tag is not a romaji const st = s.sortName || s.titleSort; if (st && !_isJp(st)) return st; return romajiLookup(s.id)?.titleRomaji || s.title || ''; } // Songs don't carry artist sort — Navidrome only exposes sortName on artist objects. // Artist sort is handled via _artistSortMap in dispArtistById. export function dispArtist(s) { if (!S.cfg.showRomaji) return s.artist || ''; const ov = getRomajiOverrides(); if (s.artist && ov[s.artist]) return ov[s.artist]; if (s.albumArtist && ov[s.albumArtist]) return ov[s.albumArtist]; if (!_isJp(s.artist || '')) return s.artist || ''; // non-JP artist → no romaji substitution return romajiLookup(s.id)?.artistRomaji || s.artist || ''; } // Songs don't carry album sort — Navidrome only puts sortName on album objects. // Check the override list, then return the raw album name. export function dispAlbum(s) { if (!S.cfg.showRomaji) return s.album || ''; const ov = getRomajiOverrides(); if (s.album && ov[s.album]) return ov[s.album]; return 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 ov = getRomajiOverrides(); if (s.artist && ov[s.artist]) return [{ name: ov[s.artist], originalName: s.artist, id: parts[0]?.id || '' }]; if (s.albumArtist && ov[s.albumArtist]) return [{ name: ov[s.albumArtist], originalName: s.albumArtist, id: parts[0]?.id || '' }]; if (!_isJp(s.artist || parts[0]?.name || '')) return parts; // non-JP artist → keep original (e.g. ☆NoAki☆) const st = s.sortArtist || s.artistSort; if (st && !_isJp(st)) return [{ name: st, originalName: s.artist || parts[0]?.name || '', id: parts[0]?.id || '' }]; const info = romajiLookup(s.id); if (!info?.artistRomaji) return parts; return [{ name: info.artistRomaji, originalName: s.artist || parts[0]?.name || '', 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; } } // ─── DEEZER (search + charts source) ─── // Apple's iTunes /search returns 403 from server IPs, so Deezer is the metadata backend for // everything: search, album/artist lookups AND the "Neu & Beliebt" charts. Free, no key, returns // explicit versions + artist images + track durations. Proxied through the backend to avoid any // CORS surprises. All results are mapped to the legacy "DeezerItem" shape so the UI + download // flow stay unchanged. function _deezerBase() { return S.cfg.backendUrl ? `${S.cfg.backendUrl}/api/deezer` : 'https://api.deezer.com'; } function _dzTrack(t) { return { wrapperType: 'track', kind: 'song', trackId: t.id, trackName: t.title, artistId: t.artist?.id, artistName: t.artist?.name || '', collectionId: t.album?.id, collectionName: t.album?.title || '', artworkUrl100: t.album?.cover_big || t.album?.cover_medium || t.artist?.picture_medium || '', previewUrl: t.preview || '', trackTimeMillis: t.duration ? t.duration * 1000 : undefined, trackNumber: t.track_position || undefined, discNumber: t.disk_number || undefined, trackExplicitness: t.explicit_lyrics ? 'explicit' : 'notExplicit', }; } function _dzAlbum(a) { return { wrapperType: 'collection', collectionType: 'Album', collectionId: a.id, collectionName: a.title, artistId: a.artist?.id, artistName: a.artist?.name || '', artworkUrl100: a.cover_big || a.cover_medium || '', trackCount: a.nb_tracks || undefined, releaseDate: a.release_date || undefined, }; } function _dzArtist(a) { return { wrapperType: 'artist', artistId: a.id, artistName: a.name, artworkUrl100: a.picture_big || a.picture_medium || '', // Deezer's artist search does NOT rank by popularity — several artists can share a name // (5× "Joji"). fanCount lets us pick the one the user means (see deezerSearch + detail.js). fanCount: a.nb_fan ?? undefined, }; } // Last search outcome: null = ok, else HTTP status or 'network'. Read by the search view to tell // "no results" apart from "search backend down". let _deezerLastError = null; export function deezerLastError() { return _deezerLastError; } // Keeps the old name so all callers stay unchanged. entity: musicTrack | album | musicArtist. export async function deezerSearch(query, entity='musicTrack', limit=25) { try { const path = entity === 'album' ? 'search/album' : entity === 'musicArtist' ? 'search/artist' : 'search'; const r = await fetch(`${_deezerBase()}/${path}?q=${encodeURIComponent(query)}&limit=${limit}`); if (!r.ok) { _deezerLastError = r.status; return []; } _deezerLastError = null; const txt = await r.text(); const data = (txt ? JSON.parse(txt) : {}).data || []; if (entity === 'album') return data.map(_dzAlbum); // Sort artists by popularity so a name collision resolves to the artist the user means // (Deezer returns them in an unhelpful order — the real "Joji" isn't first). if (entity === 'musicArtist') return data.map(_dzArtist).sort((x, y) => (y.fanCount || 0) - (x.fanCount || 0)); return data.map(_dzTrack); } catch { _deezerLastError = 'network'; return []; } } // Album tracks (for the album detail view + album download). Deezer's /album/{id} embeds tracks + // album-level release/genre/cover, so one request gives everything. export async function deezerLookupAlbum(collectionId) { try { const r = await fetch(`${_deezerBase()}/album/${collectionId}`); const a = await r.json(); const cover = a.cover_big || a.cover_medium || ''; return (a.tracks?.data || []).map(t => ({ ..._dzTrack({ ...t, album: { id: a.id, title: a.title, cover_big: a.cover_big, cover_medium: a.cover_medium } }), collectionArtistName: a.artist?.name || '', artworkUrl100: cover, releaseDate: a.release_date || undefined, primaryGenreName: a.genres?.data?.[0]?.name, trackCount: a.nb_tracks || undefined, })); } catch { return []; } } export async function deezerLookupArtistSongs(artistId, limit=15) { try { const r = await fetch(`${_deezerBase()}/artist/${artistId}/top?limit=${limit}`); return ((await r.json()).data || []).map(_dzTrack); } catch { return []; } } // Deezer's /artist/{id}/albums list OMITS the artist object, so _dzAlbum yields artistName:''. // We already know whose page this is → backfill artistName so downstream download/detail cards // don't fall back to the literal "Unknown Artist" (which poisoned the yt-dlp search). export async function deezerLookupArtistAlbums(artistId, limit=100, artistName='') { try { const r = await fetch(`${_deezerBase()}/artist/${artistId}/albums?limit=${limit}`); return ((await r.json()).data || []) .map(_dzAlbum) .filter(a => a.collectionType === 'Album') .map(a => (a.artistName ? a : { ...a, artistName })); } catch { return []; } } // Deezer artist photo (for the hero on non-local artist pages). iTunes artist entities had none. export async function deezerArtistImage(artistId) { try { const a = await (await fetch(`${_deezerBase()}/artist/${artistId}`)).json(); return a.picture_xl || a.picture_big || a.picture_medium || null; } catch { return null; } } // "Neu & Beliebt" — Deezer's global chart (Apple's charts RSS needed a proxy and the search // fallback is 403 from server IPs). Mapped to { name, artistName, artworkUrl, artworkUrl100 }. export async function deezerTopCharts() { try { const r = await fetch(`${_deezerBase()}/chart/0/albums?limit=50`); const d = await r.json(); return (d.data || []).map(a => ({ name: a.title, artistName: a.artist?.name || '', artworkUrl: a.cover_big || a.cover_medium || '', artworkUrl100: a.cover_medium || '', })); } catch { return []; } } export async function deezerTopSongs() { try { const r = await fetch(`${_deezerBase()}/chart/0/tracks?limit=50`); const d = await r.json(); return (d.data || []).map(t => ({ name: t.title, artistName: t.artist?.name || '', artworkUrl: t.album?.cover_medium || t.album?.cover_big || '', previewUrl: t.preview || '', })); } catch { return []; } }