import { makeCache } from "@solid-primitives/resource"; import { type Component, createResource, createSignal, For, type JSXElement, type Resource, Show, Suspense, } from "solid-js"; const LB_API_URL = `https://api.listenbrainz.org`; const COVERARTARCHIVE_URL = "https://coverartarchive.org"; const MB_API_URL = "https://musicbrainz.org"; export type Settings = { username: string; range: Range; count: number; }; export default () => { const [settings, setSettings] = createSignal({ username: "", range: "this_month", count: 10, }); const [artistFetcher] = makeCache( (set: Settings) => { if (set.username) return artists(set.username, undefined, set.range, set.count); }, { storage: localStorage, sourceHash(source) { return `artists-${JSON.stringify(source)}-${new Date().toISOString().split("T")[0]}`; }, }, ); const [artistRes] = createResource(settings, artistFetcher); const [releasesFetcher] = makeCache( (set: Settings) => { if (set.username) return releases(set.username, undefined, set.range, set.count); }, { storage: localStorage, sourceHash(source) { return `groups-${JSON.stringify(source)}-${new Date().toISOString().split("T")[0]}`; }, }, ); const [releasesRes] = createResource(settings, releasesFetcher); return (

Your ListenBrainz Stats

{ setSettings(() => ({ ...settings(), username: e.target.value, })); }} placeholder="Enter username" class="p-4 rounded-lg bg-gray-800 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 w-full sm:w-auto" />
Loading...

} > Waiting for a valid username...

} >

Top Artists

Top Albums

); }; const Artists: Component<{ artists: ArtistStatsArtist[] }> = (props) => { return (
{(artist) => }
); }; export const ArtistsItem: Component<{ item: ArtistStatsArtist }> = (props) => { const [imageFetcher] = makeCache( async (artist: ArtistStatsArtist) => { if (!artist.artist_mbid) return null; return await getWikidataURL(artist.artist_mbid).then((s) => s ? getWikidataThumbnail(s) : null, ); }, { storage: localStorage }, ); const [image] = createResource(() => props.item, imageFetcher); return ( ); }; const Releases: Component<{ groups: Release[] }> = (props) => { return (
{(release) => }
); }; export const ReleaseItem: Component<{ item: Release }> = (props) => { const [imageFetcher] = makeCache( async (release: Release) => { if (!release.caa_release_mbid) return null; const result = await getReleaseImageURL( "release", release.caa_release_mbid, ); return result[0]?.image.replace("http://", "https://"); }, { storage: localStorage }, ); const [image] = createResource(() => props.item, imageFetcher); return ( ); }; type CardSectionProps = { title: string; resource: Resource; ItemComponent: (props: { item: T }) => JSXElement; fallbackMessage: string; }; export const CardSection = (props: CardSectionProps) => { return (

{props.title}

0} fallback={

{props.fallbackMessage}

} >
{(item) => }
); }; type CardItemProps = { imageUrl: string | null | undefined; imageFallbackUrl: string; imageLoading: boolean; title: string; subtitle: string; linkUrl: string; isCircularImage?: boolean; }; export const CardItem: Component = (props) => { return (
} > {`Thumbnail

{props.title}

{props.subtitle}

); }; const ranges = ["this_week", "this_month", "this_year", "all_time"] as const; export type Range = (typeof ranges)[number]; export type ArtistStats = { artists: ArtistStatsArtist[]; count: number; from_ts: number; last_updated: number; offset: number; range: Range; to_ts: number; total_artist_count: number; user_id: string; }; type ArtistStatsArtist = { artist_mbid: string; artist_name: string; listen_count: number; }; export type ReleasesStats = { count: number; from_ts: number; last_updated: number; offset: number; range: string; releases: Release[]; to_ts: number; total_release_count: number; user_id: string; }; export interface Release { artist_mbids: string[]; artist_name: string; artists?: Artist[]; caa_id?: number; caa_release_mbid?: string; listen_count: number; release_mbid: string; release_name: string; } export interface Artist { artist_credit_name: string; artist_mbid: string; join_phrase: string; } async function artists( user: string, offset: number = 0, range: Range = "this_week", count: number = 5, ): Promise { const url = new URL(`${LB_API_URL}/1/stats/user/${user}/artists`); url.searchParams.set("offset", offset.toString()); url.searchParams.set("range", range); url.searchParams.set("count", count.toString()); return getPayload(url); } async function releases( user: string, offset: number = 0, range: Range = "this_week", count: number = 5, ): Promise { const url = new URL(`${LB_API_URL}/1/stats/user/${user}/releases`); url.searchParams.set("offset", offset.toString()); url.searchParams.set("range", range); url.searchParams.set("count", count.toString()); return getPayload(url); } async function getPayload(url: URL): Promise { const response = await fetch(url, {}); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); return (data as { payload: T }).payload; } export interface Image { approved: boolean; back: boolean; comment: string; edit: number; front: boolean; id: number; image: string; thumbnails: Thumbnails; types: string[]; } export interface Thumbnails { "1200": string; "250": string; "500": string; large: string; small: string; } const defaultHeaders = { "User-Agent": "listenframe/0.1", }; async function getReleaseImageURL( kind: "release" | "release-group", mbid: string, ): Promise { const url = new URL(`${COVERARTARCHIVE_URL}/${kind}/${mbid}`); const response = await fetch(url, {}); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); return (data as { images: Image[] }).images; } async function getWikidataURL(mbid: string): Promise { const url = new URL(`${MB_API_URL}/ws/2/artist/${mbid}`); url.searchParams.set("inc", "url-rels"); const response = await fetch(url, {}); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const parser = new DOMParser(); const doc = parser.parseFromString(await response.text(), "text/xml"); return doc?.querySelector("[type=wikidata] > target")?.textContent || null; } async function getWikidataThumbnail( wikidataUrl: string, ): Promise { try { const urlParts = wikidataUrl.split("/"); const wikidataId = urlParts[urlParts.length - 1]; if (!wikidataId || !wikidataId.startsWith("Q")) { console.error("Invalid Wikidata URL."); return null; } const wikidataApiUrl = `https://www.wikidata.org/w/api.php?action=wbgetentities&ids=${wikidataId}&props=claims&format=json&origin=*`; const wikidataResponse = await fetch(wikidataApiUrl, { headers: defaultHeaders, }); const wikidataData = await wikidataResponse.json(); const claims = wikidataData.entities[wikidataId]?.claims; const imageClaim = claims?.P18?.[0]; // P18 is the property ID for 'image' if (!imageClaim) { console.log("No image found for this Wikidata item."); return null; } const imageFilename = imageClaim.mainsnak.datavalue.value; if (!imageFilename) { console.error("Could not extract image filename."); return null; } const commonsApiUrl = `https://commons.wikimedia.org/w/api.php?action=query&titles=File:${imageFilename}&prop=imageinfo&iiprop=url&iiurlwidth=300&format=json&origin=*`; const commonsResponse = await fetch(commonsApiUrl, { headers: defaultHeaders, }); const commonsData = await commonsResponse.json(); const pages = commonsData.query.pages; const pageId = Object.keys(pages)[0]; const imageUrl = pages[pageId]?.imageinfo?.[0]?.thumburl; if (!imageUrl) { console.error("Could not find image URL."); return null; } return imageUrl; } catch (error) { console.error("An error occurred:", error); return null; } }