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([ 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 }