this repo has no description

get artist covers working

karitham.dev 7d679aa1 90c93b68

verified
Changed files
+152 -28
src
+152 -28
src/App.tsx
··· 1 - import { type Component, createResource, For, Suspense, Show } from "solid-js"; 1 + import { 2 + type Component, 3 + createResource, 4 + createSignal, 5 + For, 6 + Show, 7 + Suspense, 8 + } from "solid-js"; 9 + 10 + export type Settings = { 11 + username: string; 12 + range: Range; 13 + }; 2 14 3 15 export default () => { 4 - const [artistRes] = createResource<ArtistStats>(() => artists("karitham")); 5 - const [groupsRes] = createResource<ReleaseGroupsStats>(() => 6 - release_groups("karitham"), 16 + const [settings, setSettings] = createSignal<Settings>({ 17 + username: "karitham", 18 + range: "all_time", 19 + }); 20 + const [artistRes] = createResource<ArtistStats, Settings>(settings, (set) => 21 + artists(set.username, undefined, set.range), 22 + ); 23 + const [groupsRes] = createResource<ReleaseGroupsStats, Settings>( 24 + settings, 25 + (set) => releaseGroups(set.username, undefined, set.range), 7 26 ); 8 27 9 28 return ( 10 - <Suspense fallback={<p>Loading...</p>}> 11 - <Artists artists={artistRes()?.artists || []} /> 12 - <ReleaseGroups groups={groupsRes()?.release_groups || []} /> 13 - </Suspense> 29 + <div class="bg-black min-h-screen text-white p-8"> 30 + <h1 class="text-4xl font-bold mb-8 text-center"> 31 + Your ListenBrainz Stats 32 + </h1> 33 + <div class="flex flex-col sm:flex-row items-center justify-center gap-4 mb-8"> 34 + <input 35 + type="text" 36 + value={settings().username} 37 + onChange={(e) => { 38 + setSettings(() => ({ 39 + ...settings(), 40 + username: e.target.value, 41 + })); 42 + }} 43 + placeholder="Enter username" 44 + 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" 45 + /> 46 + <select 47 + value={settings().range} 48 + onChange={(e) => { 49 + setSettings(() => ({ 50 + ...settings(), 51 + range: e.target.value as Range, 52 + })); 53 + }} 54 + 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" 55 + > 56 + {ranges.map((r) => ( 57 + <option value={r}>{r}</option> 58 + ))} 59 + </select> 60 + </div> 61 + <Suspense fallback={<p class="text-center text-gray-400">Loading...</p>}> 62 + <h2 class="text-2xl font-semibold my-6 text-center">Top Artists</h2> 63 + <Artists artists={artistRes()?.artists || []} /> 64 + <h2 class="text-2xl font-semibold my-6 text-center">Top Albums</h2> 65 + <ReleaseGroups groups={groupsRes()?.release_groups || []} /> 66 + </Suspense> 67 + </div> 14 68 ); 15 69 }; 16 70 17 71 const Artists: Component<{ artists: ArtistStatsArtist[] }> = (props) => { 18 72 return ( 19 - <div class="flex flex-wrap justify-center"> 73 + <div class="flex flex-wrap justify-center gap-4"> 20 74 <For each={props.artists}> 21 75 {(artist) => <ArtistsItem artist={artist} />} 22 76 </For> ··· 30 84 return null; 31 85 } 32 86 33 - return await getArtistImageURL(props.artist.artist_mbid); 87 + return await getWikidataURL(props.artist.artist_mbid).then((s) => 88 + s ? getWikidataThumbnail(s) : null, 89 + ); 34 90 }); 35 91 36 92 return ( 37 - <div class="flex flex-col items-center p-4 m-2 bg-gray-200 rounded-lg shadow-lg"> 93 + <div class="group relative w-40 p-4 rounded-lg bg-gray-900 transition-all duration-300 hover:bg-gray-800 cursor-pointer"> 38 94 <Show 39 95 when={!image.loading} 40 96 fallback={ 41 - <div class="w-32 h-32 bg-gray-300 rounded-md mb-6 animate-pulse"></div> 97 + <div class="relative w-32 h-32 mx-auto rounded-full mb-4 bg-gray-700 animate-pulse"></div> 42 98 } 43 99 > 44 100 <img 45 - src={image() || "/fallback"} 101 + src={ 102 + image() || 103 + `https://placehold.co/1000x1000/000000/ffffff?text=${props.artist.artist_name}` 104 + } 46 105 alt={`Thumbnail for ${props.artist.artist_name}`} 47 - class="w-32 h-32 object-cover rounded-md mb-6 transition-transform duration-300 hover:scale-105" 106 + class="w-32 h-32 mx-auto object-cover rounded-full shadow-lg mb-4 transition-all duration-300 group-hover:scale-105" 48 107 /> 49 108 </Show> 50 - <p class="text-black text-center font-bold truncate w-32"> 109 + <p class="text-white text-center font-bold text-base truncate mb-1"> 51 110 {props.artist.artist_name} 52 111 </p> 53 - <p class="text-gray-800 text-sm">{props.artist.listen_count} listens</p> 112 + <p class="text-gray-500 text-base truncate mb-1"> 113 + {props.artist.listen_count} listens 114 + </p> 54 115 </div> 55 116 ); 56 117 }; 57 118 58 119 const ReleaseGroups: Component<{ groups: ReleaseGroupsGroup[] }> = (props) => { 59 120 return ( 60 - <div class="flex flex-wrap justify-center"> 121 + <div class="flex flex-wrap justify-center gap-4"> 61 122 <For each={props.groups}> 62 123 {(group) => <ReleaseGroupItem group={group} />} 63 124 </For> ··· 74 135 "release", 75 136 props.group.caa_release_mbid, 76 137 ); 77 - return result[0]?.image; 138 + return result[0]?.image.replace("http://", "https://"); 78 139 }); 79 140 80 141 return ( 81 - <div class="flex flex-col items-center p-4 m-2 bg-gray-200 rounded-lg shadow-lg"> 142 + <div class="group relative w-40 p-4 rounded-lg bg-gray-900 transition-all duration-300 hover:bg-gray-800 cursor-pointer"> 82 143 <Show 83 144 when={!image.loading} 84 145 fallback={ 85 - <div class="w-32 h-32 bg-gray-300 rounded-md mb-6 animate-pulse"></div> 146 + <div class="relative w-32 h-32 mx-auto rounded-full mb-4 bg-gray-700 animate-pulse"></div> 86 147 } 87 148 > 88 149 <img 89 - src={image() || "/fallback"} 150 + src={ 151 + image() || 152 + `https://placehold.co/1000x1000/000000/ffffff?text=${props.group.release_group_name}` 153 + } 90 154 alt={`Cover for ${props.group.release_group_name}`} 91 - class="w-32 h-32 object-cover rounded-md mb-6 transition-transform duration-300 hover:scale-105" 155 + class="w-32 h-32 mx-auto object-cover rounded-full shadow-lg mb-4 transition-all duration-300 group-hover:scale-105" 92 156 /> 93 157 </Show> 94 - <p class="text-black text-center font-bold truncate w-32"> 158 + <p class="text-white text-center font-bold text-base truncate mb-1"> 95 159 {props.group.release_group_name} 96 160 </p> 97 - <p class="text-gray-800 text-sm">{props.group.listen_count} listens</p> 161 + <p class="text-gray-500 text-base truncate mb-1"> 162 + {props.group.listen_count} listens 163 + </p> 98 164 </div> 99 165 ); 100 166 }; ··· 103 169 const COVERARTARCHIVE_URL = "https://coverartarchive.org"; 104 170 const MB_API_URL = "https://musicbrainz.org"; 105 171 106 - export type Range = "this_week" | "this_month" | "this_year" | "all_time"; 172 + const ranges = ["this_week", "this_month", "this_year", "all_time"] as const; 173 + export type Range = (typeof ranges)[number]; 107 174 108 175 export type ArtistStats = { 109 176 artists: ArtistStatsArtist[]; ··· 167 234 return getPayload(url); 168 235 } 169 236 170 - async function release_groups( 237 + async function releaseGroups( 171 238 user: string, 172 239 offset: number = 0, 173 240 range: Range = "this_week", ··· 214 281 small: string; 215 282 } 216 283 284 + const defaultHeaders = { 285 + "User-Agent": "listenframe/0.1", 286 + }; 287 + 217 288 async function getReleaseImageURL( 218 289 kind: "release" | "release-group", 219 290 mbid: string, ··· 231 302 return (data as { images: Image[] }).images; 232 303 } 233 304 234 - async function getArtistImageURL(mbid: string): Promise<string | null> { 305 + async function getWikidataURL(mbid: string): Promise<string | null> { 235 306 const url = new URL(`${MB_API_URL}/ws/2/artist/${mbid}`); 236 307 237 308 url.searchParams.set("inc", "url-rels"); ··· 246 317 247 318 const doc = parser.parseFromString(await response.text(), "text/xml"); 248 319 249 - return doc?.querySelector("type=image")?.textContent || null; 320 + return doc?.querySelector("[type=wikidata] > target")?.textContent || null; 321 + } 322 + 323 + async function getWikidataThumbnail( 324 + wikidataUrl: string, 325 + ): Promise<string | null> { 326 + try { 327 + const urlParts = wikidataUrl.split("/"); 328 + const wikidataId = urlParts[urlParts.length - 1]; 329 + if (!wikidataId || !wikidataId.startsWith("Q")) { 330 + console.error("Invalid Wikidata URL."); 331 + return null; 332 + } 333 + 334 + const wikidataApiUrl = `https://www.wikidata.org/w/api.php?action=wbgetentities&ids=${wikidataId}&props=claims&format=json&origin=*`; 335 + const wikidataResponse = await fetch(wikidataApiUrl, { 336 + headers: defaultHeaders, 337 + }); 338 + const wikidataData = await wikidataResponse.json(); 339 + 340 + const claims = wikidataData.entities[wikidataId]?.claims; 341 + const imageClaim = claims?.P18?.[0]; // P18 is the property ID for 'image' 342 + 343 + if (!imageClaim) { 344 + console.log("No image found for this Wikidata item."); 345 + return null; 346 + } 347 + 348 + const imageFilename = imageClaim.mainsnak.datavalue.value; 349 + if (!imageFilename) { 350 + console.error("Could not extract image filename."); 351 + return null; 352 + } 353 + 354 + const commonsApiUrl = `https://commons.wikimedia.org/w/api.php?action=query&titles=File:${imageFilename}&prop=imageinfo&iiprop=url&iiurlwidth=300&format=json&origin=*`; 355 + const commonsResponse = await fetch(commonsApiUrl, { 356 + headers: defaultHeaders, 357 + }); 358 + const commonsData = await commonsResponse.json(); 359 + 360 + const pages = commonsData.query.pages; 361 + const pageId = Object.keys(pages)[0]; 362 + const imageUrl = pages[pageId]?.imageinfo?.[0]?.thumburl; 363 + 364 + if (!imageUrl) { 365 + console.error("Could not find image URL."); 366 + return null; 367 + } 368 + 369 + return imageUrl; 370 + } catch (error) { 371 + console.error("An error occurred:", error); 372 + return null; 373 + } 250 374 }