Init commit

This commit is contained in:
ShreejitPanchal
2026-05-19 19:56:02 +08:00
commit 6601501eff
4047 changed files with 2185372 additions and 0 deletions

View File

@@ -0,0 +1,541 @@
import { HttpStatusCode } from '@peertube/peertube-models'
import { logger } from '@server/helpers/logger.js'
import { buildRtspSinkUrlForTargetCamera } from '@server/lib/live-videos/rtsp-url.js'
import { LiveVideoModel, LiveVideoStatus } from '@server/models/video/live-video.js'
import { CONFIG } from '@server/initializers/config.js'
import { asyncMiddleware } from '@server/middlewares/async.js'
import express from 'express'
import { readFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { mkdir, rm } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { setTimeout as wait } from 'node:timers/promises'
import { ChildProcessWithoutNullStreams, spawn } from 'node:child_process'
import yaml from 'js-yaml'
type StreamConfig = {
streams?: Record<string, string | string[]>
}
type LiveSession = {
ffmpegProcess?: ChildProcessWithoutNullStreams
keepAliveUntil?: number
startedAt: number
lastAccessAt: number
startingPromise?: Promise<void>
restartTimer?: NodeJS.Timeout
lastError?: string
stopped?: boolean
}
const SESSION_IDLE_TIMEOUT_MS = 300_000
const SESSION_SWEEP_INTERVAL_MS = 20_000
const MANIFEST_WAIT_TIMEOUT_MS = 20_000
const MANIFEST_WAIT_INTERVAL_MS = 250
const LIVE_CONFIG_PATH = join(process.cwd(), 'livestream-sources.yaml')
const LIVE_WORKDIR = join(CONFIG.STORAGE.TMP_PERSISTENT_DIR, 'native-live')
const liveStreamRouter = express.Router()
const liveSessions = new Map<string, LiveSession>()
if(!CONFIG.LIVE.AUTO_START_FFMPEG_STREAMING) {
const sweepTimer = setInterval(cleanupInactiveSessions, SESSION_SWEEP_INTERVAL_MS)
sweepTimer.unref()
}
liveStreamRouter.get('/streams', asyncMiddleware(listStreams))
liveStreamRouter.get('/streams/:streamKey/index.m3u8', asyncMiddleware(getManifest))
liveStreamRouter.get('/streams/:streamKey/:segmentName', asyncMiddleware(getSegment))
liveStreamRouter.get('/scheduled-streams/:cameraId/index.m3u8', asyncMiddleware(getScheduledManifest))
liveStreamRouter.get('/scheduled-streams/:cameraId/:segmentName', asyncMiddleware(getScheduledSegment))
export { liveStreamRouter, initLiveStreams, startScheduledLiveSession, stopScheduledLiveSession }
async function listStreams (req: express.Request, res: express.Response) {
const streams = await readConfiguredStreams()
return res.json({
data: Object.keys(streams)
})
}
async function getManifest (req: express.Request, res: express.Response) {
const streamKey = sanitizeStreamKey(req.params.streamKey)
if (!streamKey) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Invalid stream key.'
})
}
const sourceUrl = await findStreamSourceUrl(streamKey)
if (!sourceUrl) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: `Unknown stream key "${streamKey}".`
})
}
return serveManifest(res, streamKey, sourceUrl)
}
async function getScheduledManifest (req: express.Request, res: express.Response) {
const cameraId = sanitizeStreamKey(req.params.cameraId)
if (!cameraId) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Invalid camera id.'
})
}
const activeLiveVideo = await loadActiveScheduledLiveVideo(cameraId)
if (!activeLiveVideo) {
await stopScheduledLiveSession(cameraId)
.catch(err => logger.error('Cannot stop stale scheduled sink stream session after expired manifest request.', { err, cameraId }))
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'No live stream is currently active for this camera.'
})
}
return serveManifest(res, buildScheduledStreamSessionKey(cameraId), buildRtspSinkUrlForTargetCamera(cameraId))
}
async function getSegment (req: express.Request, res: express.Response) {
const streamKey = sanitizeStreamKey(req.params.streamKey)
const segmentName = req.params.segmentName
if (!streamKey) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Invalid stream key.'
})
}
if (!segmentName || !/^segment_[0-9]{6}\.ts$/.test(segmentName)) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Invalid segment name.'
})
}
const sourceUrl = await findStreamSourceUrl(streamKey)
if (!sourceUrl) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: `Unknown stream key "${streamKey}".`
})
}
return serveSegment(res, streamKey, segmentName, sourceUrl)
}
async function getScheduledSegment (req: express.Request, res: express.Response) {
const cameraId = sanitizeStreamKey(req.params.cameraId)
const segmentName = req.params.segmentName
if (!cameraId) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Invalid camera id.'
})
}
if (!segmentName || !/^segment_[0-9]{6}\.ts$/.test(segmentName)) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Invalid segment name.'
})
}
const activeLiveVideo = await loadActiveScheduledLiveVideo(cameraId)
if (!activeLiveVideo) {
await stopScheduledLiveSession(cameraId)
.catch(err => logger.error('Cannot stop stale scheduled sink stream session after expired segment request.', { err, cameraId, segmentName }))
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'No live stream is currently active for this camera.'
})
}
return serveSegment(res, buildScheduledStreamSessionKey(cameraId), segmentName, buildRtspSinkUrlForTargetCamera(cameraId))
}
async function serveManifest (res: express.Response, streamKey: string, sourceUrl: string) {
const readyToServe = await ensureLiveSession(streamKey, sourceUrl)
if (!readyToServe) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'No live stream is currently active for this camera.'
})
}
const manifestPath = buildStreamPath(streamKey, 'index.m3u8')
const ready = await waitForFile(manifestPath, MANIFEST_WAIT_TIMEOUT_MS)
if (!ready) {
const session = liveSessions.get(streamKey)
return res.fail({
status: HttpStatusCode.SERVICE_UNAVAILABLE_503,
message: session?.lastError || 'Stream is starting, please retry in a few seconds.'
})
}
touchSession(streamKey)
res.setHeader('Cache-Control', 'public, max-age=1, must-revalidate')
return res.sendFile(resolve(manifestPath))
}
async function serveSegment (res: express.Response, streamKey: string, segmentName: string, sourceUrl: string) {
const readyToServe = await ensureLiveSession(streamKey, sourceUrl)
if (!readyToServe) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'No live stream is currently active for this camera.'
})
}
const segmentPath = buildStreamPath(streamKey, segmentName)
if (!existsSync(segmentPath)) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Segment not found.'
})
}
touchSession(streamKey)
res.setHeader('Cache-Control', 'public, max-age=30, immutable')
return res.sendFile(resolve(segmentPath))
}
async function readConfiguredStreams () {
if (!existsSync(LIVE_CONFIG_PATH)) {
return {}
}
const raw = await readFile(LIVE_CONFIG_PATH, 'utf-8')
const parsed = yaml.load(raw) as StreamConfig | undefined
return parsed?.streams || {}
}
async function findStreamSourceUrl (streamKey: string) {
const streams = await readConfiguredStreams()
const streamValue = streams[streamKey]
if (!streamValue) {
return buildRtspSinkUrlForTargetCamera(streamKey)
}
if (typeof streamValue === 'string') return streamValue
const firstRtsp = streamValue.find(v => typeof v === 'string' && v.startsWith('rtsp://'))
if (firstRtsp) return firstRtsp
return streamValue.find(v => typeof v === 'string') || buildRtspSinkUrlForTargetCamera(streamKey)
}
function sanitizeStreamKey (streamKey: string) {
if (!streamKey) return undefined
if (!/^[A-Za-z0-9._-]+$/.test(streamKey)) return undefined
return streamKey
}
function buildStreamPath (streamKey: string, fileName: string) {
return join(LIVE_WORKDIR, streamKey, fileName)
}
function buildScheduledStreamSessionKey (cameraId: string) {
return `scheduled-${cameraId}`
}
async function startScheduledLiveSession (cameraId: string, options: { keepAliveUntil?: Date | number } = {}) {
const streamKey = buildScheduledStreamSessionKey(cameraId)
const keepAliveUntil = normalizeKeepAliveUntil(options.keepAliveUntil)
return ensureLiveSession(streamKey, buildRtspSinkUrlForTargetCamera(cameraId), { keepAliveUntil })
}
async function stopScheduledLiveSession (cameraId: string) {
return stopLiveSession(buildScheduledStreamSessionKey(cameraId))
}
function touchSession (streamKey: string) {
const session = liveSessions.get(streamKey)
if (!session) return
session.lastAccessAt = Date.now()
}
async function ensureLiveSession (streamKey: string, sourceUrl: string, options: { keepAliveUntil?: number } = {}) {
let session = liveSessions.get(streamKey)
const now = Date.now()
if ((options.keepAliveUntil || 0) <= now && options.keepAliveUntil !== undefined) {
await stopLiveSession(streamKey)
return false
}
if (session && (session.keepAliveUntil || 0) <= now && session.keepAliveUntil !== undefined) {
await stopLiveSession(streamKey)
return false
}
if (!session) {
session = {
startedAt: 0,
lastAccessAt: Date.now(),
lastError: undefined,
stopped: false
}
liveSessions.set(streamKey, session)
}
session.stopped = false
clearRestartTimer(session)
if (options.keepAliveUntil) {
session.keepAliveUntil = Math.max(session.keepAliveUntil || 0, options.keepAliveUntil)
}
if (isChildProcessAlive(session.ffmpegProcess)) {
touchSession(streamKey)
return true
}
if (session.startingPromise) {
await session.startingPromise
touchSession(streamKey)
return true
}
session.startingPromise = startSession(streamKey, sourceUrl)
try {
await session.startingPromise
} finally {
session.startingPromise = undefined
}
touchSession(streamKey)
return true
}
async function startSession (streamKey: string, sourceUrl: string) {
const outputDirectory = join(LIVE_WORKDIR, streamKey)
await rm(outputDirectory, { recursive: true, force: true })
await mkdir(outputDirectory, { recursive: true })
const manifestPath = join(outputDirectory, 'index.m3u8')
const segmentPattern = join(outputDirectory, 'segment_%06d.ts')
const ffmpegPath = CONFIG.LIVE.FFMPEG.PATH?.trim() || 'ffmpeg'
const segmentDuration = Math.max(1, CONFIG.LIVE.FFMPEG.HLS_TIME)
const hlsListSize = Math.max(1, CONFIG.LIVE.FFMPEG.HLS_LIST_SIZE)
const ffmpegArgs = [
'-hide_banner',
'-loglevel', 'warning',
'-fflags', '+genpts+igndts+discardcorrupt',
'-rtsp_transport', 'tcp',
'-thread_queue_size', '512',
'-i', sourceUrl,
'-map', '0:v:0',
'-map', '0:a:0?',
'-c', 'copy',
'-f', 'hls',
'-hls_time', String(segmentDuration),
'-hls_list_size', String(hlsListSize),
'-hls_delete_threshold', '2',
'-hls_flags', 'delete_segments+independent_segments+omit_endlist',
'-hls_segment_filename', segmentPattern,
manifestPath
]
const ffmpegProcess = spawn(ffmpegPath, ffmpegArgs, {
stdio: [ 'ignore', 'ignore', 'pipe' ]
})
const session = liveSessions.get(streamKey)
if (!session || session.stopped) {
ffmpegProcess.kill('SIGTERM')
return
}
session.ffmpegProcess = ffmpegProcess
session.startedAt = Date.now()
session.lastAccessAt = Date.now()
session.lastError = undefined
let stderrBuffer = ''
ffmpegProcess.stderr.on('data', chunk => {
// Keep pipe drained to avoid stalling ffmpeg and keep last lines for diagnostics.
const line = chunk?.toString?.() || ''
stderrBuffer = (stderrBuffer + line).slice(-4_000)
})
ffmpegProcess.once('error', err => {
logger.error('Cannot start ffmpeg live session', { err, streamKey })
const existing = liveSessions.get(streamKey)
if (!existing) return
existing.lastError = `Cannot start ffmpeg: ${err.message}`
})
ffmpegProcess.once('exit', () => {
const existing = liveSessions.get(streamKey)
if (!existing) return
if (existing.ffmpegProcess !== ffmpegProcess) return
existing.ffmpegProcess = undefined
existing.startedAt = 0
const trimmedError = stderrBuffer.trim()
if (trimmedError) {
const lines = trimmedError.split('\n')
existing.lastError = lines.slice(-3).join(' | ')
}
const shouldRestart = CONFIG.LIVE.AUTO_START_FFMPEG_STREAMING ||
((existing.keepAliveUntil || 0) > Date.now())
if (shouldRestart && !existing.stopped) {
const keepAliveUntil = existing.keepAliveUntil
clearRestartTimer(existing)
existing.restartTimer = setTimeout(() => {
const current = liveSessions.get(streamKey)
if (current !== existing || current?.stopped) return
void ensureLiveSession(streamKey, sourceUrl, { keepAliveUntil })
}, 3_000)
}
})
}
async function stopLiveSession (streamKey: string) {
const session = liveSessions.get(streamKey)
if (!session) return false
session.stopped = true
session.keepAliveUntil = undefined
session.startingPromise = undefined
clearRestartTimer(session)
if (session.ffmpegProcess) {
stopSessionProcess(session.ffmpegProcess, streamKey)
}
liveSessions.delete(streamKey)
return true
}
async function waitForFile (filePath: string, timeoutMs: number) {
const end = Date.now() + timeoutMs
while (Date.now() < end) {
if (existsSync(filePath)) return true
await wait(MANIFEST_WAIT_INTERVAL_MS)
}
return false
}
async function initLiveStreams () {
const streams = await readConfiguredStreams()
for (const streamKey of Object.keys(streams)) {
const sourceUrl = await findStreamSourceUrl(streamKey)
if (sourceUrl) {
try {
await ensureLiveSession(streamKey, sourceUrl)
} catch (err) {
logger.warn('Failed to start live stream %s', streamKey, { err })
}
}
}
}
function cleanupInactiveSessions () {
const now = Date.now()
for (const [ streamKey, session ] of liveSessions.entries()) {
if ((session.keepAliveUntil || 0) > now) {
continue
}
if (!isChildProcessAlive(session.ffmpegProcess)) {
liveSessions.delete(streamKey)
continue
}
if (now - session.lastAccessAt < SESSION_IDLE_TIMEOUT_MS) continue
stopSessionProcess(session.ffmpegProcess, streamKey)
liveSessions.delete(streamKey)
}
}
function normalizeKeepAliveUntil (keepAliveUntil?: Date | number) {
if (keepAliveUntil instanceof Date) return keepAliveUntil.getTime()
if (typeof keepAliveUntil === 'number' && Number.isFinite(keepAliveUntil)) return keepAliveUntil
return undefined
}
function clearRestartTimer (session: LiveSession) {
if (!session.restartTimer) return
clearTimeout(session.restartTimer)
session.restartTimer = undefined
}
function isChildProcessAlive (childProcess?: ChildProcessWithoutNullStreams) {
if (!childProcess?.pid) return false
if (childProcess.exitCode !== null || childProcess.signalCode !== null) return false
try {
process.kill(childProcess.pid, 0)
return true
} catch {
return false
}
}
function stopSessionProcess (childProcess: ChildProcessWithoutNullStreams, streamKey: string) {
const pid = childProcess.pid
if (!pid || !isChildProcessAlive(childProcess)) return
childProcess.kill('SIGTERM')
const forceKillTimer = setTimeout(() => {
if (!isChildProcessAlive(childProcess)) return
logger.warn('Force killing stale live session ffmpeg process for %s.', streamKey, { pid })
try {
process.kill(pid, 'SIGKILL')
} catch (err) {
logger.warn('Cannot force kill live session ffmpeg process for %s.', streamKey, { err, pid })
}
}, 5_000)
forceKillTimer.unref()
}
async function loadActiveScheduledLiveVideo (cameraId: string) {
const now = new Date()
const liveVideos = await LiveVideoModel.listForStarting({
startBefore: now,
endAfter: now
})
return liveVideos.find(liveVideo =>
liveVideo.targetCamera === cameraId &&
liveVideo.status === LiveVideoStatus.SCHEDULED
)
}