Files
TidaLuna-Plugins/plugins/radiant-lyrics-luna/src/api.ts
T
Daniel d76ed2ce55 fix: animated artwork album cache poisoned by title-only fallback
Only cache artwork results that were matched by album. Previously the
first track looked up in an album decided the cached answer for every
other track: the title-only fallback (and its miss) was stored under the
album key, so later tracks in the album inherited it even when a
title-specific lookup would have found artwork.

fetchAnimatedArtwork now returns a scope key alongside the result — the
album key only when album-matched, else the title key. The dedupe skips
in artwork.ts and index.ts compare against that effective scope, so
skipping within an album stays free whenever the album matched.
2026-10-02 09:00:41 +02:00

421 lines
12 KiB
TypeScript

import { Tracer } from "@luna/core";
import { settings } from "./Settings";
const { trace } = Tracer("[Radiant Lyrics]");
const sylTrace = (...args: unknown[]) => {
if (settings.syllableLogging) trace.log(...args);
};
export const RL_PLATFORM = "rl";
const RL_ACCESS_TOKEN_ID = "58hy4s86";
const RL_ACCESS_TOKEN = "xjehy2lfg5h5mjwotoxrcqugam";
// Yup that's right, plaintext token in a public Repo!!
// The API does not return sensitive data & won't be plain text like this in the future <3
let cachedPublicIP: string | undefined;
export async function ip(): Promise<string | undefined> {
if (cachedPublicIP) return cachedPublicIP;
try {
const res = await fetch("https://api.ipify.org?format=text");
if (res.ok) cachedPublicIP = (await res.text()).trim();
} catch {}
return cachedPublicIP;
}
export async function auth(): Promise<Record<string, string>> {
const clientIP = await ip();
return {
"P-Access-Token-Id": RL_ACCESS_TOKEN_ID,
"P-Access-Token": RL_ACCESS_TOKEN,
"x-client-ip": clientIP ?? "null",
};
}
// Platform param (just for DX logging)
const platformQs = `platform=${encodeURIComponent(RL_PLATFORM)}`;
// Query string & params
function query(
title: string,
artist: string,
isrc: string | undefined,
options?: { romanize?: boolean; flush?: boolean; synthesize?: boolean },
): string {
let q = `?title=${encodeURIComponent(title)}&artist=${encodeURIComponent(artist)}`;
if (isrc) q += `&isrc=${encodeURIComponent(isrc)}`;
if (options?.romanize) q += "&romanize=true";
if (options?.flush) q += "&flush=true";
if (options?.synthesize) q += "&synthesize=true";
q += `&${platformQs}`;
return q;
}
// Response types
export interface WordTiming {
text: string;
time: number;
duration: number;
isBackground: boolean;
romanized?: string;
/** 0..1 model confidence for synthesized timings; unset for provider data */
confidence?: number;
}
export interface WordLine {
text: string;
startTime: number;
duration: number;
endTime: number;
syllabus: WordTiming[];
element: {
key: string;
songPart?: string;
songPartIndex?: number;
singer: string;
};
translation: string | null;
romanized?: string;
}
export interface ApiLine {
text: string;
startTime: number;
duration: number;
endTime: number;
syllabus?: WordTiming[];
element?: {
key: string;
songPart?: string;
songPartIndex?: number;
singer?: string;
};
translation?: string | null;
romanized?: string;
}
export interface WordLyricsResponse {
type: "Word";
data: WordLine[];
metadata: {
title: string;
language: string;
totalDuration: string;
agents?: Record<string, { type: string; name: string; alias: string }>;
songParts?: Array<{ name: string; time: number; duration: number }>;
};
_cached?: boolean;
/** true when timings came from the hosted Radiant AI model (?synthesize=true) */
_synthesized?: boolean;
/** original line-level data, kept so AI timings can be toggled off without a refetch */
lines?: ApiLine[];
}
export interface LineLyricsResponse {
type: "Line";
data: ApiLine[];
metadata: {
title: string;
language: string;
totalDuration: string;
agents?: Record<string, { type: string; name: string; alias: string }>;
songParts?: Array<{ name: string; time: number; duration: number }>;
};
_cached?: boolean;
}
export type LyricsApiResponse = WordLyricsResponse | LineLyricsResponse;
type FetchOutcome =
| { status: "ok"; data: LyricsApiResponse | null }
| { status: "404" }
| { status: "500" }
| { status: "err" };
// Lyrics lookup (network)
export async function fetchLyrics(
title: string,
artist: string,
isrc: string | undefined,
romanize: boolean,
synthesize = false,
): Promise<LyricsApiResponse | null> {
const params = query(title, artist, isrc, { romanize, synthesize });
const atomixUrl = `https://api.atomix.one/rl-api${params}`;
const fallbackUrl = `https://rl-api.kineticsand.net/lyrics${params}`;
const rlApiHeaders = await auth();
const tryFetch = async (url: string, useAtomixAuth: boolean): Promise<FetchOutcome> => {
try {
sylTrace(`RL API: Fetching lyrics: ${url}`);
const res = await fetch(url, {
headers: useAtomixAuth ? rlApiHeaders : undefined,
});
if (!res.ok) {
trace.log(`RL API: fetch failed: ${res.status} from ${url}`);
if (res.status === 404) return { status: "404" };
return res.status === 500 ? { status: "500" } : { status: "err" };
}
const data = (await res.json()) as LyricsApiResponse;
if (!data?.data || !Array.isArray(data.data)) {
trace.log("Lyrics API returned invalid payload");
return { status: "ok", data: null };
}
if (data.type !== "Word" && data.type !== "Line") {
trace.log("Lyrics not available in supported format");
return { status: "ok", data: null };
}
return { status: "ok", data };
} catch (err) {
trace.log(`RL API: fetch error from ${url}: ${err}`);
return { status: "err" };
}
};
const primary = await tryFetch(atomixUrl, true);
if (primary.status === "ok") return primary.data;
if (primary.status === "404") {
trace.log("RL API: 404 — no API lyrics exist for this track");
return null;
}
if (primary.status === "500") {
trace.log("RL API: 500 (Execution Timeout) — fallback");
}
const fallback = await tryFetch(fallbackUrl, false);
if (fallback.status === "ok") return fallback.data;
if (fallback.status === "404") {
trace.log("RL API: 404 from fallback — no API lyrics exist for this track");
return null;
}
if (fallback.status === "500") {
trace.log("RL API: 500 from fallback — API IS ACTUALLY BORKED!");
return null;
}
trace.log("RL API: All Endpoints Failed");
return null;
}
export interface AnimatedArtwork {
url: string;
url_tall: string;
}
export interface ArtworkLookup {
artwork: AnimatedArtwork | null;
/**
* Scope the result is valid for, as a dedupe key: the album key only when
* the answer was matched by album. A title-only fallback (or a miss) is
* specific to this track, so its key scopes to the title — the album's
* other tracks must not inherit it.
*/
key: string;
}
/** Title-scoped artwork key: a result only valid for this exact track. */
export const artworkTitleKey = (artist: string, title: string): string =>
`${artist}\u0000\u0000${title}`;
function artworkSearchUrl(
title: string,
artist: string,
album?: string,
): string {
let q = `title=${encodeURIComponent(title)}&artist=${encodeURIComponent(artist)}`;
// Omit the album param entirely when absent (a literal "undefined" string makes the endpoint miss)
if (album && album.trim() !== "") q += `&album=${encodeURIComponent(album.trim())}`;
return `https://ama.trainswift.net/api/v1/artwork/search?${q}`;
}
// Cached including misses; most tracks have none and the answer never changes.
// Only album-matched results go under the album key — a title-only fallback
// must not stand in for the album's other tracks (see ArtworkLookup).
const animatedArtworkCache = new Map<string, AnimatedArtwork | null>();
const ARTWORK_CACHE_MAX = 200;
export async function fetchAnimatedArtwork(
title: string,
artist: string,
album?: string,
signal?: AbortSignal,
): Promise<ArtworkLookup> {
const albumName = album?.trim() || "";
const albumPresent = albumName !== "";
// Album key, matching artworkKey() in index.ts; without an album the
// answer is only ever matched by title, so key by title directly.
const cacheKey = albumPresent
? `${artist}\u0000${albumName}`
: artworkTitleKey(artist, title);
if (animatedArtworkCache.has(cacheKey)) {
return { artwork: animatedArtworkCache.get(cacheKey) ?? null, key: cacheKey };
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const onAbort = () => controller.abort();
signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted) controller.abort();
const tryFetch = async (url: string): Promise<AnimatedArtwork | null> => {
try {
sylTrace(`Animated Artwork: fetching ${url}`);
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
trace.log(`Animated Artwork: fetch failed ${res.status} | ${url}`);
return null;
}
const data = (await res.json()) as {
url?: string;
url_tall?: string;
message?: string;
};
if (data.url && data.url_tall) {
return { url: data.url, url_tall: data.url_tall };
}
trace.log(`Animated Artwork: none found | ${url}`);
return null;
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
trace.log(`Animated Artwork: request timed out | ${url}`);
} else {
trace.log(`Animated Artwork: request error | ${url} | ${err}`);
}
return null;
}
};
const remember = (result: AnimatedArtwork | null): AnimatedArtwork | null => {
// A cancelled attempt says nothing about the track.
if (controller.signal.aborted) return result;
if (animatedArtworkCache.size >= ARTWORK_CACHE_MAX) {
const oldest = animatedArtworkCache.keys().next().value;
if (oldest !== undefined) animatedArtworkCache.delete(oldest);
}
animatedArtworkCache.set(cacheKey, result);
return result;
};
try {
// Exact album first; on a miss retry once without the album (server-side disambiguation is imperfect)
const withAlbum = await tryFetch(
artworkSearchUrl(title, artist, album),
);
// Album-matched: cacheable, and its scope is the whole album.
if (withAlbum) return { artwork: remember(withAlbum), key: cacheKey };
if (albumPresent) {
// Album lookup missed: the title-only answer — hit or miss — is
// matched by title, so never cache it under the album key (that
// would poison the album's other tracks) and scope it to the title.
const artwork = await tryFetch(artworkSearchUrl(title, artist));
return { artwork, key: artworkTitleKey(artist, title) };
}
// No album: the title key is already track-specific.
return { artwork: remember(null), key: cacheKey };
} finally {
clearTimeout(timeout);
signal?.removeEventListener("abort", onAbort);
}
}
export async function flushLyrics(track: {
title: string;
artist: string;
isrc?: string;
}): Promise<
| { ok: true; data: LyricsApiResponse & { _flush?: string } }
| { ok: false; status: number; notFound: boolean }
> {
const q = query(track.title, track.artist, track.isrc, {
flush: true,
});
const url = `https://api.atomix.one/rl-api${q}`;
const headers = await auth();
const res = await fetch(url, { headers });
if (res.status === 404) {
return { ok: false, status: 404, notFound: true };
}
if (!res.ok) {
return { ok: false, status: res.status, notFound: false };
}
const data = (await res.json()) as LyricsApiResponse & { _flush?: string };
return { ok: true, data };
}
// Romanize
export async function romanizeLyrics(
lineTexts: string[],
): Promise<string[] | null> {
if (lineTexts.length === 0) return null;
const payload = {
type: "Line" as const,
data: lineTexts.map((text, idx) => ({
text,
startTime: idx,
duration: 0,
endTime: idx,
})),
};
const romanizeQuery = `?${platformQs}`;
const urls: { url: string; useAtomixAuth: boolean }[] = [
{
url: `https://api.atomix.one/rl-api/romanize${romanizeQuery}`,
useAtomixAuth: true,
},
{
url: `https://rl-api.kineticsand.net/romanize${romanizeQuery}`,
useAtomixAuth: false,
},
];
for (const { url, useAtomixAuth } of urls) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const romanizeHeaders: Record<string, string> = {
"content-type": "application/json",
};
if (useAtomixAuth) {
Object.assign(romanizeHeaders, await auth());
}
const res = await fetch(url, {
method: "POST",
headers: romanizeHeaders,
body: JSON.stringify(payload),
signal: controller.signal,
});
clearTimeout(timeout);
if (!res.ok) {
trace.log(`Romanize: request failed ${res.status} | ${url}`);
continue;
}
const data = (await res.json()) as {
type?: string;
data?: Array<{ text?: string; romanized?: string }>;
};
if (!Array.isArray(data?.data)) continue;
return lineTexts.map((original, idx) => {
const item = data.data?.[idx];
return item?.romanized ?? item?.text ?? original;
});
} catch (err) {
clearTimeout(timeout);
if (err instanceof DOMException && err.name === "AbortError") {
trace.log(`Romanize: request timed out | ${url}`);
} else {
trace.log(`Romanize: request error | ${url} | ${err}`);
}
}
}
return null;
}