mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-11 22:40:32 +01:00
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:
@@ -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")
|
||||
|
||||
@@ -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")({
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user