A simple, clean, fast browser for the AtmosphereConf(2026) VODs
0
fork

Configure Feed

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

feat: refresh embeddings from ionosphere transcripts

jack 6d8169e5 4e125685

+236 -146
+9
.github/workflows/refresh-embeddings.yml
··· 1 1 name: Refresh Embeddings Index 2 2 3 3 on: 4 + push: 5 + branches: 6 + - main 7 + paths: 8 + - '.github/workflows/refresh-embeddings.yml' 9 + - 'scripts/generate-video-embeddings.mjs' 10 + - 'src/lib/video-taxonomy.json' 11 + - 'package-lock.json' 12 + - 'package.json' 4 13 schedule: 5 14 - cron: '15 */12 * * *' 6 15 workflow_dispatch:
+108 -1
scripts/generate-video-embeddings.mjs
··· 16 16 process.env.EXISTING_EMBEDDINGS_PATH ?? process.env.EMBEDDINGS_OUTPUT_PATH ?? 'public/video-embeddings.json', 17 17 ) 18 18 const TAXONOMY_PATH = path.resolve(process.cwd(), 'src/lib/video-taxonomy.json') 19 + const IONOSPHERE_DID = 'did:plc:lkeq4oghyhnztbu4dxr3joff' 20 + const IONOSPHERE_EVENT_URI = 21 + 'at://did:plc:lkeq4oghyhnztbu4dxr3joff/tv.ionosphere.event/atmosphereconf-2026' 19 22 const EMBEDDING_BATCH_SIZE = Number.parseInt(process.env.EMBEDDING_BATCH_SIZE ?? '64', 10) 23 + 24 + function asString(value) { 25 + return typeof value === 'string' ? value : '' 26 + } 27 + 28 + function normalizeWhitespace(value) { 29 + return value.replace(/\s+/g, ' ').trim() 30 + } 20 31 21 32 function parseEnvFile(content) { 22 33 const vars = {} ··· 184 195 return records 185 196 } 186 197 198 + async function fetchAllCollectionRecords(repoDid, collection) { 199 + const pdsUrl = await resolvePdsUrl(repoDid) 200 + const records = [] 201 + let cursor = undefined 202 + 203 + do { 204 + const query = new URLSearchParams({ 205 + repo: repoDid, 206 + collection, 207 + limit: '100', 208 + }) 209 + if (cursor) { 210 + query.set('cursor', cursor) 211 + } 212 + 213 + const page = await fetchJson(`${pdsUrl}/xrpc/com.atproto.repo.listRecords?${query.toString()}`) 214 + records.push(...(page.records ?? [])) 215 + cursor = page.cursor 216 + } while (cursor) 217 + 218 + return records 219 + } 220 + 187 221 function toEmbeddingInput(record) { 188 222 const title = record.value?.title ?? 'Untitled' 189 223 const description = record.value?.description ?? '' 190 224 const creator = record.value?.creator ?? '' 225 + const transcript = record.ionosphereTranscript ?? '' 226 + const ionosphereConcepts = record.ionosphereConcepts ?? [] 191 227 const taxonomy = record.taxonomy 192 228 const taxonomyLine = taxonomy 193 229 ? [ ··· 203 239 `title: ${title}`, 204 240 description ? `description: ${description}` : '', 205 241 creator ? `creator: ${creator}` : '', 242 + transcript ? `transcript: ${transcript}` : '', 243 + ionosphereConcepts.length > 0 ? `concepts: ${ionosphereConcepts.join(', ')}` : '', 206 244 taxonomyLine ? `atmosphere-taxonomy: ${taxonomyLine}` : '', 207 245 ] 208 246 .filter(Boolean) 209 247 .join('\n') 210 248 } 211 249 250 + function computeContentSignature(record) { 251 + return toEmbeddingInput(record) 252 + } 253 + 212 254 async function embedBatch(inputs) { 213 255 const embeddingResponse = await callOpenRouter('/embeddings', { 214 256 model: EMBEDDING_MODEL, ··· 239 281 240 282 console.log(`Discovered ${repoDids.length} repos`) 241 283 284 + const [ionosphereTalkRecords, ionosphereConceptRecords, ionosphereAnnotationRecords, ionosphereTranscriptRecords] = 285 + await Promise.all([ 286 + fetchAllCollectionRecords(IONOSPHERE_DID, 'tv.ionosphere.talk'), 287 + fetchAllCollectionRecords(IONOSPHERE_DID, 'tv.ionosphere.concept'), 288 + fetchAllCollectionRecords(IONOSPHERE_DID, 'tv.ionosphere.annotation'), 289 + fetchAllCollectionRecords(IONOSPHERE_DID, 'tv.ionosphere.transcript'), 290 + ]) 291 + 292 + const ionosphereTalks = ionosphereTalkRecords.filter( 293 + (record) => record?.value?.eventUri === IONOSPHERE_EVENT_URI && asString(record?.value?.videoUri), 294 + ) 295 + const talkUriByVideoUri = new Map( 296 + ionosphereTalks.map((record) => [asString(record.value.videoUri), record.uri]), 297 + ) 298 + 299 + const conceptNameByUri = new Map( 300 + ionosphereConceptRecords 301 + .map((record) => { 302 + const aliases = Array.isArray(record?.value?.aliases) 303 + ? record.value.aliases.filter((alias) => typeof alias === 'string') 304 + : [] 305 + const conceptName = asString(record?.value?.name) || asString(aliases[0]) 306 + return [record.uri, conceptName] 307 + }) 308 + .filter((entry) => Boolean(entry[0] && entry[1])), 309 + ) 310 + 311 + const conceptsByTalkUri = new Map() 312 + for (const annotation of ionosphereAnnotationRecords) { 313 + const talkUri = asString(annotation?.value?.talkUri) 314 + const conceptUri = asString(annotation?.value?.conceptUri) 315 + if (!talkUri || !conceptUri) { 316 + continue 317 + } 318 + const conceptName = conceptNameByUri.get(conceptUri) 319 + if (!conceptName) { 320 + continue 321 + } 322 + const existing = conceptsByTalkUri.get(talkUri) ?? [] 323 + if (!existing.includes(conceptName)) { 324 + existing.push(conceptName) 325 + conceptsByTalkUri.set(talkUri, existing) 326 + } 327 + } 328 + 329 + const transcriptByTalkUri = new Map() 330 + for (const transcriptRecord of ionosphereTranscriptRecords) { 331 + const talkUri = asString(transcriptRecord?.value?.talkUri) 332 + const text = normalizeWhitespace( 333 + asString(transcriptRecord?.value?.text) || asString(transcriptRecord?.value?.transcript), 334 + ) 335 + if (!talkUri || !text) { 336 + continue 337 + } 338 + const existing = transcriptByTalkUri.get(talkUri) 339 + transcriptByTalkUri.set(talkUri, existing ? `${existing}\n${text}` : text) 340 + } 341 + 342 + console.log(`Loaded ${ionosphereTalks.length} Atmosphere talks from ionosphere`) 343 + 242 344 const allRecords = [] 243 345 for (const repoDid of repoDids) { 244 346 try { 245 347 const records = await fetchAllRecordsForRepo(repoDid) 246 348 console.log(`Fetched ${records.length} videos from ${repoDid}`) 247 349 for (const record of records) { 350 + const talkUri = talkUriByVideoUri.get(record.uri) 248 351 allRecords.push({ 249 352 ...record, 250 353 sourceRepoDid: repoDid, 251 354 taxonomy: taxonomyByUri.get(record.uri), 355 + ionosphereTranscript: talkUri ? transcriptByTalkUri.get(talkUri) ?? '' : '', 356 + ionosphereConcepts: talkUri ? conceptsByTalkUri.get(talkUri) ?? [] : [], 252 357 }) 253 358 } 254 359 } catch (error) { ··· 285 390 286 391 for (const record of orderedRecords) { 287 392 const existingEntry = existingByUri.get(record.uri) 288 - if (existingEntry) { 393 + const nextContentSignature = computeContentSignature(record) 394 + if (existingEntry?.contentSignature === nextContentSignature) { 289 395 reuseCandidates.push({ record, existingEntry }) 290 396 } else { 291 397 missingRecords.push(record) ··· 323 429 sourceRepoDid: record.sourceRepoDid, 324 430 createdAt: record.value?.createdAt, 325 431 title: record.value?.title ?? 'Untitled', 432 + contentSignature: computeContentSignature(record), 326 433 embedding, 327 434 } 328 435 })
+35 -2
src/lib/ionosphere.ts
··· 26 26 cursor?: string 27 27 } 28 28 29 + interface IonosphereTranscriptRecord { 30 + uri: string 31 + cid: string 32 + value: { 33 + $type?: string 34 + talkUri?: string 35 + text?: string 36 + transcript?: string 37 + } 38 + } 39 + 29 40 let enrichmentCache: Promise<IonosphereEnrichmentResult> | null = null 30 41 const profileCache = new Map<string, Promise<ActorProfile | null>>() 31 42 ··· 185 196 })) 186 197 } 187 198 199 + function asIonosphereTranscripts( 200 + records: GenericListRecordsResponse['records'], 201 + ): IonosphereTranscriptRecord[] { 202 + return (records ?? []).map((record) => ({ 203 + uri: record.uri, 204 + cid: record.cid, 205 + value: record.value as IonosphereTranscriptRecord['value'], 206 + })) 207 + } 208 + 188 209 async function buildEnrichment(talks: AppTalk[]): Promise<IonosphereEnrichmentResult> { 189 - const [talksRaw, conceptsRaw, annotationsRaw, speakersRaw] = await Promise.all([ 210 + const [talksRaw, conceptsRaw, annotationsRaw, speakersRaw, transcriptsRaw] = await Promise.all([ 190 211 fetchCollection('tv.ionosphere.talk'), 191 212 fetchCollection('tv.ionosphere.concept'), 192 213 fetchCollection('tv.ionosphere.annotation'), 193 214 fetchCollection('tv.ionosphere.speaker'), 215 + fetchCollection('tv.ionosphere.transcript'), 194 216 ]) 195 217 196 218 const ionosphereTalks = asIonosphereTalks(talksRaw).filter( 197 219 (record) => record.value.eventUri === IONOSPHERE_EVENT_URI, 198 220 ) 221 + const talkByVideoUri = new Map( 222 + ionosphereTalks 223 + .filter((record) => typeof record.value.videoUri === 'string' && Boolean(record.value.videoUri)) 224 + .map((record) => [record.value.videoUri as string, record]), 225 + ) 199 226 const conceptByUri = new Map( 200 227 asIonosphereConcepts(conceptsRaw).map((record) => [ 201 228 record.uri, ··· 222 249 } 223 250 224 251 const speakerByUri = new Map(asIonosphereSpeakers(speakersRaw).map((record) => [record.uri, record])) 252 + const transcriptByTalkUri = new Map( 253 + asIonosphereTranscripts(transcriptsRaw) 254 + .map((record) => [record.value.talkUri, record.value.text ?? record.value.transcript ?? ''] as const) 255 + .filter((entry) => Boolean(entry[0] && entry[1])), 256 + ) 225 257 const byVodUri = new Map<string, IonosphereEnrichment>() 226 258 const allTopics = new Set<string>() 227 259 228 260 for (const vodTalk of talks) { 229 - const match = pickBestTalkByTitle(vodTalk, ionosphereTalks) 261 + const match = talkByVideoUri.get(vodTalk.uri) ?? pickBestTalkByTitle(vodTalk, ionosphereTalks) 230 262 if (!match) { 231 263 continue 232 264 } ··· 259 291 scheduledAt: match.value.startsAt, 260 292 track: match.value.track || match.value.category, 261 293 topics, 294 + transcriptText: transcriptByTalkUri.get(match.uri), 262 295 speakerName, 263 296 speakerHandle, 264 297 speakerAvatar,
+1
src/lib/types.ts
··· 127 127 scheduledAt?: string 128 128 track?: string 129 129 topics: string[] 130 + transcriptText?: string 130 131 speakerName?: string 131 132 speakerHandle?: string 132 133 speakerAvatar?: string
+12
src/pages/about-page.tsx
··· 47 47 tag/topic metadata, so Atmosphere queries are usually more precise than general VOD queries. 48 48 </p> 49 49 <p className="mt-4 max-w-[70ch] text-sm leading-relaxed text-muted"> 50 + Additional AtmosphereConf enrichment data is available from{' '} 51 + <a 52 + href="https://ionosphere.tv/discussion" 53 + target="_blank" 54 + rel="noreferrer" 55 + className="underline-offset-4 hover:text-text hover:underline" 56 + > 57 + ionosphere.tv/discussion 58 + </a> 59 + . 60 + </p> 61 + <p className="mt-4 max-w-[70ch] text-sm leading-relaxed text-muted"> 50 62 Live deployment:{' '} 51 63 <a href="https://vods.j4ck.xyz" className="underline-offset-4 hover:text-text hover:underline"> 52 64 vods.j4ck.xyz
+70 -12
src/pages/atmosphereconf-page.tsx
··· 7 7 import { formatDateTime } from '@/lib/format' 8 8 import { fetchAtmosphereIonosphereEnrichment } from '@/lib/ionosphere' 9 9 import type { IonosphereEnrichmentResult } from '@/lib/types' 10 + import { searchTalkUris } from '@/lib/semantic-search' 10 11 import { useVideos } from '@/state/videos-context' 11 12 12 13 export function AtmosphereConfPage() { ··· 17 18 allTopics: [], 18 19 }) 19 20 const [selectedTopic, setSelectedTopic] = useState<string>('') 21 + const [query, setQuery] = useState<string>('') 22 + const [remoteUris, setRemoteUris] = useState<string[] | null>(null) 20 23 21 24 useEffect(() => { 22 25 if (filteredTalks.length === 0) { ··· 44 47 } 45 48 }, [filteredTalks]) 46 49 50 + useEffect(() => { 51 + const trimmed = query.trim() 52 + if (!trimmed) { 53 + return 54 + } 55 + 56 + const controller = new AbortController() 57 + searchTalkUris(trimmed, 300, controller.signal) 58 + .then((result) => { 59 + setRemoteUris(result.uris) 60 + }) 61 + .catch(() => { 62 + setRemoteUris(null) 63 + }) 64 + 65 + return () => { 66 + controller.abort() 67 + } 68 + }, [query]) 69 + 47 70 const filteredByTopic = useMemo(() => { 48 - if (!selectedTopic) { 49 - return filteredTalks 71 + let base = filteredTalks 72 + 73 + if (selectedTopic) { 74 + base = base.filter((talk) => 75 + (enrichment.byVodUri.get(talk.uri)?.topics ?? []).includes(selectedTopic), 76 + ) 50 77 } 51 78 52 - return filteredTalks.filter((talk) => 53 - (enrichment.byVodUri.get(talk.uri)?.topics ?? []).includes(selectedTopic), 54 - ) 55 - }, [filteredTalks, enrichment.byVodUri, selectedTopic]) 79 + const trimmed = query.trim().toLowerCase() 80 + if (trimmed) { 81 + const remoteSet = new Set(remoteUris ?? []) 82 + base = base.filter((talk) => { 83 + if (remoteSet.has(talk.uri)) { 84 + return true 85 + } 86 + 87 + const enrichmentText = enrichment.byVodUri.get(talk.uri)?.transcriptText?.toLowerCase() ?? '' 88 + const metadataText = [ 89 + enrichment.byVodUri.get(talk.uri)?.room, 90 + enrichment.byVodUri.get(talk.uri)?.track, 91 + ...(enrichment.byVodUri.get(talk.uri)?.topics ?? []), 92 + ] 93 + .filter(Boolean) 94 + .join(' ') 95 + .toLowerCase() 96 + 97 + return ( 98 + talk.title.toLowerCase().includes(trimmed) || 99 + (talk.description ?? '').toLowerCase().includes(trimmed) || 100 + enrichmentText.includes(trimmed) || 101 + metadataText.includes(trimmed) 102 + ) 103 + }) 104 + } 105 + 106 + return base 107 + }, [filteredTalks, enrichment.byVodUri, query, remoteUris, selectedTopic]) 56 108 57 109 const [featuredTalk, ...remainingTalks] = filteredByTopic 58 110 59 111 return ( 60 112 <div className="space-y-7 md:space-y-10" aria-busy={loading}> 61 113 <header className="space-y-2"> 62 - <div className="flex items-center justify-between gap-3"> 63 - <h1 className="text-2xl font-semibold text-text">AtmosphereConf 2026</h1> 64 - <span className="inline-flex min-h-11 items-center rounded-md border border-line/45 bg-surface/80 px-3 text-xs text-muted"> 65 - ✦ ionosphere.tv 66 - </span> 67 - </div> 114 + <h1 className="text-2xl font-semibold text-text">AtmosphereConf 2026</h1> 68 115 <p className="text-sm text-muted">Official conference videos from stream.place, sorted newest first.</p> 116 + 117 + <label className="mt-2 flex min-h-11 items-center gap-2 rounded-lg border border-line/45 bg-surface/80 px-3 text-sm text-muted"> 118 + <span className="sr-only">Search AtmosphereConf videos</span> 119 + <input 120 + type="search" 121 + value={query} 122 + onChange={(event) => setQuery(event.target.value)} 123 + placeholder="Search title, topics, or transcript text" 124 + className="h-11 w-full bg-transparent text-sm text-text outline-none placeholder:text-muted" 125 + /> 126 + </label> 69 127 70 128 {enrichment.allTopics.length > 0 ? ( 71 129 <div className="flex flex-wrap gap-2 pt-2">
+1 -131
src/pages/video-page.tsx
··· 3 3 ArrowLeft, 4 4 } from 'lucide-react' 5 5 import { 6 - startTransition, 7 6 useCallback, 8 7 useEffect, 9 8 useMemo, 10 9 useRef, 11 10 useState, 12 - type ChangeEvent, 13 - type FormEvent, 14 11 } from 'react' 15 12 import { Link, useNavigate, useParams } from 'react-router-dom' 16 13 ··· 24 21 hapticPlay, 25 22 hapticSeek, 26 23 hapticSuccess, 27 - hapticTap, 28 24 } from '@/lib/haptics' 29 25 import { toTagPath, toVideoUriFromParams } from '@/lib/routes' 30 26 import { getTalkTaxonomyTokens } from '@/lib/taxonomy' ··· 51 47 52 48 const [status, setStatus] = useState<PlaybackStatus>('idle') 53 49 const [error, setError] = useState<string | null>(null) 54 - const [currentTime, setCurrentTime] = useState<number>(0) 55 - const [duration, setDuration] = useState<number>(0) 56 - const rafRef = useRef<number | null>(null) 57 50 const [reloadToken, setReloadToken] = useState(0) 58 - const [playbackRate, setPlaybackRate] = useState<number>(1) 59 51 const [playlistUrl, setPlaylistUrl] = useState<string | null>(null) 60 52 const [resolvedTalk, setResolvedTalk] = useState<AppTalk | null>(null) 61 53 const [metadataLoading, setMetadataLoading] = useState<boolean>(false) ··· 71 63 ) 72 64 const talkTokens = useMemo(() => (talk ? getTalkTaxonomyTokens(talk).slice(0, 10) : []), [talk]) 73 65 74 - const playbackElapsed = formatDuration(currentTime * 1_000_000_000) 75 - const playbackTotal = formatDuration(duration * 1_000_000_000 || talk?.durationNs || 0) 76 - 77 66 useEffect(() => { 78 67 if (!resolvedUri) { 79 68 return ··· 122 111 const video = videoRef.current 123 112 setStatus('loading') 124 113 setError(null) 125 - setCurrentTime(0) 126 114 setPlaylistUrl(null) 127 115 128 116 let cancelled = false ··· 208 196 }, [resolvedUri, reloadToken]) 209 197 210 198 const onRetryPlayback = useCallback(() => { 211 - hapticTap() 212 - setReloadToken((token) => token + 1) 213 - }, []) 214 - 215 - const syncPlaybackState = useCallback(() => { 216 - if (!videoRef.current) { 217 - return 218 - } 219 - 220 - const current = videoRef.current.currentTime 221 - const total = Number.isFinite(videoRef.current.duration) ? videoRef.current.duration : 0 222 - 223 - startTransition(() => { 224 - setCurrentTime(current) 225 - setDuration(total) 226 - }) 227 - }, []) 228 - 229 - const onTimeUpdate = useCallback(() => { 230 - if (rafRef.current) { 231 - cancelAnimationFrame(rafRef.current) 232 - } 233 - 234 - rafRef.current = requestAnimationFrame(() => { 235 - syncPlaybackState() 236 - rafRef.current = null 237 - }) 238 - }, [syncPlaybackState]) 239 - 240 - useEffect(() => { 241 - return () => { 242 - if (rafRef.current) { 243 - cancelAnimationFrame(rafRef.current) 244 - } 245 - } 246 - }, []) 247 - 248 - const onSpeedChange = useCallback((event: ChangeEvent<HTMLSelectElement>) => { 249 - const nextRate = Number(event.target.value) 250 - const video = videoRef.current 251 - if (!video) { 252 - return 253 - } 254 - video.playbackRate = nextRate 255 - setPlaybackRate(nextRate) 256 - }, []) 257 - 258 - const onSeekInput = useCallback((event: FormEvent<HTMLInputElement>) => { 259 - const video = videoRef.current 260 - if (!video || !Number.isFinite(video.duration) || video.duration <= 0) { 261 - return 262 - } 263 - 264 - const percent = Number(event.currentTarget.value) 265 - if (!Number.isFinite(percent)) { 266 - return 267 - } 268 - 269 - video.currentTime = (video.duration * percent) / 100 270 - hapticSeek() 271 - }, []) 272 - 273 - const onTogglePlay = useCallback(() => { 274 - const video = videoRef.current 275 - if (!video) { 276 - return 277 - } 278 - 279 199 hapticPlay() 280 - if (video.paused) { 281 - void video.play() 282 - } else { 283 - video.pause() 284 - } 200 + setReloadToken((token) => token + 1) 285 201 }, []) 286 202 287 203 useEffect(() => { ··· 369 285 370 286 if (lower === 'f') { 371 287 event.preventDefault() 372 - hapticTap() 373 288 const container = playerContainerRef.current 374 289 if (!container) { 375 290 return ··· 386 301 if (lower === 'm') { 387 302 event.preventDefault() 388 303 video.muted = !video.muted 389 - hapticTap() 390 304 return 391 305 } 392 306 ··· 404 318 event.preventDefault() 405 319 const nextRate = Math.max(0.25, video.playbackRate - 0.25) 406 320 video.playbackRate = nextRate 407 - setPlaybackRate(nextRate) 408 - hapticTap() 409 321 return 410 322 } 411 323 ··· 413 325 event.preventDefault() 414 326 const nextRate = Math.min(4, video.playbackRate + 0.25) 415 327 video.playbackRate = nextRate 416 - setPlaybackRate(nextRate) 417 - hapticTap() 418 328 return 419 329 } 420 330 ··· 473 383 ref={videoRef} 474 384 className="aspect-video w-full" 475 385 controls 476 - onTimeUpdate={onTimeUpdate} 477 - onLoadedMetadata={onTimeUpdate} 478 386 playsInline 479 387 /> 480 388 ··· 500 408 {error} 501 409 </p> 502 410 ) : null} 503 - 504 - <div className="grid gap-3 text-sm text-muted sm:grid-cols-[auto,1fr,auto] sm:items-center"> 505 - <Button variant="secondary" onClick={onTogglePlay}> 506 - Play / Pause 507 - </Button> 508 - <p> 509 - {playbackElapsed} / {playbackTotal} 510 - </p> 511 - 512 - <label className="flex min-h-11 min-w-0 items-center gap-2 sm:col-span-3"> 513 - <span className="sr-only">Seek timeline</span> 514 - <input 515 - type="range" 516 - min={0} 517 - max={100} 518 - step={1} 519 - value={duration > 0 ? Math.round((currentTime / duration) * 100) : 0} 520 - onChange={onSeekInput} 521 - onInput={onSeekInput} 522 - className="h-11 w-full accent-[oklch(var(--accent))]" 523 - /> 524 - </label> 525 - 526 - <label className="flex min-h-11 items-center gap-2 sm:justify-self-end"> 527 - <span>Speed</span> 528 - <select 529 - value={playbackRate} 530 - onChange={onSpeedChange} 531 - className="h-11 rounded-lg border border-line/60 bg-surface/80 px-2 text-sm text-text" 532 - > 533 - <option value={0.5}>0.5x</option> 534 - <option value={1}>1x</option> 535 - <option value={1.25}>1.25x</option> 536 - <option value={1.5}>1.5x</option> 537 - <option value={2}>2x</option> 538 - </select> 539 - </label> 540 - </div> 541 411 542 412 <div className="flex flex-wrap items-center gap-3"> 543 413 {playlistUrl ? (