Aurora SpotiRip desktop and web app
- 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
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user