Files
SolTube/server/core/controllers/api/live-videos.ts
ShreejitPanchal 6601501eff Init commit
2026-05-19 19:56:02 +08:00

404 lines
15 KiB
TypeScript

import { HttpStatusCode } from '@peertube/peertube-models'
import { stopScheduledLiveSession } from './live-stream.js'
import { tryAtomicMove } from '@server/helpers/fs.js'
import { createReqFiles } from '@server/helpers/express-utils.js'
import { generateImageFilename } from '@server/helpers/image-utils.js'
import { getFormattedObjects } from '@server/helpers/utils.js'
import { logger } from '@server/helpers/logger.js'
import { createHash } from 'crypto'
import { CONFIG } from '@server/initializers/config.js'
import { MIMETYPES, THUMBNAILS_SIZE } from '@server/initializers/constants.js'
import { sequelizeTypescript } from '@server/initializers/database.js'
import { removeLiveVideoStartJob, syncLiveVideoStartJob } from '@server/lib/live-videos/live-video-jobs.js'
import { LiveVideoProcessManager } from '@server/lib/live-videos/live-video-process-manager.js'
import {
getLiveVideoBurnedPath,
getLiveVideoSubtitlePath,
syncPreprocessedLiveVideo
} from '@server/lib/live-videos/preprocessed-video.js'
import { PeerTubeSocket } from '@server/lib/peertube-socket.js'
import { processImageFromWorker } from '@server/lib/worker/parent-process.js'
import {
apiRateLimiter,
asyncMiddleware,
authenticate,
paginationValidator,
setDefaultPagination
} from '@server/middlewares/index.js'
import {
createLiveVideoValidator,
deleteLiveVideoValidator,
getLiveVideoValidator,
listLiveVideosValidator,
updateLiveVideoValidator
} from '@server/middlewares/validators/live-videos.js'
import { LiveVideoModel, LiveVideoStatus } from '@server/models/video/live-video.js'
import express from 'express'
import { ensureDir, remove } from 'fs-extra/esm'
import { readFile } from 'fs/promises'
import { basename, extname, join } from 'node:path'
const liveVideosRouter = express.Router()
const reqLiveVideoFiles = createReqFiles(
[ 'thumbnailfile', 'videofile', 'subtitlefile' ],
{ ...MIMETYPES.IMAGE.MIMETYPE_EXT, ...MIMETYPES.VIDEO.MIMETYPE_EXT, ...MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT }
)
const LIVE_VIDEOS_STORAGE_DIR = join(CONFIG.STORAGE.TMP_PERSISTENT_DIR, 'live-videos')
const SPECIAL_THUMBNAILS_TMP_DIR = join(CONFIG.STORAGE.TMP_DIR, 'special-thumbnails')
liveVideosRouter.use(apiRateLimiter)
liveVideosRouter.get(
'/',
paginationValidator,
setDefaultPagination,
listLiveVideosValidator,
asyncMiddleware(listLiveVideos)
)
liveVideosRouter.get(
'/:id',
getLiveVideoValidator,
asyncMiddleware(getLiveVideo)
)
liveVideosRouter.post(
'/',
authenticate,
reqLiveVideoFiles,
createLiveVideoValidator,
asyncMiddleware(createLiveVideo)
)
liveVideosRouter.put(
'/:id',
authenticate,
reqLiveVideoFiles,
updateLiveVideoValidator,
asyncMiddleware(updateLiveVideo)
)
liveVideosRouter.delete(
'/:id',
authenticate,
deleteLiveVideoValidator,
asyncMiddleware(deleteLiveVideo)
)
export {
liveVideosRouter
}
async function listLiveVideos (req: express.Request, res: express.Response) {
const result = await LiveVideoModel.listForApi({
start: req.query.start as number,
count: req.query.count as number,
targetCamera: req.query.targetCamera as string,
status: req.query.status as string
})
const formatted = getFormattedObjects(result.rows, result.count)
formatted.data = formatted.data.map(v => attachSubtitlePath(v))
return res.json(formatted)
}
async function getLiveVideo (req: express.Request, res: express.Response) {
const liveVideo = (res.locals as any).liveVideo as LiveVideoModel
const json = liveVideo.toFormattedJSON()
return res.json(attachSubtitlePath(json))
}
async function createLiveVideo (req: express.Request, res: express.Response) {
const files = req.files as { [fieldname: string]: Express.Multer.File[] }
const thumbnailFile = files.thumbnailfile?.[0]
const videoFile = files.videofile?.[0]
const subtitleFile = files.subtitlefile?.[0]
const user = res.locals.oauth.token.User
const liveVideo = await sequelizeTypescript.transaction(async t => {
const thumbnail = thumbnailFile
? await saveThumbnailToTemporaryFile(thumbnailFile.path)
: null
const videoStoragePath = await storeVideoFile(videoFile)
if (subtitleFile) {
await storeSubtitleFile(subtitleFile, videoStoragePath)
}
await syncPreprocessedLiveVideo(videoStoragePath)
const created = await LiveVideoModel.create({
title: req.body.title,
thumbnailFilename: thumbnail?.filename || null,
thumbnailData: thumbnail?.data || null,
thumbnailMimeType: thumbnail?.mimeType || null,
thumbnailSize: thumbnail?.size || null,
thumbnailEtag: thumbnail?.etag || null,
videoOriginalName: basename(videoFile.originalname),
videoStoragePath,
videoMimeType: videoFile.mimetype,
videoSize: videoFile.size,
startTime: new Date(req.body.startTime),
endTime: new Date(req.body.endTime),
targetCamera: req.body.targetCamera,
status: LiveVideoStatus.SCHEDULED,
createdByUserId: user.id
}, {
transaction: t
})
return created
})
await syncLiveVideoStartJob(liveVideo, { replace: true })
.catch(err => logger.error('Cannot sync scheduled live video start job after creation.', { err, liveVideoId: liveVideo.id }))
await PeerTubeSocket.Instance.closeLiveChatRoom(liveVideo.targetCamera, 'rescheduled')
.catch(err => logger.error('Cannot reset live chat room after live video creation.', { err, liveVideoId: liveVideo.id, targetCamera: liveVideo.targetCamera }))
const json = liveVideo.toFormattedJSON()
return res.status(HttpStatusCode.CREATED_201).json(attachSubtitlePath(json))
}
async function updateLiveVideo (req: express.Request, res: express.Response) {
const liveVideo = (res.locals as any).liveVideo as LiveVideoModel
const previousTargetCamera = liveVideo.targetCamera
const previousStartTime = liveVideo.startTime.getTime()
const previousEndTime = liveVideo.endTime.getTime()
const previousStatus = liveVideo.status
const files = req.files as { [fieldname: string]: Express.Multer.File[] } | undefined
const thumbnailFile = files?.thumbnailfile?.[0]
const videoFile = files?.videofile?.[0]
const subtitleFile = files?.subtitlefile?.[0]
const updatedLiveVideo = await sequelizeTypescript.transaction(async t => {
let thumbnailFilename = liveVideo.thumbnailFilename
let videoStoragePath = liveVideo.videoStoragePath
let videoOriginalName = liveVideo.videoOriginalName
let videoMimeType = liveVideo.videoMimeType
let videoSize = liveVideo.videoSize
if (thumbnailFile) {
const thumbnail = await saveThumbnailToTemporaryFile(thumbnailFile.path)
thumbnailFilename = thumbnail.filename
Object.assign(liveVideo, {
thumbnailData: thumbnail.data,
thumbnailMimeType: thumbnail.mimeType,
thumbnailSize: thumbnail.size,
thumbnailEtag: thumbnail.etag
})
}
if (videoFile) {
await remove(liveVideo.videoStoragePath).catch(() => undefined)
await remove(getLiveVideoBurnedPath(liveVideo.videoStoragePath)).catch(() => undefined)
await remove(liveVideo.videoStoragePath + '.srt').catch(() => undefined)
await remove(liveVideo.videoStoragePath + '.vtt').catch(() => undefined)
videoStoragePath = await storeVideoFile(videoFile)
videoOriginalName = basename(videoFile.originalname)
videoMimeType = videoFile.mimetype
videoSize = videoFile.size
}
if (subtitleFile) {
if (!videoFile) {
await remove(liveVideo.videoStoragePath + '.srt').catch(() => undefined)
await remove(liveVideo.videoStoragePath + '.vtt').catch(() => undefined)
}
await storeSubtitleFile(subtitleFile, videoStoragePath)
}
if (videoFile || subtitleFile) {
await syncPreprocessedLiveVideo(videoStoragePath)
}
await liveVideo.update({
title: req.body.title ?? liveVideo.title,
thumbnailFilename,
thumbnailData: thumbnailFile ? liveVideo.thumbnailData : liveVideo.thumbnailData,
thumbnailMimeType: thumbnailFile ? liveVideo.thumbnailMimeType : liveVideo.thumbnailMimeType,
thumbnailSize: thumbnailFile ? liveVideo.thumbnailSize : liveVideo.thumbnailSize,
thumbnailEtag: thumbnailFile ? liveVideo.thumbnailEtag : liveVideo.thumbnailEtag,
videoOriginalName,
videoStoragePath,
videoMimeType,
videoSize,
startTime: req.body.startTime ? new Date(req.body.startTime) : liveVideo.startTime,
endTime: req.body.endTime ? new Date(req.body.endTime) : liveVideo.endTime,
targetCamera: req.body.targetCamera ?? liveVideo.targetCamera,
status: req.body.status ?? liveVideo.status
}, {
transaction: t
})
return liveVideo
})
const scheduleChanged = previousTargetCamera !== updatedLiveVideo.targetCamera ||
previousStartTime !== updatedLiveVideo.startTime.getTime() ||
previousEndTime !== updatedLiveVideo.endTime.getTime()
if (updatedLiveVideo.status === LiveVideoStatus.SCHEDULED) {
if (scheduleChanged) {
await LiveVideoProcessManager.Instance.stop(updatedLiveVideo.id, 'manual')
.catch(err => logger.error('Cannot stop scheduled live video ffmpeg process after reschedule.', { err, liveVideoId: updatedLiveVideo.id }))
await stopScheduledLiveSession(previousTargetCamera)
.catch(err => logger.error('Cannot stop previous scheduled sink stream session after reschedule.', {
err,
liveVideoId: updatedLiveVideo.id,
targetCamera: previousTargetCamera
}))
if (previousTargetCamera !== updatedLiveVideo.targetCamera) {
await stopScheduledLiveSession(updatedLiveVideo.targetCamera)
.catch(err => logger.error('Cannot stop new scheduled sink stream session before reschedule restart.', {
err,
liveVideoId: updatedLiveVideo.id,
targetCamera: updatedLiveVideo.targetCamera
}))
}
}
await syncLiveVideoStartJob(updatedLiveVideo, { replace: true })
.catch(err => logger.error('Cannot sync scheduled live video start job after update.', { err, liveVideoId: updatedLiveVideo.id }))
} else {
await removeLiveVideoStartJob(updatedLiveVideo.id)
.catch(err => logger.error('Cannot remove scheduled live video start job after update.', { err, liveVideoId: updatedLiveVideo.id }))
await LiveVideoProcessManager.Instance.stop(
updatedLiveVideo.id,
updatedLiveVideo.status === LiveVideoStatus.CANCELLED ? 'cancelled' : 'manual'
)
.catch(err => logger.error('Cannot stop scheduled live video ffmpeg process after update.', { err, liveVideoId: updatedLiveVideo.id }))
await stopScheduledLiveSession(updatedLiveVideo.targetCamera)
.catch(err => logger.error('Cannot stop scheduled sink stream session after update.', { err, liveVideoId: updatedLiveVideo.id, targetCamera: updatedLiveVideo.targetCamera }))
if (previousTargetCamera !== updatedLiveVideo.targetCamera) {
await stopScheduledLiveSession(previousTargetCamera)
.catch(err => logger.error('Cannot stop previous scheduled sink stream session after update.', { err, liveVideoId: updatedLiveVideo.id, targetCamera: previousTargetCamera }))
}
}
const shouldResetChat = previousTargetCamera !== updatedLiveVideo.targetCamera ||
previousStartTime !== updatedLiveVideo.startTime.getTime() ||
previousEndTime !== updatedLiveVideo.endTime.getTime() ||
previousStatus !== updatedLiveVideo.status
if (!shouldResetChat) {
return res.json(attachSubtitlePath(updatedLiveVideo.toFormattedJSON()))
}
const chatRoomsToClose = new Set<string>([ updatedLiveVideo.targetCamera ])
if (previousTargetCamera !== updatedLiveVideo.targetCamera) {
chatRoomsToClose.add(previousTargetCamera)
}
for (const targetCamera of chatRoomsToClose) {
await PeerTubeSocket.Instance.closeLiveChatRoom(
targetCamera,
updatedLiveVideo.status === LiveVideoStatus.SCHEDULED ? 'rescheduled' : 'live-ended'
)
.catch(err => logger.error('Cannot reset live chat room after live video update.', {
err,
liveVideoId: updatedLiveVideo.id,
targetCamera
}))
}
const json = updatedLiveVideo.toFormattedJSON()
return res.json(attachSubtitlePath(json))
}
function attachSubtitlePath (liveVideo: any) {
const basePath = liveVideo.videoStoragePath
if (!basePath) {
liveVideo.subtitlePath = null
return liveVideo
}
liveVideo.subtitlePath = getLiveVideoSubtitlePath(basePath)
return liveVideo
}
async function deleteLiveVideo (req: express.Request, res: express.Response) {
const liveVideo = (res.locals as any).liveVideo as LiveVideoModel
await removeLiveVideoStartJob(liveVideo.id)
.catch(err => logger.error('Cannot remove scheduled live video start job before deletion.', { err, liveVideoId: liveVideo.id }))
await LiveVideoProcessManager.Instance.stop(liveVideo.id, 'deleted')
.catch(err => logger.error('Cannot stop scheduled live video ffmpeg process before deletion.', { err, liveVideoId: liveVideo.id }))
await stopScheduledLiveSession(liveVideo.targetCamera)
.catch(err => logger.error('Cannot stop scheduled sink stream session before deletion.', { err, liveVideoId: liveVideo.id, targetCamera: liveVideo.targetCamera }))
await PeerTubeSocket.Instance.closeLiveChatRoom(liveVideo.targetCamera, 'deleted')
.catch(err => logger.error('Cannot reset live chat room before live video deletion.', { err, liveVideoId: liveVideo.id, targetCamera: liveVideo.targetCamera }))
await sequelizeTypescript.transaction(async t => {
await liveVideo.destroy({ transaction: t })
})
await remove(liveVideo.videoStoragePath).catch(() => undefined)
await remove(getLiveVideoBurnedPath(liveVideo.videoStoragePath)).catch(() => undefined)
await remove(liveVideo.videoStoragePath + '.srt').catch(() => undefined)
await remove(liveVideo.videoStoragePath + '.vtt').catch(() => undefined)
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
async function saveThumbnailToTemporaryFile (sourcePath: string) {
const thumbnailFilename = generateImageFilename('.jpg')
const destination = join(SPECIAL_THUMBNAILS_TMP_DIR, thumbnailFilename)
await ensureDir(SPECIAL_THUMBNAILS_TMP_DIR)
await processImageFromWorker({
path: sourcePath,
destination,
newSize: THUMBNAILS_SIZE,
keepOriginal: false
})
const data = await readFile(destination)
await remove(destination).catch(() => undefined)
return {
filename: thumbnailFilename,
data,
size: data.byteLength,
mimeType: 'image/jpeg',
etag: createHash('sha256').update(data).digest('hex')
}
}
async function storeVideoFile (file: Express.Multer.File) {
const extension = extname(file.originalname) || '.mp4'
const filename = `${Date.now()}-${basename(file.filename, extname(file.filename))}${extension}`
const destination = join(LIVE_VIDEOS_STORAGE_DIR, filename)
await ensureDir(LIVE_VIDEOS_STORAGE_DIR)
await tryAtomicMove(file.path, destination)
return destination
}
async function storeSubtitleFile (file: Express.Multer.File, videoPath: string) {
const extension = extname(file.originalname) || '.srt'
const destination = videoPath + extension
await tryAtomicMove(file.path, destination)
return destination
}