Two gaps the pin left, on both platforms: 1. Search still showed the wrong same-name artist's photo. The library search now prefers the pin's Deezer picture for a pinned artist (web: search.js artist cards; android: LibrarySearchContent via SearchViewModel overrides). 2. Similar Artists were "weird" — they come from Last.fm keyed to the (merged/ wrong) Navidrome artist. When pinned, use Deezer "related" of the pinned artist instead (new deezerRelatedArtists / ItunesClient.lookupRelatedArtists). Also: web Similar Artists now render with profile pictures (avatar chips), like the Android app, instead of plain text chips. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
483 lines
21 KiB
JavaScript
483 lines
21 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}`;
|
||
}
|
||
// 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 []; }
|
||
}
|
||
|
||
// Related Deezer artists (real pictures + fan counts). Used for the "Ähnliche Künstler" row when a
|
||
// pin is active — Last.fm's similar list is keyed to the (possibly merged/wrong) Navidrome artist.
|
||
export async function deezerRelatedArtists(artistId, limit=12) {
|
||
try {
|
||
const r = await fetch(`${_deezerBase()}/artist/${artistId}/related?limit=${limit}`);
|
||
return ((await r.json()).data || []).map(_dzArtist);
|
||
} 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 []; }
|
||
}
|
||
|
||
// Direct artist lookup by Deezer id — the reliable route when /search/artist doesn't surface a
|
||
// low-profile same-name artist (Deezer search is a ranked, incomplete fuzzy match; e.g. the browser
|
||
// finds Nana id 80239872 but the search API never returns it). Used by the artist-pin "paste link"
|
||
// path. Returns the full _dzArtist shape (or null on miss / bad id).
|
||
export async function deezerLookupArtist(artistId) {
|
||
try {
|
||
const r = await fetch(`${_deezerBase()}/artist/${artistId}`);
|
||
if (!r.ok) return null;
|
||
const a = await r.json();
|
||
if (!a || a.error || !a.id) return null;
|
||
return _dzArtist(a);
|
||
} catch { return null; }
|
||
}
|
||
|
||
// 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 []; }
|
||
}
|