your personal website on atproto - mirror blento.app
26
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #249 from ghostdevv/listenbrainz

feat: listenbrainz cards

authored by

Florian and committed by
GitHub
54253064 e84ef259

+869
+1
package.json
··· 37 37 "tailwindcss": "^4.2.1", 38 38 "typescript": "^5.9.3", 39 39 "typescript-eslint": "^8.57.0", 40 + "valibot": "^1.3.1", 40 41 "vite": "^8.0.0" 41 42 }, 42 43 "dependencies": {
+15
pnpm-lock.yaml
··· 264 264 typescript-eslint: 265 265 specifier: ^8.57.0 266 266 version: 8.57.0(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) 267 + valibot: 268 + specifier: ^1.3.1 269 + version: 1.3.1(typescript@5.9.3) 267 270 vite: 268 271 specifier: ^8.0.0 269 272 version: 8.0.0(@types/node@25.0.10)(esbuild@0.27.3)(jiti@2.6.1) ··· 2899 2902 2900 2903 util-deprecate@1.0.2: 2901 2904 resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} 2905 + 2906 + valibot@1.3.1: 2907 + resolution: {integrity: sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==} 2908 + peerDependencies: 2909 + typescript: '>=5' 2910 + peerDependenciesMeta: 2911 + typescript: 2912 + optional: true 2902 2913 2903 2914 vite@8.0.0: 2904 2915 resolution: {integrity: sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==} ··· 5672 5683 url-polyfill@1.1.14: {} 5673 5684 5674 5685 util-deprecate@1.0.2: {} 5686 + 5687 + valibot@1.3.1(typescript@5.9.3): 5688 + optionalDependencies: 5689 + typescript: 5.9.3 5675 5690 5676 5691 vite@8.0.0(@types/node@25.0.10)(esbuild@0.27.3)(jiti@2.6.1): 5677 5692 dependencies:
+1
src/lib/cache.ts
··· 10 10 github: 60 * 60 * 12, // 12 hours 11 11 'gh-contrib': 60 * 60 * 12, // 12 hours 12 12 lastfm: 60 * 60, // 1 hour (default, overridable per-put) 13 + listenbrainz: 60 * 60, // 1 hour (default, overridable per-put) 13 14 npmx: 60 * 60 * 12, // 12 hours 14 15 og: 60 * 60 * 24 * 30, // 30 days 15 16 profile: 60 * 60 * 24, // 24 hours
+10
src/lib/cards/index.ts
··· 49 49 import { LastFMTopTracksCardDefinition } from './media/LastFMCard/LastFMTopTracksCard'; 50 50 import { LastFMTopAlbumsCardDefinition } from './media/LastFMCard/LastFMTopAlbumsCard'; 51 51 import { LastFMProfileCardDefinition } from './media/LastFMCard/LastFMProfileCard'; 52 + import { ListenBrainzRecentListensCardDefinition } from './media/ListenBrainzCard/ListenBrainzRecentListensCard'; 53 + import { ListenBrainzTopArtistsCardDefinition } from './media/ListenBrainzCard/ListenBrainzTopArtistsCard'; 54 + import { ListenBrainzTopAlbumsCardDefinition } from './media/ListenBrainzCard/ListenBrainzTopAlbumsCard'; 55 + import { ListenBrainzTopSongsCardDefinition } from './media/ListenBrainzCard/ListenBrainzTopSongsCard'; 56 + import { ListenBrainzNowPlayingCardDefinition } from './media/ListenBrainzCard/ListenBrainzNowPlayingCard'; 52 57 import { PlyrFMCardDefinition, PlyrFMCollectionCardDefinition } from './media/PlyrFMCard'; 53 58 import { MarginCardDefinition } from './social/MarginCard'; 54 59 import { SembleCollectionCardDefinition } from './social/SembleCollectionCard'; ··· 111 116 LastFMTopTracksCardDefinition, 112 117 LastFMTopAlbumsCardDefinition, 113 118 LastFMProfileCardDefinition, 119 + ListenBrainzRecentListensCardDefinition, 120 + ListenBrainzTopArtistsCardDefinition, 121 + ListenBrainzTopAlbumsCardDefinition, 122 + ListenBrainzTopSongsCardDefinition, 123 + ListenBrainzNowPlayingCardDefinition, 114 124 PlyrFMCardDefinition, 115 125 PlyrFMCollectionCardDefinition, 116 126 MarginCardDefinition,
+32
src/lib/cards/media/ListenBrainzCard/CoverArt.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + mbid?: string; 4 + alt: string; 5 + } 6 + 7 + const { mbid, alt }: Props = $props(); 8 + const src = $derived(mbid ? `https://coverartarchive.org/release/${mbid}/front-250` : null); 9 + </script> 10 + 11 + <div class="bg-base-200 size-12 shrink-0 overflow-hidden rounded-lg shadow-md"> 12 + {#if src} 13 + <img {src} {alt} class="size-full object-cover" loading="lazy" /> 14 + {:else} 15 + <div class="text-base-400 flex size-full items-center justify-center"> 16 + <svg 17 + xmlns="http://www.w3.org/2000/svg" 18 + fill="none" 19 + viewBox="0 0 24 24" 20 + stroke-width="2" 21 + stroke="currentColor" 22 + class="size-6" 23 + > 24 + <path 25 + stroke-linecap="round" 26 + stroke-linejoin="round" 27 + d="m9 9 10.5-3m0 6.553v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 1 1-.99-3.467l2.31-.66a2.25 2.25 0 0 0 1.632-2.163Zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 0 1-.99-3.467l2.31-.66A2.25 2.25 0 0 0 9 15.553Z" 28 + /> 29 + </svg> 30 + </div> 31 + {/if} 32 + </div>
+64
src/lib/cards/media/ListenBrainzCard/CreateListenBrainzCardModal.svelte
··· 1 + <script lang="ts"> 2 + import type { CreationModalComponentProps } from '../../types'; 3 + import { Button, Input, Subheading } from '@foxui/core'; 4 + import Modal from '$lib/components/modal/Modal.svelte'; 5 + 6 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 7 + 8 + let errorMessage = $state(''); 9 + </script> 10 + 11 + <Modal open closeButton={false}> 12 + <form 13 + onsubmit={() => { 14 + let input = item.cardData.href?.trim(); 15 + if (!input || typeof input !== 'string') return; 16 + 17 + let username: string | null = null; 18 + 19 + const url = URL.parse(input); 20 + 21 + if (url) { 22 + if (url.hostname !== 'listenbrainz.org') { 23 + errorMessage = 'URL is not from ListenBrainz'; 24 + return; 25 + } 26 + 27 + const [, user, uname] = url.pathname.split('/'); 28 + 29 + if (user === 'user' && uname && typeof uname === 'string') { 30 + username = uname; 31 + } 32 + } else { 33 + username = input || null; 34 + } 35 + 36 + if (!username) { 37 + errorMessage = 'Please enter a valid ListenBrainz username or profile URL'; 38 + return; 39 + } 40 + 41 + item.cardData.username = username; 42 + item.cardData.href = `https://listenbrainz.org/user/${username}`; 43 + 44 + oncreate?.(); 45 + }} 46 + class="flex flex-col gap-2" 47 + > 48 + <Subheading>Enter a ListenBrainz username or profile URL</Subheading> 49 + <Input 50 + bind:value={item.cardData.href} 51 + placeholder="username or https://listenbrainz.org/user/username" 52 + class="mt-4" 53 + /> 54 + 55 + {#if errorMessage} 56 + <p class="mt-2 text-sm text-red-600">{errorMessage}</p> 57 + {/if} 58 + 59 + <div class="mt-4 flex justify-end gap-2"> 60 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 61 + <Button type="submit">Create</Button> 62 + </div> 63 + </form> 64 + </Modal>
+98
src/lib/cards/media/ListenBrainzCard/ListenBrainzNowPlayingCard/ListenBrainzNowPlayingCard.svelte
··· 1 + <script lang="ts"> 2 + import type { ContentComponentProps } from '$lib/cards/types'; 3 + import { nowPlaying } from './nowplaying.remote'; 4 + import type { Listen } from '../types.ts'; 5 + 6 + const { item }: ContentComponentProps = $props(); 7 + const playing = $derived(await nowPlaying(item.cardData.username)); 8 + 9 + function getCoverArtUrl(listen: Listen): string | undefined { 10 + const releaseMbid = listen.track_metadata?.additional_info?.release_mbid; 11 + return releaseMbid ? `https://coverartarchive.org/release/${releaseMbid}/front-500` : undefined; 12 + } 13 + </script> 14 + 15 + {#if playing} 16 + {@const coverArtUrl = getCoverArtUrl(playing)} 17 + 18 + {#if coverArtUrl} 19 + <div class="now-playing-bg relative flex h-full w-full items-end"> 20 + <img class="absolute inset-0 -z-10 size-full object-cover" src={coverArtUrl} alt="" /> 21 + <div 22 + class="absolute inset-0 -z-10 bg-linear-to-t from-black/80 via-black/40 to-transparent to-50%" 23 + ></div> 24 + <div class="now-playing-content z-10 flex w-full items-end p-4"> 25 + <div class="now-playing-info min-w-0 flex-1"> 26 + <div class="text-xs text-white/70">Now Playing</div> 27 + <div class="min-w-0 truncate text-lg font-semibold text-white"> 28 + {playing.track_metadata.track_name} 29 + </div> 30 + <div class="min-w-0 truncate text-sm text-white/80"> 31 + {playing.track_metadata.artist_name} 32 + </div> 33 + </div> 34 + </div> 35 + </div> 36 + {:else} 37 + <div class="z-10 flex h-full w-full items-center justify-center p-4"> 38 + <div class="flex w-full items-center gap-4"> 39 + <div class="bg-base-200 flex size-14 shrink-0 items-center justify-center rounded-lg"> 40 + <svg 41 + xmlns="http://www.w3.org/2000/svg" 42 + fill="none" 43 + viewBox="0 0 24 24" 44 + stroke-width="2" 45 + stroke="currentColor" 46 + class="size-6" 47 + > 48 + <path 49 + stroke-linecap="round" 50 + stroke-linejoin="round" 51 + d="m9 9 10.5-3m0 6.553v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 1 1-.99-3.467l2.31-.66a2.25 2.25 0 0 0 1.632-2.163Zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 0 1-.99-3.467l2.31-.66A2.25 2.25 0 0 0 9 15.553Z" 52 + /> 53 + </svg> 54 + </div> 55 + <div class="min-w-0 flex-1"> 56 + <div class="text-base-500 dark:text-base-400 text-xs">Now Playing</div> 57 + <div 58 + class="text-accent-500 accent:text-accent-950 min-w-0 truncate text-lg font-semibold" 59 + > 60 + {playing.track_metadata.track_name} 61 + </div> 62 + <div class="min-w-0 truncate text-sm whitespace-nowrap"> 63 + {playing.track_metadata.artist_name} 64 + </div> 65 + </div> 66 + </div> 67 + </div> 68 + {/if} 69 + {:else} 70 + <div 71 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 72 + > 73 + Not currently playing. 74 + </div> 75 + {/if} 76 + 77 + <style> 78 + :global(:root), 79 + :global(*) { 80 + --dw: 4; 81 + } 82 + 83 + @media (min-width: 1024px) { 84 + .now-playing-bg { 85 + --dw: var(--dw, 4); 86 + } 87 + 88 + .now-playing-info { 89 + display: none; 90 + } 91 + 92 + @container card (width >= 4rem) { 93 + .now-playing-info { 94 + display: block; 95 + } 96 + } 97 + } 98 + </style>
+52
src/lib/cards/media/ListenBrainzCard/ListenBrainzNowPlayingCard/index.ts
··· 1 + import ListenBrainzNowPlayingCard from './ListenBrainzNowPlayingCard.svelte'; 2 + import CreateListenBrainzCardModal from '../CreateListenBrainzCardModal.svelte'; 3 + import { nowPlaying } from './nowplaying.remote'; 4 + import type { CardDefinition } from '../../../types'; 5 + 6 + export const ListenBrainzNowPlayingCardDefinition = { 7 + type: 'listenbrainzNowPlaying', 8 + contentComponent: ListenBrainzNowPlayingCard, 9 + creationModalComponent: CreateListenBrainzCardModal, 10 + createNew: (card) => { 11 + card.w = 2; 12 + card.mobileW = 4; 13 + card.h = 2; 14 + card.mobileH = 3; 15 + }, 16 + loadData: async (items) => { 17 + const allData: Record<string, unknown> = {}; 18 + for (const item of items) { 19 + const username = item.cardData.username; 20 + if (!username) continue; 21 + try { 22 + const data = await nowPlaying(username); 23 + if (data && typeof data !== 'boolean') allData[`listenbrainzNowPlaying:${username}`] = data; 24 + } catch (error) { 25 + console.error('Failed to fetch ListenBrainz now playing:', error); 26 + } 27 + } 28 + return allData; 29 + }, 30 + loadDataServer: async (items) => { 31 + const allData: Record<string, unknown> = {}; 32 + for (const item of items) { 33 + const username = item.cardData.username; 34 + if (!username) continue; 35 + try { 36 + const data = await nowPlaying(username); 37 + if (data && typeof data !== 'boolean') allData[`listenbrainzNowPlaying:${username}`] = data; 38 + } catch (error) { 39 + console.error('Failed to fetch ListenBrainz now playing:', error); 40 + } 41 + } 42 + return allData; 43 + }, 44 + urlHandlerPriority: 5, 45 + minW: 2, 46 + minH: 2, 47 + canHaveLabel: true, 48 + name: 'ListenBrainz Now Playing', 49 + keywords: ['music', 'scrobble', 'now playing', 'listening', 'listenbrainz', 'brainz'], 50 + groups: ['Media'], 51 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="m9 9 10.5-3m0 6.553v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 1 1-.99-3.467l2.31-.66a2.25 2.25 0 0 0 1.632-2.163Zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 0 1-.99-3.467l2.31-.66A2.25 2.25 0 0 0 9 15.553Z" /></svg>` 52 + } as CardDefinition & { type: 'listenbrainzNowPlaying' };
+33
src/lib/cards/media/ListenBrainzCard/ListenBrainzNowPlayingCard/nowplaying.remote.ts
··· 1 + import { listenBrainzFetch, usernameSchema } from '../shared.server'; 2 + import { query, getRequestEvent } from '$app/server'; 3 + import { createCache } from '$lib/cache'; 4 + import type { Listen } from '../types'; 5 + import { error } from '@sveltejs/kit'; 6 + 7 + interface ResponseData { 8 + payload: { 9 + listens: Listen[]; 10 + }; 11 + } 12 + 13 + export const nowPlaying = query(usernameSchema, async (username): Promise<Listen | null> => { 14 + const { platform } = getRequestEvent(); 15 + const cache = createCache(platform); 16 + 17 + const cacheKey = `nowPlaying:${username}`; 18 + const cached = await cache?.get('listenbrainz', cacheKey); 19 + if (cached) return JSON.parse(cached); 20 + 21 + const data = await listenBrainzFetch<ResponseData>(`/1/user/${username}/playing-now`); 22 + 23 + if (data instanceof Error) { 24 + error(500, 'failed to fetch from ListenBrainz'); 25 + } 26 + 27 + if (!data.payload.listens.length) { 28 + return null; 29 + } 30 + 31 + await cache?.put('listenbrainz', cacheKey, JSON.stringify(data.payload.listens[0]), 60); 32 + return data.payload.listens[0]; 33 + });
+39
src/lib/cards/media/ListenBrainzCard/ListenBrainzRecentListensCard/ListenBrainzRecentListensCard.svelte
··· 1 + <script lang="ts"> 2 + import type { ContentComponentProps } from '$lib/cards/types'; 3 + import { recentListens } from './listens.remote'; 4 + import { RelativeTime } from '@foxui/time'; 5 + import CoverArt from '../CoverArt.svelte'; 6 + 7 + const { item }: ContentComponentProps = $props(); 8 + const listens = $derived(await recentListens(item.cardData.username)); 9 + </script> 10 + 11 + <div class="z-10 flex h-full w-full flex-col gap-3 overflow-y-scroll p-4"> 12 + {#each listens as listen, i (`${listen.listened_at}-${i}`)} 13 + <div class="flex w-full items-center gap-3"> 14 + <CoverArt mbid={listen.track_metadata?.additional_info?.release_mbid} alt="cover art" /> 15 + 16 + <div class="min-w-0 flex-1"> 17 + <div class="inline-flex w-full max-w-full justify-between gap-2"> 18 + <p class="min-w-0 flex-1 shrink truncate font-semibold"> 19 + {listen.track_metadata.track_name} 20 + </p> 21 + 22 + <p class="shrink-0 text-xs"> 23 + <RelativeTime date={new Date(listen.listened_at * 1000)} locale="en-US" /> ago 24 + </p> 25 + </div> 26 + 27 + <p class="my-1 min-w-0 truncate text-xs whitespace-nowrap"> 28 + {listen.track_metadata.artist_name} 29 + </p> 30 + </div> 31 + </div> 32 + {:else} 33 + <p 34 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 35 + > 36 + No recent listens found. 37 + </p> 38 + {/each} 39 + </div>
+53
src/lib/cards/media/ListenBrainzCard/ListenBrainzRecentListensCard/index.ts
··· 1 + import ListenBrainzRecentListensCard from './ListenBrainzRecentListensCard.svelte'; 2 + import CreateListenBrainzCardModal from '../CreateListenBrainzCardModal.svelte'; 3 + import { recentListens } from './listens.remote'; 4 + import type { CardDefinition } from '../../../types'; 5 + 6 + export const ListenBrainzRecentListensCardDefinition = { 7 + type: 'listenbrainzRecentListens', 8 + contentComponent: ListenBrainzRecentListensCard, 9 + creationModalComponent: CreateListenBrainzCardModal, 10 + createNew: (card) => { 11 + card.w = 4; 12 + card.mobileW = 8; 13 + card.h = 3; 14 + card.mobileH = 6; 15 + }, 16 + loadData: async (items) => { 17 + const allData: Record<string, unknown> = {}; 18 + for (const item of items) { 19 + const username = item.cardData.username; 20 + if (!username) continue; 21 + try { 22 + const data = await recentListens(username); 23 + if (data) allData[`listenbrainzRecentListens:${username}`] = data ?? []; 24 + } catch (error) { 25 + console.error('Failed to fetch ListenBrainz recent listens:', error); 26 + } 27 + } 28 + return allData; 29 + }, 30 + loadDataServer: async (items) => { 31 + const allData: Record<string, unknown> = {}; 32 + for (const item of items) { 33 + const username = item.cardData.username; 34 + if (!username) continue; 35 + try { 36 + const data = await recentListens(username); 37 + if (data) allData[`listenbrainzRecentListens:${username}`] = data ?? []; 38 + } catch (error) { 39 + console.error('Failed to fetch ListenBrainz recent listens:', error); 40 + } 41 + } 42 + return allData; 43 + }, 44 + 45 + urlHandlerPriority: 5, 46 + minW: 3, 47 + minH: 2, 48 + canHaveLabel: true, 49 + name: 'ListenBrainz Recent Listens', 50 + keywords: ['music', 'scrobble', 'listening', 'songs', 'listenbrainz', 'brainz'], 51 + groups: ['Media'], 52 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="m9 9 10.5-3m0 6.553v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 1 1-.99-3.467l2.31-.66a2.25 2.25 0 0 0 1.632-2.163Zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 0 1-.99-3.467l2.31-.66A2.25 2.25 0 0 0 9 15.553Z" /></svg>` 53 + } as CardDefinition & { type: 'listenbrainzRecentListens' };
+31
src/lib/cards/media/ListenBrainzCard/ListenBrainzRecentListensCard/listens.remote.ts
··· 1 + import { listenBrainzFetch, usernameSchema } from '../shared.server'; 2 + import { query, getRequestEvent } from '$app/server'; 3 + import { createCache } from '$lib/cache'; 4 + import type { Listen } from '../types'; 5 + import { error } from '@sveltejs/kit'; 6 + 7 + interface ResponseData { 8 + payload: { 9 + listens: Listen[]; 10 + }; 11 + } 12 + 13 + export const recentListens = query(usernameSchema, async (username): Promise<Listen[]> => { 14 + const { platform } = getRequestEvent(); 15 + const cache = createCache(platform); 16 + 17 + const cacheKey = `recentListens:${username}`; 18 + const cached = await cache?.get('listenbrainz', cacheKey); 19 + if (cached) return JSON.parse(cached); 20 + 21 + const data = await listenBrainzFetch<ResponseData>(`/1/user/${username}/listens`, { 22 + count: 50 23 + }); 24 + 25 + if (data instanceof Error) { 26 + error(500, 'failed to fetch from ListenBrainz'); 27 + } 28 + 29 + await cache?.put('listenbrainz', cacheKey, JSON.stringify(data.payload.listens), 15 * 60); 30 + return data.payload.listens; 31 + });
+31
src/lib/cards/media/ListenBrainzCard/ListenBrainzTopAlbumsCard/ListenBrainzTopAlbumsCard.svelte
··· 1 + <script lang="ts"> 2 + import type { ContentComponentProps } from '$lib/cards/types'; 3 + import { topAlbums } from './albums.remote'; 4 + import CoverArt from '../CoverArt.svelte'; 5 + 6 + const { item }: ContentComponentProps = $props(); 7 + const albums = $derived(await topAlbums(item.cardData.username)); 8 + </script> 9 + 10 + <div class="z-10 flex h-full w-full flex-col gap-3 overflow-y-scroll p-4"> 11 + {#each albums as album, i (album.release_group_mbid || album.release_group_name + i)} 12 + <div class="flex w-full items-center gap-3"> 13 + <div class="text-base-400 flex w-6 shrink-0 items-center justify-center text-xs font-bold"> 14 + {i + 1} 15 + </div> 16 + <CoverArt mbid={album.caa_release_mbid} alt="cover art" /> 17 + <div class="min-w-0 flex-1"> 18 + <div class="truncate font-semibold">{album.release_group_name}</div> 19 + <div class="text-base-500 text-xs"> 20 + {album.artist_name} · {album.listen_count} listens 21 + </div> 22 + </div> 23 + </div> 24 + {:else} 25 + <div 26 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 27 + > 28 + No top albums found. 29 + </div> 30 + {/each} 31 + </div>
+33
src/lib/cards/media/ListenBrainzCard/ListenBrainzTopAlbumsCard/albums.remote.ts
··· 1 + import { listenBrainzFetch, usernameSchema } from '../shared.server'; 2 + import { query, getRequestEvent } from '$app/server'; 3 + import type { ReleaseGroup } from '../types'; 4 + import { createCache } from '$lib/cache'; 5 + import { error } from '@sveltejs/kit'; 6 + 7 + interface ResponseData { 8 + payload: { 9 + release_groups: ReleaseGroup[]; 10 + }; 11 + } 12 + 13 + export const topAlbums = query(usernameSchema, async (username): Promise<ReleaseGroup[]> => { 14 + const { platform } = getRequestEvent(); 15 + const cache = createCache(platform); 16 + 17 + const cacheKey = `topAlbums:${username}`; 18 + const cached = await cache?.get('listenbrainz', cacheKey); 19 + if (cached) return JSON.parse(cached); 20 + 21 + const data = await listenBrainzFetch<ResponseData>(`/1/stats/user/${username}/release-groups`, { 22 + count: 50, 23 + range: 'week' 24 + }); 25 + 26 + if (data instanceof Error) { 27 + error(500, 'failed to fetch from ListenBrainz'); 28 + } 29 + 30 + await cache?.put('listenbrainz', cacheKey, JSON.stringify(data.payload.release_groups), 60 * 60); 31 + 32 + return data.payload.release_groups; 33 + });
+53
src/lib/cards/media/ListenBrainzCard/ListenBrainzTopAlbumsCard/index.ts
··· 1 + import CreateListenBrainzCardModal from '../CreateListenBrainzCardModal.svelte'; 2 + import ListenBrainzTopAlbumsCard from './ListenBrainzTopAlbumsCard.svelte'; 3 + import { topAlbums } from './albums.remote'; 4 + import type { CardDefinition } from '../../../types'; 5 + 6 + export const ListenBrainzTopAlbumsCardDefinition = { 7 + type: 'listenbrainzTopAlbums', 8 + contentComponent: ListenBrainzTopAlbumsCard, 9 + creationModalComponent: CreateListenBrainzCardModal, 10 + createNew: (card) => { 11 + card.w = 4; 12 + card.h = 3; 13 + card.mobileW = 8; 14 + card.mobileH = 4; 15 + }, 16 + loadData: async (items) => { 17 + const allData: Record<string, unknown> = {}; 18 + for (const item of items) { 19 + const username = item.cardData.username; 20 + if (!username) continue; 21 + try { 22 + const data = await topAlbums(username); 23 + if (data) allData[`listenbrainzTopAlbums:${username}`] = data ?? []; 24 + } catch (error) { 25 + console.error('Failed to fetch ListenBrainz top albums:', error); 26 + } 27 + } 28 + return allData; 29 + }, 30 + loadDataServer: async (items) => { 31 + const allData: Record<string, unknown> = {}; 32 + for (const item of items) { 33 + const username = item.cardData.username; 34 + if (!username) continue; 35 + try { 36 + const data = await topAlbums(username); 37 + if (data) allData[`listenbrainzTopAlbums:${username}`] = data ?? []; 38 + } catch (error) { 39 + console.error('Failed to fetch ListenBrainz top albums:', error); 40 + } 41 + } 42 + return allData; 43 + }, 44 + allowSetColor: true, 45 + defaultColor: 'base', 46 + minW: 2, 47 + minH: 2, 48 + canHaveLabel: true, 49 + name: 'ListenBrainz Top Albums', 50 + keywords: ['music', 'scrobble', 'albums', 'release', 'groups', 'listenbrainz', 'brainz', 'top'], 51 + groups: ['Media'], 52 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="m9 9 10.5-3m0 6.553v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 1 1-.99-3.467l2.31-.66a2.25 2.25 0 0 0 1.632-2.163Zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 0 1-.99-3.467l2.31-.66A2.25 2.25 0 0 0 9 15.553Z" /></svg>` 53 + } as CardDefinition & { type: 'listenbrainzTopAlbums' };
+27
src/lib/cards/media/ListenBrainzCard/ListenBrainzTopArtistsCard/ListenBrainzTopArtistsCard.svelte
··· 1 + <script lang="ts"> 2 + import type { ContentComponentProps } from '$lib/cards/types'; 3 + import { topArtists } from './artists.remote'; 4 + 5 + const { item }: ContentComponentProps = $props(); 6 + const artists = $derived(await topArtists(item.cardData.username)); 7 + </script> 8 + 9 + <div class="z-10 flex h-full w-full flex-col gap-3 overflow-y-scroll p-4"> 10 + {#each artists as artist, i (artist.artist_mbid || artist.artist_name + i)} 11 + <div class="flex w-full items-center gap-3"> 12 + <div class="text-base-400 flex w-6 shrink-0 items-center justify-center text-xs font-bold"> 13 + {i + 1} 14 + </div> 15 + <div class="min-w-0 flex-1"> 16 + <div class="truncate font-semibold">{artist.artist_name}</div> 17 + <div class="text-base-500 text-xs">{artist.listen_count} listens</div> 18 + </div> 19 + </div> 20 + {:else} 21 + <div 22 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 23 + > 24 + No top artists found. 25 + </div> 26 + {/each} 27 + </div>
+34
src/lib/cards/media/ListenBrainzCard/ListenBrainzTopArtistsCard/artists.remote.ts
··· 1 + import { listenBrainzFetch, usernameSchema } from '../shared.server'; 2 + import { query, getRequestEvent } from '$app/server'; 3 + import { createCache } from '$lib/cache'; 4 + import type { Artist } from '../types'; 5 + import { error } from '@sveltejs/kit'; 6 + 7 + interface ResponseData { 8 + payload: { 9 + artists: Artist[]; 10 + }; 11 + } 12 + 13 + export const topArtists = query(usernameSchema, async (username): Promise<Artist[] | null> => { 14 + if (!username) return null; 15 + 16 + const { platform } = getRequestEvent(); 17 + const cache = createCache(platform); 18 + 19 + const cacheKey = `topArtists:${username}`; 20 + const cached = await cache?.get('listenbrainz', cacheKey); 21 + if (cached) return JSON.parse(cached); 22 + 23 + const data = await listenBrainzFetch<ResponseData>(`/1/stats/user/${username}/artists`, { 24 + count: 50, 25 + range: 'week' 26 + }); 27 + 28 + if (data instanceof Error) { 29 + error(500, 'failed to fetch from ListenBrainz'); 30 + } 31 + 32 + await cache?.put('listenbrainz', cacheKey, JSON.stringify(data.payload.artists), 60 * 60); 33 + return data.payload.artists; 34 + });
+53
src/lib/cards/media/ListenBrainzCard/ListenBrainzTopArtistsCard/index.ts
··· 1 + import CreateListenBrainzCardModal from '../CreateListenBrainzCardModal.svelte'; 2 + import ListenBrainzTopArtistsCard from './ListenBrainzTopArtistsCard.svelte'; 3 + import { topArtists } from './artists.remote'; 4 + import type { CardDefinition } from '../../../types'; 5 + 6 + export const ListenBrainzTopArtistsCardDefinition = { 7 + type: 'listenbrainzTopArtists', 8 + contentComponent: ListenBrainzTopArtistsCard, 9 + creationModalComponent: CreateListenBrainzCardModal, 10 + createNew: (card) => { 11 + card.w = 4; 12 + card.h = 3; 13 + card.mobileW = 8; 14 + card.mobileH = 4; 15 + }, 16 + loadData: async (items) => { 17 + const allData: Record<string, unknown> = {}; 18 + for (const item of items) { 19 + const username = item.cardData.username; 20 + if (!username) continue; 21 + try { 22 + const data = await topArtists(username); 23 + if (data) allData[`listenbrainzTopArtists:${username}`] = data ?? []; 24 + } catch (error) { 25 + console.error('Failed to fetch ListenBrainz top artists:', error); 26 + } 27 + } 28 + return allData; 29 + }, 30 + loadDataServer: async (items) => { 31 + const allData: Record<string, unknown> = {}; 32 + for (const item of items) { 33 + const username = item.cardData.username; 34 + if (!username) continue; 35 + try { 36 + const data = await topArtists(username); 37 + if (data) allData[`listenbrainzTopArtists:${username}`] = data ?? []; 38 + } catch (error) { 39 + console.error('Failed to fetch ListenBrainz top artists:', error); 40 + } 41 + } 42 + return allData; 43 + }, 44 + allowSetColor: true, 45 + defaultColor: 'base', 46 + minW: 2, 47 + minH: 2, 48 + canHaveLabel: true, 49 + name: 'ListenBrainz Top Artists', 50 + keywords: ['music', 'scrobble', 'artists', 'listenbrainz', 'brainz', 'top'], 51 + groups: ['Media'], 52 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="m9 9 10.5-3m0 6.553v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 1 1-.99-3.467l2.31-.66a2.25 2.25 0 0 0 1.632-2.163Zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 0 1-.99-3.467l2.31-.66A2.25 2.25 0 0 0 9 15.553Z" /></svg>` 53 + } as CardDefinition & { type: 'listenbrainzTopArtists' };
+31
src/lib/cards/media/ListenBrainzCard/ListenBrainzTopSongsCard/ListenBrainzTopSongsCard.svelte
··· 1 + <script lang="ts"> 2 + import { fetchListenBrainzTopSongs } from './recordings.remote'; 3 + import type { ContentComponentProps } from '$lib/cards/types'; 4 + import CoverArt from '../CoverArt.svelte'; 5 + 6 + const { item }: ContentComponentProps = $props(); 7 + const recordings = $derived(await fetchListenBrainzTopSongs(item.cardData.username)); 8 + </script> 9 + 10 + <div class="z-10 flex h-full w-full flex-col gap-3 overflow-y-scroll p-4"> 11 + {#each recordings as recording, i (recording.recording_mbid || recording.track_name + i)} 12 + <div class="flex w-full items-center gap-3"> 13 + <div class="text-base-400 flex w-6 shrink-0 items-center justify-center text-xs font-bold"> 14 + {i + 1} 15 + </div> 16 + <CoverArt mbid={recording.caa_release_mbid} alt="cover art" /> 17 + <div class="min-w-0 flex-1"> 18 + <div class="truncate font-semibold">{recording.track_name}</div> 19 + <div class="text-base-500 truncate text-xs"> 20 + {recording.artist_name} · {recording.listen_count} plays 21 + </div> 22 + </div> 23 + </div> 24 + {:else} 25 + <div 26 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 27 + > 28 + No top songs found. 29 + </div> 30 + {/each} 31 + </div>
+51
src/lib/cards/media/ListenBrainzCard/ListenBrainzTopSongsCard/index.ts
··· 1 + import ListenBrainzTopSongsCard from './ListenBrainzTopSongsCard.svelte'; 2 + import CreateListenBrainzCardModal from '../CreateListenBrainzCardModal.svelte'; 3 + import { fetchListenBrainzTopSongs } from './recordings.remote'; 4 + import type { CardDefinition } from '../../../types'; 5 + 6 + export const ListenBrainzTopSongsCardDefinition = { 7 + type: 'listenbrainzTopSongs', 8 + contentComponent: ListenBrainzTopSongsCard, 9 + creationModalComponent: CreateListenBrainzCardModal, 10 + createNew: (card) => { 11 + card.w = 4; 12 + card.mobileW = 8; 13 + card.h = 3; 14 + card.mobileH = 6; 15 + }, 16 + loadData: async (items) => { 17 + const allData: Record<string, unknown> = {}; 18 + for (const item of items) { 19 + const username = item.cardData.username; 20 + if (!username) continue; 21 + try { 22 + const data = await fetchListenBrainzTopSongs(username); 23 + if (data) allData[`listenBrainzTopSongs:${username}`] = data ?? []; 24 + } catch (error) { 25 + console.error('Failed to fetch ListenBrainz top songs:', error); 26 + } 27 + } 28 + return allData; 29 + }, 30 + loadDataServer: async (items) => { 31 + const allData: Record<string, unknown> = {}; 32 + for (const item of items) { 33 + const username = item.cardData.username; 34 + if (!username) continue; 35 + try { 36 + const data = await fetchListenBrainzTopSongs(username); 37 + if (data) allData[`listenBrainzTopSongs:${username}`] = data ?? []; 38 + } catch (error) { 39 + console.error('Failed to fetch ListenBrainz top songs:', error); 40 + } 41 + } 42 + return allData; 43 + }, 44 + minW: 3, 45 + minH: 2, 46 + canHaveLabel: true, 47 + name: 'ListenBrainz Top Songs', 48 + keywords: ['music', 'scrobble', 'songs', 'listenbrainz', 'brainz', 'top'], 49 + groups: ['Media'], 50 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="m9 9 10.5-3m0 6.553v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 1 1-.99-3.467l2.31-.66a2.25 2.25 0 0 0 1.632-2.163Zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 0 1-.99-3.467l2.31-.66A2.25 2.25 0 0 0 9 15.553Z" /></svg>` 51 + } as CardDefinition & { type: 'listenbrainzTopSongs' };
+38
src/lib/cards/media/ListenBrainzCard/ListenBrainzTopSongsCard/recordings.remote.ts
··· 1 + import { listenBrainzFetch, usernameSchema } from '../shared.server'; 2 + import { query, getRequestEvent } from '$app/server'; 3 + import type { Recording } from '../types'; 4 + import { createCache } from '$lib/cache'; 5 + import { error } from '@sveltejs/kit'; 6 + 7 + interface ResponseData { 8 + payload: { 9 + recordings: Recording[]; 10 + }; 11 + } 12 + 13 + export const fetchListenBrainzTopSongs = query( 14 + usernameSchema, 15 + async (username): Promise<Recording[] | null> => { 16 + if (!username) return null; 17 + 18 + const { platform } = getRequestEvent(); 19 + const cache = createCache(platform); 20 + 21 + const cacheKey = `topSongs:${username}`; 22 + const cached = await cache?.get('listenbrainz', cacheKey); 23 + if (cached) return JSON.parse(cached); 24 + 25 + const data = await listenBrainzFetch<ResponseData>(`/1/stats/user/${username}/recordings`, { 26 + range: 'week', 27 + count: 10, 28 + offset: 0 29 + }); 30 + 31 + if (data instanceof Error) { 32 + error(500, 'failed to fetch from ListenBrainz'); 33 + } 34 + 35 + await cache?.put('listenbrainz', cacheKey, JSON.stringify(data.payload.recordings), 60 * 60); 36 + return data.payload.recordings; 37 + } 38 + );
+51
src/lib/cards/media/ListenBrainzCard/shared.server.ts
··· 1 + import * as v from 'valibot'; 2 + 3 + /** 4 + * MusicBrainz username schema. 5 + * They are very broad in what they allow so this is unfortunately very loose 6 + */ 7 + export const usernameSchema = v.pipe(v.string(), v.trim(), v.minLength(1)); 8 + 9 + /** 10 + * Fetch from the ListenBrainz API. Returning the requested data, or null 11 + * if an error occurs. 12 + * 13 + * @param endpoint - The base endpoint to fetch from (e.g. `/1/user/${username}/now-playing`). 14 + * @param params - Optional query parameters to append to the URL. 15 + */ 16 + export async function listenBrainzFetch<R>( 17 + endpoint: string, 18 + params?: Record<string, string | number> 19 + ): Promise<R | Error> { 20 + try { 21 + const url = new URL(endpoint, 'https://api.listenbrainz.org'); 22 + 23 + if (params) { 24 + for (const [key, value] of Object.entries(params)) { 25 + url.searchParams.set(key, value.toString()); 26 + } 27 + } 28 + 29 + const response = await fetch(url, { 30 + headers: { 31 + Accept: 'application/json', 32 + 'User-Agent': 'Blento +https://github.com/flo-bit/blento' 33 + } 34 + }); 35 + 36 + if (!response.ok) { 37 + throw new Error(`response not ok: ${response.status}`); 38 + } 39 + 40 + const data = await response.json(); 41 + return data as R; 42 + } catch (e) { 43 + const error = new Error( 44 + `failed to fetch from ListenBrainz at "${endpoint}": ${e instanceof Error ? e.message : e}`, 45 + { cause: e } 46 + ); 47 + 48 + console.error(error); 49 + return error; 50 + } 51 + }
+38
src/lib/cards/media/ListenBrainzCard/types.ts
··· 1 + export interface Listen { 2 + listened_at: number; 3 + track_metadata: { 4 + artist_name: string; 5 + track_name: string; 6 + release_name?: string; 7 + additional_info?: { 8 + release_mbid?: string; 9 + }; 10 + }; 11 + } 12 + 13 + export interface ReleaseGroup { 14 + release_group_mbid: string; 15 + release_group_name: string; 16 + artist_name: string; 17 + artist_mbids?: string[]; 18 + listen_count: number; 19 + caa_id?: number; 20 + caa_release_mbid?: string; 21 + } 22 + 23 + export interface Artist { 24 + artist_name: string; 25 + artist_mbid?: string; 26 + listen_count: number; 27 + } 28 + 29 + export interface Recording { 30 + track_name: string; 31 + recording_mbid?: string; 32 + artist_name: string; 33 + artist_mbids?: string[]; 34 + release_name?: string; 35 + release_mbid?: string; 36 + caa_release_mbid?: string; 37 + listen_count: number; 38 + }