WIP: My personal website
0

Configure Feed

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

cleaning up cache, and some logic methods

+190 -55
-2
.gitignore
··· 26 26 # Extras 27 27 .playwright-mcp/ 28 28 cache.db 29 - cache-live.db 30 - cache-history.db
+1 -1
package.json
··· 44 44 "dependencies": { 45 45 "@atproto/lex": "^0.1.3", 46 46 "@sveltejs/adapter-node": "^5.5.4", 47 - "sqlite-cache": "^0.0.3" 47 + "node-sqlite-map": "^0.0.1" 48 48 } 49 49 }
+3 -11
pnpm-lock.yaml
··· 14 14 '@sveltejs/adapter-node': 15 15 specifier: ^5.5.4 16 16 version: 5.5.4(@sveltejs/kit@2.63.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.2(@typescript-eslint/types@8.60.1))(vite@8.0.16(@types/node@24.13.0)(jiti@2.7.0)))(svelte@5.56.2(@typescript-eslint/types@8.60.1))(typescript@6.0.3)(vite@8.0.16(@types/node@24.13.0)(jiti@2.7.0))) 17 - sqlite-cache: 18 - specifier: ^0.0.3 19 - version: 0.0.3 17 + node-sqlite-map: 18 + specifier: ^0.0.1 19 + version: 0.0.1 20 20 devDependencies: 21 21 '@eslint/js': 22 22 specifier: ^10.0.1 ··· 1515 1515 resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} 1516 1516 engines: {node: '>= 10.x'} 1517 1517 1518 - sqlite-cache@0.0.3: 1519 - resolution: {integrity: sha512-y4Hk3+WoWyH+CKGlfMNcC4rBje17FpIngfnTQCiyoWJIA/Y5dijFcqw616NGP3nNTzUpB2O3+5SFV11I6iAXbQ==} 1520 - engines: {node: '>=22.5'} 1521 - 1522 1518 string-width@7.2.0: 1523 1519 resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} 1524 1520 engines: {node: '>=18'} ··· 2972 2968 source-map-js@1.2.1: {} 2973 2969 2974 2970 split2@4.2.0: {} 2975 - 2976 - sqlite-cache@0.0.3: 2977 - dependencies: 2978 - node-sqlite-map: 0.0.1 2979 2971 2980 2972 string-width@7.2.0: 2981 2973 dependencies:
+10 -8
src/lib/server/atproto/books.ts
··· 2 2 import type { Main as BookRecord } from '$lib/lexicons/buzz/bookhive/book'; 3 3 import type { AtUriString } from '@atproto/lex'; 4 4 import type { CurrentlyReading, FinishedBook } from '$lib/types'; 5 - import { cache, historyCache } from '../cache'; 6 - import { blobUrl, createClient, getRepo, repoId, type Repo } from './client'; 5 + import { cache } from '$lib/server/cache'; 6 + import { blobUrl, createClient, getRepoInfo, repoId, type RepoInfo } from './client'; 7 7 8 8 type BookEntry = { value: BookRecord; uri: AtUriString }; 9 9 ··· 13 13 /** 14 14 * The currently-reading and finished views both come from the same bookhive 15 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. 16 + * the default ttl 1 day 17 17 */ 18 - async function listBooks(repo: Repo): Promise<BookEntry[]> { 18 + async function listBooks(repo: RepoInfo): Promise<BookEntry[]> { 19 19 const cached = cache.get('books') as BookEntry[] | null; 20 20 if (cached) return cached; 21 21 ··· 31 31 } 32 32 33 33 export async function fetchCurrentlyReading(): Promise<CurrentlyReading | null> { 34 - const repo = getRepo('Currently reading'); 34 + const repo = getRepoInfo('Currently reading'); 35 35 if (!repo) return null; 36 36 37 37 try { ··· 39 39 if (!reading) return null; 40 40 41 41 const v = reading.value; 42 + const test = blobUrl(repo.pds, repo.did, v.cover); 43 + console.log(test); 42 44 return { 43 45 title: v.title, 44 46 bookUrl: `https://bookhive.buzz/books/${v.hiveId}`, ··· 52 54 } 53 55 54 56 export async function fetchFinishedBooks(): Promise<FinishedBook[]> { 55 - const cached = historyCache.get('finishedBooks') as FinishedBook[] | null; 57 + const cached = cache.get('finishedBooks') as FinishedBook[] | null; 56 58 if (cached) return cached; 57 59 58 - const repo = getRepo('Books history'); 60 + const repo = getRepoInfo('Books history'); 59 61 if (!repo) return []; 60 62 61 63 try { ··· 71 73 })) 72 74 .sort((a, b) => (b.finishedAt ?? '').localeCompare(a.finishedAt ?? '')); 73 75 74 - historyCache.set('finishedBooks', finished); 76 + cache.set('finishedBooks', finished); 75 77 return finished; 76 78 } catch (error) { 77 79 console.warn('Failed to fetch finished books:', error);
+4 -3
src/lib/server/atproto/client.ts
··· 1 1 import { env } from '$env/dynamic/private'; 2 2 import { Client, getBlobCidString, type AtIdentifierString } from '@atproto/lex'; 3 3 4 - export type Repo = { did: string; pds: string }; 4 + export type RepoInfo = { did: string; pds: string }; 5 5 6 6 /** 7 7 * Reads the configured ATProto repo (DID + PDS) from the environment. 8 8 * Returns null — and logs a warning — when either is missing, so callers can 9 9 * gracefully hide the relevant section instead of throwing. 10 10 */ 11 - export function getRepo(warnLabel?: string): Repo | null { 11 + export function getRepoInfo(warnLabel?: string): RepoInfo | null { 12 12 const did = env.ATPROTO_DID; 13 13 const pds = env.ATPROTO_PDS; 14 14 if (!did || !pds) { ··· 35 35 did: string, 36 36 blob: Parameters<typeof getBlobCidString>[0] 37 37 ): string { 38 - return `${pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${getBlobCidString(blob)}`; 38 + const cidString = getBlobCidString(blob); 39 + return `${pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${cidString}`; 39 40 }
+2 -2
src/lib/server/atproto/coverArt.ts
··· 1 - import { cache } from '../cache'; 1 + import { cache, YEAR_MS } from '$lib/server/cache'; 2 2 3 3 // MusicBrainz requires an identifying User-Agent or it will block requests. 4 4 const MB_USER_AGENT = 'baileys-website/1.0 (https://tangled.org/pds.dad/my-website)'; ··· 31 31 // network error — treat as a miss 32 32 } 33 33 34 - cache.set(cacheKey, { url: resolved }); 34 + cache.setWithExpiry(cacheKey, { url: resolved }, YEAR_MS); 35 35 return resolved; 36 36 } 37 37
+8 -8
src/lib/server/atproto/music.ts
··· 2 2 import type { Main as PlayRecord } from '$lib/lexicons/fm/teal/alpha/feed/play'; 3 3 import type { AtUriString } from '@atproto/lex'; 4 4 import type { NowPlaying, RecentPlay } from '$lib/types'; 5 - import { historyCache, liveCache } from '../cache'; 6 - import { createClient, getRepo, repoId } from './client'; 5 + import { cache, MIN_MS } from '$lib/server/cache'; 6 + import { createClient, getRepoInfo, repoId } from './client'; 7 7 import { resolveCoverArt } from './coverArt'; 8 8 9 9 const RECENT_PLAYS_LIMIT = 50; ··· 28 28 } 29 29 30 30 export async function fetchNowPlaying(fetch: typeof globalThis.fetch): Promise<NowPlaying | null> { 31 - const cached = liveCache.get('nowPlaying') as { value: NowPlaying | null } | null; 31 + const cached = cache.get('nowPlaying') as { value: NowPlaying | null } | null; 32 32 if (cached) return cached.value; 33 33 34 - const repo = getRepo('Now playing'); 34 + const repo = getRepoInfo('Now playing'); 35 35 if (!repo) return null; 36 36 37 37 try { ··· 56 56 coverArt 57 57 }; 58 58 59 - liveCache.set('nowPlaying', { value: nowPlaying }); 59 + cache.setWithExpiry('nowPlaying', { value: nowPlaying }, MIN_MS); 60 60 return nowPlaying; 61 61 } catch (error) { 62 62 console.warn('Failed to fetch now playing status:', error); ··· 65 65 } 66 66 67 67 export async function fetchRecentPlays(fetch: typeof globalThis.fetch): Promise<RecentPlay[]> { 68 - const cached = historyCache.get('recentPlays') as RecentPlay[] | null; 68 + const cached = cache.get('recentPlays') as RecentPlay[] | null; 69 69 if (cached) return cached; 70 70 71 - const repo = getRepo('Music history'); 71 + const repo = getRepoInfo('Music history'); 72 72 if (!repo) return []; 73 73 74 74 try { ··· 101 101 } satisfies RecentPlay; 102 102 }); 103 103 104 - historyCache.set('recentPlays', plays); 104 + cache.setWithExpiry('recentPlays', plays, MIN_MS * 5); 105 105 return plays; 106 106 } catch (error) { 107 107 console.warn('Failed to fetch recent plays:', error);
+5 -4
src/lib/server/atproto/publications.ts
··· 2 2 import type { Main as PublicationRecord } from '$lib/lexicons/site/standard/publication'; 3 3 import type { AtUriString } from '@atproto/lex'; 4 4 import type { Publication } from '$lib/types'; 5 - import { cache } from '../cache'; 6 - import { blobUrl, createClient, getRepo, repoId } from './client'; 5 + import { cache, HOUR_MS } from '$lib/server/cache'; 6 + import { blobUrl, createClient, getRepoInfo, repoId } from './client'; 7 7 8 8 export async function fetchPublications(): Promise<Publication[]> { 9 9 const cached = cache.get('publications'); ··· 11 11 return cached as Publication[]; 12 12 } 13 13 14 - const repo = getRepo('Writing section'); 14 + const repo = getRepoInfo('Writing section'); 15 15 if (!repo) return []; 16 16 17 17 try { ··· 23 23 const records = res.records as ReadonlyArray<{ value: PublicationRecord; uri: AtUriString }>; 24 24 25 25 const publications: Publication[] = records 26 + //Sorry flo <3, just wanting to show my writings 26 27 .filter((x) => !x.uri.includes('blento.self')) 27 28 .map(({ value }) => ({ 28 29 name: value.name, ··· 31 32 image: value.icon ? blobUrl(repo.pds, repo.did, value.icon) : null 32 33 })); 33 34 34 - cache.set('publications', publications); 35 + cache.setWithExpiry('publications', publications, HOUR_MS); 35 36 return publications; 36 37 } catch (error) { 37 38 console.warn('Failed to fetch publications:', error);
-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 });
+139
src/lib/server/cache/SQLiteCache.ts
··· 1 + import { SqliteMap } from "node-sqlite-map" 2 + 3 + export class SQLiteCache<Key extends string, Value extends object> { 4 + private map: SqliteMap<Key, CacheEntry<Value>> 5 + ttl: number 6 + max: number 7 + 8 + constructor(options: CacheOptions) { 9 + if (!options) throw new SqliteCacheError("Cache options are required") 10 + if (!options.path || typeof options.path !== "string") this.error("Cache path must be a valid string") 11 + 12 + this.ttl = options.ttl ?? 60_000 13 + this.max = options.max ?? 100 14 + 15 + this.map = new SqliteMap<Key, CacheEntry<Value>>(options.path) 16 + } 17 + 18 + set(key: Key, data: Value): this { 19 + if (!key || typeof key !== "string") this.error("Cache key must be a valid string") 20 + if (!data || typeof data !== "object") this.error("Cache value must be a valid object") 21 + 22 + this._evictExpired() 23 + this._evictOverflow() 24 + 25 + this.map.set(key, { value: data, expires: Date.now() + this.ttl, createdAt: Date.now() }) 26 + 27 + return this 28 + } 29 + 30 + setWithExpiry(key: Key, data: Value, expires: number): this { 31 + if (!key || typeof key !== "string") this.error("Cache key must be a valid string") 32 + if (!data || typeof data !== "object") this.error("Cache value must be a valid object") 33 + 34 + this._evictExpired() 35 + this._evictOverflow() 36 + 37 + this.map.set(key, { value: data, expires: Date.now() + expires, createdAt: Date.now() }) 38 + 39 + return this 40 + } 41 + 42 + get(key: Key): Value | null { 43 + const entry = this.map.get(key) 44 + if (!entry) return null 45 + 46 + if (Date.now() > entry.expires) { 47 + this.map.delete(key) 48 + 49 + return null 50 + } 51 + 52 + return entry.value 53 + } 54 + 55 + has(key: Key): boolean { 56 + const entry = this.map.get(key) 57 + return !!entry && Date.now() <= entry.expires 58 + } 59 + 60 + delete(key: Key): boolean { 61 + return this.map.delete(key) 62 + } 63 + 64 + clear(): void { 65 + this.map.clear() 66 + } 67 + 68 + get size(): number { 69 + return [...this.map.values()].filter((e) => Date.now() <= e.expires).length 70 + } 71 + 72 + keys(): IterableIterator<Key> { 73 + return this._liveEntries() 74 + .map(([k]) => k) 75 + [Symbol.iterator]() 76 + } 77 + 78 + values(): IterableIterator<Value> { 79 + return this._liveEntries() 80 + .map(([, e]) => e.value) 81 + [Symbol.iterator]() 82 + } 83 + 84 + entries(): IterableIterator<[Key, Value]> { 85 + return this._liveEntries() 86 + .map(([k, e]) => [k, e.value] as [Key, Value]) 87 + [Symbol.iterator]() 88 + } 89 + 90 + forEach(callback: (value: Value, key: Key, map: this) => void): void { 91 + for (const [k, e] of this._liveEntries()) callback(e.value, k, this) 92 + } 93 + 94 + [Symbol.iterator](): IterableIterator<[Key, Value]> { 95 + return this.entries() 96 + } 97 + 98 + private _liveEntries(): [Key, CacheEntry<Value>][] { 99 + const now = Date.now() 100 + return [...this.map.entries()].filter(([, e]) => now <= e.expires) 101 + } 102 + 103 + private _evictExpired(): void { 104 + const now = Date.now() 105 + for (const [k, e] of this.map.entries()) { 106 + if (now > e.expires) this.map.delete(k) 107 + } 108 + } 109 + 110 + private _evictOverflow(): void { 111 + const entries = [...this.map.entries()] 112 + .filter(([, e]) => Date.now() <= e.expires) 113 + .sort(([, a], [, b]) => a.createdAt - b.createdAt) 114 + for (const [k] of entries.slice(this.max)) this.map.delete(k) 115 + } 116 + 117 + private error(message: string): never { 118 + throw new SqliteCacheError(message) 119 + } 120 + } 121 + 122 + export class SqliteCacheError extends Error { 123 + constructor(message: string) { 124 + super(message) 125 + this.name = "SqliteCacheError" 126 + } 127 + } 128 + 129 + export type CacheEntry<V> = { 130 + value: V 131 + expires: number 132 + createdAt: number 133 + } 134 + 135 + export type CacheOptions = { 136 + path: string 137 + max?: number 138 + ttl?: number 139 + }
+17
src/lib/server/cache/index.ts
··· 1 + import { SQLiteCache } from './SQLiteCache'; 2 + 3 + export const DAY_MS = 86_400_000; // 1 day 4 + export const MIN_MS = 60_000; // 1 min 5 + export const QUARTER_HOUR_MS = 900_000; // 15 min 6 + export const HOUR_MS = 3_600_000; // 1 hour 7 + 8 + export const YEAR_MS = 31_536_000_000; // 1 year 9 + 10 + /** General-purpose cache for data that rarely changes (publications, sponsors, books, cover art). */ 11 + export const cache = new SQLiteCache({ path: './cache.db', ttl: DAY_MS }); 12 + 13 + // /** Short-lived cache for "now playing", which changes often. */ 14 + // export const liveCache = new SQLiteCache({ path: './cache-live.db', ttl: MIN_MS }); 15 + 16 + // /** Medium-lived cache for the history pages (recent plays, finished books). */ 17 + // export const historyCache = new SQLiteCache({ path: './cache-history.db', ttl: QUARTER_HOUR_MS });
+1 -2
src/routes/history/music/+page.svelte
··· 3 3 import type { PageProps } from './$types'; 4 4 5 5 let { data }: PageProps = $props(); 6 - const { recentPlays } = data; 7 6 </script> 8 7 9 8 <svelte:head> ··· 13 12 <meta name="og:description" content="Tracks I've been listening to recently." /> 14 13 </svelte:head> 15 14 16 - <MusicHistory {recentPlays} /> 15 + <MusicHistory recentPlays={data.recentPlays} />