184 lines
6.3 KiB
TypeScript
184 lines
6.3 KiB
TypeScript
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
|
import { stopScheduledLiveSession } from '@server/controllers/api/live-stream.js'
|
|
import { CONFIG } from '@server/initializers/config.js'
|
|
import { PeerTubeSocket } from '@server/lib/peertube-socket.js'
|
|
import { LiveVideoModel, LiveVideoStatus } from '@server/models/video/live-video.js'
|
|
import { getLiveVideoPlaybackInputPath } from './preprocessed-video.js'
|
|
import { buildRtspSourceUrlForTargetCamera } from './rtsp-url.js'
|
|
import { ChildProcessWithoutNullStreams, spawn } from 'node:child_process'
|
|
import { existsSync } from 'node:fs'
|
|
|
|
type StopReason = 'end-time' | 'cancelled' | 'deleted' | 'manual' | 'shutdown'
|
|
|
|
type RunningProcess = {
|
|
endTimer?: NodeJS.Timeout
|
|
ffmpegProcess: ChildProcessWithoutNullStreams
|
|
liveVideoId: number
|
|
stderrBuffer: string
|
|
stopReason?: StopReason
|
|
targetCamera: string
|
|
}
|
|
|
|
const lTags = loggerTagsFactory('live-videos', 'process-manager')
|
|
|
|
export class LiveVideoProcessManager {
|
|
private static instance: LiveVideoProcessManager
|
|
|
|
private readonly activeByLiveVideoId = new Map<number, RunningProcess>()
|
|
private readonly liveVideoIdByTargetCamera = new Map<string, number>()
|
|
|
|
async start (liveVideo: LiveVideoModel) {
|
|
const existing = this.activeByLiveVideoId.get(liveVideo.id)
|
|
if (existing) return false
|
|
|
|
const otherLiveVideoId = this.liveVideoIdByTargetCamera.get(liveVideo.targetCamera)
|
|
if (otherLiveVideoId && otherLiveVideoId !== liveVideo.id) {
|
|
throw new Error(`Target camera ${liveVideo.targetCamera} is already streaming another live video.`)
|
|
}
|
|
|
|
const inputPath = getLiveVideoPlaybackInputPath(liveVideo.videoStoragePath)
|
|
if (!existsSync(inputPath)) {
|
|
throw new Error(`Input file does not exist: ${inputPath}`)
|
|
}
|
|
|
|
const ffmpegPath = CONFIG.LIVE.FFMPEG.PATH?.trim() || 'ffmpeg'
|
|
const rtspTargetUrl = buildRtspSourceUrlForTargetCamera(liveVideo.targetCamera)
|
|
const ffmpegArgs = [
|
|
'-re',
|
|
'-i', inputPath,
|
|
'-vf', 'scale=-2:\'min(480,ih)\'',
|
|
'-c:v', 'libx264',
|
|
'-preset', 'ultrafast',
|
|
'-tune', 'zerolatency',
|
|
'-b:v', '2500k',
|
|
'-maxrate', '2800k',
|
|
'-bufsize', '5000k',
|
|
'-profile:v', 'main',
|
|
'-force_key_frames', 'expr:gte(t,n_forced*1)',
|
|
'-c:a', 'aac',
|
|
'-b:a', '128k',
|
|
'-f', 'rtsp',
|
|
'-rtsp_transport', 'tcp',
|
|
rtspTargetUrl
|
|
]
|
|
|
|
logger.info('Starting scheduled live video %d on camera %s.', liveVideo.id, liveVideo.targetCamera, {
|
|
inputPath,
|
|
usesPreprocessedInput: inputPath !== liveVideo.videoStoragePath,
|
|
...lTags()
|
|
})
|
|
const ffmpegProcess = spawn(ffmpegPath, ffmpegArgs, {
|
|
stdio: [ 'ignore', 'ignore', 'pipe' ]
|
|
})
|
|
|
|
const runningProcess: RunningProcess = {
|
|
ffmpegProcess,
|
|
liveVideoId: liveVideo.id,
|
|
stderrBuffer: '',
|
|
targetCamera: liveVideo.targetCamera
|
|
}
|
|
|
|
this.activeByLiveVideoId.set(liveVideo.id, runningProcess)
|
|
this.liveVideoIdByTargetCamera.set(liveVideo.targetCamera, liveVideo.id)
|
|
|
|
ffmpegProcess.stderr.on('data', chunk => {
|
|
const line = chunk?.toString?.() || ''
|
|
runningProcess.stderrBuffer = (runningProcess.stderrBuffer + line).slice(-4_000)
|
|
})
|
|
|
|
ffmpegProcess.once('error', err => {
|
|
logger.error('Cannot start ffmpeg for scheduled live video %d.', liveVideo.id, { err, ...lTags() })
|
|
})
|
|
|
|
ffmpegProcess.once('exit', (code, signal) => {
|
|
void this.onProcessExit(runningProcess, code, signal)
|
|
})
|
|
|
|
const stopDelay = Math.max(0, liveVideo.endTime.getTime() - Date.now())
|
|
runningProcess.endTimer = setTimeout(() => {
|
|
void this.stop(liveVideo.id, 'end-time')
|
|
}, stopDelay)
|
|
|
|
return true
|
|
}
|
|
|
|
async stop (liveVideoId: number, reason: StopReason) {
|
|
const runningProcess = this.activeByLiveVideoId.get(liveVideoId)
|
|
if (!runningProcess) return false
|
|
|
|
runningProcess.stopReason = reason
|
|
|
|
if (runningProcess.endTimer) {
|
|
clearTimeout(runningProcess.endTimer)
|
|
runningProcess.endTimer = undefined
|
|
}
|
|
|
|
if (!runningProcess.ffmpegProcess.killed) {
|
|
runningProcess.ffmpegProcess.kill('SIGTERM')
|
|
|
|
setTimeout(() => {
|
|
const existing = this.activeByLiveVideoId.get(liveVideoId)
|
|
if (!existing || existing.ffmpegProcess.killed) return
|
|
|
|
existing.ffmpegProcess.kill('SIGKILL')
|
|
}, 5_000)
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
async stopAll () {
|
|
await Promise.all(Array.from(this.activeByLiveVideoId.keys()).map(id => this.stop(id, 'shutdown')))
|
|
}
|
|
|
|
private async onProcessExit (runningProcess: RunningProcess, code: number | null, signal: NodeJS.Signals | null) {
|
|
if (runningProcess.endTimer) {
|
|
clearTimeout(runningProcess.endTimer)
|
|
runningProcess.endTimer = undefined
|
|
}
|
|
|
|
this.activeByLiveVideoId.delete(runningProcess.liveVideoId)
|
|
this.liveVideoIdByTargetCamera.delete(runningProcess.targetCamera)
|
|
|
|
const stderr = runningProcess.stderrBuffer.trim()
|
|
if (code !== 0 && runningProcess.stopReason === undefined) {
|
|
logger.error('Scheduled live video %d ffmpeg exited with code %s and signal %s.', runningProcess.liveVideoId, code, signal, {
|
|
stderr,
|
|
...lTags()
|
|
})
|
|
} else {
|
|
logger.info('Scheduled live video %d ffmpeg exited.', runningProcess.liveVideoId, {
|
|
code,
|
|
signal,
|
|
reason: runningProcess.stopReason,
|
|
...lTags()
|
|
})
|
|
}
|
|
|
|
const liveVideo = await LiveVideoModel.loadById(runningProcess.liveVideoId)
|
|
const targetCamera = runningProcess.targetCamera
|
|
|
|
await stopScheduledLiveSession(targetCamera)
|
|
.catch(err => logger.error('Cannot stop scheduled sink stream session after ffmpeg exit.', Object.assign({ err, liveVideoId: runningProcess.liveVideoId, targetCamera }, lTags())))
|
|
|
|
if (!liveVideo) return
|
|
if (liveVideo.status !== LiveVideoStatus.SCHEDULED) return
|
|
if (code !== 0 && runningProcess.stopReason === undefined) return
|
|
if (runningProcess.stopReason && runningProcess.stopReason !== 'end-time') return
|
|
|
|
await liveVideo.update({
|
|
status: LiveVideoStatus.COMPLETED
|
|
})
|
|
|
|
await PeerTubeSocket.Instance.closeLiveChatRoom(targetCamera, 'live-ended')
|
|
.catch(err => logger.error(
|
|
'Cannot reset live chat room after scheduled live video completion.',
|
|
Object.assign({ err, liveVideoId: runningProcess.liveVideoId, targetCamera }, lTags())
|
|
))
|
|
}
|
|
|
|
static get Instance () {
|
|
return this.instance || (this.instance = new this())
|
|
}
|
|
}
|