Files
NovaSonic/src/api.js
T
HoneyandClaude Opus 4.8 02960cf886 feat(artist): pin a local artist to a specific Deezer artist (same-name fix, shared)
Fixes same-name collisions where NovaSonic guessed the wrong Deezer artist (e.g.
'Nana' → a random DJ): fans, top songs, missing albums and the hero image were all
off. A '⚙ Falscher Künstler?' button on the artist page opens a picker (same-name
Deezer candidates with image + fan count); the chosen mapping is stored SERVER-SIDE
(shared across users, like the romaji overrides) in /data/deezer-artist-overrides.json.

Once pinned, the artist page short-circuits the name search to the pinned Deezer id
(drives albums/top-songs/fans) and overrides the hero image with the pinned artist's
Deezer picture (NovaSonic-side, so it works even for tag-merged same-name artists —
Navidrome can't disambiguate them without an MBID). New endpoints
GET/POST/DELETE /api/artist-overrides; api.js load/get/set/remove; loaded at startup.
SW cache -> ns-v74. Needs on-device runtime test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 21:38:42 +02:00

460 lines
20 KiB
JavaScript
Raw 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.
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<songId, { titleRomaji, artistRomaji, albumRomaji }>
let _artistRomajiMap = null; // Map<artistId, artistRomaji> — kuromoji backend
let _artistSortMap = null; // Map<artistId, sortName> — Navidrome sort tag (preferred)
let _albumRomajiMap = null; // Map<albumName(JP), albumRomaji> — 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 []; }
}