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.

fix: improve deep-link playback and ionosphere linking

jack 4c962c27 6d8169e5

+77 -6
+59 -1
scripts/generate-video-embeddings.mjs
··· 17 17 ) 18 18 const TAXONOMY_PATH = path.resolve(process.cwd(), 'src/lib/video-taxonomy.json') 19 19 const IONOSPHERE_DID = 'did:plc:lkeq4oghyhnztbu4dxr3joff' 20 + const ATMOSPHERE_REPO_DID = 'did:plc:rbvrr34edl5ddpuwcubjiost' 20 21 const IONOSPHERE_EVENT_URI = 21 22 'at://did:plc:lkeq4oghyhnztbu4dxr3joff/tv.ionosphere.event/atmosphereconf-2026' 22 23 const EMBEDDING_BATCH_SIZE = Number.parseInt(process.env.EMBEDDING_BATCH_SIZE ?? '64', 10) ··· 27 28 28 29 function normalizeWhitespace(value) { 29 30 return value.replace(/\s+/g, ' ').trim() 31 + } 32 + 33 + function normalizeTitle(value) { 34 + return String(value ?? '') 35 + .toLowerCase() 36 + .replace(/[^a-z0-9\s]/g, ' ') 37 + .replace(/\s+/g, ' ') 38 + .trim() 39 + } 40 + 41 + function titleScore(left, right) { 42 + if (!left || !right) { 43 + return 0 44 + } 45 + 46 + if (left === right) { 47 + return 1 48 + } 49 + 50 + if (left.includes(right) || right.includes(left)) { 51 + return 0.86 52 + } 53 + 54 + const leftWords = new Set(left.split(' ')) 55 + const rightWords = new Set(right.split(' ')) 56 + const overlap = [...leftWords].filter((word) => rightWords.has(word)).length 57 + return overlap / Math.max(leftWords.size, rightWords.size, 1) 58 + } 59 + 60 + function pickBestIonosphereTalkByTitle(videoTitle, ionosphereTalks) { 61 + const normalizedVideoTitle = normalizeTitle(videoTitle) 62 + let best = undefined 63 + let bestScore = 0 64 + 65 + for (const talk of ionosphereTalks) { 66 + const normalizedTalkTitle = normalizeTitle(talk?.value?.title) 67 + const score = titleScore(normalizedVideoTitle, normalizedTalkTitle) 68 + if (score > bestScore) { 69 + best = talk 70 + bestScore = score 71 + } 72 + } 73 + 74 + return bestScore >= 0.9 ? best : undefined 30 75 } 31 76 32 77 function parseEnvFile(content) { ··· 342 387 console.log(`Loaded ${ionosphereTalks.length} Atmosphere talks from ionosphere`) 343 388 344 389 const allRecords = [] 390 + let fallbackTitleMatchCount = 0 345 391 for (const repoDid of repoDids) { 346 392 try { 347 393 const records = await fetchAllRecordsForRepo(repoDid) 348 394 console.log(`Fetched ${records.length} videos from ${repoDid}`) 349 395 for (const record of records) { 350 - const talkUri = talkUriByVideoUri.get(record.uri) 396 + const directTalkUri = talkUriByVideoUri.get(record.uri) 397 + const fallbackTalk = 398 + !directTalkUri && repoDid === ATMOSPHERE_REPO_DID 399 + ? pickBestIonosphereTalkByTitle(record?.value?.title, ionosphereTalks) 400 + : undefined 401 + const talkUri = directTalkUri ?? fallbackTalk?.uri 402 + if (!directTalkUri && fallbackTalk) { 403 + fallbackTitleMatchCount += 1 404 + } 351 405 allRecords.push({ 352 406 ...record, 353 407 sourceRepoDid: repoDid, ··· 363 417 364 418 if (allRecords.length === 0) { 365 419 throw new Error('No video records available for embedding generation') 420 + } 421 + 422 + if (fallbackTitleMatchCount > 0) { 423 + console.log(`Applied ${fallbackTitleMatchCount} fallback title matches for ionosphere talk linking`) 366 424 } 367 425 368 426 const existing = await loadExistingIndex()
+12 -4
src/lib/api.ts
··· 314 314 return talk.sourceRepoDid === ATMOSPHERE_REPO_DID 315 315 } 316 316 317 - function parseVideoUri(uri: string): { did: string } | null { 318 - const match = uri.match(/^at:\/\/(did:[^/]+)\/place\.stream\.video\/[^/]+$/) 317 + function parseVideoUri(uri: string): { did: string; rkey: string } | null { 318 + const match = uri.match(/^at:\/\/(did:[^/]+)\/place\.stream\.video\/([^/]+)$/) 319 319 if (!match) { 320 320 return null 321 321 } 322 322 323 - return { did: match[1] } 323 + return { 324 + did: match[1], 325 + rkey: match[2], 326 + } 324 327 } 325 328 326 329 async function toAppTalkFromRecord(record: GetRecordResponse): Promise<AppTalk> { ··· 361 364 } 362 365 363 366 const pdsUrl = await resolvePdsUrl(uriInfo.did) 364 - const query = new URLSearchParams({ uri }) 367 + const query = new URLSearchParams({ 368 + repo: uriInfo.did, 369 + collection: STREAMPLACE_VIDEO_COLLECTION, 370 + rkey: uriInfo.rkey, 371 + uri, 372 + }) 365 373 const record = await fetchJson<GetRecordResponse>( 366 374 `${pdsUrl}/xrpc/com.atproto.repo.getRecord?${query.toString()}`, 367 375 )
+6 -1
src/lib/ionosphere.ts
··· 218 218 const ionosphereTalks = asIonosphereTalks(talksRaw).filter( 219 219 (record) => record.value.eventUri === IONOSPHERE_EVENT_URI, 220 220 ) 221 + const unmatchedTalksByTitle = ionosphereTalks.filter( 222 + (record) => typeof record.value.videoUri === 'string' && !talks.some((talk) => talk.uri === record.value.videoUri), 223 + ) 221 224 const talkByVideoUri = new Map( 222 225 ionosphereTalks 223 226 .filter((record) => typeof record.value.videoUri === 'string' && Boolean(record.value.videoUri)) ··· 258 261 const allTopics = new Set<string>() 259 262 260 263 for (const vodTalk of talks) { 261 - const match = talkByVideoUri.get(vodTalk.uri) ?? pickBestTalkByTitle(vodTalk, ionosphereTalks) 264 + const directMatch = talkByVideoUri.get(vodTalk.uri) 265 + const titleFallbackPool = directMatch ? ionosphereTalks : unmatchedTalksByTitle 266 + const match = directMatch ?? pickBestTalkByTitle(vodTalk, titleFallbackPool) 262 267 if (!match) { 263 268 continue 264 269 }