vod frog, frog with the vods
3
fork

Configure Feed

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

switch to VodSlice GraphQL + Slingshot resolver, remove actorHandle, server-side cursor pagination

+203 -78
lexicon.zip

This is a binary file and will not be displayed.

+70
lexicon/place/stream/video.json
··· 1 + { 2 + "$type": "com.atproto.lexicon.schema", 3 + "lexicon": 1, 4 + "id": "place.stream.video", 5 + "defs": { 6 + "archiveBlob": { 7 + "type": "object", 8 + "required": [], 9 + "properties": { 10 + "ref": { 11 + "type": "string" 12 + }, 13 + "size": { 14 + "type": "integer" 15 + }, 16 + "mimeType": { 17 + "type": "string" 18 + } 19 + } 20 + }, 21 + "main": { 22 + "type": "record", 23 + "description": "A forum thread", 24 + "key": "tid", 25 + "record": { 26 + "type": "object", 27 + "required": [ 28 + "title", 29 + "source", 30 + "createdAt" 31 + ], 32 + "properties": { 33 + "title": { 34 + "type": "string", 35 + "description": "the video title" 36 + }, 37 + "source": { 38 + "type": "ref", 39 + "ref": "#archiveBlob" 40 + }, 41 + "creator": { 42 + "type": "string", 43 + "format": "did" 44 + }, 45 + "duration": { 46 + "type": "integer" 47 + }, 48 + "createdAt": { 49 + "type": "string", 50 + "format": "datetime", 51 + "description": "Thread creation timestamp" 52 + }, 53 + "livestream": { 54 + "type": "object", 55 + "required": [], 56 + "properties": { 57 + "cid": { 58 + "type": "string" 59 + }, 60 + "uri": { 61 + "type": "string", 62 + "format": "at-uri" 63 + } 64 + } 65 + } 66 + } 67 + } 68 + } 69 + } 70 + }
+126 -75
src/lib/api.ts
··· 5 5 6 6 /** The AT Protocol lexicon collection for stream.place videos */ 7 7 export const COLLECTION = 'place.stream.video'; 8 + 8 9 /** HLS playlist endpoint — takes an at:// URI and returns an m3u8 manifest */ 9 10 export const PLAYBACK_BASE = 10 11 'https://vod-beta.stream.place/xrpc/place.stream.playback.getVideoPlaylist'; 11 12 12 - /** UFOs API — indexes all AT Protocol records across the network */ 13 - export const UFOS_API = 'https://ufos-api.microcosm.blue'; 13 + /** VodSlice GraphQL API — indexes all place.stream.video records with rich metadata */ 14 + export const VODSLICE_API = 'https://vodslice.sky.boo/graphql'; 14 15 15 - /** A video record in our normalized format, with an at:// URI */ 16 + /** Slingshot identity resolver — fast cached DID→handle+PDS resolution */ 17 + export const SLINGSHOT_API = 'https://slingshot.microcosm.blue'; 18 + 19 + /** A video record in our normalized format */ 16 20 export interface VideoRecord { 17 21 uri: string; 18 22 cid: string; ··· 35 39 }; 36 40 } 37 41 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; 42 + /** GraphQL query for fetching paginated videos with all needed fields */ 43 + const VIDEOS_QUERY = ` 44 + query GetVideos($first: Int, $after: String) { 45 + placeStreamVideo(first: $first, after: $after) { 46 + edges { 47 + node { 48 + uri 49 + cid 50 + creator 51 + title 52 + duration 53 + createdAt 54 + livestream 55 + source { 56 + ref 57 + size 58 + mimeType 59 + } 60 + } 61 + cursor 62 + } 63 + pageInfo { 64 + hasNextPage 65 + endCursor 66 + } 67 + } 68 + }`; 69 + 70 + /** Response shape from the VodSlice GraphQL API */ 71 + interface VodSliceEdge { 72 + node: { 73 + uri: string; 74 + cid: string; 75 + creator: string; 45 76 title: string; 46 - source: any; 47 - creator: string; 48 77 duration: number; 49 78 createdAt: string; 50 - livestream?: { 51 - cid: string; 52 - uri: string; 53 - }; 79 + livestream: string | { uri: string; cid: string } | null; 80 + source: { ref: string; size: number; mimeType: string }; 54 81 }; 55 - time_us: number; 82 + cursor: string; 56 83 } 57 84 58 - export interface ListRecordsResponse { 85 + /** Fetch a page of videos from the VodSlice GraphQL API with cursor-based pagination */ 86 + export async function listVideos(cursor?: string, pageSize = 9): Promise<{ 59 87 records: VideoRecord[]; 60 - cursor?: string; 61 - } 88 + hasMore: boolean; 89 + endCursor?: string; 90 + }> { 91 + const res = await fetch(VODSLICE_API, { 92 + method: 'POST', 93 + headers: { 'Content-Type': 'application/json' }, 94 + body: JSON.stringify({ 95 + query: VIDEOS_QUERY, 96 + variables: { first: pageSize, after: cursor || null } 97 + }) 98 + }); 62 99 63 - /** In-memory cache — the UFOs API returns all records at once, so we fetch once and paginate client-side */ 64 - let allVideosCache: VideoRecord[] | null = null; 100 + if (!res.ok) throw new Error(`Failed to fetch videos: ${res.status}`); 101 + const json = await res.json(); 102 + const data = json.data?.placeStreamVideo; 103 + if (!data) throw new Error('No data in GraphQL response'); 65 104 66 - /** Fetch all place.stream.video records from across the AT Protocol network */ 67 - export async function fetchAllVideos(): Promise<VideoRecord[]> { 68 - if (allVideosCache) return allVideosCache; 105 + const records: VideoRecord[] = data.edges.map((edge: VodSliceEdge) => { 106 + const n = edge.node; 69 107 70 - const res = await fetch(`${UFOS_API}/records?collection=${COLLECTION}`); 71 - if (!res.ok) throw new Error(`Failed to fetch records: ${res.status}`); 108 + // Normalize the livestream field — GraphQL returns it as either a string (JSON) or object 109 + let livestream: { cid: string; uri: string } | undefined; 110 + if (typeof n.livestream === 'string') { 111 + try { livestream = JSON.parse(n.livestream); } catch { /* ignore */ } 112 + } else if (n.livestream && typeof n.livestream === 'object') { 113 + livestream = n.livestream as { uri: string; cid: string }; 114 + } 72 115 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 - })); 116 + return { 117 + uri: n.uri, 118 + cid: n.cid || '', 119 + value: { 120 + $type: COLLECTION, 121 + title: n.title, 122 + source: { ...n.source, $type: 'place.stream.muxl.defs#archiveBlob' }, 123 + creator: n.creator, 124 + duration: n.duration, 125 + createdAt: n.createdAt, 126 + livestream 127 + } 128 + }; 129 + }); 83 130 84 - return allVideosCache; 131 + return { 132 + records, 133 + hasMore: data.pageInfo.hasNextPage, 134 + endCursor: data.pageInfo.endCursor 135 + }; 85 136 } 86 137 87 - /** Return a page of videos from the cached full list */ 88 - export async function listVideos(page = 0, pageSize = 9): Promise<{ records: VideoRecord[]; hasMore: boolean }> { 89 - const all = await fetchAllVideos(); 90 - const start = page * pageSize; 91 - const records = all.slice(start, start + pageSize); 92 - return { records, hasMore: start + pageSize < all.length }; 138 + /** Fetch all videos (for deep-link lookup). Uses pagination internally. */ 139 + export async function fetchAllVideos(): Promise<VideoRecord[]> { 140 + const all: VideoRecord[] = []; 141 + let cursor: string | undefined; 142 + let hasMore = true; 143 + while (hasMore) { 144 + const page = await listVideos(cursor, 50); 145 + all.push(...page.records); 146 + hasMore = page.hasMore; 147 + cursor = page.endCursor; 148 + } 149 + return all; 93 150 } 94 151 95 152 /** Build an HLS playlist URL from an at:// video URI */ ··· 144 201 return res.json(); 145 202 } 146 203 147 - // --- Handle resolution --- 204 + // --- Identity resolution via Slingshot --- 148 205 149 - const handleCache = new Map<string, string>(); 206 + interface MiniDoc { 207 + did: string; 208 + handle: string; 209 + pds: string; 210 + } 150 211 151 - /** Resolve a DID to a human-readable @handle via the PLC directory */ 152 - export async function resolveHandle(did: string): Promise<string> { 153 - if (handleCache.has(did)) return handleCache.get(did)!; 212 + const identityCache = new Map<string, MiniDoc>(); 213 + 214 + /** Resolve a DID to handle + PDS via the Slingshot cached resolver */ 215 + async function resolveMiniDoc(did: string): Promise<MiniDoc | null> { 216 + if (identityCache.has(did)) return identityCache.get(did)!; 154 217 try { 155 - const res = await fetch(`https://plc.directory/${did}`); 156 - if (!res.ok) return did; 157 - const doc = await res.json(); 158 - const aka = doc.alsoKnownAs?.[0]; 159 - const handle = aka ? aka.replace('at://', '@') : did; 160 - handleCache.set(did, handle); 161 - return handle; 218 + const res = await fetch(`${SLINGSHOT_API}/xrpc/blue.microcosm.identity.resolveMiniDoc?identifier=${encodeURIComponent(did)}`); 219 + if (!res.ok) return null; 220 + const doc: MiniDoc = await res.json(); 221 + identityCache.set(did, doc); 222 + return doc; 162 223 } catch { 163 - return did; 224 + return null; 164 225 } 165 226 } 166 227 167 - // --- PDS resolution --- 168 - 169 - const pdsCache = new Map<string, string>(); 228 + /** Resolve a DID to a human-readable @handle */ 229 + export async function resolveHandle(did: string): Promise<string> { 230 + const doc = await resolveMiniDoc(did); 231 + return doc ? `@${doc.handle}` : did; 232 + } 170 233 171 234 /** Resolve a DID to its PDS (Personal Data Server) endpoint URL */ 172 235 export async function resolvePds(did: string): Promise<string | null> { 173 - if (pdsCache.has(did)) return pdsCache.get(did)!; 174 - try { 175 - const res = await fetch(`https://plc.directory/${did}`); 176 - if (!res.ok) return null; 177 - const doc = await res.json(); 178 - const svc = doc.service?.find( 179 - (s: any) => s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer' 180 - ); 181 - const endpoint = svc?.serviceEndpoint ?? null; 182 - if (endpoint) pdsCache.set(did, endpoint); 183 - return endpoint; 184 - } catch { 185 - return null; 186 - } 236 + const doc = await resolveMiniDoc(did); 237 + return doc?.pds ?? null; 187 238 } 188 239 189 240 // --- Thumbnail resolution --- 190 241 191 - // Parse an at:// URI into { repo, collection, rkey } 242 + /** Parse an at:// URI into { repo, collection, rkey } */ 192 243 function parseAtUri(uri: string): { repo: string; collection: string; rkey: string } | null { 193 244 const m = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/([^/]+)$/); 194 245 if (!m) return null;
+7 -3
src/routes/+page.svelte
··· 25 25 let selectedAvatar = $state(""); 26 26 let error = $state(""); 27 27 28 - // Pagination state 28 + // Pagination state — store cursor history so we can go back 29 + let cursorHistory: (string | undefined)[] = $state([undefined]); 29 30 let pageIndex = $state(0); 30 31 let hasMore = $state(true); 31 32 ··· 33 34 loading = true; 34 35 error = ""; 35 36 try { 36 - const res = await listVideos(pageIndex, PAGE_SIZE); 37 + const res = await listVideos(cursorHistory[pageIndex], PAGE_SIZE); 37 38 videos = res.records; 38 39 hasMore = res.hasMore; 40 + // Store the next page's cursor if we haven't visited it yet 41 + if (res.hasMore && res.endCursor && cursorHistory.length <= pageIndex + 1) { 42 + cursorHistory = [...cursorHistory, res.endCursor]; 43 + } 39 44 } catch (e: any) { 40 45 error = e.message; 41 46 } ··· 61 66 selectedAvatar = ""; 62 67 resolveHandle(video.value.creator).then((h) => { 63 68 selectedHandle = h; 64 - // Fetch avatar from profile 65 69 const handle = h.replace('@', ''); 66 70 getProfile(handle).then((p) => { if (p.avatar) selectedAvatar = p.avatar; }).catch(() => {}); 67 71 });