atmo.rsvp
4
fork

Configure Feed

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

add mapping

Florian 4b08ef74 9443beea

+913 -100
+108
scripts/download-vods.ts
··· 1 + /** 2 + * Downloads all matched ATmosphereConf VOD MP4s using ffmpeg. 3 + * Uses the HLS playlist URL so both audio and video are muxed properly. 4 + * 5 + * Requires: ffmpeg 6 + * 7 + * Usage: npx tsx scripts/download-vods.ts [output-dir] [filter] 8 + * 9 + * Default output dir: ./vods 10 + * Optional filter: case-insensitive substring match on event title 11 + */ 12 + 13 + import { existsSync, mkdirSync } from 'fs'; 14 + import { execFile } from 'child_process'; 15 + import vodMap from '../src/lib/vod-map.json'; 16 + 17 + const STREAM_PLACE_DID = 'did:plc:rbvrr34edl5ddpuwcubjiost'; 18 + const PLAYBACK_BASE = 'https://vod-beta.stream.place/xrpc/place.stream.playback.getVideoPlaylist'; 19 + 20 + const outDir = process.argv[2] || './vods'; 21 + const filter = process.argv[3]?.toLowerCase(); 22 + 23 + function sanitizeFilename(name: string): string { 24 + return name.replace(/[/\\?%*:|"<>]/g, '-').replace(/\s+/g, ' ').trim(); 25 + } 26 + 27 + function getPlaylistUrl(vodRkey: string): string { 28 + const uri = `at://${STREAM_PLACE_DID}/place.stream.video/${vodRkey}`; 29 + return `${PLAYBACK_BASE}?uri=${encodeURIComponent(uri)}`; 30 + } 31 + 32 + function downloadWithFfmpeg(playlistUrl: string, dest: string): Promise<void> { 33 + return new Promise((resolve, reject) => { 34 + const proc = execFile( 35 + 'ffmpeg', 36 + [ 37 + '-y', 38 + '-i', playlistUrl, 39 + '-c', 'copy', 40 + dest 41 + ], 42 + { maxBuffer: 10 * 1024 * 1024 }, 43 + (err) => { 44 + if (err) reject(err); 45 + else resolve(); 46 + } 47 + ); 48 + 49 + // Show ffmpeg progress 50 + proc.stderr?.on('data', (data: Buffer) => { 51 + const line = data.toString(); 52 + const timeMatch = line.match(/time=(\d+:\d+:\d+\.\d+)/); 53 + if (timeMatch) { 54 + process.stdout.write(`\r time: ${timeMatch[1]} `); 55 + } 56 + }); 57 + }); 58 + } 59 + 60 + async function main() { 61 + if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true }); 62 + 63 + const entries = filter 64 + ? vodMap.filter((e) => e.eventTitle.toLowerCase().includes(filter)) 65 + : vodMap; 66 + 67 + if (entries.length === 0) { 68 + console.log(`No VODs matching "${filter}"`); 69 + return; 70 + } 71 + 72 + console.log(`Downloading ${entries.length} VOD${entries.length > 1 ? 's' : ''} to ${outDir}/\n`); 73 + 74 + let downloaded = 0; 75 + let skipped = 0; 76 + let failed = 0; 77 + 78 + for (const entry of entries) { 79 + const filename = `${sanitizeFilename(entry.eventTitle)}.mp4`; 80 + const dest = `${outDir}/${filename}`; 81 + 82 + if (existsSync(dest)) { 83 + console.log(` SKIP ${filename} (already exists)`); 84 + skipped++; 85 + continue; 86 + } 87 + 88 + console.log(` GET ${filename}`); 89 + 90 + try { 91 + const playlistUrl = getPlaylistUrl(entry.vodRkey); 92 + await downloadWithFfmpeg(playlistUrl, dest); 93 + process.stdout.write('\r done \n'); 94 + downloaded++; 95 + } catch (e) { 96 + process.stdout.write('\n'); 97 + console.log(` FAILED (${e instanceof Error ? e.message : e})`); 98 + failed++; 99 + } 100 + } 101 + 102 + console.log(`\nDone: ${downloaded} downloaded, ${skipped} skipped, ${failed} failed`); 103 + } 104 + 105 + main().catch((e) => { 106 + console.error(e); 107 + process.exit(1); 108 + });
+176
scripts/match-vods.ts
··· 1 + /** 2 + * Matches ATmosphereConf events to stream.place VODs by title. 3 + * Outputs a JSON mapping to src/lib/vod-map.json. 4 + * 5 + * Usage: npx tsx scripts/match-vods.ts 6 + */ 7 + 8 + const STREAM_PLACE_DID = 'did:plc:rbvrr34edl5ddpuwcubjiost'; 9 + const STREAM_PLACE_PDS = 'https://iameli.com'; 10 + const VOD_COLLECTION = 'place.stream.video'; 11 + const CONTRAIL_URL = 'http://contrail.atmo.rsvp'; 12 + 13 + interface VodRecord { 14 + uri: string; 15 + rkey: string; 16 + title: string; 17 + } 18 + 19 + interface EventRecord { 20 + uri: string; 21 + rkey: string; 22 + name: string; 23 + type?: string; 24 + } 25 + 26 + interface VodMapping { 27 + eventRkey: string; 28 + eventTitle: string; 29 + vodRkey: string; 30 + vodTitle: string; 31 + } 32 + 33 + const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, ''); 34 + 35 + function extractRkey(uri: string): string { 36 + return uri.split('/').pop()!; 37 + } 38 + 39 + async function fetchAllVods(): Promise<VodRecord[]> { 40 + const all: VodRecord[] = []; 41 + let cursor: string | undefined; 42 + 43 + do { 44 + const params = new URLSearchParams({ 45 + repo: STREAM_PLACE_DID, 46 + collection: VOD_COLLECTION, 47 + limit: '100' 48 + }); 49 + if (cursor) params.set('cursor', cursor); 50 + 51 + const res = await fetch(`${STREAM_PLACE_PDS}/xrpc/com.atproto.repo.listRecords?${params}`); 52 + if (!res.ok) break; 53 + 54 + const data = (await res.json()) as { 55 + cursor?: string; 56 + records: Array<{ uri: string; value: { title: string } }>; 57 + }; 58 + 59 + for (const r of data.records ?? []) { 60 + all.push({ 61 + uri: r.uri, 62 + rkey: extractRkey(r.uri), 63 + title: r.value.title 64 + }); 65 + } 66 + cursor = data.cursor; 67 + } while (cursor); 68 + 69 + return all; 70 + } 71 + 72 + async function fetchAllEvents(): Promise<EventRecord[]> { 73 + const res = await fetch( 74 + `${CONTRAIL_URL}/xrpc/community.lexicon.calendar.event.listRecords?actor=atmosphereconf.org&sort=startsAt&order=asc&limit=200` 75 + ); 76 + if (!res.ok) throw new Error(`Failed to fetch events: ${res.status}`); 77 + const data = (await res.json()) as { 78 + records: Array<{ 79 + uri: string; 80 + record: { name: string; additionalData?: { isAtmosphereconf?: boolean; type?: string } }; 81 + }>; 82 + }; 83 + 84 + return data.records 85 + .filter((r) => r.record?.additionalData?.isAtmosphereconf) 86 + .map((r) => ({ 87 + uri: r.uri, 88 + rkey: extractRkey(r.uri), 89 + name: r.record.name, 90 + type: r.record.additionalData?.type 91 + })); 92 + } 93 + 94 + function matchEvents(events: EventRecord[], vods: VodRecord[]): VodMapping[] { 95 + const mappings: VodMapping[] = []; 96 + const usedVods = new Set<string>(); 97 + 98 + for (const event of events) { 99 + const eventNorm = normalize(event.name); 100 + 101 + // Exact match 102 + let match = vods.find((v) => !usedVods.has(v.uri) && normalize(v.title) === eventNorm); 103 + 104 + // Substring match 105 + if (!match && eventNorm.length >= 10) { 106 + match = vods.find((v) => { 107 + if (usedVods.has(v.uri)) return false; 108 + const vodNorm = normalize(v.title); 109 + return vodNorm.length >= 10 && (eventNorm.includes(vodNorm) || vodNorm.includes(eventNorm)); 110 + }); 111 + } 112 + 113 + if (match) { 114 + usedVods.add(match.uri); 115 + mappings.push({ 116 + eventRkey: event.rkey, 117 + eventTitle: event.name, 118 + vodRkey: match.rkey, 119 + vodTitle: match.title 120 + }); 121 + } 122 + } 123 + 124 + return mappings; 125 + } 126 + 127 + async function main() { 128 + console.log('Fetching VODs...'); 129 + const vods = await fetchAllVods(); 130 + console.log(`Found ${vods.length} VODs`); 131 + 132 + console.log('Fetching events...'); 133 + const events = await fetchAllEvents(); 134 + console.log(`Found ${events.length} atmosphere conf events`); 135 + 136 + const mappings = matchEvents(events, vods); 137 + console.log(`\nMatched ${mappings.length} of ${events.length} events to VODs`); 138 + 139 + // Show unmatched events 140 + const matchedRkeys = new Set(mappings.map((m) => m.eventRkey)); 141 + const unmatched = events.filter((e) => !matchedRkeys.has(e.rkey)); 142 + if (unmatched.length > 0) { 143 + console.log(`\nUnmatched events (${unmatched.length}):`); 144 + for (const e of unmatched) { 145 + console.log(` [${e.type || '?'}] ${e.name}`); 146 + } 147 + } 148 + 149 + // Show unmatched VODs 150 + const matchedVodRkeys = new Set(mappings.map((m) => m.vodRkey)); 151 + const unmatchedVods = vods.filter((v) => !matchedVodRkeys.has(v.rkey)); 152 + if (unmatchedVods.length > 0) { 153 + console.log(`\nUnmatched VODs (${unmatchedVods.length}):`); 154 + for (const v of unmatchedVods) { 155 + console.log(` - ${v.title}`); 156 + } 157 + } 158 + 159 + // Write the mapping 160 + const outPath = new URL('../src/lib/vod-map.json', import.meta.url); 161 + const output = mappings.map(({ eventRkey, vodRkey, eventTitle, vodTitle }) => ({ 162 + eventRkey, 163 + vodRkey, 164 + eventTitle, 165 + vodTitle 166 + })); 167 + const { writeFileSync } = await import('fs'); 168 + const { fileURLToPath } = await import('url'); 169 + writeFileSync(fileURLToPath(outPath), JSON.stringify(output, null, '\t') + '\n'); 170 + console.log(`\nWrote ${mappings.length} mappings to src/lib/vod-map.json`); 171 + } 172 + 173 + main().catch((e) => { 174 + console.error(e); 175 + process.exit(1); 176 + });
+1 -4
src/lib/components/VodPlayer.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 + import 'plyr/dist/plyr.css'; 3 4 import type HlsType from 'hls.js'; 4 5 import type PlyrType from 'plyr'; 5 6 ··· 77 78 } 78 79 } 79 80 </script> 80 - 81 - <svelte:head> 82 - <link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" /> 83 - </svelte:head> 84 81 85 82 {#if error} 86 83 <div class="bg-base-100 dark:bg-base-900 border-base-200 dark:border-base-800 flex aspect-video w-full items-center justify-center rounded-xl border">
+596
src/lib/vod-map.json
··· 1 + [ 2 + { 3 + "eventRkey": "thurs-lunch", 4 + "vodRkey": "3mi5csvtgdg22", 5 + "eventTitle": "Lunch Break (Not Catered)", 6 + "vodTitle": "Lunch Break" 7 + }, 8 + { 9 + "eventRkey": "Y5XGQQ6", 10 + "vodRkey": "3miabfvrtei26", 11 + "eventTitle": "Abstracting the Appview Workshop", 12 + "vodTitle": "Abstracting the AppView" 13 + }, 14 + { 15 + "eventRkey": "ats26-open", 16 + "vodRkey": "3mi7jorroaa2h", 17 + "eventTitle": "Opening Remarks", 18 + "vodTitle": "Day 2 Opening Remarks!" 19 + }, 20 + { 21 + "eventRkey": "ats26-keynote", 22 + "vodRkey": "3mi2jdevvu626", 23 + "eventTitle": "Keynote: Towards Modular Open Science", 24 + "vodTitle": "Keynote: Towards Modular Open Science with @row1.ca and @matsulab.com" 25 + }, 26 + { 27 + "eventRkey": "ats26-commons", 28 + "vodRkey": "3mi2rtfj5ec2m", 29 + "eventTitle": "Can decentralists cooperate? Rethinking commons and collective action in the age of platforms and AI", 30 + "vodTitle": "Can decentralists cooperate? Rethinking commons and collective action in the age of platforms and AI" 31 + }, 32 + { 33 + "eventRkey": "ats26-biokea", 34 + "vodRkey": "3mi2ryxrnin2a", 35 + "eventTitle": "Reproducible, citation-aware automated paper reviews", 36 + "vodTitle": "Reproducible, citation-aware automated paper reviews @seanjungblluth.bsky.social" 37 + }, 38 + { 39 + "eventRkey": "ats26-skysquare", 40 + "vodRkey": "3mi2u6pl6ah2z", 41 + "eventTitle": "Skysquare is context as a service", 42 + "vodTitle": "Skysquare is context as a service - Travis Simpson - @skysquare.app" 43 + }, 44 + { 45 + "eventRkey": "ats26-viewsift", 46 + "vodRkey": "3mi2son7re62m", 47 + "eventTitle": "Building collective intelligence to reduce division at ViewSift", 48 + "vodTitle": "Building collective intelligence to reduce division at ViewSift @viewsift.com" 49 + }, 50 + { 51 + "eventRkey": "ats26-seams", 52 + "vodRkey": "3mi2tfsbq4p25", 53 + "eventTitle": "Making wisdom together", 54 + "vodTitle": "Making wisdom together - seams.so - @hyl.st" 55 + }, 56 + { 57 + "eventRkey": "ats26-astrosky", 58 + "vodRkey": "3mi2wu5u5rs2i", 59 + "eventTitle": "The Astrosky Ecosystem: An independent online home for astronomy", 60 + "vodTitle": "The Astrosky Ecosystem: An independent online home for astronomy" 61 + }, 62 + { 63 + "eventRkey": "ats26-our-socialmedia-future", 64 + "vodRkey": "3mi33ycoo5d2a", 65 + "eventTitle": "Future of Science Social Media", 66 + "vodTitle": "Future of Science Social Media" 67 + }, 68 + { 69 + "eventRkey": "ats26-research-synthesis", 70 + "vodRkey": "3mi34ps3oym2i", 71 + "eventTitle": "Crowdsourced Research Synthesis on ATProto: Envisioning an Inclusive Future", 72 + "vodTitle": "Crowdsourced Research Synthesis on ATProto: Envisioning an Inclusive Future - Jay Patel - @infotainment.bsky.social" 73 + }, 74 + { 75 + "eventRkey": "ats26-paperskygest", 76 + "vodRkey": "3mi35eplsua23", 77 + "eventTitle": "Studying social media through the Atmosphere", 78 + "vodTitle": "Studying social media through the Atmosphere - " 79 + }, 80 + { 81 + "eventRkey": "ats26-comm-archive", 82 + "vodRkey": "3mi36lcxgce2b", 83 + "eventTitle": "Narrative strands & memetic lineages in community social data using Community Archive", 84 + "vodTitle": "Narrative strands & memetic lineages in community social data using Community Archive - " 85 + }, 86 + { 87 + "eventRkey": "ats26-decentralize", 88 + "vodRkey": "3mi5r2rp7a62e", 89 + "eventTitle": "How (de)centralized is Bluesky, really?", 90 + "vodTitle": "How (de)centralized is Bluesky, really?" 91 + }, 92 + { 93 + "eventRkey": "J9yOpYz", 94 + "vodRkey": "3mi54lyc5ts25", 95 + "eventTitle": "The Aggregation Era burned journalism institutions to the ground. The federated era is emerging from those embers", 96 + "vodTitle": "The Aggregation Era burned journalism institutions to the ground. The federated era is emerging from those embers" 97 + }, 98 + { 99 + "eventRkey": "BzrpDQK", 100 + "vodRkey": "3mi54nec66s2w", 101 + "eventTitle": "Feature / Product / Business: A Framework for Sustainable ATProto Projects", 102 + "vodTitle": "Feature / Product / Business: A Framework for Sustainable ATProto Projects" 103 + }, 104 + { 105 + "eventRkey": "QK9Ae6Y", 106 + "vodRkey": "3mi54oonum62b", 107 + "eventTitle": "Groundings with my Siblings: Lessons Learned Building for Community", 108 + "vodTitle": "Groundings with my Siblings: Lessons Learned Building for Community" 109 + }, 110 + { 111 + "eventRkey": "obLbvQV", 112 + "vodRkey": "3mi56m3hnrq2z", 113 + "eventTitle": "The Economics of Sovereign Media: A Roadmap for AT Protocol", 114 + "vodTitle": "The Economics of Sovereign Media: A Roadmap for AT Protocol" 115 + }, 116 + { 117 + "eventRkey": "obaP26x", 118 + "vodRkey": "3mi56n6j2g22d", 119 + "eventTitle": "Who owns the group chat? Building collaborative spaces on ATProto", 120 + "vodTitle": "Who owns the group chat? Building collaborative spaces on ATProto" 121 + }, 122 + { 123 + "eventRkey": "gDPLaGd", 124 + "vodRkey": "3mi56pnvulh2o", 125 + "eventTitle": "Did Lexicon just accidentally solve the enterprise data problem?", 126 + "vodTitle": "Did Lexicon just accidentally solve the enterprise data problem?" 127 + }, 128 + { 129 + "eventRkey": "ODxNLMM", 130 + "vodRkey": "3mi5a2y3tej2z", 131 + "eventTitle": "This isn't over until we all listen to kpop", 132 + "vodTitle": "This isn't over until we all listen to kpop" 133 + }, 134 + { 135 + "eventRkey": "81VNEBO", 136 + "vodRkey": "3mi5aarspma27", 137 + "eventTitle": "Creators First: Video & Media as the Foundation of a Thriving Creator Economy on ATProto", 138 + "vodTitle": "Creators First: Video & Media as the Foundation of a Thriving Creator Economy on ATProto" 139 + }, 140 + { 141 + "eventRkey": "9qkWqPG", 142 + "vodRkey": "3mi5ag3scgk2k", 143 + "eventTitle": "Building Future of Artificial Intelligence on AT Protocol", 144 + "vodTitle": "Building Future of Artificial Intelligence on AT Protocol" 145 + }, 146 + { 147 + "eventRkey": "VLa69bl", 148 + "vodRkey": "3mi5bya72ub2d", 149 + "eventTitle": "A discussion with news creators", 150 + "vodTitle": "A discussion with news creators" 151 + }, 152 + { 153 + "eventRkey": "7Rrv0E0", 154 + "vodRkey": "3mi5bcpyqg32a", 155 + "eventTitle": "Beyond Bluesky: Community infrastructure", 156 + "vodTitle": "Beyond Bluesky: Community infrastructure" 157 + }, 158 + { 159 + "eventRkey": "VLerG2y", 160 + "vodRkey": "3mi5c65iee42h", 161 + "eventTitle": "Understanding the Landscape of Custom Feeds on Bluesky", 162 + "vodTitle": "Understanding the Landscape of Custom Feeds on Bluesky" 163 + }, 164 + { 165 + "eventRkey": "000Syverson", 166 + "vodRkey": "3mi5hza73vs2z", 167 + "eventTitle": "Sattestations", 168 + "vodTitle": "Sattestations" 169 + }, 170 + { 171 + "eventRkey": "QKlrLXG", 172 + "vodRkey": "3mi5hx4v6cr26", 173 + "eventTitle": "Advocating for Digital Sovereignty: European Experiences and Global Lessons", 174 + "vodTitle": "Advocating for Digital Sovereignty: European Experiences and Global Lessons" 175 + }, 176 + { 177 + "eventRkey": "XxP4RzL", 178 + "vodRkey": "3mi5hy457fu2r", 179 + "eventTitle": "Building Cirrus: a single-user, serverless PDS", 180 + "vodTitle": "Building Cirrus: a single-user, serverless PDS" 181 + }, 182 + { 183 + "eventRkey": "81Xovjr", 184 + "vodRkey": "3mi5itmi65s2z", 185 + "eventTitle": "Feeds Are The New Websites", 186 + "vodTitle": "Feeds Are The New Websites" 187 + }, 188 + { 189 + "eventRkey": "LZxV6dv", 190 + "vodRkey": "3mi5jmsg6kn2m", 191 + "eventTitle": "Consent Before Cryptography", 192 + "vodTitle": "Consent Before Cryptography" 193 + }, 194 + { 195 + "eventRkey": "MelQ8Ak", 196 + "vodRkey": "3mi5k3brfax2b", 197 + "eventTitle": "Rethinking the Client: Why User Choice is the Key to Growth for ATProto", 198 + "vodTitle": "Rethinking the Client: Why User Choice is the Key to Growth for ATProto" 199 + }, 200 + { 201 + "eventRkey": "OD2PpYA", 202 + "vodRkey": "3mi5jrjmk6y2n", 203 + "eventTitle": "Open social tech and geopolitical risk", 204 + "vodTitle": "Open social tech and geopolitical risk" 205 + }, 206 + { 207 + "eventRkey": "ja4ooAa", 208 + "vodRkey": "3mi5lexqbqf2z", 209 + "eventTitle": "Building Public-Interest Infrastructure on ATProto", 210 + "vodTitle": "Building Public-Interest Infrastructure on ATProto" 211 + }, 212 + { 213 + "eventRkey": "gDP6A8N", 214 + "vodRkey": "3mi5lb763vn2j", 215 + "eventTitle": "Account logic in ATProto using Trusted Execution Environments", 216 + "vodTitle": "Account logic in ATProto using Trusted Execution Environments" 217 + }, 218 + { 219 + "eventRkey": "Y561Qv6", 220 + "vodRkey": "3mi5ldvd46r2i", 221 + "eventTitle": "From protocol to product: How Expo powers the next wave of AT Proto applications", 222 + "vodTitle": "From protocol to product: How Expo powers the next wave of AT Proto applications" 223 + }, 224 + { 225 + "eventRkey": "aQ1J9GE", 226 + "vodRkey": "3mi5mvmovsn2i", 227 + "eventTitle": "2026 Atmosphere Report", 228 + "vodTitle": "join us in https://stream.place/stream1.atmosphereconf.org for 2026 Atmosphere Report" 229 + }, 230 + { 231 + "eventRkey": "Bzr448Q", 232 + "vodRkey": "3mi5q3oibtu2i", 233 + "eventTitle": "Why Gander Social?", 234 + "vodTitle": "Why Gander Social?" 235 + }, 236 + { 237 + "eventRkey": "9qP16Kp", 238 + "vodRkey": "3mi5q4ey4232y", 239 + "eventTitle": "Stop Hallucinating the Protocol: Grounding your AI Agents with the Official ATproto Docs", 240 + "vodTitle": "Stop Hallucinating the Protocol: Grounding your AI Agents with the Official ATproto Docs" 241 + }, 242 + { 243 + "eventRkey": "OD6Gd0A", 244 + "vodRkey": "3mi5q57diht27", 245 + "eventTitle": "Semble: Rediscovering the Magic of Trails", 246 + "vodTitle": "Semble: Rediscovering the Magic of Trails" 247 + }, 248 + { 249 + "eventRkey": "000WSocial", 250 + "vodRkey": "3mi5qzkwfe42z", 251 + "eventTitle": "Who, Where, Why, What about W Social", 252 + "vodTitle": "Who, Where, Why, What about W Social" 253 + }, 254 + { 255 + "eventRkey": "QKZoLBX", 256 + "vodRkey": "3mi37ha3edb23", 257 + "eventTitle": "How (de)centralized is Bluesky, really?", 258 + "vodTitle": "How (de)centralized is Bluesky, really? - " 259 + }, 260 + { 261 + "eventRkey": "2EG4YMj", 262 + "vodRkey": "3mi5qywid6z25", 263 + "eventTitle": "What 350,000 users taught me about growing on Open Social", 264 + "vodTitle": "What 350,000 users taught me about growing on Open Social" 265 + }, 266 + { 267 + "eventRkey": "000Ryo", 268 + "vodRkey": "3mi5rl4r4a725", 269 + "eventTitle": "Bridging Social Graphs: How Sky Follower Bridge helps people move to Bluesky", 270 + "vodTitle": "Bridging Social Graphs: How Sky Follower Bridge helps people move to Bluesky" 271 + }, 272 + { 273 + "eventRkey": "Xxd7xqj", 274 + "vodRkey": "3mi5rnhjugb2h", 275 + "eventTitle": "E2EE DMs for Solidarity Social", 276 + "vodTitle": "E2EE DMs for Solidarity Social" 277 + }, 278 + { 279 + "eventRkey": "000Jer", 280 + "vodRkey": "3mi5rm4y7ts2b", 281 + "eventTitle": "The Future of Open Source is Social", 282 + "vodTitle": "The Future of Open Source is Social" 283 + }, 284 + { 285 + "eventRkey": "2EGLPML", 286 + "vodRkey": "3mi5s2x6tkd2d", 287 + "eventTitle": "Burning down data walls in the US Fire Service and Beyond", 288 + "vodTitle": "Burning down data walls in the US Fire Service and Beyond" 289 + }, 290 + { 291 + "eventRkey": "7Rr2zW6", 292 + "vodRkey": "3mi5s5mqmgj2r", 293 + "eventTitle": "Pollen: Prototyping a toolkit for journalists and researchers to restore source transparency in an AI-saturated feed", 294 + "vodTitle": "Pollen: Prototyping a toolkit for journalists and researchers to restore source transparency in an AI-saturated feed" 295 + }, 296 + { 297 + "eventRkey": "lbkWPeN", 298 + "vodRkey": "3mi5s4foj7h2a", 299 + "eventTitle": "Oaklog: Building a community calendar in the Oakland Bay Area", 300 + "vodTitle": "Oaklog: Building a community calendar in the Oakland Bay Area" 301 + }, 302 + { 303 + "eventRkey": "RGqppqd", 304 + "vodRkey": "3mi5stzyxji2e", 305 + "eventTitle": "How Streamplace Works: VODs", 306 + "vodTitle": "How Streamplace Works: VODs" 307 + }, 308 + { 309 + "eventRkey": "EkGROKB", 310 + "vodRkey": "3mi5sn7zmfy23", 311 + "eventTitle": "A Free Press needs Free Protocols", 312 + "vodTitle": "A Free Press needs Free Protocols" 313 + }, 314 + { 315 + "eventRkey": "OD2G9j8", 316 + "vodRkey": "3mi5spj6izy2a", 317 + "eventTitle": "The Phoenix Architecture", 318 + "vodTitle": "The Phoenix Architecture" 319 + }, 320 + { 321 + "eventRkey": "J9EgEdX", 322 + "vodRkey": "3mi5uqr2spg2l", 323 + "eventTitle": "Hypercerts on ATProto: Collective Funding, Evaluation, and Ownership as Social Data", 324 + "vodTitle": "Hypercerts on ATProto: Collective Funding, Evaluation, and Ownership as Social Data" 325 + }, 326 + { 327 + "eventRkey": "PdJ6Q8d", 328 + "vodRkey": "3mi5unzkbat27", 329 + "eventTitle": "Journalism must create its own algorithms", 330 + "vodTitle": "Journalism must create its own algorithms" 331 + }, 332 + { 333 + "eventRkey": "rj8Xv62", 334 + "vodRkey": "3mi5unhqisv22", 335 + "eventTitle": "This Title Left Intentionally Blank", 336 + "vodTitle": "This Title Left Intentionally Blank" 337 + }, 338 + { 339 + "eventRkey": "day-3-closing-remarks", 340 + "vodRkey": "3miafr5amxa2m", 341 + "eventTitle": "Day 3 Closing Remarks", 342 + "vodTitle": "closing remarks" 343 + }, 344 + { 345 + "eventRkey": "VLXBbzJ", 346 + "vodRkey": "3mi7ldfbjwe26", 347 + "eventTitle": "How and Why News Organizations Should Build on the ATProtocol", 348 + "vodTitle": "How and Why News Organizations Should Build on the ATProtocol" 349 + }, 350 + { 351 + "eventRkey": "VLgVWM6", 352 + "vodRkey": "3mi7mb4jbag23", 353 + "eventTitle": "tangled: The Lewis end", 354 + "vodTitle": "tangled: The Lewis end" 355 + }, 356 + { 357 + "eventRkey": "Mej2N5X", 358 + "vodRkey": "3mi7lapdbjc2t", 359 + "eventTitle": "Roomy and community organizing for system change", 360 + "vodTitle": "Roomy and community organizing for system change" 361 + }, 362 + { 363 + "eventRkey": "WOkL11Q", 364 + "vodRkey": "3mi7ok5zmuk2k", 365 + "eventTitle": "How to have more non-english speaking users", 366 + "vodTitle": "How to have more non-english speaking users" 367 + }, 368 + { 369 + "eventRkey": "gDPMMa1", 370 + "vodRkey": "3mi7ofq7ob72a", 371 + "eventTitle": "One Year of Graze - lessons learned funding, building, and growing in the atmosphere", 372 + "vodTitle": "One Year of Graze - lessons learned funding, building, and growing in the atmosphere" 373 + }, 374 + { 375 + "eventRkey": "jaAWVRY", 376 + "vodRkey": "3mi7o5amqqx2d", 377 + "eventTitle": "Bringing Self Sovereign Identities to the Masses via ATproto (and how to maximize coherence between upcoming DID:PLC forks)", 378 + "vodTitle": "Bringing Self Sovereign Identities to the Masses via ATproto" 379 + }, 380 + { 381 + "eventRkey": "ODqQQJA", 382 + "vodRkey": "3mi7pjfdkhv2b", 383 + "eventTitle": "Waiting for the Future to Load", 384 + "vodTitle": "Waiting for the Future to Load" 385 + }, 386 + { 387 + "eventRkey": "1AzdYWM", 388 + "vodRkey": "3mi7pxl6xkz22", 389 + "eventTitle": "Bluenotes: Community Notes for ATProto", 390 + "vodTitle": "Bluenotes: Community Notes for ATProto" 391 + }, 392 + { 393 + "eventRkey": "zxRkxk8", 394 + "vodRkey": "3mi7q5mjlbe2c", 395 + "eventTitle": "Blousques: Case Study on the Challenges in Translating Bluesky's UI", 396 + "vodTitle": "Blousques: Case Study on the Challenges in Translating Bluesky's UI" 397 + }, 398 + { 399 + "eventRkey": "q4qlVLY", 400 + "vodRkey": "3mi7rpxijg725", 401 + "eventTitle": "Community privacy in a decentralized network", 402 + "vodTitle": "Community privacy in a decentralized network" 403 + }, 404 + { 405 + "eventRkey": "kdobWjj", 406 + "vodRkey": "3mi7rvqdpj722", 407 + "eventTitle": "A Fireside Chat on Resonant Computing: Why we wrote the manifesto and where we go from here", 408 + "vodTitle": "A Fireside Chat on Resonant Computing: Why we wrote the manifesto and where we go from here" 409 + }, 410 + { 411 + "eventRkey": "2E9XG1b", 412 + "vodRkey": "3mi7tcodsqu2t", 413 + "eventTitle": "Creating a Safer Web: Blacksky's Moderation Tool", 414 + "vodTitle": "Creating a Safer Web: Blacksky's Moderation Tool" 415 + }, 416 + { 417 + "eventRkey": "9q8ZX5Q", 418 + "vodRkey": "3mi7tqadgv22d", 419 + "eventTitle": "Social Components", 420 + "vodTitle": "Social Components" 421 + }, 422 + { 423 + "eventRkey": "EkexvrN", 424 + "vodRkey": "3mi7tbwgymp25", 425 + "eventTitle": "~~Compete or kill~~ Cooperate and Succeed!", 426 + "vodTitle": "Compete or kill Cooperate and Succeed!" 427 + }, 428 + { 429 + "eventRkey": "GxEe0Vz", 430 + "vodRkey": "3mi7ztge2sf2h", 431 + "eventTitle": "Designing for the social web", 432 + "vodTitle": "Designing for the social web" 433 + }, 434 + { 435 + "eventRkey": "LZ4oWrj", 436 + "vodRkey": "3mia26ffz2j2c", 437 + "eventTitle": "Two Years of Skywatch: Lessons Learned for Community Moderation", 438 + "vodTitle": "Two Years of Skywatch: Lessons Learned for Community Moderation" 439 + }, 440 + { 441 + "eventRkey": "q4QAgVk", 442 + "vodRkey": "3mi7zxhekcu2d", 443 + "eventTitle": "Building decentralized AI on atproto", 444 + "vodTitle": "Building decentralized AI on atproto" 445 + }, 446 + { 447 + "eventRkey": "xXWE9Dv", 448 + "vodRkey": "3mia3vqrg4r23", 449 + "eventTitle": "Data Sovereignty for Games (and Everything Else): Building Decentralized Industry Infrastructure on ATProto", 450 + "vodTitle": "Data Sovereignty for Games (and Everything Else): Building Decentralized Industry Infrastructure on ATProto" 451 + }, 452 + { 453 + "eventRkey": "0Qq9NlZ", 454 + "vodRkey": "3mia3vm5oyd2u", 455 + "eventTitle": "Coop: Open source Trust & Safety infrastructure for all", 456 + "vodTitle": "Coop: Open source Trust & Safety infrastructure for all" 457 + }, 458 + { 459 + "eventRkey": "WObY04Q", 460 + "vodRkey": "3mia3svkvjw22", 461 + "eventTitle": "furryli.st — Building Communities Without Landlords From the Protocol Up", 462 + "vodTitle": "furryli.st — Building Communities Without Landlords From the Protocol Up" 463 + }, 464 + { 465 + "eventRkey": "000WebTiles", 466 + "vodRkey": "3mia5q5vu4q2h", 467 + "eventTitle": "WebTiles Showcase", 468 + "vodTitle": "WebTiles Showcase" 469 + }, 470 + { 471 + "eventRkey": "rjQ96kl", 472 + "vodRkey": "3mia5khdija2h", 473 + "eventTitle": "Protocol Governance & Hard Decentralization", 474 + "vodTitle": "Protocol Governance & Hard Decentralization" 475 + }, 476 + { 477 + "eventRkey": "9qjDJZG", 478 + "vodRkey": "3mia5mbp4tf22", 479 + "eventTitle": "From Toilets to Moths: The Future of Social Media is Weird and Not For Everyone", 480 + "vodTitle": "From Toilets to Moths: The Future of Social Media is Weird and Not For Everyone" 481 + }, 482 + { 483 + "eventRkey": "81gXlXP", 484 + "vodRkey": "3mia74qg3622a", 485 + "eventTitle": "Building Bridgy, Not Walls", 486 + "vodTitle": "Building Bridgy, Not Walls" 487 + }, 488 + { 489 + "eventRkey": "5B02jaM", 490 + "vodRkey": "3mia7iun75l2x", 491 + "eventTitle": "Keywords vs Embeddings", 492 + "vodTitle": "Keywords vs Embeddings" 493 + }, 494 + { 495 + "eventRkey": "q4QdXj7", 496 + "vodRkey": "3mia7dt6fnp2m", 497 + "eventTitle": "ATProto design philosophy behind BookHive", 498 + "vodTitle": "ATProto design philosophy behind BookHive" 499 + }, 500 + { 501 + "eventRkey": "QKNkKMX", 502 + "vodRkey": "3miabh2g67c2c", 503 + "eventTitle": "Scaling the Atmosphere", 504 + "vodTitle": "Scaling the Atmosphere" 505 + }, 506 + { 507 + "eventRkey": "aQJAWl9", 508 + "vodRkey": "3miac22soys22", 509 + "eventTitle": "Affordances of the Atmosphere", 510 + "vodTitle": "Affordances of the Atmosphere" 511 + }, 512 + { 513 + "eventRkey": "ob8N65V", 514 + "vodRkey": "3miacmdq4sm2a", 515 + "eventTitle": "How to use Bluesky to easily and securely preview a software product to users.", 516 + "vodTitle": "How to use Bluesky to easily and securely preview a software product to users." 517 + }, 518 + { 519 + "eventRkey": "xX5yRJr", 520 + "vodRkey": "3miac5ez6a22r", 521 + "eventTitle": "Skylimit: A curating web client with fine-grained controls to mimic the newspaper experience", 522 + "vodTitle": "Skylimit: A curating web client with fine-grained controls to mimic the newspaper experience" 523 + }, 524 + { 525 + "eventRkey": "686gZde", 526 + "vodRkey": "3miac3urhgt23", 527 + "eventTitle": "Using GraphQL to build with ATProto", 528 + "vodTitle": "Using GraphQL to build with ATProto" 529 + }, 530 + { 531 + "eventRkey": "ZjL74D0", 532 + "vodRkey": "3miacmwnd2z2z", 533 + "eventTitle": "Jacquard Magic: how to make atproto actually easy with Rust", 534 + "vodTitle": "Jacquard Magic: how to make atproto actually easy with Rust" 535 + }, 536 + { 537 + "eventRkey": "RG6Nepp", 538 + "vodRkey": "3miadb23fme2a", 539 + "eventTitle": "Wherever You Get Your Podcasts: Interoperability in the Atmosphere", 540 + "vodTitle": "Wherever You Get Your Podcasts: Interoperability in the Atmosphere" 541 + }, 542 + { 543 + "eventRkey": "000TLog", 544 + "vodRkey": "3miacona6fc2e", 545 + "eventTitle": "AT Transparency Logs: accountable record collections", 546 + "vodTitle": "AT Transparency Logs: accountable record collections" 547 + }, 548 + { 549 + "eventRkey": "ZjMOl7o", 550 + "vodRkey": "3miadd43al32c", 551 + "eventTitle": "Matadata! Publishing scientific data straight to AT", 552 + "vodTitle": "Matadata! Publishing scientific data straight to AT" 553 + }, 554 + { 555 + "eventRkey": "A7YLlpl", 556 + "vodRkey": "3miaec7exqi25", 557 + "eventTitle": "An artist dreaming in the Atmosphere: visions about community, sustainability and creativity", 558 + "vodTitle": "An artist dreaming in the Atmosphere: visions about community, sustainability and creativity" 559 + }, 560 + { 561 + "eventRkey": "PdNOkAP", 562 + "vodRkey": "3miaef2b4mh2h", 563 + "eventTitle": "Rewilding the internet with ATProto", 564 + "vodTitle": "Rewilding the internet with ATProto" 565 + }, 566 + { 567 + "eventRkey": "QKqWDrG", 568 + "vodRkey": "3miadvkvhfv2h", 569 + "eventTitle": "DID:PLC War Games", 570 + "vodTitle": "DID:PLC War Games" 571 + }, 572 + { 573 + "eventRkey": "gDELD0M", 574 + "vodRkey": "3mi54jqrm372z", 575 + "eventTitle": "Landslide", 576 + "vodTitle": "(#KelpFacts) Landslide" 577 + }, 578 + { 579 + "eventRkey": "MeWBzWX", 580 + "vodRkey": "3mi7kaogqjs22", 581 + "eventTitle": "npmx: a modern browser for the npm registry", 582 + "vodTitle": "npmx - a fast, modern browser for the npm registry" 583 + }, 584 + { 585 + "eventRkey": "day-4-closing-remarks", 586 + "vodRkey": "3mi5wqocga62h", 587 + "eventTitle": "Day 4 Closing Remarks", 588 + "vodTitle": "Closing remarks, I guess, if i have to." 589 + }, 590 + { 591 + "eventRkey": "3mhzjk45462rg", 592 + "vodRkey": "3mi2ikg6gij26", 593 + "eventTitle": "ATScience Unconference", 594 + "vodTitle": "ATScience at ATmosphereConf 2026!" 595 + } 596 + ]
+20 -77
src/lib/vods.ts
··· 1 + import vodMap from './vod-map.json'; 2 + 1 3 const STREAM_PLACE_DID = 'did:plc:rbvrr34edl5ddpuwcubjiost'; 2 - const STREAM_PLACE_PDS = 'https://iameli.com'; 3 - const VOD_COLLECTION = 'place.stream.video'; 4 4 const VOD_PLAYBACK_BASE = 'https://vod-beta.stream.place/xrpc/place.stream.playback.getVideoPlaylist'; 5 5 6 6 export interface VodRecord { 7 - uri: string; 8 - title: string; 9 - creator: string; 10 - duration: number; // nanoseconds 7 + vodRkey: string; 8 + vodTitle: string; 11 9 playlistUrl: string; 12 10 } 13 11 14 - let cachedVods: VodRecord[] | null = null; 15 - let cacheTime = 0; 16 - const CACHE_TTL = 5 * 60 * 1000; // 5 minutes 12 + function makePlaylistUrl(vodRkey: string): string { 13 + const uri = `at://${STREAM_PLACE_DID}/place.stream.video/${vodRkey}`; 14 + return `${VOD_PLAYBACK_BASE}?uri=${encodeURIComponent(uri)}`; 15 + } 17 16 18 - export async function fetchVods(): Promise<VodRecord[]> { 19 - if (cachedVods && Date.now() - cacheTime < CACHE_TTL) { 20 - return cachedVods; 21 - } 17 + const vodByEventRkey = new Map<string, VodRecord>(); 18 + for (const entry of vodMap) { 19 + vodByEventRkey.set(entry.eventRkey, { 20 + vodRkey: entry.vodRkey, 21 + vodTitle: entry.vodTitle, 22 + playlistUrl: makePlaylistUrl(entry.vodRkey) 23 + }); 24 + } 22 25 23 - const allRecords: VodRecord[] = []; 24 - let cursor: string | undefined; 25 - 26 - do { 27 - const params = new URLSearchParams({ 28 - repo: STREAM_PLACE_DID, 29 - collection: VOD_COLLECTION, 30 - limit: '100' 31 - }); 32 - if (cursor) params.set('cursor', cursor); 33 - 34 - const res = await fetch(`${STREAM_PLACE_PDS}/xrpc/com.atproto.repo.listRecords?${params}`); 35 - if (!res.ok) break; 36 - 37 - const data = (await res.json()) as { 38 - cursor?: string; 39 - records: Array<{ 40 - uri: string; 41 - value: { 42 - title: string; 43 - creator: string; 44 - duration: number; 45 - }; 46 - }>; 47 - }; 48 - 49 - for (const r of data.records ?? []) { 50 - allRecords.push({ 51 - uri: r.uri, 52 - title: r.value.title, 53 - creator: r.value.creator, 54 - duration: r.value.duration, 55 - playlistUrl: `${VOD_PLAYBACK_BASE}?uri=${encodeURIComponent(r.uri)}` 56 - }); 57 - } 58 - 59 - cursor = data.cursor; 60 - } while (cursor); 61 - 62 - cachedVods = allRecords; 63 - cacheTime = Date.now(); 64 - return cachedVods; 26 + export function getVodForEvent(eventRkey: string): VodRecord | null { 27 + return vodByEventRkey.get(eventRkey) ?? null; 65 28 } 66 29 67 - const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, ''); 68 - 69 - export async function findVodForEvent(eventName: string): Promise<VodRecord | null> { 70 - const vods = await fetchVods(); 71 - const eventNorm = normalize(eventName); 72 - 73 - // Exact normalized match 74 - const exact = vods.find((v) => normalize(v.title) === eventNorm); 75 - if (exact) return exact; 76 - 77 - // Substring match (event name in VOD title or vice versa), require reasonable length 78 - if (eventNorm.length >= 10) { 79 - const partial = vods.find( 80 - (v) => { 81 - const vodNorm = normalize(v.title); 82 - return vodNorm.length >= 10 && (eventNorm.includes(vodNorm) || vodNorm.includes(eventNorm)); 83 - } 84 - ); 85 - if (partial) return partial; 86 - } 87 - 88 - return null; 30 + export function getAllEventVods(): Map<string, VodRecord> { 31 + return vodByEventRkey; 89 32 }
+4 -5
src/routes/(app)/p/[actor]/e/[rkey]/+page.server.ts
··· 12 12 listEventAttendeesFromContrail, 13 13 RSVP_HYDRATE_LIMIT 14 14 } from '$lib/contrail'; 15 - import { findVodForEvent } from '$lib/vods'; 15 + import { getVodForEvent } from '$lib/vods'; 16 16 17 17 export async function load({ params, locals, url }) { 18 18 const { rkey } = params; ··· 44 44 | Array<{ id: string; name: string }> 45 45 | undefined) ?? []; 46 46 47 - const [attendees, viewerRsvpRecord, parentEvent, vod, ...speakerProfiles] = await Promise.all([ 47 + const vod = isAtmosphereconf ? getVodForEvent(rkey) : null; 48 + 49 + const [attendees, viewerRsvpRecord, parentEvent, ...speakerProfiles] = await Promise.all([ 48 50 listEventAttendeesFromContrail(fullEventRecord.uri), 49 51 locals.did 50 52 ? getViewerRsvpFromContrail({ eventUri: fullEventRecord.uri, actor: locals.did }) ··· 53 55 ? getEventRecordFromContrail({ did: 'did:plc:lehcqqkwzcwvjvw66uthu5oq', rkey: '3lte3c7x43l2e', profiles: true }) 54 56 .then((r) => r ? flattenEventRecord(r) : null) 55 57 .catch(() => null) 56 - : null, 57 - isAtmosphereconf 58 - ? findVodForEvent(eventData.name).catch(() => null) 59 58 : null, 60 59 ...speakers.map((s) => 61 60 s.id
+1 -2
src/routes/(app)/p/[actor]/e/[rkey]/+page.svelte
··· 11 11 import EventAttendees from './EventAttendees.svelte'; 12 12 import VodPlayer from '$lib/components/VodPlayer.svelte'; 13 13 import { page } from '$app/state'; 14 - import { goto } from '$app/navigation'; 15 14 import { marked } from 'marked'; 16 15 import { sanitize } from '$lib/cal/sanitize'; 17 16 import { generateICalEvent } from '$lib/cal/ical'; ··· 377 376 <!-- VOD --> 378 377 {#if data.vod} 379 378 <div class="mb-6"> 380 - <VodPlayer playlistUrl={data.vod.playlistUrl} title={data.vod.title} /> 379 + <VodPlayer playlistUrl={data.vod.playlistUrl} title={data.vod.vodTitle} /> 381 380 </div> 382 381 {/if} 383 382
+7 -12
src/routes/(app)/p/atmosphereconf.org/+page.server.ts
··· 4 4 listEventRecordsFromContrail, 5 5 contrail 6 6 } from '$lib/contrail'; 7 - import { fetchVods, type VodRecord } from '$lib/vods'; 7 + import { getAllEventVods, type VodRecord } from '$lib/vods'; 8 8 9 9 export async function load({ locals }) { 10 10 const actor = 'atmosphereconf.org'; 11 11 12 - const [profile, response, rsvpResponse, vods] = await Promise.all([ 12 + const [profile, response, rsvpResponse] = await Promise.all([ 13 13 getProfileFromContrail(actor), 14 14 listEventRecordsFromContrail({ 15 15 actor, ··· 21 21 ? contrail.get('community.lexicon.calendar.rsvp.listRecords', { 22 22 params: { actor: locals.did, limit: 200 } 23 23 }) 24 - : null, 25 - fetchVods().catch(() => [] as VodRecord[]) 24 + : null 26 25 ]); 27 26 28 27 const events = response ? flattenEventRecords(response.records) : []; ··· 42 41 } 43 42 } 44 43 45 - // Build map of event name → VOD for quick lookup in the schedule 46 - const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, ''); 47 - const vodsByName: Record<string, VodRecord> = {}; 48 - for (const vod of vods) { 49 - vodsByName[normalize(vod.title)] = vod; 50 - } 44 + // Build event URI → VOD map from static mapping 45 + const vodsByRkey = getAllEventVods(); 51 46 const eventVods: Record<string, VodRecord> = {}; 52 47 for (const event of events) { 53 - const match = vodsByName[normalize(event.name)]; 54 - if (match) eventVods[event.uri] = match; 48 + const vod = vodsByRkey.get(event.rkey); 49 + if (vod) eventVods[event.uri] = vod; 55 50 } 56 51 57 52 return {