404 lines
15 KiB
TypeScript
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
|
|
}
|