WIP: My personal website
0

Configure Feed

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

wip on some more history type stuff

+1103 -312
+1
.gitignore
··· 27 27 .playwright-mcp/ 28 28 cache.db 29 29 cache-live.db 30 + cache-history.db
+95
lexicons/fm/teal/alpha/feed/play.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "fm.teal.alpha.feed.play", 4 + "description": "This lexicon is in a not officially released state. It is subject to change. | A record of a single track play. NOTE: this schema is authored locally for this site; MusicBrainz ID fields are typed as plain strings (not format:uri) because real records store bare UUIDs, and @atproto/lex would otherwise silently drop those records during list().", 5 + "defs": { 6 + "main": { 7 + "type": "record", 8 + "description": "A track that was played.", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": ["trackName", "artists"], 13 + "properties": { 14 + "trackName": { 15 + "type": "string", 16 + "minLength": 1, 17 + "maxLength": 256, 18 + "maxGraphemes": 2560, 19 + "description": "The name of the track" 20 + }, 21 + "trackMbId": { 22 + "type": "string", 23 + "description": "The MusicBrainz ID of the track" 24 + }, 25 + "recordingMbId": { 26 + "type": "string", 27 + "description": "The MusicBrainz recording ID of the track" 28 + }, 29 + "duration": { 30 + "type": "integer", 31 + "description": "The length of the track in seconds" 32 + }, 33 + "artists": { 34 + "type": "array", 35 + "items": { 36 + "type": "ref", 37 + "ref": "#artist" 38 + }, 39 + "description": "Array of artists in order of original appearance." 40 + }, 41 + "releaseName": { 42 + "type": "string", 43 + "maxLength": 256, 44 + "maxGraphemes": 2560, 45 + "description": "The name of the release/album" 46 + }, 47 + "releaseMbId": { 48 + "type": "string", 49 + "description": "The MusicBrainz release ID" 50 + }, 51 + "isrc": { 52 + "type": "string", 53 + "description": "The ISRC code associated with the recording" 54 + }, 55 + "originUrl": { 56 + "type": "string", 57 + "description": "The URL associated with this track" 58 + }, 59 + "musicServiceBaseDomain": { 60 + "type": "string", 61 + "description": "The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com." 62 + }, 63 + "submissionClientAgent": { 64 + "type": "string", 65 + "maxLength": 256, 66 + "maxGraphemes": 2560, 67 + "description": "A user-agent style string specifying the submission client." 68 + }, 69 + "playedTime": { 70 + "type": "string", 71 + "format": "datetime", 72 + "description": "The time the track was played" 73 + } 74 + } 75 + } 76 + }, 77 + "artist": { 78 + "type": "object", 79 + "required": ["artistName"], 80 + "properties": { 81 + "artistName": { 82 + "type": "string", 83 + "minLength": 1, 84 + "maxLength": 256, 85 + "maxGraphemes": 2560, 86 + "description": "The name of the artist" 87 + }, 88 + "artistMbId": { 89 + "type": "string", 90 + "description": "The MusicBrainz artist ID" 91 + } 92 + } 93 + } 94 + } 95 + }
+112
src/lib/components/BookHistory.svelte
··· 1 + <script lang="ts"> 2 + import { reveal } from '$lib/actions/reveal'; 3 + import type { CurrentlyReading, FinishedBook } from '$lib/types'; 4 + 5 + let { 6 + currentlyReading, 7 + finishedBooks 8 + }: { currentlyReading: CurrentlyReading | null; finishedBooks: FinishedBook[] } = $props(); 9 + 10 + function finishedLabel(iso: string | null): string { 11 + if (!iso) return ''; 12 + const date = new Date(iso); 13 + if (Number.isNaN(date.getTime())) return ''; 14 + return date.toLocaleDateString('en', { month: 'short', year: 'numeric' }); 15 + } 16 + </script> 17 + 18 + <section class="mx-auto mt-24 max-w-4xl px-2 md:mt-32"> 19 + <div class="flex flex-col items-center justify-center"> 20 + <h1 class="text-center font-urbanist text-3xl font-semibold md:text-5xl">Books</h1> 21 + <span class="text-md mt-2 px-2 text-center font-urbanist opacity-70 md:mt-4 md:text-xl"> 22 + What I'm reading &amp; what I've recently finished 23 + </span> 24 + </div> 25 + 26 + {#if currentlyReading} 27 + <div class="card mx-auto mt-10 max-w-xl bg-base-100 shadow-sm"> 28 + <div class="card-body flex-row items-start gap-5"> 29 + {#if currentlyReading.cover} 30 + <img 31 + class="h-40 w-28 flex-none rounded-md object-cover shadow-sm" 32 + src={currentlyReading.cover} 33 + alt={currentlyReading.title} 34 + loading="lazy" 35 + /> 36 + {/if} 37 + <div class="flex flex-col"> 38 + <span class="badge font-urbanist badge-primary">Currently reading</span> 39 + <h2 class="mt-2 card-title font-urbanist text-2xl font-black"> 40 + {currentlyReading.title} 41 + </h2> 42 + <p class="text-md font-urbanist font-medium opacity-60">{currentlyReading.authors}</p> 43 + <div class="mt-3 card-actions"> 44 + <a 45 + class="btn font-urbanist btn-sm btn-primary" 46 + href={currentlyReading.bookUrl} 47 + target="_blank" 48 + rel="noopener noreferrer" 49 + > 50 + Read 51 + </a> 52 + </div> 53 + </div> 54 + </div> 55 + </div> 56 + {/if} 57 + 58 + {#if finishedBooks.length > 0} 59 + <h3 class="mt-14 text-center font-urbanist text-xl font-semibold opacity-80 md:text-2xl"> 60 + Recently finished 61 + </h3> 62 + <div class="mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> 63 + {#each finishedBooks as book (book.bookUrl)} 64 + <div class="card bg-base-100 shadow-sm transition duration-300 hover:-translate-y-1"> 65 + <div class="card-body flex-row items-start gap-4"> 66 + {#if book.cover} 67 + <img 68 + class="h-32 w-22 flex-none rounded-md object-cover shadow-sm" 69 + src={book.cover} 70 + alt={book.title} 71 + loading="lazy" 72 + /> 73 + {/if} 74 + <div class="flex min-w-0 flex-col"> 75 + <h4 class="font-urbanist text-lg font-black">{book.title}</h4> 76 + <p class="font-urbanist text-sm font-medium opacity-60">{book.authors}</p> 77 + {#if book.stars} 78 + <p 79 + class="mt-1 font-urbanist text-sm text-warning" 80 + aria-label={`${book.stars} stars`} 81 + > 82 + {'★'.repeat(Math.round(book.stars))}<span class="opacity-30" 83 + >{'★'.repeat(Math.max(0, 5 - Math.round(book.stars)))}</span 84 + > 85 + </p> 86 + {/if} 87 + {#if book.finishedAt} 88 + <p class="mt-auto pt-2 font-urbanist text-xs opacity-40"> 89 + {finishedLabel(book.finishedAt)} 90 + </p> 91 + {/if} 92 + <div class="mt-2 card-actions"> 93 + <a 94 + class="btn font-urbanist btn-ghost btn-xs" 95 + href={book.bookUrl} 96 + target="_blank" 97 + rel="noopener noreferrer" 98 + > 99 + View 100 + </a> 101 + </div> 102 + </div> 103 + </div> 104 + </div> 105 + {/each} 106 + </div> 107 + {/if} 108 + 109 + {#if !currentlyReading && finishedBooks.length === 0} 110 + <p class="mt-10 text-center font-urbanist opacity-60">Nothing to show right now.</p> 111 + {/if} 112 + </section>
+106
src/lib/components/MusicHistory.svelte
··· 1 + <script lang="ts"> 2 + import { reveal } from '$lib/actions/reveal'; 3 + import type { RecentPlay } from '$lib/types'; 4 + 5 + let { recentPlays }: { recentPlays: RecentPlay[] } = $props(); 6 + 7 + // Track which rows' cover art failed to load so we can swap in the fallback. 8 + let coverFailed = $state<Record<number, boolean>>({}); 9 + 10 + const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); 11 + const DIVISIONS: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [ 12 + { amount: 60, unit: 'seconds' }, 13 + { amount: 60, unit: 'minutes' }, 14 + { amount: 24, unit: 'hours' }, 15 + { amount: 7, unit: 'days' }, 16 + { amount: 4.34524, unit: 'weeks' }, 17 + { amount: 12, unit: 'months' }, 18 + { amount: Number.POSITIVE_INFINITY, unit: 'years' } 19 + ]; 20 + 21 + function relativeTime(iso: string | null): string { 22 + if (!iso) return ''; 23 + const date = new Date(iso); 24 + if (Number.isNaN(date.getTime())) return ''; 25 + let duration = (date.getTime() - Date.now()) / 1000; 26 + for (const division of DIVISIONS) { 27 + if (Math.abs(duration) < division.amount) { 28 + return rtf.format(Math.round(duration), division.unit); 29 + } 30 + duration /= division.amount; 31 + } 32 + return ''; 33 + } 34 + </script> 35 + 36 + <section class="mx-auto mt-24 max-w-3xl px-2 md:mt-32"> 37 + <div class="flex flex-col items-center justify-center"> 38 + <h1 class="text-center font-urbanist text-3xl font-semibold md:text-5xl">Music</h1> 39 + <span class="text-md mt-2 px-2 text-center font-urbanist opacity-70 md:mt-4 md:text-xl"> 40 + Tracks I've been listening to recently 41 + </span> 42 + </div> 43 + 44 + {#if recentPlays.length === 0} 45 + <p class="mt-10 text-center font-urbanist opacity-60">Nothing to show right now.</p> 46 + {:else} 47 + <ul class="mt-10 flex flex-col gap-3"> 48 + {#each recentPlays as play, i (i)} 49 + <li 50 + class="card flex-row items-center gap-4 bg-base-100 p-3 shadow-sm transition duration-300 hover:-translate-y-0.5" 51 + > 52 + {#if play.coverArt && !coverFailed[i]} 53 + <img 54 + class="h-16 w-16 flex-none rounded-md object-cover shadow-sm" 55 + src={play.coverArt} 56 + alt={play.releaseName ?? play.trackName} 57 + onerror={() => (coverFailed[i] = true)} 58 + loading="lazy" 59 + /> 60 + {:else} 61 + <div 62 + class="flex h-16 w-16 flex-none items-center justify-center rounded-md bg-base-300" 63 + > 64 + <svg 65 + xmlns="http://www.w3.org/2000/svg" 66 + class="h-7 w-7 opacity-60" 67 + viewBox="0 0 24 24" 68 + fill="currentColor" 69 + > 70 + <path 71 + d="M12 3v10.55A4 4 0 1 0 14 17V7h4V3h-6zm-2 16a2 2 0 1 1 0-4 2 2 0 0 1 0 4z" 72 + /> 73 + </svg> 74 + </div> 75 + {/if} 76 + 77 + <div class="flex min-w-0 flex-1 flex-col"> 78 + <h2 class="truncate font-urbanist text-lg font-black">{play.trackName}</h2> 79 + <p class="truncate font-urbanist text-sm font-medium opacity-60">{play.artists}</p> 80 + {#if play.releaseName} 81 + <p class="truncate font-urbanist text-xs font-medium opacity-40"> 82 + {play.releaseName} 83 + </p> 84 + {/if} 85 + </div> 86 + 87 + <div class="flex flex-none flex-col items-end gap-2"> 88 + {#if play.playedTime} 89 + <span class="font-urbanist text-xs opacity-50">{relativeTime(play.playedTime)}</span> 90 + {/if} 91 + {#if play.url} 92 + <a 93 + class="btn font-urbanist btn-ghost btn-xs" 94 + href={play.url} 95 + target="_blank" 96 + rel="noopener noreferrer" 97 + > 98 + Listen 99 + </a> 100 + {/if} 101 + </div> 102 + </li> 103 + {/each} 104 + </ul> 105 + {/if} 106 + </section>
+75 -21
src/lib/components/NavBar.svelte
··· 1 1 <script lang="ts"> 2 - import { onMount } from 'svelte'; 3 - import { replaceState } from '$app/navigation'; 4 - import ThemeToggle from './ThemeToggle.svelte'; 5 2 import PumpkinHead from '$lib/assets/pumpkin_head.png'; 6 3 7 - const navigation = [ 8 - { name: 'Home', href: '#home' }, 9 - { name: 'Projects', href: '#projects' }, 10 - { name: 'Writings', href: '#writings' }, 11 - { name: 'Free Time', href: '#freetime' }, 12 - { name: 'Sponsors', href: '#sponsors' } 4 + type NavItem = { 5 + name: string; 6 + href?: string; 7 + children?: { name: string; href: string }[]; 8 + }; 9 + 10 + const navigation: NavItem[] = [ 11 + { name: 'Home', href: '/#home' }, 12 + { name: 'Projects', href: '/#projects' }, 13 + { name: 'Writings', href: '/#writings' }, 14 + { 15 + name: 'Free Time', 16 + children: [ 17 + { name: 'Music', href: '/history/music' }, 18 + { name: 'Books', href: '/history/books' } 19 + ] 20 + }, 21 + { name: 'Sponsors', href: '/#sponsors' } 13 22 ]; 14 23 15 24 let headSizes = $state([ ··· 36 45 }; 37 46 38 47 let active = $state(navigation[0].href); 48 + 49 + function onActivate(href: string | undefined) { 50 + if (href) active = href; 51 + } 39 52 40 53 // onMount(() => { 41 54 // const sections = navigation ··· 104 117 class="dropdown-content menu z-[1] mt-3 w-52 gap-2 menu-md rounded-box bg-base-100 p-2 shadow" 105 118 > 106 119 {#each navigation as item (item.name)} 107 - <li> 108 - <a href={item.href} class="font-urbanist">{item.name}</a> 109 - </li> 120 + {#if item.children} 121 + <li> 122 + <details> 123 + <summary class="font-urbanist">{item.name}</summary> 124 + <ul> 125 + {#each item.children as child (child.name)} 126 + <li><a href={child.href} class="font-urbanist">{child.name}</a></li> 127 + {/each} 128 + </ul> 129 + </details> 130 + </li> 131 + {:else} 132 + <li> 133 + <a href={item.href} class="font-urbanist">{item.name}</a> 134 + </li> 135 + {/if} 110 136 {/each} 111 137 </ul> 112 138 </div> ··· 117 143 <div class="ml-10 navbar-center hidden lg:flex"> 118 144 {#each navigation as item (item.name)} 119 145 <nav class="menu menu-horizontal px-1"> 120 - <a 121 - href={item.href} 122 - class="btn rounded-full font-urbanist text-sm font-light btn-ghost {active === item.href 123 - ? 'bg-base-300' 124 - : ''}" 125 - onclick={() => (active = item.href)} 126 - > 127 - {item.name} 128 - </a> 146 + {#if item.children} 147 + <div class="dropdown-hover dropdown"> 148 + <div 149 + tabindex="0" 150 + role="button" 151 + class="btn rounded-full font-urbanist text-sm font-light btn-ghost" 152 + > 153 + {item.name} 154 + </div> 155 + <ul 156 + class="dropdown-content menu z-[1] mt-2 w-40 gap-1 rounded-box bg-base-100 p-2 shadow" 157 + > 158 + {#each item.children as child (child.name)} 159 + <li> 160 + <a 161 + href={child.href} 162 + class="font-urbanist text-sm" 163 + onclick={() => (active = child.href)} 164 + > 165 + {child.name} 166 + </a> 167 + </li> 168 + {/each} 169 + </ul> 170 + </div> 171 + {:else} 172 + <a 173 + href={item.href} 174 + class="btn rounded-full font-urbanist text-sm font-light btn-ghost {active === 175 + item.href 176 + ? 'bg-base-300' 177 + : ''}" 178 + onclick={() => onActivate(item.href)} 179 + > 180 + {item.name} 181 + </a> 182 + {/if} 129 183 </nav> 130 184 {/each} 131 185 </div>
+1
src/lib/lexicons/fm/teal/alpha/feed.ts
··· 3 3 */ 4 4 5 5 export * as defs from './feed/defs.js' 6 + export * as play from './feed/play.js'
+154
src/lib/lexicons/fm/teal/alpha/feed/play.defs.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + import { l } from '@atproto/lex' 6 + 7 + const $nsid = 'fm.teal.alpha.feed.play' 8 + 9 + export { $nsid } 10 + 11 + /** A track that was played. */ 12 + type Main = { 13 + $type: 'fm.teal.alpha.feed.play' 14 + 15 + /** 16 + * The name of the track 17 + */ 18 + trackName: string 19 + 20 + /** 21 + * The MusicBrainz ID of the track 22 + */ 23 + trackMbId?: string 24 + 25 + /** 26 + * The MusicBrainz recording ID of the track 27 + */ 28 + recordingMbId?: string 29 + 30 + /** 31 + * The length of the track in seconds 32 + */ 33 + duration?: number 34 + 35 + /** 36 + * Array of artists in order of original appearance. 37 + */ 38 + artists: Artist[] 39 + 40 + /** 41 + * The name of the release/album 42 + */ 43 + releaseName?: string 44 + 45 + /** 46 + * The MusicBrainz release ID 47 + */ 48 + releaseMbId?: string 49 + 50 + /** 51 + * The ISRC code associated with the recording 52 + */ 53 + isrc?: string 54 + 55 + /** 56 + * The URL associated with this track 57 + */ 58 + originUrl?: string 59 + 60 + /** 61 + * The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com. 62 + */ 63 + musicServiceBaseDomain?: string 64 + 65 + /** 66 + * A user-agent style string specifying the submission client. 67 + */ 68 + submissionClientAgent?: string 69 + 70 + /** 71 + * The time the track was played 72 + */ 73 + playedTime?: l.DatetimeString 74 + } 75 + 76 + export type { Main } 77 + 78 + /** A track that was played. */ 79 + const main = /*#__PURE__*/ l.record<'tid', Main>( 80 + 'tid', 81 + $nsid, 82 + /*#__PURE__*/ l.object({ 83 + trackName: /*#__PURE__*/ l.string({ 84 + minLength: 1, 85 + maxLength: 256, 86 + maxGraphemes: 2560, 87 + }), 88 + trackMbId: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()), 89 + recordingMbId: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()), 90 + duration: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.integer()), 91 + artists: /*#__PURE__*/ l.array( 92 + /*#__PURE__*/ l.ref<Artist>((() => artist) as any), 93 + ), 94 + releaseName: /*#__PURE__*/ l.optional( 95 + /*#__PURE__*/ l.string({ maxLength: 256, maxGraphemes: 2560 }), 96 + ), 97 + releaseMbId: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()), 98 + isrc: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()), 99 + originUrl: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()), 100 + musicServiceBaseDomain: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()), 101 + submissionClientAgent: /*#__PURE__*/ l.optional( 102 + /*#__PURE__*/ l.string({ maxLength: 256, maxGraphemes: 2560 }), 103 + ), 104 + playedTime: /*#__PURE__*/ l.optional( 105 + /*#__PURE__*/ l.string({ format: 'datetime' }), 106 + ), 107 + }), 108 + ) 109 + 110 + export { main } 111 + 112 + export const $type = $nsid 113 + export const $isTypeOf = /*#__PURE__*/ main.isTypeOf.bind(main) 114 + export const $build = /*#__PURE__*/ main.build.bind(main) 115 + export const $assert = /*#__PURE__*/ main.assert.bind(main) 116 + export const $check = /*#__PURE__*/ main.check.bind(main) 117 + export const $cast = /*#__PURE__*/ main.cast.bind(main) 118 + export const $ifMatches = /*#__PURE__*/ main.ifMatches.bind(main) 119 + export const $matches = /*#__PURE__*/ main.matches.bind(main) 120 + export const $parse = /*#__PURE__*/ main.parse.bind(main) 121 + export const $safeParse = /*#__PURE__*/ main.safeParse.bind(main) 122 + export const $validate = /*#__PURE__*/ main.validate.bind(main) 123 + export const $safeValidate = /*#__PURE__*/ main.safeValidate.bind(main) 124 + 125 + type Artist = { 126 + $type?: 'fm.teal.alpha.feed.play#artist' 127 + 128 + /** 129 + * The name of the artist 130 + */ 131 + artistName: string 132 + 133 + /** 134 + * The MusicBrainz artist ID 135 + */ 136 + artistMbId?: string 137 + } 138 + 139 + export type { Artist } 140 + 141 + const artist = /*#__PURE__*/ l.typedObject<Artist>( 142 + $nsid, 143 + 'artist', 144 + /*#__PURE__*/ l.object({ 145 + artistName: /*#__PURE__*/ l.string({ 146 + minLength: 1, 147 + maxLength: 256, 148 + maxGraphemes: 2560, 149 + }), 150 + artistMbId: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()), 151 + }), 152 + ) 153 + 154 + export { artist }
+6
src/lib/lexicons/fm/teal/alpha/feed/play.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + export * from './play.defs.js' 6 + export { main as default } from './play.defs.js'
+80
src/lib/server/atproto/books.ts
··· 1 + import { buzz } from '$lib/lexicons'; 2 + import type { Main as BookRecord } from '$lib/lexicons/buzz/bookhive/book'; 3 + import type { AtUriString } from '@atproto/lex'; 4 + import type { CurrentlyReading, FinishedBook } from '$lib/types'; 5 + import { cache, historyCache } from '../cache'; 6 + import { blobUrl, createClient, getRepo, repoId, type Repo } from './client'; 7 + 8 + type BookEntry = { value: BookRecord; uri: AtUriString }; 9 + 10 + const READING = 'buzz.bookhive.defs#reading'; 11 + const FINISHED = 'buzz.bookhive.defs#finished'; 12 + 13 + /** 14 + * The currently-reading and finished views both come from the same bookhive 15 + * collection, so we list it once and cache the raw records. Cached under the 16 + * general (1 day) cache since the library changes slowly. 17 + */ 18 + async function listBooks(repo: Repo): Promise<BookEntry[]> { 19 + const cached = cache.get('books') as BookEntry[] | null; 20 + if (cached) return cached; 21 + 22 + const client = createClient(repo.pds); 23 + const res = await client.list(buzz.bookhive.book, { 24 + repo: repoId(repo.did), 25 + limit: 100 26 + }); 27 + const records = res.records as ReadonlyArray<BookEntry>; 28 + const books = [...records]; 29 + cache.set('books', books); 30 + return books; 31 + } 32 + 33 + export async function fetchCurrentlyReading(): Promise<CurrentlyReading | null> { 34 + const repo = getRepo('Currently reading'); 35 + if (!repo) return null; 36 + 37 + try { 38 + const reading = (await listBooks(repo)).find((r) => r.value.status === READING); 39 + if (!reading) return null; 40 + 41 + const v = reading.value; 42 + return { 43 + title: v.title, 44 + bookUrl: `https://bookhive.buzz/books/${v.hiveId}`, 45 + authors: v.authors.split('\t').join(', '), 46 + cover: v.cover ? blobUrl(repo.pds, repo.did, v.cover) : null 47 + }; 48 + } catch (error) { 49 + console.warn('Failed to fetch currently reading book:', error); 50 + return null; 51 + } 52 + } 53 + 54 + export async function fetchFinishedBooks(): Promise<FinishedBook[]> { 55 + const cached = historyCache.get('finishedBooks') as FinishedBook[] | null; 56 + if (cached) return cached; 57 + 58 + const repo = getRepo('Books history'); 59 + if (!repo) return []; 60 + 61 + try { 62 + const finished = (await listBooks(repo)) 63 + .filter((r) => r.value.status === FINISHED) 64 + .map(({ value }) => ({ 65 + title: value.title, 66 + authors: value.authors.split('\t').join(', '), 67 + bookUrl: `https://bookhive.buzz/books/${value.hiveId}`, 68 + cover: value.cover ? blobUrl(repo.pds, repo.did, value.cover) : null, 69 + finishedAt: value.finishedAt ?? value.createdAt ?? null, 70 + stars: value.stars ?? null 71 + })) 72 + .sort((a, b) => (b.finishedAt ?? '').localeCompare(a.finishedAt ?? '')); 73 + 74 + historyCache.set('finishedBooks', finished); 75 + return finished; 76 + } catch (error) { 77 + console.warn('Failed to fetch finished books:', error); 78 + return []; 79 + } 80 + }
+39
src/lib/server/atproto/client.ts
··· 1 + import { env } from '$env/dynamic/private'; 2 + import { Client, getBlobCidString, type AtIdentifierString } from '@atproto/lex'; 3 + 4 + export type Repo = { did: string; pds: string }; 5 + 6 + /** 7 + * Reads the configured ATProto repo (DID + PDS) from the environment. 8 + * Returns null — and logs a warning — when either is missing, so callers can 9 + * gracefully hide the relevant section instead of throwing. 10 + */ 11 + export function getRepo(warnLabel?: string): Repo | null { 12 + const did = env.ATPROTO_DID; 13 + const pds = env.ATPROTO_PDS; 14 + if (!did || !pds) { 15 + console.warn( 16 + `ATPROTO_DID / ATPROTO_PDS are not set${warnLabel ? ` — ${warnLabel} will be hidden.` : '.'}` 17 + ); 18 + return null; 19 + } 20 + return { did, pds }; 21 + } 22 + 23 + export function createClient(pds: string): Client { 24 + return new Client(pds); 25 + } 26 + 27 + /** The repo DID as the branded identifier type the lexicon client expects. */ 28 + export function repoId(did: string): AtIdentifierString { 29 + return did as AtIdentifierString; 30 + } 31 + 32 + /** Builds a public getBlob URL for a blob ref stored on a record. */ 33 + export function blobUrl( 34 + pds: string, 35 + did: string, 36 + blob: Parameters<typeof getBlobCidString>[0] 37 + ): string { 38 + return `${pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${getBlobCidString(blob)}`; 39 + }
+105
src/lib/server/atproto/coverArt.ts
··· 1 + import { cache } from '../cache'; 2 + 3 + // MusicBrainz requires an identifying User-Agent or it will block requests. 4 + const MB_USER_AGENT = 'baileys-website/1.0 (https://tangled.org/pds.dad/my-website)'; 5 + 6 + /** Release MBIDs arrive either bare or as `mbid:<uuid>` — normalise to bare. */ 7 + function normaliseMbid(mbid: string): string { 8 + return mbid.replace(/^mbid:/, ''); 9 + } 10 + 11 + /** 12 + * Probe the Cover Art Archive for a release's front cover. Not every release 13 + * has art in the CAA, so we HEAD the URL and only return it when it resolves. 14 + * Results (including misses) are memoised per MBID in the shared cache so the 15 + * same release isn't re-probed across a page full of plays. 16 + */ 17 + async function probeCoverArt(fetch: typeof globalThis.fetch, mbid: string): Promise<string | null> { 18 + const id = normaliseMbid(mbid); 19 + if (!id) return null; 20 + 21 + const cacheKey = `coverart:${id}`; 22 + const cached = cache.get(cacheKey) as { url: string | null } | null; 23 + if (cached) return cached.url; 24 + 25 + const url = `https://coverartarchive.org/release/${id}/front-500`; 26 + let resolved: string | null = null; 27 + try { 28 + const res = await fetch(url, { method: 'HEAD', redirect: 'follow' }); 29 + if (res.ok) resolved = url; 30 + } catch { 31 + // network error — treat as a miss 32 + } 33 + 34 + cache.set(cacheKey, { url: resolved }); 35 + return resolved; 36 + } 37 + 38 + /** 39 + * Resolve album artwork for a single track. When a `releaseMbId` is available 40 + * (e.g. play-history records) the direct CAA probe is enough. The ISRC → 41 + * MusicBrainz → release lookup is the richer fallback used for "now playing", 42 + * but it costs extra requests and MusicBrainz rate-limits (~1 req/s), so it is 43 + * opt-in via `useIsrcLookup` and should stay off for bulk history. 44 + */ 45 + export async function resolveCoverArt( 46 + fetch: typeof globalThis.fetch, 47 + opts: { 48 + isrc?: string | null; 49 + releaseMbId?: string | null; 50 + releaseName?: string | null; 51 + useIsrcLookup?: boolean; 52 + } 53 + ): Promise<string | null> { 54 + const candidates: string[] = []; 55 + 56 + if (opts.useIsrcLookup && opts.isrc) { 57 + try { 58 + const isrcRes = await fetch( 59 + `https://musicbrainz.org/ws/2/isrc/${encodeURIComponent(opts.isrc)}?fmt=json`, 60 + { headers: { 'User-Agent': MB_USER_AGENT } } 61 + ); 62 + const recordingId = isrcRes.ok 63 + ? ((await isrcRes.json()) as { recordings?: { id: string }[] }).recordings?.[0]?.id 64 + : undefined; 65 + 66 + if (recordingId) { 67 + const relRes = await fetch( 68 + `https://musicbrainz.org/ws/2/release?recording=${recordingId}&fmt=json`, 69 + { headers: { 'User-Agent': MB_USER_AGENT } } 70 + ); 71 + if (relRes.ok) { 72 + const releases = 73 + ((await relRes.json()) as { releases?: { id: string; title?: string }[] }).releases ?? 74 + []; 75 + // An ISRC's recording often appears on many releases (singles, 76 + // comps, regional editions). Prefer the one whose title matches 77 + // what's playing. 78 + const wanted = opts.releaseName?.toLowerCase(); 79 + const seen = new Set<string>(); 80 + for (const r of [ 81 + ...releases.filter((r) => wanted && r.title?.toLowerCase() === wanted), 82 + ...releases 83 + ]) { 84 + if (!seen.has(r.id)) { 85 + seen.add(r.id); 86 + candidates.push(r.id); 87 + } 88 + } 89 + } 90 + } 91 + } catch (error) { 92 + console.warn('MusicBrainz ISRC lookup failed:', error); 93 + } 94 + } 95 + 96 + // Release MBID supplied directly by the record. 97 + if (opts.releaseMbId) candidates.push(opts.releaseMbId); 98 + 99 + for (const mbid of candidates.slice(0, 5)) { 100 + const url = await probeCoverArt(fetch, mbid); 101 + if (url) return url; 102 + } 103 + 104 + return null; 105 + }
+110
src/lib/server/atproto/music.ts
··· 1 + import { fm } from '$lib/lexicons'; 2 + import type { Main as PlayRecord } from '$lib/lexicons/fm/teal/alpha/feed/play'; 3 + import type { AtUriString } from '@atproto/lex'; 4 + import type { NowPlaying, RecentPlay } from '$lib/types'; 5 + import { historyCache, liveCache } from '../cache'; 6 + import { createClient, getRepo, repoId } from './client'; 7 + import { resolveCoverArt } from './coverArt'; 8 + 9 + const RECENT_PLAYS_LIMIT = 50; 10 + const COVER_ART_CONCURRENCY = 5; 11 + 12 + /** Resolve `fn` over `items` with a bounded number of in-flight calls. */ 13 + async function mapWithConcurrency<T, R>( 14 + items: T[], 15 + limit: number, 16 + fn: (item: T, index: number) => Promise<R> 17 + ): Promise<R[]> { 18 + const results = new Array<R>(items.length); 19 + let cursor = 0; 20 + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { 21 + while (cursor < items.length) { 22 + const index = cursor++; 23 + results[index] = await fn(items[index], index); 24 + } 25 + }); 26 + await Promise.all(workers); 27 + return results; 28 + } 29 + 30 + export async function fetchNowPlaying(fetch: typeof globalThis.fetch): Promise<NowPlaying | null> { 31 + const cached = liveCache.get('nowPlaying') as { value: NowPlaying | null } | null; 32 + if (cached) return cached.value; 33 + 34 + const repo = getRepo('Now playing'); 35 + if (!repo) return null; 36 + 37 + try { 38 + const client = createClient(repo.pds); 39 + const res = await client.get(fm.teal.alpha.actor.status, { 40 + repo: repoId(repo.did) 41 + }); 42 + const item = res.value.item; 43 + 44 + const coverArt = await resolveCoverArt(fetch, { 45 + isrc: item.isrc ?? null, 46 + releaseMbId: item.releaseMbId ?? null, 47 + releaseName: item.releaseName ?? null, 48 + useIsrcLookup: true 49 + }); 50 + 51 + const nowPlaying: NowPlaying = { 52 + trackName: item.trackName, 53 + artists: item.artists.map((a) => a.artistName).join(', '), 54 + releaseName: item.releaseName ?? null, 55 + url: item.originUrl ?? null, 56 + coverArt 57 + }; 58 + 59 + liveCache.set('nowPlaying', { value: nowPlaying }); 60 + return nowPlaying; 61 + } catch (error) { 62 + console.warn('Failed to fetch now playing status:', error); 63 + return null; 64 + } 65 + } 66 + 67 + export async function fetchRecentPlays(fetch: typeof globalThis.fetch): Promise<RecentPlay[]> { 68 + const cached = historyCache.get('recentPlays') as RecentPlay[] | null; 69 + if (cached) return cached; 70 + 71 + const repo = getRepo('Music history'); 72 + if (!repo) return []; 73 + 74 + try { 75 + const client = createClient(repo.pds); 76 + const res = await client.list(fm.teal.alpha.feed.play, { 77 + repo: repoId(repo.did), 78 + limit: RECENT_PLAYS_LIMIT 79 + }); 80 + const records = res.records as ReadonlyArray<{ value: PlayRecord; uri: AtUriString }>; 81 + 82 + // TID rkeys are already time-ordered, but sort defensively (newest first). 83 + const sorted = [...records].sort((a, b) => 84 + (b.value.playedTime ?? '').localeCompare(a.value.playedTime ?? '') 85 + ); 86 + 87 + const plays = await mapWithConcurrency(sorted, COVER_ART_CONCURRENCY, async ({ value }) => { 88 + // History uses the release MBID directly — no MusicBrainz ISRC lookup. 89 + const coverArt = await resolveCoverArt(fetch, { 90 + releaseMbId: value.releaseMbId ?? null, 91 + releaseName: value.releaseName ?? null 92 + }); 93 + 94 + return { 95 + trackName: value.trackName, 96 + artists: value.artists.map((a) => a.artistName).join(', '), 97 + releaseName: value.releaseName ?? null, 98 + url: value.originUrl ?? null, 99 + coverArt, 100 + playedTime: value.playedTime ?? null 101 + } satisfies RecentPlay; 102 + }); 103 + 104 + historyCache.set('recentPlays', plays); 105 + return plays; 106 + } catch (error) { 107 + console.warn('Failed to fetch recent plays:', error); 108 + return []; 109 + } 110 + }
+40
src/lib/server/atproto/publications.ts
··· 1 + import { site } from '$lib/lexicons'; 2 + import type { Main as PublicationRecord } from '$lib/lexicons/site/standard/publication'; 3 + import type { AtUriString } from '@atproto/lex'; 4 + import type { Publication } from '$lib/types'; 5 + import { cache } from '../cache'; 6 + import { blobUrl, createClient, getRepo, repoId } from './client'; 7 + 8 + export async function fetchPublications(): Promise<Publication[]> { 9 + const cached = cache.get('publications'); 10 + if (cached) { 11 + return cached as Publication[]; 12 + } 13 + 14 + const repo = getRepo('Writing section'); 15 + if (!repo) return []; 16 + 17 + try { 18 + const client = createClient(repo.pds); 19 + const res = await client.list(site.standard.publication, { 20 + repo: repoId(repo.did), 21 + limit: 50 22 + }); 23 + const records = res.records as ReadonlyArray<{ value: PublicationRecord; uri: AtUriString }>; 24 + 25 + const publications: Publication[] = records 26 + .filter((x) => !x.uri.includes('blento.self')) 27 + .map(({ value }) => ({ 28 + name: value.name, 29 + description: value.description ?? '', 30 + href: value.url, 31 + image: value.icon ? blobUrl(repo.pds, repo.did, value.icon) : null 32 + })); 33 + 34 + cache.set('publications', publications); 35 + return publications; 36 + } catch (error) { 37 + console.warn('Failed to fetch publications:', error); 38 + return []; 39 + } 40 + }
+14
src/lib/server/cache.ts
··· 1 + import { SQLiteCache } from 'sqlite-cache'; 2 + 3 + const DAY_MS = 86_400_000; // 1 day 4 + const MIN_MS = 60_000; // 1 min 5 + const QUARTER_HOUR_MS = 900_000; // 15 min 6 + 7 + /** General-purpose cache for data that rarely changes (publications, sponsors, books, cover art). */ 8 + export const cache = new SQLiteCache({ path: './cache.db', ttl: DAY_MS }); 9 + 10 + /** Short-lived cache for "now playing", which changes often. */ 11 + export const liveCache = new SQLiteCache({ path: './cache-live.db', ttl: MIN_MS }); 12 + 13 + /** Medium-lived cache for the history pages (recent plays, finished books). */ 14 + export const historyCache = new SQLiteCache({ path: './cache-history.db', ttl: QUARTER_HOUR_MS });
+83
src/lib/server/github.ts
··· 1 + import { env } from '$env/dynamic/private'; 2 + import type { Sponsor } from '$lib/types'; 3 + import { cache } from './cache'; 4 + 5 + const SPONSORS_LOGIN = 'fatfingers23'; 6 + 7 + type SponsorNode = { 8 + login: string; 9 + name: string | null; 10 + avatarUrl: string; 11 + url: string; 12 + }; 13 + 14 + const SPONSORS_QUERY = ` 15 + query ($login: String!) { 16 + user(login: $login) { 17 + sponsorshipsAsMaintainer(first: 100, includePrivate: false, activeOnly: true) { 18 + nodes { 19 + sponsorEntity { 20 + ... on User { login name avatarUrl(size: 160) url } 21 + ... on Organization { login name avatarUrl(size: 160) url } 22 + } 23 + } 24 + } 25 + } 26 + } 27 + `; 28 + 29 + export async function fetchSponsors(fetch: typeof globalThis.fetch): Promise<Sponsor[]> { 30 + const cached = cache.get('sponsors'); 31 + if (cached) { 32 + return cached as Sponsor[]; 33 + } 34 + 35 + const token = env.GITHUB_TOKEN; 36 + if (!token) { 37 + console.warn('GITHUB_TOKEN is not set — Sponsors section will be hidden.'); 38 + return []; 39 + } 40 + 41 + try { 42 + const res = await fetch('https://api.github.com/graphql', { 43 + method: 'POST', 44 + headers: { 45 + Authorization: `Bearer ${token}`, 46 + 'Content-Type': 'application/json' 47 + }, 48 + body: JSON.stringify({ 49 + query: SPONSORS_QUERY, 50 + variables: { login: SPONSORS_LOGIN } 51 + }) 52 + }); 53 + 54 + if (!res.ok) { 55 + console.warn(`GitHub Sponsors request failed: ${res.status} ${res.statusText}`); 56 + return []; 57 + } 58 + 59 + const json = await res.json(); 60 + if (json.errors) { 61 + console.warn('GitHub Sponsors GraphQL errors:', json.errors); 62 + return []; 63 + } 64 + 65 + const nodes: { sponsorEntity: SponsorNode | null }[] = 66 + json.data?.user?.sponsorshipsAsMaintainer?.nodes ?? []; 67 + const sponsors: Sponsor[] = nodes 68 + .map((node) => node.sponsorEntity) 69 + .filter((entity): entity is SponsorNode => Boolean(entity?.login)) 70 + .map((entity) => ({ 71 + login: entity.login, 72 + name: entity.name ?? entity.login, 73 + avatarUrl: entity.avatarUrl ?? `https://github.com/${entity.login}.png`, 74 + url: entity.url 75 + })); 76 + 77 + cache.set('sponsors', sponsors); 78 + return sponsors; 79 + } catch (error) { 80 + console.warn('Failed to fetch GitHub Sponsors:', error); 81 + return []; 82 + } 83 + }
+18
src/lib/types.ts
··· 26 26 url: string | null; 27 27 coverArt: string | null; 28 28 }; 29 + 30 + export type RecentPlay = { 31 + trackName: string; 32 + artists: string; 33 + releaseName: string | null; 34 + url: string | null; 35 + coverArt: string | null; 36 + playedTime: string | null; 37 + }; 38 + 39 + export type FinishedBook = { 40 + title: string; 41 + authors: string; 42 + bookUrl: string; 43 + cover: string | null; 44 + finishedAt: string | null; 45 + stars: number | null; 46 + };
+5 -291
src/routes/+page.server.ts
··· 1 - import { env } from '$env/dynamic/private'; 2 - import { Client, getBlobCidString, type AtIdentifierString, type AtUriString } from '@atproto/lex'; 3 - import { buzz, fm, site } from '$lib/lexicons'; 4 - import type { Main as PublicationRecord } from '$lib/lexicons/site/standard/publication'; 5 - import type { Main as BookRecord } from '$lib/lexicons/buzz/bookhive/book'; 6 - import type { CurrentlyReading, NowPlaying, Publication, Sponsor } from '$lib/types'; 7 1 import type { PageServerLoad } from './$types'; 8 - import { SQLiteCache } from 'sqlite-cache'; 9 - 10 - const SPONSORS_LOGIN = 'fatfingers23'; 11 - const CACHE_TTL_MS = 86_400_000; // 1 day 12 - const LIVE_CACHE_TTL_MS = 60_000; // 1 min — "now playing" changes often 13 - 14 - const cache = new SQLiteCache({ path: './cache.db', ttl: CACHE_TTL_MS }); 15 - const liveCache = new SQLiteCache({ path: './cache-live.db', ttl: LIVE_CACHE_TTL_MS }); 16 - 17 - type SponsorNode = { 18 - login: string; 19 - name: string | null; 20 - avatarUrl: string; 21 - url: string; 22 - }; 23 - 24 - const SPONSORS_QUERY = ` 25 - query ($login: String!) { 26 - user(login: $login) { 27 - sponsorshipsAsMaintainer(first: 100, includePrivate: false, activeOnly: true) { 28 - nodes { 29 - sponsorEntity { 30 - ... on User { login name avatarUrl(size: 160) url } 31 - ... on Organization { login name avatarUrl(size: 160) url } 32 - } 33 - } 34 - } 35 - } 36 - } 37 - `; 38 - 39 - async function fetchSponsors(fetch: typeof globalThis.fetch): Promise<Sponsor[]> { 40 - // if (cache && Date.now() - cache.fetchedAt < CACHE_TTL_MS) { 41 - // return cache.sponsors; 42 - // } 43 - 44 - const cached = cache.get('sponsors'); 45 - if (cached) { 46 - return cached as Sponsor[]; 47 - } 48 - 49 - const token = env.GITHUB_TOKEN; 50 - if (!token) { 51 - console.warn('GITHUB_TOKEN is not set — Sponsors section will be hidden.'); 52 - return []; 53 - } 54 - 55 - try { 56 - const res = await fetch('https://api.github.com/graphql', { 57 - method: 'POST', 58 - headers: { 59 - Authorization: `Bearer ${token}`, 60 - 'Content-Type': 'application/json' 61 - }, 62 - body: JSON.stringify({ 63 - query: SPONSORS_QUERY, 64 - variables: { login: SPONSORS_LOGIN } 65 - }) 66 - }); 67 - 68 - if (!res.ok) { 69 - console.warn(`GitHub Sponsors request failed: ${res.status} ${res.statusText}`); 70 - return []; 71 - } 72 - 73 - const json = await res.json(); 74 - if (json.errors) { 75 - console.warn('GitHub Sponsors GraphQL errors:', json.errors); 76 - return []; 77 - } 78 - 79 - const nodes: { sponsorEntity: SponsorNode | null }[] = 80 - json.data?.user?.sponsorshipsAsMaintainer?.nodes ?? []; 81 - const sponsors: Sponsor[] = nodes 82 - .map((node) => node.sponsorEntity) 83 - .filter((entity): entity is SponsorNode => Boolean(entity?.login)) 84 - .map((entity) => ({ 85 - login: entity.login, 86 - name: entity.name ?? entity.login, 87 - avatarUrl: entity.avatarUrl ?? `https://github.com/${entity.login}.png`, 88 - url: entity.url 89 - })); 90 - 91 - cache.set('sponsors', sponsors); 92 - return sponsors; 93 - } catch (error) { 94 - console.warn('Failed to fetch GitHub Sponsors:', error); 95 - return []; 96 - } 97 - } 98 - 99 - async function fetchPublications(): Promise<Publication[]> { 100 - const cached = cache.get('publications'); 101 - if (cached) { 102 - return cached as Publication[]; 103 - } 104 - 105 - const did = env.ATPROTO_DID; 106 - const pds = env.ATPROTO_PDS; 107 - if (!did || !pds) { 108 - console.warn('ATPROTO_DID / ATPROTO_PDS are not set — Writing section will be hidden.'); 109 - return []; 110 - } 111 - 112 - try { 113 - const client = new Client(pds); 114 - const res = await client.list(site.standard.publication, { 115 - repo: did as AtIdentifierString, 116 - limit: 50 117 - }); 118 - const records = res.records as ReadonlyArray<{ value: PublicationRecord; uri: AtUriString }>; 119 - 120 - const publications: Publication[] = records 121 - .filter((x) => !x.uri.includes('blento.self')) 122 - .map(({ value }) => ({ 123 - name: value.name, 124 - description: value.description ?? '', 125 - href: value.url, 126 - image: value.icon 127 - ? `${pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${getBlobCidString(value.icon)}` 128 - : null 129 - })); 130 - 131 - cache.set('publications', publications); 132 - return publications; 133 - } catch (error) { 134 - console.warn('Failed to fetch publications:', error); 135 - return []; 136 - } 137 - } 138 - 139 - async function fetchCurrentlyReading(): Promise<CurrentlyReading | null> { 140 - const cached = cache.get('currentlyReading') as { value: CurrentlyReading | null } | null; 141 - if (cached) { 142 - return cached.value; 143 - } 144 - 145 - const did = env.ATPROTO_DID; 146 - const pds = env.ATPROTO_PDS; 147 - if (!did || !pds) { 148 - console.warn('ATPROTO_DID / ATPROTO_PDS are not set — Currently reading will be hidden.'); 149 - return null; 150 - } 151 - 152 - try { 153 - const client = new Client(pds); 154 - const res = await client.list(buzz.bookhive.book, { 155 - repo: did as AtIdentifierString, 156 - limit: 100 157 - }); 158 - const records = res.records as ReadonlyArray<{ value: BookRecord; uri: AtUriString }>; 159 - const reading = records.find((r) => r.value.status === 'buzz.bookhive.defs#reading'); 160 - 161 - let currentlyReading: CurrentlyReading | null = null; 162 - if (reading) { 163 - const v = reading.value; 164 - currentlyReading = { 165 - title: v.title, 166 - bookUrl: `https://bookhive.buzz/books/${v.hiveId}`, 167 - authors: v.authors.split('\t').join(', '), 168 - cover: v.cover 169 - ? `${pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${getBlobCidString(v.cover)}` 170 - : null 171 - }; 172 - } 173 - 174 - cache.set('currentlyReading', { value: currentlyReading }); 175 - return currentlyReading; 176 - } catch (error) { 177 - console.warn('Failed to fetch currently reading book:', error); 178 - return null; 179 - } 180 - } 181 - 182 - // MusicBrainz requires an identifying User-Agent or it will block requests. 183 - const MB_USER_AGENT = 'baileys-website/1.0 (https://tangled.org/pds.dad/my-website)'; 184 - 185 - /// Got to go isrc to a mbid to get cover art 186 - async function resolveCoverArt( 187 - fetch: typeof globalThis.fetch, 188 - opts: { isrc?: string | null; releaseMbId?: string | null; releaseName?: string | null } 189 - ): Promise<string | null> { 190 - const candidates: string[] = []; 191 - 192 - if (opts.isrc) { 193 - try { 194 - const isrcRes = await fetch( 195 - `https://musicbrainz.org/ws/2/isrc/${encodeURIComponent(opts.isrc)}?fmt=json`, 196 - { headers: { 'User-Agent': MB_USER_AGENT } } 197 - ); 198 - const recordingId = isrcRes.ok 199 - ? ((await isrcRes.json()) as { recordings?: { id: string }[] }).recordings?.[0]?.id 200 - : undefined; 201 - 202 - if (recordingId) { 203 - const relRes = await fetch( 204 - `https://musicbrainz.org/ws/2/release?recording=${recordingId}&fmt=json`, 205 - { headers: { 'User-Agent': MB_USER_AGENT } } 206 - ); 207 - if (relRes.ok) { 208 - const releases = 209 - ((await relRes.json()) as { releases?: { id: string; title?: string }[] }).releases ?? 210 - []; 211 - // An ISRC's recording often appears on many releases (singles, 212 - // comps, regional editions). Prefer the one whose title matches 213 - // what's playing. 214 - const wanted = opts.releaseName?.toLowerCase(); 215 - const seen = new Set<string>(); 216 - for (const r of [ 217 - ...releases.filter((r) => wanted && r.title?.toLowerCase() === wanted), 218 - ...releases 219 - ]) { 220 - if (!seen.has(r.id)) { 221 - seen.add(r.id); 222 - candidates.push(r.id); 223 - } 224 - } 225 - } 226 - } 227 - } catch (error) { 228 - console.warn('MusicBrainz ISRC lookup failed:', error); 229 - } 230 - } 231 - 232 - // Fallback: release MBID supplied directly by the status record. 233 - if (opts.releaseMbId) candidates.push(opts.releaseMbId); 234 - 235 - // Not every release has art in the CAA — probe candidates and return the 236 - // first that resolves to an actual image. 237 - for (const mbid of candidates.slice(0, 5)) { 238 - const url = `https://coverartarchive.org/release/${mbid}/front-500`; 239 - try { 240 - const res = await fetch(url, { method: 'HEAD', redirect: 'follow' }); 241 - if (res.ok) return url; 242 - } catch { 243 - // ignore and try the next candidate 244 - } 245 - } 246 - 247 - return null; 248 - } 249 - 250 - async function fetchNowPlaying(fetch: typeof globalThis.fetch): Promise<NowPlaying | null> { 251 - const cached = liveCache.get('nowPlaying') as { value: NowPlaying | null } | null; 252 - if (cached) { 253 - return cached.value; 254 - } 255 - 256 - const did = env.ATPROTO_DID; 257 - const pds = env.ATPROTO_PDS; 258 - if (!did || !pds) { 259 - console.warn('ATPROTO_DID / ATPROTO_PDS are not set — Now playing will be hidden.'); 260 - return null; 261 - } 2 + import { fetchSponsors } from '$lib/server/github'; 3 + import { fetchPublications } from '$lib/server/atproto/publications'; 4 + import { fetchCurrentlyReading } from '$lib/server/atproto/books'; 5 + import { fetchNowPlaying } from '$lib/server/atproto/music'; 262 6 263 - try { 264 - const client = new Client(pds); 265 - const res = await client.get(fm.teal.alpha.actor.status, { 266 - repo: did as AtIdentifierString 267 - }); 268 - const item = res.value.item; 269 - 270 - const coverArt = await resolveCoverArt(fetch, { 271 - isrc: item.isrc ?? null, 272 - releaseMbId: item.releaseMbId?.replace(/^mbid:/, '') ?? null, 273 - releaseName: item.releaseName ?? null 274 - }); 275 - 276 - const nowPlaying: NowPlaying = { 277 - trackName: item.trackName, 278 - artists: item.artists.map((a) => a.artistName).join(', '), 279 - releaseName: item.releaseName ?? null, 280 - url: item.originUrl ?? null, 281 - coverArt 282 - }; 283 - 284 - liveCache.set('nowPlaying', { value: nowPlaying }); 285 - return nowPlaying; 286 - } catch (error) { 287 - console.warn('Failed to fetch now playing status:', error); 288 - return null; 289 - } 290 - } 291 - 292 - export const load: PageServerLoad = async ({ request, fetch }) => { 293 - const host = request.headers.get('host') ?? ''; 7 + export const load: PageServerLoad = async ({ fetch }) => { 294 8 const [sponsors, publications, currentlyReading, nowPlaying] = await Promise.all([ 295 9 fetchSponsors(fetch), 296 10 fetchPublications(),
+10
src/routes/history/+layout.svelte
··· 1 + <script lang="ts"> 2 + import NavBar from '$lib/components/NavBar.svelte'; 3 + 4 + let { children } = $props(); 5 + </script> 6 + 7 + <div class="p-2 md:px-10"> 8 + <NavBar /> 9 + {@render children()} 10 + </div>
+10
src/routes/history/books/+page.server.ts
··· 1 + import type { PageServerLoad } from './$types'; 2 + import { fetchCurrentlyReading, fetchFinishedBooks } from '$lib/server/atproto/books'; 3 + 4 + export const load: PageServerLoad = async () => { 5 + const [currentlyReading, finishedBooks] = await Promise.all([ 6 + fetchCurrentlyReading(), 7 + fetchFinishedBooks() 8 + ]); 9 + return { currentlyReading, finishedBooks }; 10 + };
+16
src/routes/history/books/+page.svelte
··· 1 + <script lang="ts"> 2 + import BookHistory from '$lib/components/BookHistory.svelte'; 3 + import type { PageProps } from './$types'; 4 + 5 + let { data }: PageProps = $props(); 6 + const { currentlyReading, finishedBooks } = data; 7 + </script> 8 + 9 + <svelte:head> 10 + <title>Books | Bailey Townsend</title> 11 + <meta name="description" content="What I'm reading and books I've recently finished." /> 12 + <meta name="og:title" content="Books | Bailey Townsend" /> 13 + <meta name="og:description" content="What I'm reading and books I've recently finished." /> 14 + </svelte:head> 15 + 16 + <BookHistory {currentlyReading} {finishedBooks} />
+7
src/routes/history/music/+page.server.ts
··· 1 + import type { PageServerLoad } from './$types'; 2 + import { fetchRecentPlays } from '$lib/server/atproto/music'; 3 + 4 + export const load: PageServerLoad = async ({ fetch }) => { 5 + const recentPlays = await fetchRecentPlays(fetch); 6 + return { recentPlays }; 7 + };
+16
src/routes/history/music/+page.svelte
··· 1 + <script lang="ts"> 2 + import MusicHistory from '$lib/components/MusicHistory.svelte'; 3 + import type { PageProps } from './$types'; 4 + 5 + let { data }: PageProps = $props(); 6 + const { recentPlays } = data; 7 + </script> 8 + 9 + <svelte:head> 10 + <title>Music History | Bailey Townsend</title> 11 + <meta name="description" content="Tracks I've been listening to recently." /> 12 + <meta name="og:title" content="Music History | Bailey Townsend" /> 13 + <meta name="og:description" content="Tracks I've been listening to recently." /> 14 + </svelte:head> 15 + 16 + <MusicHistory {recentPlays} />