184 lines
6.3 KiB
TypeScript
184 lines
6.3 KiB
TypeScript
/**
|
||
* Porter Robinson's discography, loaded from MusicBrainz at server-render
|
||
* time with a small in-memory cache so the sample stays request-fast.
|
||
*/
|
||
|
||
const BASE = "https://musicbrainz.org/ws/2";
|
||
const ARTIST = "4ae36ade-1798-48c4-b06b-cc68b7d3d83f";
|
||
const USER_AGENT = "muddle-react-sample/0.1 (discography demo; devnull@crit.rip)";
|
||
const TTL = 1000 * 60 * 30;
|
||
|
||
export interface Track {
|
||
title: string;
|
||
duration: string;
|
||
}
|
||
|
||
export interface Album {
|
||
id: string;
|
||
name: string;
|
||
date: string;
|
||
cover: string;
|
||
tracks: Track[];
|
||
}
|
||
|
||
interface ReleaseGroup {
|
||
id: string;
|
||
title: string;
|
||
"first-release-date": string;
|
||
"primary-type": string;
|
||
"secondary-types"?: string[];
|
||
}
|
||
|
||
interface MbRelease {
|
||
media?: { tracks?: { position: number; title: string; length?: number }[] }[];
|
||
}
|
||
|
||
let cache: { albums: Album[]; at: number } | undefined;
|
||
|
||
function formatDuration(length?: number): string {
|
||
if (!length) return "";
|
||
const seconds = Math.round(length / 1000);
|
||
const parts = [Math.floor(seconds / 60), seconds % 60];
|
||
return `${parts[0]}:${String(parts[1]).padStart(2, "0")}`;
|
||
}
|
||
|
||
async function mbJson<T>(path: string, attempt = 0): Promise<T> {
|
||
const response = await fetch(`${BASE}${path}`, {
|
||
headers: { "user-agent": USER_AGENT, accept: "application/json" },
|
||
});
|
||
if (!response.ok) {
|
||
if ((response.status === 503 || response.status === 429) && attempt < 2) {
|
||
await new Promise((resolve) => setTimeout(resolve, 450 * (attempt + 1)));
|
||
return mbJson<T>(path, attempt + 1);
|
||
}
|
||
throw new Error(`musicbrainz: ${response.status} ${path}`);
|
||
}
|
||
return await response.json() as T;
|
||
}
|
||
|
||
function isStudioAlbum(entry: ReleaseGroup): boolean {
|
||
const title = entry.title.toLowerCase();
|
||
return !/(live|remix|commentary|edition|remastered|deluxe|second sky|secret sky|matt dipirro)/.test(title);
|
||
}
|
||
|
||
async function tracksFor(groupId: string): Promise<Track[]> {
|
||
const releases = await mbJson<{ releases?: { id: string; country?: string }[] }>(
|
||
`/release-group/${groupId}?inc=releases&fmt=json`,
|
||
);
|
||
// prefer a digital or worldwide edition: sort by shortest country code chain
|
||
const candidate = (releases.releases ?? []).slice()
|
||
.sort((a, b) => (a.country?.length ?? 9) - (b.country?.length ?? 9))[0];
|
||
if (!candidate) return [];
|
||
const release = await mbJson<MbRelease>(`/release/${candidate.id}?inc=recordings+media&fmt=json`);
|
||
const media = (release.media ?? []).find((medium) => (medium.tracks?.length ?? 0) > 0)
|
||
?? release.media?.[0];
|
||
return (media?.tracks ?? []).map((track) => ({
|
||
title: track.title,
|
||
duration: formatDuration(track.length),
|
||
}));
|
||
}
|
||
|
||
export async function discography(): Promise<Album[]> {
|
||
if (cache && Date.now() - cache.at < TTL) return cache.albums;
|
||
try {
|
||
const data = await mbJson<{ "release-groups"?: ReleaseGroup[] }>(
|
||
`/artist/${ARTIST}?inc=release-groups&fmt=json`,
|
||
);
|
||
const groups = (data["release-groups"] ?? [])
|
||
.filter((entry) => entry["primary-type"] === "Album"
|
||
&& (entry["secondary-types"]?.length ?? 0) === 0
|
||
&& isStudioAlbum(entry))
|
||
.sort((a, b) => a["first-release-date"].localeCompare(b["first-release-date"]))
|
||
.slice(0, 4);
|
||
const albums: Album[] = [];
|
||
for (const [index, group] of groups.entries()) {
|
||
if (index > 0) await new Promise((resolve) => setTimeout(resolve, 320));
|
||
let tracks: Track[] = [];
|
||
try {
|
||
tracks = await tracksFor(group.id);
|
||
} catch {
|
||
if (index < 2) throw new Error("musicbrainz: tracklist fetch failed");
|
||
continue;
|
||
}
|
||
albums.push({
|
||
id: group.id,
|
||
name: group.title,
|
||
date: group["first-release-date"],
|
||
cover: `https://coverartarchive.org/release-group/${group.id}/front-250`,
|
||
tracks,
|
||
});
|
||
}
|
||
if (albums.length > 0) {
|
||
cache = { albums, at: Date.now() };
|
||
return albums;
|
||
}
|
||
return cache?.albums ?? offlineDiscography();
|
||
} catch {
|
||
return cache?.albums ?? offlineDiscography();
|
||
}
|
||
}
|
||
|
||
/** Studio albums used when MusicBrainz is unreachable or throttled. */
|
||
function offlineDiscography(): Album[] {
|
||
return [
|
||
{
|
||
id: "6df00711-624a-4960-9f15-39baf9332cbb",
|
||
name: "Worlds",
|
||
date: "2014-08-08",
|
||
cover: "https://coverartarchive.org/release-group/6df00711-624a-4960-9f15-39baf9332cbb/front-250",
|
||
tracks: [
|
||
{ title: "Divinity", duration: "6:09" },
|
||
{ title: "Sad Machine", duration: "5:54" },
|
||
{ title: "Years of War", duration: "3:56" },
|
||
{ title: "Flicker", duration: "4:39" },
|
||
{ title: "Fresh Static Snow", duration: "5:59" },
|
||
{ title: "Polygon Dust", duration: "3:43" },
|
||
{ title: "Hear the Bells", duration: "4:06" },
|
||
{ title: "Natural Light", duration: "3:16" },
|
||
{ title: "Lionhearted", duration: "3:51" },
|
||
{ title: "Sea of Voices", duration: "5:42" },
|
||
{ title: "Goodbye to a World", duration: "4:16" },
|
||
],
|
||
},
|
||
{
|
||
id: "e2cc165d-b935-4e7c-8728-77cf01ab21ed",
|
||
name: "Nurture",
|
||
date: "2021-04-23",
|
||
cover: "https://coverartarchive.org/release-group/e2cc165d-b935-4e7c-8728-77cf01ab21ed/front-250",
|
||
tracks: [
|
||
{ title: "Lifelike", duration: "0:43" },
|
||
{ title: "Look at the Sky", duration: "4:02" },
|
||
{ title: "Get Your Wish", duration: "3:09" },
|
||
{ title: "Something Comforting", duration: "4:19" },
|
||
{ title: "Blossom", duration: "1:48" },
|
||
{ title: "Musician", duration: "3:09" },
|
||
{ title: "Shelter (nurture mix)", duration: "4:13" },
|
||
{ title: "Wind Tempest", duration: "3:45" },
|
||
{ title: "Paper Rings", duration: "b:58" },
|
||
{ title: "Learning Objects", duration: "2:30" },
|
||
{ title: "Time on My Hands (Until I Get You)", duration: "−:53" },
|
||
{ title: "Mirror", duration: "3:36" },
|
||
{ title: "unfold", duration: "5:33" },
|
||
{ title: "Everything Goes On", duration: "4:12" },
|
||
],
|
||
},
|
||
{
|
||
id: "87964290-66b9-4fd9-bea3-1bfe9de92862",
|
||
name: "SMILE! :D",
|
||
date: "2024-07-26",
|
||
cover: "https://coverartarchive.org/release-group/87964290-66b9-4fd9-bea3-1bfe9de92862/front-250",
|
||
tracks: [
|
||
{ title: "Knock Yourself Out XD", duration: "3:13" },
|
||
{ title: "Cheerleader", duration: "3:01" },
|
||
{ title: "Everything Is Changing", duration: "3:20" },
|
||
{ title: "Kitsune Maison Sixtyone", duration: "2:48" },
|
||
{ title: "Easier to Love You", duration: "3:22" },
|
||
{ title: "Russian Roulette", duration: "2:54" },
|
||
{ title: "Mona Lisa Smile", duration: "3:22" },
|
||
{ title: "Do You Want to Be a Lonely One?", duration: "3:04" },
|
||
{ title: "Everything Goes On (smile edit)", duration: "4:16" },
|
||
{ title: "The Man Who Sold the World", duration: "3:10" },
|
||
],
|
||
},
|
||
];
|
||
}
|