import express from 'express' import { move } from 'fs-extra/esm' import { sequelizeTypescript } from '@server/initializers/database.js' import { CreateJobArgument, CreateJobOptions, JobQueue } from '@server/lib/job-queue/index.js' import { Hooks } from '@server/lib/plugins/hooks.js' import { regenerateMiniaturesIfNeeded } from '@server/lib/thumbnail.js' import { uploadx } from '@server/lib/uploadx.js' import { buildMoveJob, buildStoryboardJobIfNeeded } from '@server/lib/video-jobs.js' import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist.js' import { buildNewFile } from '@server/lib/video-file.js' import { VideoPathManager } from '@server/lib/video-path-manager.js' import { buildNextVideoState } from '@server/lib/video-state.js' import { openapiOperationDoc } from '@server/middlewares/doc.js' import { VideoModel } from '@server/models/video/video.js' import { VideoSourceModel } from '@server/models/video/video-source.js' import { MStreamingPlaylistFiles, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models/index.js' import { VideoState } from '@peertube/peertube-models' import { logger, loggerTagsFactory } from '../../../helpers/logger.js' import { asyncMiddleware, authenticate, replaceVideoSourceResumableInitValidator, replaceVideoSourceResumableValidator, videoSourceGetLatestValidator } from '../../../middlewares/index.js' const lTags = loggerTagsFactory('api', 'video') const videoSourceRouter = express.Router() videoSourceRouter.get('/:id/source', openapiOperationDoc({ operationId: 'getVideoSource' }), authenticate, asyncMiddleware(videoSourceGetLatestValidator), getVideoLatestSource ) videoSourceRouter.post('/:id/source/replace-resumable', authenticate, asyncMiddleware(replaceVideoSourceResumableInitValidator), (req, res) => uploadx.upload(req, res) // Prevent next() call, explicitely tell to uploadx it's the end ) videoSourceRouter.delete('/:id/source/replace-resumable', authenticate, (req, res) => uploadx.upload(req, res) // Prevent next() call, explicitely tell to uploadx it's the end ) videoSourceRouter.put('/:id/source/replace-resumable', authenticate, uploadx.upload, // uploadx doesn't next() before the file upload completes asyncMiddleware(replaceVideoSourceResumableValidator), asyncMiddleware(replaceVideoSourceResumable) ) // --------------------------------------------------------------------------- export { videoSourceRouter } // --------------------------------------------------------------------------- function getVideoLatestSource (req: express.Request, res: express.Response) { return res.json(res.locals.videoSource.toFormattedJSON()) } async function replaceVideoSourceResumable (req: express.Request, res: express.Response) { const videoPhysicalFile = res.locals.updateVideoFileResumable const user = res.locals.oauth.token.User const videoFile = await buildNewFile({ path: videoPhysicalFile.path, mode: 'web-video' }) const originalFilename = videoPhysicalFile.originalname const videoFileMutexReleaser = await VideoPathManager.Instance.lockFiles(res.locals.videoAll.uuid) try { const destination = VideoPathManager.Instance.getFSVideoFileOutputPath(res.locals.videoAll, videoFile) await move(videoPhysicalFile.path, destination) let oldWebVideoFiles: MVideoFile[] = [] let oldStreamingPlaylists: MStreamingPlaylistFiles[] = [] const inputFileUpdatedAt = new Date() const video = await sequelizeTypescript.transaction(async transaction => { const video = await VideoModel.loadFull(res.locals.videoAll.id, transaction) oldWebVideoFiles = video.VideoFiles oldStreamingPlaylists = video.VideoStreamingPlaylists for (const file of video.VideoFiles) { await file.destroy({ transaction }) } for (const playlist of oldStreamingPlaylists) { await playlist.destroy({ transaction }) } videoFile.videoId = video.id await videoFile.save({ transaction }) video.VideoFiles = [ videoFile ] video.VideoStreamingPlaylists = [] video.state = buildNextVideoState() video.duration = videoPhysicalFile.duration video.inputFileUpdatedAt = inputFileUpdatedAt await video.save({ transaction }) await autoBlacklistVideoIfNeeded({ video, user, isRemote: false, isNew: false, isNewFile: true, transaction }) return video }) await removeOldFiles({ video, files: oldWebVideoFiles, playlists: oldStreamingPlaylists }) const source = await VideoSourceModel.create({ filename: originalFilename, videoId: video.id, createdAt: inputFileUpdatedAt }) await regenerateMiniaturesIfNeeded(video) await video.VideoChannel.setAsUpdated() await addVideoJobsAfterUpload(video, video.getMaxQualityFile()) logger.info('Replaced video file of video %s with uuid %s.', video.name, video.uuid, lTags(video.uuid)) Hooks.runAction('action:api.video.file-updated', { video, req, res }) return res.json(source.toFormattedJSON()) } finally { videoFileMutexReleaser() } } async function addVideoJobsAfterUpload (video: MVideoFullLight, videoFile: MVideoFile) { const jobs: (CreateJobArgument & CreateJobOptions)[] = [ { type: 'manage-video-torrent' as 'manage-video-torrent', payload: { videoId: video.id, videoFileId: videoFile.id, action: 'create' } }, buildStoryboardJobIfNeeded({ video, federate: false }), { type: 'federate-video' as 'federate-video', payload: { videoUUID: video.uuid, isNewVideoForFederation: false } } ] if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) { jobs.push(await buildMoveJob({ video, isNewVideo: false, previousVideoState: undefined, type: 'move-to-object-storage' })) } if (video.state === VideoState.TO_TRANSCODE) { jobs.push({ type: 'transcoding-job-builder' as 'transcoding-job-builder', payload: { videoUUID: video.uuid, optimizeJob: { isNewVideo: false } } }) } return JobQueue.Instance.createSequentialJobFlow(...jobs) } async function removeOldFiles (options: { video: MVideo files: MVideoFile[] playlists: MStreamingPlaylistFiles[] }) { const { video, files, playlists } = options for (const file of files) { await video.removeWebVideoFile(file) } for (const playlist of playlists) { await video.removeStreamingPlaylistFiles(playlist) } }