···11+{
22+ "lexicon": 1,
33+ "id": "fm.teal.alpha.feed.play",
44+ "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().",
55+ "defs": {
66+ "main": {
77+ "type": "record",
88+ "description": "A track that was played.",
99+ "key": "tid",
1010+ "record": {
1111+ "type": "object",
1212+ "required": ["trackName", "artists"],
1313+ "properties": {
1414+ "trackName": {
1515+ "type": "string",
1616+ "minLength": 1,
1717+ "maxLength": 256,
1818+ "maxGraphemes": 2560,
1919+ "description": "The name of the track"
2020+ },
2121+ "trackMbId": {
2222+ "type": "string",
2323+ "description": "The MusicBrainz ID of the track"
2424+ },
2525+ "recordingMbId": {
2626+ "type": "string",
2727+ "description": "The MusicBrainz recording ID of the track"
2828+ },
2929+ "duration": {
3030+ "type": "integer",
3131+ "description": "The length of the track in seconds"
3232+ },
3333+ "artists": {
3434+ "type": "array",
3535+ "items": {
3636+ "type": "ref",
3737+ "ref": "#artist"
3838+ },
3939+ "description": "Array of artists in order of original appearance."
4040+ },
4141+ "releaseName": {
4242+ "type": "string",
4343+ "maxLength": 256,
4444+ "maxGraphemes": 2560,
4545+ "description": "The name of the release/album"
4646+ },
4747+ "releaseMbId": {
4848+ "type": "string",
4949+ "description": "The MusicBrainz release ID"
5050+ },
5151+ "isrc": {
5252+ "type": "string",
5353+ "description": "The ISRC code associated with the recording"
5454+ },
5555+ "originUrl": {
5656+ "type": "string",
5757+ "description": "The URL associated with this track"
5858+ },
5959+ "musicServiceBaseDomain": {
6060+ "type": "string",
6161+ "description": "The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com."
6262+ },
6363+ "submissionClientAgent": {
6464+ "type": "string",
6565+ "maxLength": 256,
6666+ "maxGraphemes": 2560,
6767+ "description": "A user-agent style string specifying the submission client."
6868+ },
6969+ "playedTime": {
7070+ "type": "string",
7171+ "format": "datetime",
7272+ "description": "The time the track was played"
7373+ }
7474+ }
7575+ }
7676+ },
7777+ "artist": {
7878+ "type": "object",
7979+ "required": ["artistName"],
8080+ "properties": {
8181+ "artistName": {
8282+ "type": "string",
8383+ "minLength": 1,
8484+ "maxLength": 256,
8585+ "maxGraphemes": 2560,
8686+ "description": "The name of the artist"
8787+ },
8888+ "artistMbId": {
8989+ "type": "string",
9090+ "description": "The MusicBrainz artist ID"
9191+ }
9292+ }
9393+ }
9494+ }
9595+}
···33 */
4455export * as defs from './feed/defs.js'
66+export * as play from './feed/play.js'
+154
src/lib/lexicons/fm/teal/alpha/feed/play.defs.ts
···11+/*
22+ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
33+ */
44+55+import { l } from '@atproto/lex'
66+77+const $nsid = 'fm.teal.alpha.feed.play'
88+99+export { $nsid }
1010+1111+/** A track that was played. */
1212+type Main = {
1313+ $type: 'fm.teal.alpha.feed.play'
1414+1515+ /**
1616+ * The name of the track
1717+ */
1818+ trackName: string
1919+2020+ /**
2121+ * The MusicBrainz ID of the track
2222+ */
2323+ trackMbId?: string
2424+2525+ /**
2626+ * The MusicBrainz recording ID of the track
2727+ */
2828+ recordingMbId?: string
2929+3030+ /**
3131+ * The length of the track in seconds
3232+ */
3333+ duration?: number
3434+3535+ /**
3636+ * Array of artists in order of original appearance.
3737+ */
3838+ artists: Artist[]
3939+4040+ /**
4141+ * The name of the release/album
4242+ */
4343+ releaseName?: string
4444+4545+ /**
4646+ * The MusicBrainz release ID
4747+ */
4848+ releaseMbId?: string
4949+5050+ /**
5151+ * The ISRC code associated with the recording
5252+ */
5353+ isrc?: string
5454+5555+ /**
5656+ * The URL associated with this track
5757+ */
5858+ originUrl?: string
5959+6060+ /**
6161+ * The base domain of the music service. e.g. music.apple.com, tidal.com, spotify.com.
6262+ */
6363+ musicServiceBaseDomain?: string
6464+6565+ /**
6666+ * A user-agent style string specifying the submission client.
6767+ */
6868+ submissionClientAgent?: string
6969+7070+ /**
7171+ * The time the track was played
7272+ */
7373+ playedTime?: l.DatetimeString
7474+}
7575+7676+export type { Main }
7777+7878+/** A track that was played. */
7979+const main = /*#__PURE__*/ l.record<'tid', Main>(
8080+ 'tid',
8181+ $nsid,
8282+ /*#__PURE__*/ l.object({
8383+ trackName: /*#__PURE__*/ l.string({
8484+ minLength: 1,
8585+ maxLength: 256,
8686+ maxGraphemes: 2560,
8787+ }),
8888+ trackMbId: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()),
8989+ recordingMbId: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()),
9090+ duration: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.integer()),
9191+ artists: /*#__PURE__*/ l.array(
9292+ /*#__PURE__*/ l.ref<Artist>((() => artist) as any),
9393+ ),
9494+ releaseName: /*#__PURE__*/ l.optional(
9595+ /*#__PURE__*/ l.string({ maxLength: 256, maxGraphemes: 2560 }),
9696+ ),
9797+ releaseMbId: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()),
9898+ isrc: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()),
9999+ originUrl: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()),
100100+ musicServiceBaseDomain: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()),
101101+ submissionClientAgent: /*#__PURE__*/ l.optional(
102102+ /*#__PURE__*/ l.string({ maxLength: 256, maxGraphemes: 2560 }),
103103+ ),
104104+ playedTime: /*#__PURE__*/ l.optional(
105105+ /*#__PURE__*/ l.string({ format: 'datetime' }),
106106+ ),
107107+ }),
108108+)
109109+110110+export { main }
111111+112112+export const $type = $nsid
113113+export const $isTypeOf = /*#__PURE__*/ main.isTypeOf.bind(main)
114114+export const $build = /*#__PURE__*/ main.build.bind(main)
115115+export const $assert = /*#__PURE__*/ main.assert.bind(main)
116116+export const $check = /*#__PURE__*/ main.check.bind(main)
117117+export const $cast = /*#__PURE__*/ main.cast.bind(main)
118118+export const $ifMatches = /*#__PURE__*/ main.ifMatches.bind(main)
119119+export const $matches = /*#__PURE__*/ main.matches.bind(main)
120120+export const $parse = /*#__PURE__*/ main.parse.bind(main)
121121+export const $safeParse = /*#__PURE__*/ main.safeParse.bind(main)
122122+export const $validate = /*#__PURE__*/ main.validate.bind(main)
123123+export const $safeValidate = /*#__PURE__*/ main.safeValidate.bind(main)
124124+125125+type Artist = {
126126+ $type?: 'fm.teal.alpha.feed.play#artist'
127127+128128+ /**
129129+ * The name of the artist
130130+ */
131131+ artistName: string
132132+133133+ /**
134134+ * The MusicBrainz artist ID
135135+ */
136136+ artistMbId?: string
137137+}
138138+139139+export type { Artist }
140140+141141+const artist = /*#__PURE__*/ l.typedObject<Artist>(
142142+ $nsid,
143143+ 'artist',
144144+ /*#__PURE__*/ l.object({
145145+ artistName: /*#__PURE__*/ l.string({
146146+ minLength: 1,
147147+ maxLength: 256,
148148+ maxGraphemes: 2560,
149149+ }),
150150+ artistMbId: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()),
151151+ }),
152152+)
153153+154154+export { artist }
+6
src/lib/lexicons/fm/teal/alpha/feed/play.ts
···11+/*
22+ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
33+ */
44+55+export * from './play.defs.js'
66+export { main as default } from './play.defs.js'
+80
src/lib/server/atproto/books.ts
···11+import { buzz } from '$lib/lexicons';
22+import type { Main as BookRecord } from '$lib/lexicons/buzz/bookhive/book';
33+import type { AtUriString } from '@atproto/lex';
44+import type { CurrentlyReading, FinishedBook } from '$lib/types';
55+import { cache, historyCache } from '../cache';
66+import { blobUrl, createClient, getRepo, repoId, type Repo } from './client';
77+88+type BookEntry = { value: BookRecord; uri: AtUriString };
99+1010+const READING = 'buzz.bookhive.defs#reading';
1111+const FINISHED = 'buzz.bookhive.defs#finished';
1212+1313+/**
1414+ * The currently-reading and finished views both come from the same bookhive
1515+ * collection, so we list it once and cache the raw records. Cached under the
1616+ * general (1 day) cache since the library changes slowly.
1717+ */
1818+async function listBooks(repo: Repo): Promise<BookEntry[]> {
1919+ const cached = cache.get('books') as BookEntry[] | null;
2020+ if (cached) return cached;
2121+2222+ const client = createClient(repo.pds);
2323+ const res = await client.list(buzz.bookhive.book, {
2424+ repo: repoId(repo.did),
2525+ limit: 100
2626+ });
2727+ const records = res.records as ReadonlyArray<BookEntry>;
2828+ const books = [...records];
2929+ cache.set('books', books);
3030+ return books;
3131+}
3232+3333+export async function fetchCurrentlyReading(): Promise<CurrentlyReading | null> {
3434+ const repo = getRepo('Currently reading');
3535+ if (!repo) return null;
3636+3737+ try {
3838+ const reading = (await listBooks(repo)).find((r) => r.value.status === READING);
3939+ if (!reading) return null;
4040+4141+ const v = reading.value;
4242+ return {
4343+ title: v.title,
4444+ bookUrl: `https://bookhive.buzz/books/${v.hiveId}`,
4545+ authors: v.authors.split('\t').join(', '),
4646+ cover: v.cover ? blobUrl(repo.pds, repo.did, v.cover) : null
4747+ };
4848+ } catch (error) {
4949+ console.warn('Failed to fetch currently reading book:', error);
5050+ return null;
5151+ }
5252+}
5353+5454+export async function fetchFinishedBooks(): Promise<FinishedBook[]> {
5555+ const cached = historyCache.get('finishedBooks') as FinishedBook[] | null;
5656+ if (cached) return cached;
5757+5858+ const repo = getRepo('Books history');
5959+ if (!repo) return [];
6060+6161+ try {
6262+ const finished = (await listBooks(repo))
6363+ .filter((r) => r.value.status === FINISHED)
6464+ .map(({ value }) => ({
6565+ title: value.title,
6666+ authors: value.authors.split('\t').join(', '),
6767+ bookUrl: `https://bookhive.buzz/books/${value.hiveId}`,
6868+ cover: value.cover ? blobUrl(repo.pds, repo.did, value.cover) : null,
6969+ finishedAt: value.finishedAt ?? value.createdAt ?? null,
7070+ stars: value.stars ?? null
7171+ }))
7272+ .sort((a, b) => (b.finishedAt ?? '').localeCompare(a.finishedAt ?? ''));
7373+7474+ historyCache.set('finishedBooks', finished);
7575+ return finished;
7676+ } catch (error) {
7777+ console.warn('Failed to fetch finished books:', error);
7878+ return [];
7979+ }
8080+}
+39
src/lib/server/atproto/client.ts
···11+import { env } from '$env/dynamic/private';
22+import { Client, getBlobCidString, type AtIdentifierString } from '@atproto/lex';
33+44+export type Repo = { did: string; pds: string };
55+66+/**
77+ * Reads the configured ATProto repo (DID + PDS) from the environment.
88+ * Returns null — and logs a warning — when either is missing, so callers can
99+ * gracefully hide the relevant section instead of throwing.
1010+ */
1111+export function getRepo(warnLabel?: string): Repo | null {
1212+ const did = env.ATPROTO_DID;
1313+ const pds = env.ATPROTO_PDS;
1414+ if (!did || !pds) {
1515+ console.warn(
1616+ `ATPROTO_DID / ATPROTO_PDS are not set${warnLabel ? ` — ${warnLabel} will be hidden.` : '.'}`
1717+ );
1818+ return null;
1919+ }
2020+ return { did, pds };
2121+}
2222+2323+export function createClient(pds: string): Client {
2424+ return new Client(pds);
2525+}
2626+2727+/** The repo DID as the branded identifier type the lexicon client expects. */
2828+export function repoId(did: string): AtIdentifierString {
2929+ return did as AtIdentifierString;
3030+}
3131+3232+/** Builds a public getBlob URL for a blob ref stored on a record. */
3333+export function blobUrl(
3434+ pds: string,
3535+ did: string,
3636+ blob: Parameters<typeof getBlobCidString>[0]
3737+): string {
3838+ return `${pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${getBlobCidString(blob)}`;
3939+}
+105
src/lib/server/atproto/coverArt.ts
···11+import { cache } from '../cache';
22+33+// MusicBrainz requires an identifying User-Agent or it will block requests.
44+const MB_USER_AGENT = 'baileys-website/1.0 (https://tangled.org/pds.dad/my-website)';
55+66+/** Release MBIDs arrive either bare or as `mbid:<uuid>` — normalise to bare. */
77+function normaliseMbid(mbid: string): string {
88+ return mbid.replace(/^mbid:/, '');
99+}
1010+1111+/**
1212+ * Probe the Cover Art Archive for a release's front cover. Not every release
1313+ * has art in the CAA, so we HEAD the URL and only return it when it resolves.
1414+ * Results (including misses) are memoised per MBID in the shared cache so the
1515+ * same release isn't re-probed across a page full of plays.
1616+ */
1717+async function probeCoverArt(fetch: typeof globalThis.fetch, mbid: string): Promise<string | null> {
1818+ const id = normaliseMbid(mbid);
1919+ if (!id) return null;
2020+2121+ const cacheKey = `coverart:${id}`;
2222+ const cached = cache.get(cacheKey) as { url: string | null } | null;
2323+ if (cached) return cached.url;
2424+2525+ const url = `https://coverartarchive.org/release/${id}/front-500`;
2626+ let resolved: string | null = null;
2727+ try {
2828+ const res = await fetch(url, { method: 'HEAD', redirect: 'follow' });
2929+ if (res.ok) resolved = url;
3030+ } catch {
3131+ // network error — treat as a miss
3232+ }
3333+3434+ cache.set(cacheKey, { url: resolved });
3535+ return resolved;
3636+}
3737+3838+/**
3939+ * Resolve album artwork for a single track. When a `releaseMbId` is available
4040+ * (e.g. play-history records) the direct CAA probe is enough. The ISRC →
4141+ * MusicBrainz → release lookup is the richer fallback used for "now playing",
4242+ * but it costs extra requests and MusicBrainz rate-limits (~1 req/s), so it is
4343+ * opt-in via `useIsrcLookup` and should stay off for bulk history.
4444+ */
4545+export async function resolveCoverArt(
4646+ fetch: typeof globalThis.fetch,
4747+ opts: {
4848+ isrc?: string | null;
4949+ releaseMbId?: string | null;
5050+ releaseName?: string | null;
5151+ useIsrcLookup?: boolean;
5252+ }
5353+): Promise<string | null> {
5454+ const candidates: string[] = [];
5555+5656+ if (opts.useIsrcLookup && opts.isrc) {
5757+ try {
5858+ const isrcRes = await fetch(
5959+ `https://musicbrainz.org/ws/2/isrc/${encodeURIComponent(opts.isrc)}?fmt=json`,
6060+ { headers: { 'User-Agent': MB_USER_AGENT } }
6161+ );
6262+ const recordingId = isrcRes.ok
6363+ ? ((await isrcRes.json()) as { recordings?: { id: string }[] }).recordings?.[0]?.id
6464+ : undefined;
6565+6666+ if (recordingId) {
6767+ const relRes = await fetch(
6868+ `https://musicbrainz.org/ws/2/release?recording=${recordingId}&fmt=json`,
6969+ { headers: { 'User-Agent': MB_USER_AGENT } }
7070+ );
7171+ if (relRes.ok) {
7272+ const releases =
7373+ ((await relRes.json()) as { releases?: { id: string; title?: string }[] }).releases ??
7474+ [];
7575+ // An ISRC's recording often appears on many releases (singles,
7676+ // comps, regional editions). Prefer the one whose title matches
7777+ // what's playing.
7878+ const wanted = opts.releaseName?.toLowerCase();
7979+ const seen = new Set<string>();
8080+ for (const r of [
8181+ ...releases.filter((r) => wanted && r.title?.toLowerCase() === wanted),
8282+ ...releases
8383+ ]) {
8484+ if (!seen.has(r.id)) {
8585+ seen.add(r.id);
8686+ candidates.push(r.id);
8787+ }
8888+ }
8989+ }
9090+ }
9191+ } catch (error) {
9292+ console.warn('MusicBrainz ISRC lookup failed:', error);
9393+ }
9494+ }
9595+9696+ // Release MBID supplied directly by the record.
9797+ if (opts.releaseMbId) candidates.push(opts.releaseMbId);
9898+9999+ for (const mbid of candidates.slice(0, 5)) {
100100+ const url = await probeCoverArt(fetch, mbid);
101101+ if (url) return url;
102102+ }
103103+104104+ return null;
105105+}