vod jam and earl vod.atverkackt.de
4
fork

Configure Feed

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

UFO caps at 42, build json with vods and let client render from there

+179 -83
+2 -1
.gitignore
··· 7 7 *.afphoto 8 8 *.afphoto~lock~ 9 9 infra 10 - node_modules 10 + node_modules 11 + static/data
+1
.npmrc
··· 1 1 engine-strict=true 2 + registry=https://registry.npmjs.org/
+1
package.json
··· 5 5 "type": "module", 6 6 "scripts": { 7 7 "dev": "vite dev", 8 + "prebuild": "node scripts/fetch-videos.mjs", 8 9 "build": "vite build", 9 10 "preview": "vite preview", 10 11 "prepare": "svelte-kit sync || echo ''",
+164
scripts/fetch-videos.mjs
··· 1 + /** 2 + * Pre-build script: fetches all AtmosphereConf schedule events and their 3 + * linked video records, then writes static JSON files into static/ so the 4 + * client can load them instantly without runtime API calls. 5 + * 6 + * Outputs: 7 + * static/data/videos.json — VideoRecord[] 8 + * static/data/schedule.json — { [vodAtUri]: ScheduleEvent } 9 + */ 10 + 11 + const BSKY_PUBLIC_API = 'https://public.api.bsky.app'; 12 + const ATMOSPHERECONF_HANDLE = 'atmosphereconf.org'; 13 + const CALENDAR_COLLECTION = 'community.lexicon.calendar.event'; 14 + 15 + import { writeFileSync, mkdirSync } from 'node:fs'; 16 + import { join, dirname } from 'node:path'; 17 + import { fileURLToPath } from 'node:url'; 18 + 19 + const __dirname = dirname(fileURLToPath(import.meta.url)); 20 + const outDir = join(__dirname, '..', 'static', 'data'); 21 + 22 + async function getProfile(actor) { 23 + const params = new URLSearchParams({ actor }); 24 + const res = await fetch(`${BSKY_PUBLIC_API}/xrpc/app.bsky.actor.getProfile?${params}`); 25 + if (!res.ok) throw new Error(`Failed to fetch profile: ${res.status}`); 26 + return res.json(); 27 + } 28 + 29 + async function resolvePds(did) { 30 + const res = await fetch(`https://plc.directory/${did}`); 31 + if (!res.ok) return null; 32 + const doc = await res.json(); 33 + const svc = doc.service?.find( 34 + (s) => s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer' 35 + ); 36 + return svc?.serviceEndpoint ?? null; 37 + } 38 + 39 + function parseAtUri(uri) { 40 + const m = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/([^/]+)$/); 41 + if (!m) return null; 42 + return { repo: m[1], collection: m[2], rkey: m[3] }; 43 + } 44 + 45 + async function fetchScheduleEvents() { 46 + const profile = await getProfile(ATMOSPHERECONF_HANDLE); 47 + const pds = await resolvePds(profile.did); 48 + if (!pds) throw new Error('Could not resolve PDS for atmosphereconf.org'); 49 + 50 + const events = []; 51 + let cursor; 52 + 53 + do { 54 + const params = new URLSearchParams({ 55 + repo: profile.did, 56 + collection: CALENDAR_COLLECTION, 57 + limit: '100' 58 + }); 59 + if (cursor) params.set('cursor', cursor); 60 + 61 + const res = await fetch(`${pds}/xrpc/com.atproto.repo.listRecords?${params}`); 62 + if (!res.ok) throw new Error(`Failed to fetch schedule: ${res.status}`); 63 + 64 + const data = await res.json(); 65 + for (const rec of data.records ?? []) { 66 + const v = rec.value; 67 + if (!v.additionalData?.isAtmosphereconf) continue; 68 + events.push({ 69 + name: v.name, 70 + description: v.description, 71 + speakers: v.additionalData.speakers, 72 + vodAtUri: v.additionalData.vodAtUri, 73 + startsAt: v.startsAt, 74 + endsAt: v.endsAt, 75 + category: v.additionalData.category 76 + }); 77 + } 78 + cursor = data.cursor; 79 + } while (cursor); 80 + 81 + return events; 82 + } 83 + 84 + async function fetchVideoRecord(pdsCache, uri) { 85 + const parsed = parseAtUri(uri); 86 + if (!parsed) return null; 87 + 88 + let pds = pdsCache.get(parsed.repo); 89 + if (!pds) { 90 + pds = await resolvePds(parsed.repo); 91 + if (!pds) return null; 92 + pdsCache.set(parsed.repo, pds); 93 + } 94 + 95 + const params = new URLSearchParams({ 96 + repo: parsed.repo, 97 + collection: parsed.collection, 98 + rkey: parsed.rkey 99 + }); 100 + const res = await fetch(`${pds}/xrpc/com.atproto.repo.getRecord?${params}`); 101 + if (!res.ok) return null; 102 + 103 + const data = await res.json(); 104 + return { 105 + uri: data.uri, 106 + cid: data.cid ?? '', 107 + value: data.value 108 + }; 109 + } 110 + 111 + async function main() { 112 + console.log('Fetching schedule events from atmosphereconf.org...'); 113 + const events = await fetchScheduleEvents(); 114 + console.log(` Found ${events.length} schedule events`); 115 + 116 + // Build schedule map keyed by vodAtUri 117 + const scheduleMap = {}; 118 + const vodUris = []; 119 + for (const evt of events) { 120 + if (evt.vodAtUri) { 121 + scheduleMap[evt.vodAtUri] = evt; 122 + vodUris.push(evt.vodAtUri); 123 + } 124 + } 125 + console.log(` ${vodUris.length} events have vodAtUri`); 126 + 127 + // Fetch video records in parallel (with concurrency limit) 128 + console.log('Fetching video records from PDS...'); 129 + const pdsCache = new Map(); 130 + const CONCURRENCY = 10; 131 + const videos = []; 132 + 133 + for (let i = 0; i < vodUris.length; i += CONCURRENCY) { 134 + const batch = vodUris.slice(i, i + CONCURRENCY); 135 + const results = await Promise.allSettled( 136 + batch.map((uri) => fetchVideoRecord(pdsCache, uri)) 137 + ); 138 + for (const r of results) { 139 + if (r.status === 'fulfilled' && r.value) { 140 + videos.push(r.value); 141 + } 142 + } 143 + } 144 + 145 + // Sort newest first 146 + videos.sort((a, b) => new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime()); 147 + console.log(` Fetched ${videos.length} video records`); 148 + 149 + // Write output 150 + mkdirSync(outDir, { recursive: true }); 151 + 152 + writeFileSync(join(outDir, 'videos.json'), JSON.stringify(videos)); 153 + console.log(` Wrote static/data/videos.json`); 154 + 155 + writeFileSync(join(outDir, 'schedule.json'), JSON.stringify(scheduleMap)); 156 + console.log(` Wrote static/data/schedule.json`); 157 + 158 + console.log('Done!'); 159 + } 160 + 161 + main().catch((err) => { 162 + console.error('Failed to fetch video data:', err); 163 + process.exit(1); 164 + });
+11 -82
src/lib/api.ts
··· 35 35 }; 36 36 } 37 37 38 - /** Raw record shape from the UFOs API */ 39 - interface UfosRecord { 40 - did: string; 41 - collection: string; 42 - rkey: string; 43 - record: { 44 - $type: string; 45 - title: string; 46 - source: any; 47 - creator: string; 48 - duration: number; 49 - createdAt: string; 50 - livestream?: { 51 - cid: string; 52 - uri: string; 53 - }; 54 - }; 55 - time_us: number; 56 - } 57 - 58 38 export interface ListRecordsResponse { 59 39 records: VideoRecord[]; 60 40 cursor?: string; 61 41 } 62 42 63 - /** In-memory cache — the UFOs API returns all records at once, so we fetch once and paginate client-side */ 43 + /** In-memory cache for all video records */ 64 44 let allVideosCache: VideoRecord[] | null = null; 65 45 66 - /** Fetch all place.stream.video records from across the AT Protocol network */ 46 + /** Fetch all VoD video records from the pre-built static JSON */ 67 47 export async function fetchAllVideos(): Promise<VideoRecord[]> { 68 48 if (allVideosCache) return allVideosCache; 69 49 70 - const res = await fetch(`${UFOS_API}/records?collection=${COLLECTION}`); 71 - if (!res.ok) throw new Error(`Failed to fetch records: ${res.status}`); 72 - 73 - const ufosRecords: UfosRecord[] = await res.json(); 74 - 75 - // Convert UFOs format to our VideoRecord format, sorted newest first 76 - allVideosCache = ufosRecords 77 - .sort((a, b) => b.time_us - a.time_us) 78 - .map((r) => ({ 79 - uri: `at://${r.did}/${r.collection}/${r.rkey}`, 80 - cid: '', 81 - value: r.record 82 - })); 83 - 84 - return allVideosCache; 50 + const res = await fetch('/data/videos.json'); 51 + if (!res.ok) throw new Error(`Failed to fetch videos: ${res.status}`); 52 + allVideosCache = await res.json(); 53 + return allVideosCache!; 85 54 } 86 55 87 56 /** Return a page of videos from the cached full list */ ··· 189 158 190 159 // --- Schedule events (AtmosphereConf) --- 191 160 192 - const ATMOSPHERECONF_HANDLE = 'atmosphereconf.org'; 193 - const CALENDAR_COLLECTION = 'community.lexicon.calendar.event'; 194 - 195 161 export interface ScheduleEvent { 196 162 name: string; 197 163 description?: string; ··· 204 170 205 171 let scheduleCache: Map<string, ScheduleEvent> | null = null; 206 172 207 - /** Fetch AtmosphereConf calendar events from the PDS, returning a Map keyed by vodAtUri */ 173 + /** Fetch AtmosphereConf schedule from the pre-built static JSON, returning a Map keyed by vodAtUri */ 208 174 export async function fetchScheduleEvents(): Promise<Map<string, ScheduleEvent>> { 209 175 if (scheduleCache) return scheduleCache; 210 176 211 - const profile = await getProfile(ATMOSPHERECONF_HANDLE); 212 - const pds = await resolvePds(profile.did); 213 - if (!pds) throw new Error('Could not resolve PDS for atmosphereconf.org'); 214 - 215 - const events: ScheduleEvent[] = []; 216 - let cursor: string | undefined; 217 - 218 - do { 219 - const params = new URLSearchParams({ 220 - repo: profile.did, 221 - collection: CALENDAR_COLLECTION, 222 - limit: '100' 223 - }); 224 - if (cursor) params.set('cursor', cursor); 225 - 226 - const res = await fetch(`${pds}/xrpc/com.atproto.repo.listRecords?${params}`); 227 - if (!res.ok) throw new Error(`Failed to fetch schedule: ${res.status}`); 228 - 229 - const data = await res.json(); 230 - for (const rec of data.records ?? []) { 231 - const v = rec.value; 232 - if (!v.additionalData?.isAtmosphereconf) continue; 233 - events.push({ 234 - name: v.name, 235 - description: v.description, 236 - speakers: v.additionalData.speakers, 237 - vodAtUri: v.additionalData.vodAtUri, 238 - startsAt: v.startsAt, 239 - endsAt: v.endsAt, 240 - category: v.additionalData.category 241 - }); 242 - } 243 - cursor = data.cursor; 244 - } while (cursor); 177 + const res = await fetch('/data/schedule.json'); 178 + if (!res.ok) throw new Error(`Failed to fetch schedule: ${res.status}`); 245 179 246 - scheduleCache = new Map(); 247 - for (const evt of events) { 248 - if (evt.vodAtUri) { 249 - scheduleCache.set(evt.vodAtUri, evt); 250 - } 251 - } 252 - 180 + const obj: Record<string, ScheduleEvent> = await res.json(); 181 + scheduleCache = new Map(Object.entries(obj)); 253 182 return scheduleCache; 254 183 } 255 184