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