this repo has no description
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}