muddle/samples/react/modules/app/musicbrainz.ts
2026-10-23 09:12:49 -04:00

199 lines
6.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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" },
],
},
];
}