/** * AT Protocol API client for fetching videos, resolving handles/profiles, * and looking up thumbnails across the network. */ /** The AT Protocol lexicon collection for stream.place videos */ export const COLLECTION = 'place.stream.video'; /** HLS playlist endpoint — takes an at:// URI and returns an m3u8 manifest */ export const PLAYBACK_BASE = 'https://vod-beta.stream.place/xrpc/place.stream.playback.getVideoPlaylist'; /** VodSlice GraphQL API — indexes all place.stream.video records with rich metadata */ export const VODSLICE_API = 'https://vodslice.sky.boo/graphql'; /** Slingshot identity resolver — fast cached DID→handle+PDS resolution */ export const SLINGSHOT_API = 'https://slingshot.microcosm.blue'; /** A video record in our normalized format */ export interface VideoRecord { uri: string; cid: string; value: { $type: string; title: string; source: { ref: string; size: number; $type: string; mimeType: string; }; creator: string; duration: number; // nanoseconds createdAt: string; livestream?: { cid: string; uri: string; }; }; } /** GraphQL query for fetching paginated videos with all needed fields */ const VIDEOS_QUERY = ` query GetVideos($first: Int, $after: String) { placeStreamVideo(first: $first, after: $after) { edges { node { uri cid creator title duration createdAt livestream source { ref size mimeType } } cursor } pageInfo { hasNextPage endCursor } } }`; /** Response shape from the VodSlice GraphQL API */ interface VodSliceEdge { node: { uri: string; cid: string; creator: string; title: string; duration: number; createdAt: string; livestream: string | { uri: string; cid: string } | null; source: { ref: string; size: number; mimeType: string }; }; cursor: string; } /** Fetch a page of videos from the VodSlice GraphQL API with cursor-based pagination */ export async function listVideos(cursor?: string, pageSize = 9): Promise<{ records: VideoRecord[]; hasMore: boolean; endCursor?: string; }> { const res = await fetch(VODSLICE_API, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: VIDEOS_QUERY, variables: { first: pageSize, after: cursor || null } }) }); if (!res.ok) throw new Error(`Failed to fetch videos: ${res.status}`); const json = await res.json(); const data = json.data?.placeStreamVideo; if (!data) throw new Error('No data in GraphQL response'); const records: VideoRecord[] = data.edges.map((edge: VodSliceEdge) => { const n = edge.node; // Normalize the livestream field — GraphQL returns it as either a string (JSON) or object let livestream: { cid: string; uri: string } | undefined; if (typeof n.livestream === 'string') { try { livestream = JSON.parse(n.livestream); } catch { /* ignore */ } } else if (n.livestream && typeof n.livestream === 'object') { livestream = n.livestream as { uri: string; cid: string }; } return { uri: n.uri, cid: n.cid || '', value: { $type: COLLECTION, title: n.title, source: { ...n.source, $type: 'place.stream.muxl.defs#archiveBlob' }, creator: n.creator, duration: n.duration, createdAt: n.createdAt, livestream } }; }); return { records, hasMore: data.pageInfo.hasNextPage, endCursor: data.pageInfo.endCursor }; } /** Fetch paginated videos by a specific creator DID */ export async function listVideosByCreator(creatorDid: string, cursor?: string, pageSize = 9): Promise<{ records: VideoRecord[]; hasMore: boolean; endCursor?: string; }> { const query = ` query GetVideosByCreator($creatorDid: String!, $first: Int, $after: String) { placeStreamVideo(first: $first, after: $after, where: { creator: { eq: $creatorDid } }) { edges { node { uri cid creator title duration createdAt livestream source { ref size mimeType } } } pageInfo { hasNextPage endCursor } } }`; const res = await fetch(VODSLICE_API, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables: { creatorDid, first: pageSize, after: cursor || null } }) }); if (!res.ok) throw new Error(`Failed to fetch creator videos: ${res.status}`); const json = await res.json(); const data = json.data?.placeStreamVideo; if (!data) return { records: [], hasMore: false }; const records = data.edges.map((edge: VodSliceEdge) => { const n = edge.node; let livestream: { cid: string; uri: string } | undefined; if (typeof n.livestream === 'string') { try { livestream = JSON.parse(n.livestream); } catch { /* ignore */ } } else if (n.livestream && typeof n.livestream === 'object') { livestream = n.livestream as { uri: string; cid: string }; } return { uri: n.uri, cid: n.cid || '', value: { $type: COLLECTION, title: n.title, source: { ...n.source, $type: 'place.stream.muxl.defs#archiveBlob' }, creator: n.creator, duration: n.duration, createdAt: n.createdAt, livestream } }; }); return { records, hasMore: data.pageInfo.hasNextPage, endCursor: data.pageInfo.endCursor }; } /** Fetch all videos (for deep-link lookup). Uses pagination internally. */ export async function fetchAllVideos(): Promise { const all: VideoRecord[] = []; let cursor: string | undefined; let hasMore = true; while (hasMore) { const page = await listVideos(cursor, 50); all.push(...page.records); hasMore = page.hasMore; cursor = page.endCursor; } return all; } /** Build an HLS playlist URL from an at:// video URI */ export function getPlaylistUrl(uri: string): string { return `${PLAYBACK_BASE}?uri=${encodeURIComponent(uri)}`; } /** Format nanosecond duration to human-readable "H:MM:SS" or "M:SS" */ export function formatDuration(nanos: number): string { const totalSeconds = Math.floor(nanos / 1_000_000_000); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; if (hours > 0) { return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; } return `${minutes}:${String(seconds).padStart(2, '0')}`; } export function formatDate(iso: string): string { return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } // --- Profile --- export const BSKY_PUBLIC_API = 'https://public.api.bsky.app'; export interface BskyProfile { did: string; handle: string; displayName?: string; description?: string; avatar?: string; banner?: string; followersCount?: number; followsCount?: number; postsCount?: number; createdAt?: string; } /** Fetch a Bluesky profile by handle or DID via the public API */ export async function getProfile(actor: string): Promise { const params = new URLSearchParams({ actor }); const res = await fetch(`${BSKY_PUBLIC_API}/xrpc/app.bsky.actor.getProfile?${params}`); if (!res.ok) throw new Error(`Failed to fetch profile: ${res.status}`); return res.json(); } // --- Identity resolution via Slingshot --- interface MiniDoc { did: string; handle: string; pds: string; } const identityCache = new Map(); /** Resolve a DID to handle + PDS via the Slingshot cached resolver */ async function resolveMiniDoc(did: string): Promise { if (identityCache.has(did)) return identityCache.get(did)!; try { const res = await fetch(`${SLINGSHOT_API}/xrpc/blue.microcosm.identity.resolveMiniDoc?identifier=${encodeURIComponent(did)}`); if (!res.ok) return null; const doc: MiniDoc = await res.json(); identityCache.set(did, doc); return doc; } catch { return null; } } /** Resolve a DID to its identity (handle + PDS) */ export async function resolveIdentity(did: string): Promise { return resolveMiniDoc(did); } /** Resolve a DID to a human-readable @handle */ export async function resolveHandle(did: string): Promise { const doc = await resolveMiniDoc(did); return doc ? `@${doc.handle}` : did; } /** Resolve a DID to its PDS (Personal Data Server) endpoint URL */ export async function resolvePds(did: string): Promise { const doc = await resolveMiniDoc(did); return doc?.pds ?? null; } // --- Thumbnail resolution --- /** Parse an at:// URI into { repo, collection, rkey } */ function parseAtUri(uri: string): { repo: string; collection: string; rkey: string } | null { const m = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/([^/]+)$/); if (!m) return null; return { repo: m[1], collection: m[2], rkey: m[3] }; } const thumbCache = new Map(); /** * Given a video record, resolve the thumbnail URL by fetching the linked * livestream record from the creator's PDS and extracting the thumb blob ref. */ export async function getThumbnailUrl(video: VideoRecord): Promise { const lsUri = video.value.livestream?.uri; if (!lsUri) return null; if (thumbCache.has(lsUri)) return thumbCache.get(lsUri)!; try { const parsed = parseAtUri(lsUri); if (!parsed) return null; const pds = await resolvePds(parsed.repo); if (!pds) return null; const params = new URLSearchParams({ repo: parsed.repo, collection: parsed.collection, rkey: parsed.rkey }); const res = await fetch(`${pds}/xrpc/com.atproto.repo.getRecord?${params}`); if (!res.ok) return null; const record = await res.json(); const thumbRef = record.value?.thumb?.ref?.$link; if (!thumbRef) return null; const blobUrl = `${pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(parsed.repo)}&cid=${encodeURIComponent(thumbRef)}`; thumbCache.set(lsUri, blobUrl); return blobUrl; } catch { thumbCache.set(lsUri, null); return null; } }