Implement chunk retrieval endpoint and enhance HLS live stream detection logic

- Add `getChunk` handler to serve specific recording chunks based on `streamId` and `index` query params
- Modify route handler to dispatch requests to either list recordings or fetch chunks
- Improve `isHlsPlaylistLive` function with:
  - Basic validation for manifest structure
  - Detection of master playlists and VOD markers
  - Segment presence check
  - Timestamp freshness validation via `#EXT-X-PROGRAM-DATE-TIME`
  - Conservative fallback using media sequence when timestamps are absent
This commit is contained in:
Nikita
2025-12-25 10:37:57 -08:00
parent 9b0026b8d4
commit 2be1e74e3b
3 changed files with 106 additions and 6 deletions

View File

@@ -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 // GET /api/stream-recording/list - List all recordings
const listRecordings = async ({ request }: { request: Request }) => { const listRecordings = async ({ request }: { request: Request }) => {
try { try {
@@ -212,7 +249,15 @@ const listRecordings = async ({ request }: { request: Request }) => {
export const Route = createFileRoute("/api/stream-recording")({ export const Route = createFileRoute("/api/stream-recording")({
server: { server: {
handlers: { 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) => { POST: (ctx) => {
const url = new URL(ctx.request.url) const url = new URL(ctx.request.url)
const action = url.searchParams.get("action") const action = url.searchParams.get("action")

View File

@@ -47,12 +47,68 @@ const resolveEnvCloudflareHlsUrl = (): string | null => {
function isHlsPlaylistLive(manifest: string): boolean { function isHlsPlaylistLive(manifest: string): boolean {
const upper = manifest.toUpperCase() 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 hasEndlist = upper.includes("#EXT-X-ENDLIST")
const isVod = upper.includes("#EXT-X-PLAYLIST-TYPE:VOD") 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 hasSegments = upper.includes("#EXTINF") || upper.includes("#EXT-X-PART")
const isValidManifest = upper.includes("#EXTM3U") if (!hasSegments) return false
const isMasterPlaylist = upper.includes("#EXT-X-STREAM-INF")
return isValidManifest && (isMasterPlaylist || (!hasEndlist && !isVod && hasSegments)) // 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")({ export const Route = createFileRoute("/api/streams/$username/check-hls")({

View File

@@ -103,9 +103,8 @@ function StreamsPage() {
// Fetch and push chunks // Fetch and push chunks
for (const chunk of rec.chunks) { for (const chunk of rec.chunks) {
try { 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( 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()) ).then((r) => r.arrayBuffer())
fileStream.push(new Uint8Array(chunkData)) fileStream.push(new Uint8Array(chunkData))