this repo has no description
at main 12 kB view raw
1import { makeCache } from "@solid-primitives/resource"; 2import { 3 type Component, 4 createResource, 5 createSignal, 6 For, 7 type JSXElement, 8 type Resource, 9 Show, 10 Suspense, 11} from "solid-js"; 12 13const LB_API_URL = `https://api.listenbrainz.org`; 14const COVERARTARCHIVE_URL = "https://coverartarchive.org"; 15const MB_API_URL = "https://musicbrainz.org"; 16 17export type Settings = { 18 username: string; 19 range: Range; 20 count: number; 21}; 22 23export default () => { 24 const [settings, setSettings] = createSignal<Settings>({ 25 username: "", 26 range: "this_month", 27 count: 10, 28 }); 29 const [artistFetcher] = makeCache( 30 (set: Settings) => { 31 if (set.username) 32 return artists(set.username, undefined, set.range, set.count); 33 }, 34 { 35 storage: localStorage, 36 sourceHash(source) { 37 return `artists-${JSON.stringify(source)}-${new Date().toISOString().split("T")[0]}`; 38 }, 39 }, 40 ); 41 const [artistRes] = createResource(settings, artistFetcher); 42 const [releasesFetcher] = makeCache( 43 (set: Settings) => { 44 if (set.username) 45 return releases(set.username, undefined, set.range, set.count); 46 }, 47 { 48 storage: localStorage, 49 sourceHash(source) { 50 return `groups-${JSON.stringify(source)}-${new Date().toISOString().split("T")[0]}`; 51 }, 52 }, 53 ); 54 const [releasesRes] = createResource(settings, releasesFetcher); 55 56 return ( 57 <main class="bg-black min-h-screen text-white p-8"> 58 <div class="m-auto max-w-4xl flex flex-col align-center"> 59 <h1 class="text-4xl font-bold mb-8 text-center"> 60 Your ListenBrainz Stats 61 </h1> 62 <div class="flex flex-col sm:flex-row items-center justify-center gap-4 mb-8"> 63 <input 64 type="text" 65 value={settings().username} 66 onChange={(e) => { 67 setSettings(() => ({ 68 ...settings(), 69 username: e.target.value, 70 })); 71 }} 72 placeholder="Enter username" 73 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" 74 /> 75 <select 76 value={settings().range} 77 onChange={(e) => { 78 setSettings(() => ({ 79 ...settings(), 80 range: e.target.value as Range, 81 })); 82 }} 83 class="p-4 rounded-lg bg-gray-800 text-white focus:outline-none focus:ring-2 focus:ring-green-500 w-full sm:w-auto capitalize" 84 > 85 {ranges.map((r) => ( 86 <option value={r} class="capitalize"> 87 {r.split("_").join(" ")} 88 </option> 89 ))} 90 </select> 91 </div> 92 <Suspense 93 fallback={<p class="text-center text-gray-400">Loading...</p>} 94 > 95 <Show 96 when={artistRes()} 97 fallback={ 98 <p class="text-center text-gray-400"> 99 Waiting for a valid username... 100 </p> 101 } 102 > 103 <div> 104 <div> 105 <h2 class="text-2xl font-semibold my-6 text-center"> 106 Top Artists 107 </h2> 108 <Artists artists={artistRes()?.artists || []} /> 109 </div> 110 <div> 111 <h2 class="text-2xl font-semibold my-6 text-center"> 112 Top Albums 113 </h2> 114 <Releases groups={releasesRes()?.releases || []} /> 115 </div> 116 </div> 117 </Show> 118 </Suspense> 119 </div> 120 </main> 121 ); 122}; 123 124const Artists: Component<{ artists: ArtistStatsArtist[] }> = (props) => { 125 return ( 126 <div class="flex flex-wrap justify-center gap-4"> 127 <For each={props.artists}> 128 {(artist) => <ArtistsItem item={artist} />} 129 </For> 130 </div> 131 ); 132}; 133 134export const ArtistsItem: Component<{ item: ArtistStatsArtist }> = (props) => { 135 const [imageFetcher] = makeCache( 136 async (artist: ArtistStatsArtist) => { 137 if (!artist.artist_mbid) return null; 138 return await getWikidataURL(artist.artist_mbid).then((s) => 139 s ? getWikidataThumbnail(s) : null, 140 ); 141 }, 142 { storage: localStorage }, 143 ); 144 const [image] = createResource(() => props.item, imageFetcher); 145 146 return ( 147 <CardItem 148 imageUrl={image()} 149 imageFallbackUrl={`https://placehold.co/100x100/000000/ffffff?text=${props.item.artist_name}`} 150 imageLoading={image.loading} 151 title={props.item.artist_name} 152 subtitle={`${props.item.listen_count} listens`} 153 linkUrl={`${MB_API_URL}/artist/${props.item.artist_mbid}`} 154 isCircularImage={true} 155 /> 156 ); 157}; 158 159const Releases: Component<{ groups: Release[] }> = (props) => { 160 return ( 161 <div class="flex flex-wrap justify-center gap-4"> 162 <For each={props.groups}> 163 {(release) => <ReleaseItem item={release} />} 164 </For> 165 </div> 166 ); 167}; 168 169export const ReleaseItem: Component<{ item: Release }> = (props) => { 170 const [imageFetcher] = makeCache( 171 async (release: Release) => { 172 if (!release.caa_release_mbid) return null; 173 const result = await getReleaseImageURL( 174 "release", 175 release.caa_release_mbid, 176 ); 177 return result[0]?.image.replace("http://", "https://"); 178 }, 179 { storage: localStorage }, 180 ); 181 const [image] = createResource(() => props.item, imageFetcher); 182 183 return ( 184 <CardItem 185 imageUrl={image()} 186 imageFallbackUrl={`https://placehold.co/100x100/000000/ffffff?text=${props.item.release_name}`} 187 imageLoading={image.loading} 188 title={props.item.release_name} 189 subtitle={`${props.item.listen_count} listens`} 190 linkUrl={`${MB_API_URL}/release/${props.item.caa_release_mbid}`} 191 /> 192 ); 193}; 194 195type CardSectionProps<T> = { 196 title: string; 197 resource: Resource<T[] | undefined>; 198 ItemComponent: (props: { item: T }) => JSXElement; 199 fallbackMessage: string; 200}; 201 202export const CardSection = <T,>(props: CardSectionProps<T>) => { 203 return ( 204 <div> 205 <h2 class="text-2xl font-semibold my-6 text-center">{props.title}</h2> 206 <Show 207 when={(props.resource()?.length || 0) > 0} 208 fallback={ 209 <p class="text-center text-gray-400">{props.fallbackMessage}</p> 210 } 211 > 212 <div class="flex flex-wrap justify-center gap-4"> 213 <For each={props.resource()}> 214 {(item) => <props.ItemComponent item={item} />} 215 </For> 216 </div> 217 </Show> 218 </div> 219 ); 220}; 221 222type CardItemProps = { 223 imageUrl: string | null | undefined; 224 imageFallbackUrl: string; 225 imageLoading: boolean; 226 title: string; 227 subtitle: string; 228 linkUrl: string; 229 isCircularImage?: boolean; 230}; 231 232export const CardItem: Component<CardItemProps> = (props) => { 233 return ( 234 <div class="group relative w-40 p-4 rounded-lg bg-gray-900 transition-all duration-300 hover:bg-gray-800 cursor-pointer"> 235 <Show 236 when={!props.imageLoading} 237 fallback={ 238 <div 239 class={`relative w-32 h-32 mx-auto mb-4 bg-gray-700 animate-pulse ${props.isCircularImage ? "rounded-full" : "rounded-lg"}`} 240 ></div> 241 } 242 > 243 <img 244 src={props.imageUrl || props.imageFallbackUrl} 245 alt={`Thumbnail for ${props.title}`} 246 class={`w-32 h-32 mx-auto object-cover shadow-lg mb-4 transition-all duration-300 group-hover:scale-105 ${props.isCircularImage ? "rounded-full" : "rounded-lg"}`} 247 /> 248 </Show> 249 <p class="text-white text-center font-bold text-base truncate mb-1"> 250 <a target="_blank" href={props.linkUrl}> 251 {props.title} 252 </a> 253 </p> 254 <p class="text-gray-500 text-base truncate mb-1">{props.subtitle}</p> 255 </div> 256 ); 257}; 258 259const ranges = ["this_week", "this_month", "this_year", "all_time"] as const; 260export type Range = (typeof ranges)[number]; 261 262export type ArtistStats = { 263 artists: ArtistStatsArtist[]; 264 count: number; 265 from_ts: number; 266 last_updated: number; 267 offset: number; 268 range: Range; 269 to_ts: number; 270 total_artist_count: number; 271 user_id: string; 272}; 273 274type ArtistStatsArtist = { 275 artist_mbid: string; 276 artist_name: string; 277 listen_count: number; 278}; 279 280export type ReleasesStats = { 281 count: number; 282 from_ts: number; 283 last_updated: number; 284 offset: number; 285 range: string; 286 releases: Release[]; 287 to_ts: number; 288 total_release_count: number; 289 user_id: string; 290}; 291 292export interface Release { 293 artist_mbids: string[]; 294 artist_name: string; 295 artists?: Artist[]; 296 caa_id?: number; 297 caa_release_mbid?: string; 298 listen_count: number; 299 release_mbid: string; 300 release_name: string; 301} 302 303export interface Artist { 304 artist_credit_name: string; 305 artist_mbid: string; 306 join_phrase: string; 307} 308 309async function artists( 310 user: string, 311 offset: number = 0, 312 range: Range = "this_week", 313 count: number = 5, 314): Promise<ArtistStats> { 315 const url = new URL(`${LB_API_URL}/1/stats/user/${user}/artists`); 316 317 url.searchParams.set("offset", offset.toString()); 318 url.searchParams.set("range", range); 319 url.searchParams.set("count", count.toString()); 320 321 return getPayload(url); 322} 323 324async function releases( 325 user: string, 326 offset: number = 0, 327 range: Range = "this_week", 328 count: number = 5, 329): Promise<ReleasesStats> { 330 const url = new URL(`${LB_API_URL}/1/stats/user/${user}/releases`); 331 332 url.searchParams.set("offset", offset.toString()); 333 url.searchParams.set("range", range); 334 url.searchParams.set("count", count.toString()); 335 336 return getPayload(url); 337} 338 339async function getPayload<T>(url: URL): Promise<T> { 340 const response = await fetch(url, {}); 341 342 if (!response.ok) { 343 throw new Error(`HTTP error! status: ${response.status}`); 344 } 345 346 const data = await response.json(); 347 348 return (data as { payload: T }).payload; 349} 350 351export interface Image { 352 approved: boolean; 353 back: boolean; 354 comment: string; 355 edit: number; 356 front: boolean; 357 id: number; 358 image: string; 359 thumbnails: Thumbnails; 360 types: string[]; 361} 362 363export interface Thumbnails { 364 "1200": string; 365 "250": string; 366 "500": string; 367 large: string; 368 small: string; 369} 370 371const defaultHeaders = { 372 "User-Agent": "listenframe/0.1", 373}; 374 375async function getReleaseImageURL( 376 kind: "release" | "release-group", 377 mbid: string, 378): Promise<Image[]> { 379 const url = new URL(`${COVERARTARCHIVE_URL}/${kind}/${mbid}`); 380 381 const response = await fetch(url, {}); 382 383 if (!response.ok) { 384 throw new Error(`HTTP error! status: ${response.status}`); 385 } 386 387 const data = await response.json(); 388 389 return (data as { images: Image[] }).images; 390} 391 392async function getWikidataURL(mbid: string): Promise<string | null> { 393 const url = new URL(`${MB_API_URL}/ws/2/artist/${mbid}`); 394 395 url.searchParams.set("inc", "url-rels"); 396 397 const response = await fetch(url, {}); 398 399 if (!response.ok) { 400 throw new Error(`HTTP error! status: ${response.status}`); 401 } 402 403 const parser = new DOMParser(); 404 405 const doc = parser.parseFromString(await response.text(), "text/xml"); 406 407 return doc?.querySelector("[type=wikidata] > target")?.textContent || null; 408} 409 410async function getWikidataThumbnail( 411 wikidataUrl: string, 412): Promise<string | null> { 413 try { 414 const urlParts = wikidataUrl.split("/"); 415 const wikidataId = urlParts[urlParts.length - 1]; 416 if (!wikidataId || !wikidataId.startsWith("Q")) { 417 console.error("Invalid Wikidata URL."); 418 return null; 419 } 420 421 const wikidataApiUrl = `https://www.wikidata.org/w/api.php?action=wbgetentities&ids=${wikidataId}&props=claims&format=json&origin=*`; 422 const wikidataResponse = await fetch(wikidataApiUrl, { 423 headers: defaultHeaders, 424 }); 425 const wikidataData = await wikidataResponse.json(); 426 427 const claims = wikidataData.entities[wikidataId]?.claims; 428 const imageClaim = claims?.P18?.[0]; // P18 is the property ID for 'image' 429 430 if (!imageClaim) { 431 console.log("No image found for this Wikidata item."); 432 return null; 433 } 434 435 const imageFilename = imageClaim.mainsnak.datavalue.value; 436 if (!imageFilename) { 437 console.error("Could not extract image filename."); 438 return null; 439 } 440 441 const commonsApiUrl = `https://commons.wikimedia.org/w/api.php?action=query&titles=File:${imageFilename}&prop=imageinfo&iiprop=url&iiurlwidth=300&format=json&origin=*`; 442 const commonsResponse = await fetch(commonsApiUrl, { 443 headers: defaultHeaders, 444 }); 445 const commonsData = await commonsResponse.json(); 446 447 const pages = commonsData.query.pages; 448 const pageId = Object.keys(pages)[0]; 449 const imageUrl = pages[pageId]?.imageinfo?.[0]?.thumburl; 450 451 if (!imageUrl) { 452 console.error("Could not find image URL."); 453 return null; 454 } 455 456 return imageUrl; 457 } catch (error) { 458 console.error("An error occurred:", error); 459 return null; 460 } 461}