Files
Aurora-SpotiRip/endpoints/metadata-enrich_POST.ts
T

218 lines
7.0 KiB
TypeScript
Raw Normal View History

2026-09-25 23:20:57 -05:00
import superjson from "superjson";
import { schema, type OutputType } from "./metadata-enrich_POST.schema";
const MB_HEADERS = {
"User-Agent":
"Aurora-SpotiRip/1.0 (" +
((process.env as Record<string, string | undefined>).SPOTIRIP_BASE_URL ||
"https://spotirip.auroradragon.dev") +
")",
};
let musicBrainzQueue: Promise<unknown> = Promise.resolve();
let lastMusicBrainzRequestAt = 0;
type MusicBrainzSearch = {
recordings?: Array<{
id?: string;
isrcs?: string[];
"first-release-date"?: string;
}>;
};
type MusicBrainzRecording = {
id?: string;
isrcs?: string[];
"first-release-date"?: string;
genres?: Array<{ name?: string; count?: number }>;
tags?: Array<{ name?: string; count?: number }>;
"artist-credit"?: Array<{ artist?: { name?: string } }>;
relations?: Array<{ type?: string; work?: { id?: string } }>;
};
type MusicBrainzWork = {
relations?: Array<{ type?: string; artist?: { name?: string } }>;
};
type DeezerTrack = {
id?: number;
isrc?: string;
album?: { id?: number; cover_xl?: string; cover_big?: string };
};
type DeezerAlbum = {
genres?: { data?: Array<{ name?: string }> };
artist?: { name?: string };
cover_xl?: string;
cover_big?: string;
};
type LrclibResult = {
plainLyrics?: string | null;
syncedLyrics?: string | null;
};
function bestGenre(values: Array<{ name?: string; count?: number }> | undefined) {
return [...(values ?? [])]
.filter((entry) => entry.name)
.sort((a, b) => (b.count ?? 0) - (a.count ?? 0))[0]?.name;
}
async function fetchJson<T>(url: string, headers?: Record<string, string>) {
const response = await fetch(url, { headers });
if (!response.ok) return undefined;
return (await response.json()) as T;
}
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchMusicBrainz<T>(url: string) {
const request = musicBrainzQueue.then(async () => {
const elapsed = Date.now() - lastMusicBrainzRequestAt;
if (elapsed < 1100) await delay(1100 - elapsed);
lastMusicBrainzRequestAt = Date.now();
return fetchJson<T>(url, MB_HEADERS);
});
musicBrainzQueue = request.then(
() => undefined,
() => undefined,
);
return request;
}
async function fetchLrclib<T>(url: string) {
for (let attempt = 0; attempt < 2; attempt += 1) {
const response = await fetch(url, {
headers: { "User-Agent": "Aurora-SpotiRip/1.0" },
});
if (response.status === 404) return undefined;
if (response.status === 429 && attempt === 0) {
const retryAfter = Number(response.headers.get("retry-after") || 1);
await delay(Math.min(5000, Math.max(500, retryAfter * 1000)));
continue;
}
if (!response.ok) return undefined;
return (await response.json()) as T;
}
return undefined;
}
async function findRecording(input: {
title: string;
artist: string;
isrc?: string;
}) {
const query = input.isrc
? `isrc:${input.isrc}`
: `recording:"${input.title.replace(/"/g, "")}" AND artist:"${input.artist.replace(/"/g, "")}"`;
const search = await fetchMusicBrainz<MusicBrainzSearch>(
`https://musicbrainz.org/ws/2/recording?query=${encodeURIComponent(query)}&fmt=json&limit=5`,
);
return search?.recordings?.[0];
}
export async function handle(request: Request) {
try {
const input = schema.parse(superjson.parse(await request.text()));
const sources = new Set<string>();
const output: OutputType = { sources: [] };
try {
const candidate = await findRecording(input);
if (candidate?.id) {
sources.add("MusicBrainz");
const recording = await fetchMusicBrainz<MusicBrainzRecording>(
`https://musicbrainz.org/ws/2/recording/${candidate.id}?inc=artist-credits+isrcs+work-rels+genres+tags&fmt=json`,
);
if (recording) {
output.musicBrainzRecordingId = recording.id || candidate.id;
output.isrc = input.isrc || recording.isrcs?.[0] || candidate.isrcs?.[0];
output.releaseDate =
recording["first-release-date"] || candidate["first-release-date"];
output.genre =
bestGenre(recording.genres) || bestGenre(recording.tags);
output.albumArtist = recording["artist-credit"]
?.map((entry) => entry.artist?.name)
.filter(Boolean)
.join(", ");
const workId = recording.relations?.find(
(relation) => relation.type === "performance" && relation.work?.id,
)?.work?.id;
if (workId) {
const work = await fetchMusicBrainz<MusicBrainzWork>(
`https://musicbrainz.org/ws/2/work/${workId}?inc=artist-rels&fmt=json`,
);
const composers = work?.relations
?.filter((relation) =>
["composer", "writer", "lyricist"].includes(relation.type || ""),
)
.map((relation) => relation.artist?.name)
.filter(Boolean);
if (composers?.length) {
output.composer = [...new Set(composers)].join(", ");
}
}
}
}
} catch {
}
const resolvedIsrc = output.isrc || input.isrc;
if (resolvedIsrc) {
try {
const deezerTrack = await fetchJson<DeezerTrack>(
`https://api.deezer.com/track/isrc:${encodeURIComponent(resolvedIsrc)}`,
);
if (deezerTrack?.id) {
sources.add("Deezer");
output.isrc = output.isrc || deezerTrack.isrc;
output.artworkUrl =
deezerTrack.album?.cover_xl || deezerTrack.album?.cover_big;
if (deezerTrack.album?.id) {
const album = await fetchJson<DeezerAlbum>(
`https://api.deezer.com/album/${deezerTrack.album.id}`,
);
output.genre =
output.genre || album?.genres?.data?.find((entry) => entry.name)?.name;
output.albumArtist = output.albumArtist || album?.artist?.name;
output.artworkUrl =
output.artworkUrl || album?.cover_xl || album?.cover_big;
}
}
} catch {
}
}
try {
const params = new URLSearchParams({
artist_name: input.artist,
track_name: input.title,
});
if (input.album) params.set("album_name", input.album);
if (input.durationMs) {
params.set("duration", String(Math.round(input.durationMs / 1000)));
}
const lyrics = await fetchLrclib<LrclibResult>(
`https://lrclib.net/api/get?${params.toString()}`,
);
if (lyrics?.plainLyrics || lyrics?.syncedLyrics) {
sources.add("LRCLIB");
output.lyrics = {
plain: lyrics.plainLyrics || undefined,
synced: lyrics.syncedLyrics || undefined,
};
}
} catch {
}
output.sources = [...sources];
return new Response(superjson.stringify(output));
} catch (error) {
const message =
error instanceof Error ? error.message : "Unable to enrich track metadata.";
return new Response(superjson.stringify({ error: message }), { status: 400 });
}
}