- Spotify playlist, library, album and artist browsing with in-app playback - Song, playlist and album downloads via spotdl with YouTube and Bandcamp fallbacks - Electron desktop app (castLabs Widevine) with a permanent sidebar layout - Linux and Windows packaging scripts
218 lines
7.0 KiB
TypeScript
218 lines
7.0 KiB
TypeScript
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 });
|
|
}
|
|
} |