diff --git a/packages/web/src/routes/api/stream-recording.ts b/packages/web/src/routes/api/stream-recording.ts index f0f81c6b..290c42c0 100644 --- a/packages/web/src/routes/api/stream-recording.ts +++ b/packages/web/src/routes/api/stream-recording.ts @@ -177,6 +177,43 @@ const endRecording = async ({ request }: { request: Request }) => { } } +// GET /api/stream-recording/chunk - Get a specific chunk file +const getChunk = async ({ request }: { request: Request }) => { + try { + const url = new URL(request.url) + const streamId = url.searchParams.get("streamId") + const index = url.searchParams.get("index") + + if (!streamId || index === null) { + return new Response( + JSON.stringify({ error: "Missing streamId or index" }), + { status: 400, headers: { "content-type": "application/json" } } + ) + } + + const chunkPath = `${STORAGE_PATH}/${streamId}/chunk-${String(index).padStart(6, "0")}.bin` + + try { + const chunkData = await fs.readFile(chunkPath) + return new Response(chunkData, { + status: 200, + headers: { "content-type": "application/octet-stream" } + }) + } catch (err) { + return new Response( + JSON.stringify({ error: "Chunk not found" }), + { status: 404, headers: { "content-type": "application/json" } } + ) + } + } catch (error) { + console.error("[stream-recording] Get chunk error:", error) + return new Response( + JSON.stringify({ error: "Internal server error" }), + { status: 500, headers: { "content-type": "application/json" } } + ) + } +} + // GET /api/stream-recording/list - List all recordings const listRecordings = async ({ request }: { request: Request }) => { try { @@ -212,7 +249,15 @@ const listRecordings = async ({ request }: { request: Request }) => { export const Route = createFileRoute("/api/stream-recording")({ server: { handlers: { - GET: listRecordings, + GET: (ctx) => { + const url = new URL(ctx.request.url) + // If streamId and index are provided, return chunk data + if (url.searchParams.has("streamId") && url.searchParams.has("index")) { + return getChunk(ctx) + } + // Otherwise list recordings + return listRecordings(ctx) + }, POST: (ctx) => { const url = new URL(ctx.request.url) const action = url.searchParams.get("action") diff --git a/packages/web/src/routes/api/streams.$username.check-hls.ts b/packages/web/src/routes/api/streams.$username.check-hls.ts index ef69d869..cd33d195 100644 --- a/packages/web/src/routes/api/streams.$username.check-hls.ts +++ b/packages/web/src/routes/api/streams.$username.check-hls.ts @@ -47,12 +47,68 @@ const resolveEnvCloudflareHlsUrl = (): string | null => { function isHlsPlaylistLive(manifest: string): boolean { const upper = manifest.toUpperCase() + + // Basic validation + const isValidManifest = upper.includes("#EXTM3U") + if (!isValidManifest) return false + + // Master playlists are always "live" in the sense they redirect to variants + const isMasterPlaylist = upper.includes("#EXT-X-STREAM-INF") + if (isMasterPlaylist) return true + + // Check for obvious VOD markers const hasEndlist = upper.includes("#EXT-X-ENDLIST") const isVod = upper.includes("#EXT-X-PLAYLIST-TYPE:VOD") + if (hasEndlist || isVod) return false + + // Must have segments const hasSegments = upper.includes("#EXTINF") || upper.includes("#EXT-X-PART") - const isValidManifest = upper.includes("#EXTM3U") - const isMasterPlaylist = upper.includes("#EXT-X-STREAM-INF") - return isValidManifest && (isMasterPlaylist || (!hasEndlist && !isVod && hasSegments)) + if (!hasSegments) return false + + // CRITICAL: Check for segment freshness + // Extract #EXT-X-PROGRAM-DATE-TIME tags which indicate segment timestamps + const programDateTimeMatches = manifest.match(/#EXT-X-PROGRAM-DATE-TIME:([^\n]+)/gi) + + if (programDateTimeMatches && programDateTimeMatches.length > 0) { + // Get the most recent timestamp + const lastTimestamp = programDateTimeMatches[programDateTimeMatches.length - 1] + .replace(/#EXT-X-PROGRAM-DATE-TIME:/i, '') + .trim() + + try { + const segmentDate = new Date(lastTimestamp) + const now = new Date() + const ageSeconds = (now.getTime() - segmentDate.getTime()) / 1000 + + // Only consider live if last segment is less than 60 seconds old + // This prevents showing old recordings as "live" + if (ageSeconds > 60) { + console.log(`[check-hls] Segment too old: ${ageSeconds}s ago - NOT LIVE`) + return false + } + + console.log(`[check-hls] Fresh segment: ${ageSeconds}s ago - LIVE`) + return true + } catch (err) { + console.error(`[check-hls] Failed to parse timestamp: ${lastTimestamp}`, err) + } + } + + // If no program-date-time tags, check for media sequence + // A live stream should have a non-zero media sequence + const mediaSequenceMatch = manifest.match(/#EXT-X-MEDIA-SEQUENCE:(\d+)/i) + if (mediaSequenceMatch) { + const sequence = parseInt(mediaSequenceMatch[1], 10) + // If media sequence is incrementing, it's likely live + // But without timestamps, we can't be sure it's not an old recording + console.log(`[check-hls] Media sequence: ${sequence}, no timestamps - assuming NOT LIVE (safety)`) + return false + } + + // If we get here, manifest looks like a live stream but has no timestamps + // Be conservative and return false to avoid showing old streams + console.log(`[check-hls] No timestamp markers - assuming NOT LIVE (safety)`) + return false } export const Route = createFileRoute("/api/streams/$username/check-hls")({ diff --git a/packages/web/src/routes/streams.tsx b/packages/web/src/routes/streams.tsx index 30c8fa53..9e1efbd3 100644 --- a/packages/web/src/routes/streams.tsx +++ b/packages/web/src/routes/streams.tsx @@ -103,9 +103,8 @@ function StreamsPage() { // Fetch and push chunks for (const chunk of rec.chunks) { try { - const chunkPath = `/Users/nikiv/fork-i/garden-co/jazz/glide-storage/stream-recordings/${rec.streamId}/chunk-${String(chunk.index).padStart(6, "0")}.bin` const chunkData = await fetch( - `/api/stream-recording/chunk?path=${encodeURIComponent(chunkPath)}` + `/api/stream-recording/chunk?streamId=${encodeURIComponent(rec.streamId)}&index=${chunk.index}` ).then((r) => r.arrayBuffer()) fileStream.push(new Uint8Array(chunkData))