/** * VOD Bundle: AtmosphereConf video backend * * Handles video listing, muxing, and quality selection */ import { endpoint, manifest, v } from "@at-run/runtime" import { AtpAgent } from "@atproto/api" import { IdResolver } from "@atproto/identity" import { AtUri } from "@atproto/syntax" import { Parser } from "m3u8-parser" // Re-export v for sandbox validation export { v } const VOD_BETA_URL = "https://vod-beta.stream.place" const VOD_XRPC_BASE = `${VOD_BETA_URL}/xrpc/` const CACHE_DIR = "/tmp/vod-cache" // ============================================================================= // Bundle Manifest // ============================================================================= export const bundle = manifest({ name: "atmosphereconf-vod", description: "AtmosphereConf 2026 VOD backend - video listing, muxing, and quality selection", permissions: { net: [ "vod-beta.stream.place", // HLS playlists and segments "iameli.com", // stream.place PDS "plc.directory", // DID resolution "public.api.bsky.app", // Bluesky public API for profiles "*.modal.run", // Modal accelerator endpoints "*.mainasara.dev", // AppView (at-run hosted) "cdn.bsky.app", // Bluesky CDN for avatars ], write: ["/tmp/vod-cache/"], read: ["/tmp/vod-cache/"], env: ["APPVIEW_URL"], }, }) // Identity resolver for DID -> PDS resolution const idResolver = new IdResolver() async function sha256(data: string | Uint8Array): Promise { const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data const hash = await crypto.subtle.digest("SHA-256", bytes.buffer as ArrayBuffer) return [...new Uint8Array(hash)].map(b => b.toString(16).padStart(2, "0")).join("") } async function exists(path: string): Promise { try { await Deno.stat(path) return true } catch { return false } } /** * Get an AtpAgent for a given DID's PDS */ async function getAgentForDid(did: string): Promise { const didDoc = await idResolver.did.resolve(did) if (!didDoc) { throw new Error(`Failed to resolve DID: ${did}`) } const pdsUrl = didDoc.service?.find( (s) => s.id === "#atproto_pds" || s.id === `${did}#atproto_pds` )?.serviceEndpoint as string | undefined if (!pdsUrl) { throw new Error("No PDS service found in DID document") } return new AtpAgent({ service: pdsUrl }) } // Types for parsed HLS manifest interface ParsedPlaylist { attributes: { BANDWIDTH: number CODECS: string RESOLUTION?: { width: number; height: number } "FRAME-RATE"?: number AUDIO?: string } uri: string } interface ParsedAudioTrack { default: boolean autoselect: boolean uri: string } interface ParsedManifest { playlists: ParsedPlaylist[] mediaGroups: { AUDIO: Record> } } /** * Rewrite relative URIs in m3u8 content to absolute URLs */ function rewritePlaylistUrls(content: string, baseUrl: string): string { return content .split("\n") .map((line) => { const trimmed = line.trim() // Skip empty lines, comments, and tags (except URI= attributes) if (!trimmed || trimmed.startsWith("#")) { // Handle URI= attributes in tags like #EXT-X-MEDIA if (trimmed.includes('URI="') && !trimmed.includes('URI="http')) { return trimmed.replace(/URI="([^"]+)"/, `URI="${baseUrl}$1"`) } return line } // Non-comment, non-empty lines are URIs - make them absolute if relative if (!trimmed.startsWith("http")) { return baseUrl + trimmed } return line }) .join("\n") } /** * Fetch and parse the master HLS playlist for a video */ async function fetchMasterPlaylist(videoUri: string): Promise { let masterPlaylist = "" const basePath = `/tmp/vod-cache/${await sha256(videoUri)}` const playlistPath = `${basePath}/master.m3u8` if (!(await exists(playlistPath))) { // Ensure cache directory exists await Deno.mkdir(basePath, { recursive: true }) const playlistUrl = `${VOD_XRPC_BASE}place.stream.playback.getVideoPlaylist?uri=${encodeURIComponent(videoUri)}` const res = await fetch(playlistUrl) if (!res.ok) { throw new Error(`Failed to fetch playlist: ${res.status}`) } const rawPlaylist = await res.text() // Rewrite relative URIs to absolute URLs for local ffmpeg access masterPlaylist = rewritePlaylistUrls(rawPlaylist, VOD_XRPC_BASE) await Deno.writeTextFile(playlistPath, masterPlaylist) } else { masterPlaylist = await Deno.readTextFile(playlistPath) } const parser = new Parser() parser.push(masterPlaylist) parser.end() return parser.manifest as any } /** * Generate an HLS master playlist with absolute URLs */ function generateMasterPlaylist( manifest: ParsedManifest, options?: { quality?: string } ): string { const lines: string[] = ["#EXTM3U", "#EXT-X-VERSION:6"] // Find audio tracks and add them const audioGroups = manifest.mediaGroups?.AUDIO || {} for (const [groupId, tracks] of Object.entries(audioGroups)) { for (const [trackName, track] of Object.entries(tracks)) { const absoluteUri = track.uri.startsWith("http") ? track.uri : `${VOD_XRPC_BASE}${track.uri}` lines.push( `#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="${groupId}",NAME="${trackName}",` + `DEFAULT=${track.default ? "YES" : "NO"},AUTOSELECT=${track.autoselect ? "YES" : "NO"},` + `URI="${absoluteUri}"` ) } } // Filter and add video playlists let playlists = manifest.playlists || [] // Filter by quality if specified if (options?.quality) { const targetHeight = parseInt(options.quality.replace("p", ""), 10) const filtered = playlists.filter( (p) => p.attributes.RESOLUTION?.height === targetHeight ) if (filtered.length > 0) { playlists = filtered } } // Sort by bandwidth (highest first) playlists.sort((a, b) => b.attributes.BANDWIDTH - a.attributes.BANDWIDTH) for (const playlist of playlists) { const attrs = playlist.attributes const parts: string[] = [] if (attrs.BANDWIDTH) parts.push(`BANDWIDTH=${attrs.BANDWIDTH}`) if (attrs.CODECS) parts.push(`CODECS="${attrs.CODECS}"`) if (attrs.RESOLUTION) { parts.push(`RESOLUTION=${attrs.RESOLUTION.width}x${attrs.RESOLUTION.height}`) } if (attrs["FRAME-RATE"]) parts.push(`FRAME-RATE=${attrs["FRAME-RATE"]}`) if (attrs.AUDIO) parts.push(`AUDIO="${attrs.AUDIO}"`) lines.push(`#EXT-X-STREAM-INF:${parts.join(",")}`) const absoluteUri = playlist.uri.startsWith("http") ? playlist.uri : `${VOD_XRPC_BASE}${playlist.uri}` lines.push(absoluteUri) } return lines.join("\n") } // ============================================================================ // Schemas & Types // ============================================================================ const AtUriSchema = v.pipe( v.string(), v.startsWith("at://", "Must be a valid AT URI") ) const ListVideosSchema = v.object({ limit: v.optional(v.pipe(v.number(), v.minValue(1), v.maxValue(100))), cursor: v.optional(v.string()), }) const UriOnlySchema = v.object({ uri: AtUriSchema, }) const GetPlaylistSchema = v.object({ uri: AtUriSchema, quality: v.optional(v.picklist(["1080p", "720p", "480p", "240p"])), }) interface Video { uri: string title: string creator: string duration: number createdAt: string } interface StreamInfo { quality: string width: number height: number bandwidth: number codecs: string frameRate?: number } interface VideoStreamsResult { uri: string streams: StreamInfo[] audioTracks: Array<{ name: string; default: boolean }> } const DidSchema = v.object({ did: v.pipe(v.string(), v.startsWith("did:", "Must be a valid DID")), }) interface Profile { did: string handle: string displayName?: string avatar?: string description?: string } // ============================================================================ // Endpoints // ============================================================================ /** * List all VODs from server cache with cursor-based pagination */ export const listVideos = endpoint, { videos: Video[]; cursor?: string }>({ input: ListVideosSchema, async handler(args) { const limit = args.limit ?? 50 const cursorDate = args.cursor ? new Date(args.cursor) : null // Read all videos from server cache const entries = Deno.readDir(CACHE_DIR) const allVideos: Video[] = [] for await (const entry of entries) { if (!entry.isDirectory) continue try { const path = `${CACHE_DIR}/${entry.name}/video.json` const content = await Deno.readTextFile(path) const video = JSON.parse(content) as Video allVideos.push(video) } catch { // Skip entries without valid video.json } } // Sort by createdAt descending (newest first) allVideos.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ) // Filter by cursor if provided let filtered = allVideos if (cursorDate) { filtered = allVideos.filter(v => new Date(v.createdAt) < cursorDate) } // Apply limit + 1 to check if there's more const hasMore = filtered.length > limit const videos = filtered.slice(0, limit) // Next cursor is the createdAt of the last video returned const nextCursor = hasMore && videos.length > 0 ? videos[videos.length - 1].createdAt : undefined return { videos, cursor: nextCursor, } }, }) /** * Get available streams for a video (for UI to show quality options) */ export const getVideoStreams = endpoint, VideoStreamsResult>({ input: UriOnlySchema, async handler(args) { const manifest = await fetchMasterPlaylist(args.uri) const streams: StreamInfo[] = (manifest.playlists || []).map((p: any) => ({ quality: p.attributes.RESOLUTION ? `${p.attributes.RESOLUTION.height}p` : "unknown", width: p.attributes.RESOLUTION?.width || 0, height: p.attributes.RESOLUTION?.height || 0, bandwidth: p.attributes.BANDWIDTH, codecs: p.attributes.CODECS, frameRate: p.attributes["FRAME-RATE"], })) const audioTracks: Array<{ name: string; default: boolean }> = [] const audioGroups = manifest.mediaGroups?.AUDIO || {} for (const tracks of Object.values(audioGroups)) { for (const [name, track] of Object.entries(tracks as Array)) { audioTracks.push({ name, default: track.default }) } } return { uri: args.uri, streams, audioTracks, } }, }) /** * Get an HLS master playlist for a video with video+audio muxed * * Returns a valid HLS master playlist with absolute URLs that can be * directly consumed by video players (hls.js, native Safari, etc.) * * @param uri - AT URI of the video record * @param quality - Optional quality filter (e.g., "1080p", "720p") */ export const getPlaylist = endpoint, Response>({ input: GetPlaylistSchema, async handler(args) { const manifest = await fetchMasterPlaylist(args.uri) const playlist = generateMasterPlaylist(manifest, { quality: args.quality }) return new Response(playlist, { headers: { "Content-Type": "application/vnd.apple.mpegurl", "Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=300", }, }) }, }) /** * Get video metadata */ export const getVideo = endpoint, Video>({ input: UriOnlySchema, async handler(args) { const atUri = new AtUri(args.uri) const agent = await getAgentForDid(atUri.host) const response = await agent.com.atproto.repo.getRecord({ repo: atUri.host, collection: atUri.collection, rkey: atUri.rkey, }) const value = response.data.value as { title: string creator: string duration: number createdAt: string } return { uri: response.data.uri, title: value.title, creator: value.creator, duration: value.duration, createdAt: value.createdAt, } }, }) /** * Get thumbnail image for a video (browser-cacheable) * Returns raw JPEG with cache headers */ export const getThumbnail = endpoint, Response>({ input: UriOnlySchema, async handler({ uri }) { const hash = await sha256(uri) const thumbnailPath = `/tmp/vod-cache/${hash}/thumbnail.jpg` if (!(await exists(thumbnailPath))) { return new Response("Not found", { status: 404 }) } const bytes = await Deno.readFile(thumbnailPath) return new Response(bytes.buffer as ArrayBuffer, { headers: { "Content-Type": "image/jpeg", "Cache-Control": "public, max-age=86400, immutable", "Access-Control-Allow-Origin": "*", }, }) }, }) /** * Get sprite sheet for a video (browser-cacheable) * Returns raw JPEG with cache headers */ export const getSprite = endpoint, Response>({ input: UriOnlySchema, async handler({ uri }) { const hash = await sha256(uri) const spritePath = `/tmp/vod-cache/${hash}/sprites.jpg` if (!(await exists(spritePath))) { return new Response("Not found", { status: 404 }) } const bytes = await Deno.readFile(spritePath) return new Response(bytes.buffer as ArrayBuffer, { headers: { "Content-Type": "image/jpeg", "Cache-Control": "public, max-age=86400, immutable", // 24 hours, immutable "Access-Control-Allow-Origin": "*", }, }) }, }) /** * Get VTT file for sprite coordinates (browser-cacheable) * Returns raw VTT with cache headers */ export const getVtt = endpoint, Response>({ input: UriOnlySchema, async handler({ uri }) { const hash = await sha256(uri) const vttPath = `/tmp/vod-cache/${hash}/sprites.vtt` if (!(await exists(vttPath))) { return new Response("Not found", { status: 404 }) } const content = await Deno.readTextFile(vttPath) return new Response(content, { headers: { "Content-Type": "text/vtt", "Cache-Control": "public, max-age=86400, immutable", // 24 hours, immutable "Access-Control-Allow-Origin": "*", }, }) }, }) /** * Check if video metadata is ready (lightweight check) */ export const isMetadataReady = endpoint, { ready: boolean }>({ input: UriOnlySchema, async handler({ uri }) { const hash = await sha256(uri) const spritePath = `/tmp/vod-cache/${hash}/sprites.jpg` const ready = await exists(spritePath) return { ready } }, }) /** * Batch check metadata readiness for multiple videos */ const BatchMetadataSchema = v.object({ uris: v.array(v.string()), }) export const batchMetadataReady = endpoint, Record>({ input: BatchMetadataSchema, async handler({ uris }) { const results: Record = {} await Promise.all( uris.map(async (uri) => { const hash = await sha256(uri) const spritePath = `/tmp/vod-cache/${hash}/sprites.jpg` results[uri] = await exists(spritePath) }) ) return results }, }) /** * Batch fetch profiles for multiple DIDs */ const BatchProfilesSchema = v.object({ dids: v.array(v.pipe(v.string(), v.startsWith("did:"))), }) export const batchProfiles = endpoint, Record>({ input: BatchProfilesSchema, async handler({ dids }) { const results: Record = {} await Promise.all( dids.map(async (did) => { try { const url = `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}` const res = await fetch(url) if (!res.ok) { results[did] = null return } const data = await res.json() as { did: string handle: string displayName?: string avatar?: string description?: string } results[did] = { did: data.did, handle: data.handle, displayName: data.displayName, avatar: data.avatar, description: data.description, } } catch { results[did] = null } }) ) return results }, }) /** * Get user profile from Bluesky (browser-cacheable) */ export const getProfile = endpoint, Response>({ input: DidSchema, async handler({ did }) { const url = `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}` const res = await fetch(url) if (!res.ok) { throw new Error(`Failed to fetch profile: ${res.status}`) } const data = await res.json() as { did: string handle: string displayName?: string avatar?: string description?: string } const profile: Profile = { did: data.did, handle: data.handle, displayName: data.displayName, avatar: data.avatar, description: data.description, } return new Response(JSON.stringify(profile), { headers: { "Content-Type": "application/json", "Cache-Control": "public, max-age=3600", // 1 hour (profiles can change) "Access-Control-Allow-Origin": "*", }, }) }, }) // ============================================================================ // Share Pages (OG meta tags + redirect) // ============================================================================ const FRONTEND_URL = "https://streamhut.wisp.place" const API_BASE_URL = "https://at-run.mainasara.dev/bundle/did:plc:p3cygo5s7wru2argeci6wfv6/atmosphereconf-vod/latest" /** * Format duration in nanoseconds to human-readable string */ function formatDurationForOG(durationNs: number): string { const totalSeconds = Math.floor(durationNs / 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")}` } /** * Generate share page HTML with OG meta tags */ function generateShareHTML(options: { title: string description: string imageUrl: string canonicalUrl: string redirectUrl: string type?: "video.other" | "profile" }): string { const { title, description, imageUrl, canonicalUrl, redirectUrl, type = "video.other" } = options return ` Streamhut | ${escapeHtml(title)}

Redirecting to Streamhut...

` } function escapeHtml(str: string): string { return str .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'") } /** * Share page for videos - returns HTML with OG tags and redirects to frontend */ export const shareVideo = endpoint, Response>({ input: UriOnlySchema, async handler({ uri }) { // Fetch video metadata from cache const hash = await sha256(uri) const metaPath = `${CACHE_DIR}/${hash}/video.json` let video: Video try { const content = await Deno.readTextFile(metaPath) video = JSON.parse(content) as Video } catch { return new Response("Video not found", { status: 404 }) } // Fetch creator profile for description let creatorName = "Unknown Creator" try { const profileUrl = `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(video.creator)}` const res = await fetch(profileUrl) if (res.ok) { const profile = await res.json() as { displayName?: string; handle: string } creatorName = profile.displayName || `@${profile.handle}` } } catch { // Use default } const duration = formatDurationForOG(video.duration) const description = `${duration} video by ${creatorName}` const imageUrl = `${API_BASE_URL}/getThumbnail?uri=${encodeURIComponent(uri)}` const canonicalUrl = `${API_BASE_URL}/shareVideo?uri=${encodeURIComponent(uri)}` const redirectUrl = `${FRONTEND_URL}/watch?v=${encodeURIComponent(uri)}` const html = generateShareHTML({ title: video.title, description, imageUrl, canonicalUrl, redirectUrl, type: "video.other", }) return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "public, max-age=3600", }, }) }, }) /** * Share page for creators - returns HTML with OG tags and redirects to frontend */ export const shareCreator = endpoint, Response>({ input: DidSchema, async handler({ did }) { // Fetch creator profile const profileUrl = `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}` const res = await fetch(profileUrl) if (!res.ok) { return new Response("Creator not found", { status: 404 }) } const profile = await res.json() as { did: string handle: string displayName?: string avatar?: string description?: string } const displayName = profile.displayName || `@${profile.handle}` const description = profile.description || `Watch videos from ${displayName} on Streamhut` const imageUrl = profile.avatar || "" const canonicalUrl = `${API_BASE_URL}/shareCreator?did=${encodeURIComponent(did)}` const redirectUrl = `${FRONTEND_URL}/profile/${encodeURIComponent(did)}` const html = generateShareHTML({ title: displayName, description, imageUrl, canonicalUrl, redirectUrl, type: "profile", }) return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "public, max-age=3600", }, }) }, })