1 import * as express from 'express'
2 import { move } from 'fs-extra'
3 import { extname } from 'path'
4 import { deleteResumableUploadMetaFile, getResumableUploadPath } from '@server/helpers/upload'
5 import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
6 import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url'
7 import { addOptimizeOrMergeAudioJob, buildLocalVideoFromReq, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
8 import { generateVideoFilename, getVideoFilePath } from '@server/lib/video-paths'
9 import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
10 import { uploadx } from '@uploadx/core'
11 import { VideoCreate, VideoState } from '../../../../shared'
12 import { HttpStatusCode } from '../../../../shared/core-utils/miscs'
13 import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
14 import { retryTransactionWrapper } from '../../../helpers/database-utils'
15 import { createReqFiles } from '../../../helpers/express-utils'
16 import { getMetadataFromFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffprobe-utils'
17 import { logger, loggerTagsFactory } from '../../../helpers/logger'
18 import { CONFIG } from '../../../initializers/config'
19 import { DEFAULT_AUDIO_RESOLUTION, MIMETYPES } from '../../../initializers/constants'
20 import { sequelizeTypescript } from '../../../initializers/database'
21 import { federateVideoIfNeeded } from '../../../lib/activitypub/videos'
22 import { Notifier } from '../../../lib/notifier'
23 import { Hooks } from '../../../lib/plugins/hooks'
24 import { generateVideoMiniature } from '../../../lib/thumbnail'
25 import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
28 asyncRetryTransactionMiddleware,
30 videosAddLegacyValidator,
31 videosAddResumableInitValidator,
32 videosAddResumableValidator
33 } from '../../../middlewares'
34 import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
35 import { VideoModel } from '../../../models/video/video'
36 import { VideoFileModel } from '../../../models/video/video-file'
38 const lTags = loggerTagsFactory('api', 'video')
39 const auditLogger = auditLoggerFactory('videos')
40 const uploadRouter = express.Router()
41 const uploadxMiddleware = uploadx.upload({ directory: getResumableUploadPath() })
43 const reqVideoFileAdd = createReqFiles(
44 [ 'videofile', 'thumbnailfile', 'previewfile' ],
45 Object.assign({}, MIMETYPES.VIDEO.MIMETYPE_EXT, MIMETYPES.IMAGE.MIMETYPE_EXT),
47 videofile: CONFIG.STORAGE.TMP_DIR,
48 thumbnailfile: CONFIG.STORAGE.TMP_DIR,
49 previewfile: CONFIG.STORAGE.TMP_DIR
53 const reqVideoFileAddResumable = createReqFiles(
54 [ 'thumbnailfile', 'previewfile' ],
55 MIMETYPES.IMAGE.MIMETYPE_EXT,
57 thumbnailfile: getResumableUploadPath(),
58 previewfile: getResumableUploadPath()
62 uploadRouter.post('/upload',
65 asyncMiddleware(videosAddLegacyValidator),
66 asyncRetryTransactionMiddleware(addVideoLegacy)
69 uploadRouter.post('/upload-resumable',
71 reqVideoFileAddResumable,
72 asyncMiddleware(videosAddResumableInitValidator),
76 uploadRouter.delete('/upload-resumable',
81 uploadRouter.put('/upload-resumable',
83 uploadxMiddleware, // uploadx doesn't use call next() before the file upload completes
84 asyncMiddleware(videosAddResumableValidator),
85 asyncMiddleware(addVideoResumable)
88 // ---------------------------------------------------------------------------
94 // ---------------------------------------------------------------------------
96 export async function addVideoLegacy (req: express.Request, res: express.Response) {
97 // Uploading the video could be long
98 // Set timeout to 10 minutes, as Express's default is 2 minutes
99 req.setTimeout(1000 * 60 * 10, () => {
100 logger.error('Upload video has timed out.')
101 return res.sendStatus(HttpStatusCode.REQUEST_TIMEOUT_408)
104 const videoPhysicalFile = req.files['videofile'][0]
105 const videoInfo: VideoCreate = req.body
106 const files = req.files
108 return addVideo({ res, videoPhysicalFile, videoInfo, files })
111 export async function addVideoResumable (_req: express.Request, res: express.Response) {
112 const videoPhysicalFile = res.locals.videoFileResumable
113 const videoInfo = videoPhysicalFile.metadata
114 const files = { previewfile: videoInfo.previewfile }
116 // Don't need the meta file anymore
117 await deleteResumableUploadMetaFile(videoPhysicalFile.path)
119 return addVideo({ res, videoPhysicalFile, videoInfo, files })
122 async function addVideo (options: {
123 res: express.Response
124 videoPhysicalFile: express.VideoUploadFile
125 videoInfo: VideoCreate
126 files: express.UploadFiles
128 const { res, videoPhysicalFile, videoInfo, files } = options
129 const videoChannel = res.locals.videoChannel
130 const user = res.locals.oauth.token.User
132 const videoData = buildLocalVideoFromReq(videoInfo, videoChannel.id)
134 videoData.state = CONFIG.TRANSCODING.ENABLED
135 ? VideoState.TO_TRANSCODE
136 : VideoState.PUBLISHED
138 videoData.duration = videoPhysicalFile.duration // duration was added by a previous middleware
140 const video = new VideoModel(videoData) as MVideoFullLight
141 video.VideoChannel = videoChannel
142 video.url = getLocalVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object
144 const videoFile = await buildNewFile(video, videoPhysicalFile)
146 // Move physical file
147 const destination = getVideoFilePath(video, videoFile)
148 await move(videoPhysicalFile.path, destination)
149 // This is important in case if there is another attempt in the retry process
150 videoPhysicalFile.filename = getVideoFilePath(video, videoFile)
151 videoPhysicalFile.path = destination
153 const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({
156 fallback: type => generateVideoMiniature({ video, videoFile, type })
159 const { videoCreated } = await sequelizeTypescript.transaction(async t => {
160 const sequelizeOptions = { transaction: t }
162 const videoCreated = await video.save(sequelizeOptions) as MVideoFullLight
164 await videoCreated.addAndSaveThumbnail(thumbnailModel, t)
165 await videoCreated.addAndSaveThumbnail(previewModel, t)
167 // Do not forget to add video channel information to the created video
168 videoCreated.VideoChannel = res.locals.videoChannel
170 videoFile.videoId = video.id
171 await videoFile.save(sequelizeOptions)
173 video.VideoFiles = [ videoFile ]
175 await setVideoTags({ video, tags: videoInfo.tags, transaction: t })
177 // Schedule an update in the future?
178 if (videoInfo.scheduleUpdate) {
179 await ScheduleVideoUpdateModel.create({
181 updateAt: new Date(videoInfo.scheduleUpdate.updateAt),
182 privacy: videoInfo.scheduleUpdate.privacy || null
186 // Channel has a new content, set as updated
187 await videoCreated.VideoChannel.setAsUpdated(t)
189 await autoBlacklistVideoIfNeeded({
197 auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON()))
198 logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid, lTags(videoCreated.uuid))
200 return { videoCreated }
203 createTorrentFederate(video, videoFile)
205 if (video.state === VideoState.TO_TRANSCODE) {
206 await addOptimizeOrMergeAudioJob(videoCreated, videoFile, user)
209 Hooks.runAction('action:api.video.uploaded', { video: videoCreated })
214 uuid: videoCreated.uuid
219 async function buildNewFile (video: MVideo, videoPhysicalFile: express.VideoUploadFile) {
220 const videoFile = new VideoFileModel({
221 extname: extname(videoPhysicalFile.filename),
222 size: videoPhysicalFile.size,
223 videoStreamingPlaylistId: null,
224 metadata: await getMetadataFromFile(videoPhysicalFile.path)
227 if (videoFile.isAudio()) {
228 videoFile.resolution = DEFAULT_AUDIO_RESOLUTION
230 videoFile.fps = await getVideoFileFPS(videoPhysicalFile.path)
231 videoFile.resolution = (await getVideoFileResolution(videoPhysicalFile.path)).videoFileResolution
234 videoFile.filename = generateVideoFilename(video, false, videoFile.resolution, videoFile.extname)
239 async function createTorrentAndSetInfoHashAsync (video: MVideo, fileArg: MVideoFile) {
240 await createTorrentAndSetInfoHash(video, fileArg)
242 // Refresh videoFile because the createTorrentAndSetInfoHash could be long
243 const refreshedFile = await VideoFileModel.loadWithVideo(fileArg.id)
244 // File does not exist anymore, remove the generated torrent
245 if (!refreshedFile) return fileArg.removeTorrent()
247 refreshedFile.infoHash = fileArg.infoHash
248 refreshedFile.torrentFilename = fileArg.torrentFilename
250 return refreshedFile.save()
253 function createTorrentFederate (video: MVideoFullLight, videoFile: MVideoFile): void {
254 // Create the torrent file in async way because it could be long
255 createTorrentAndSetInfoHashAsync(video, videoFile)
256 .catch(err => logger.error('Cannot create torrent file for video %s', video.url, { err, ...lTags(video.uuid) }))
257 .then(() => VideoModel.loadAndPopulateAccountAndServerAndTags(video.id))
258 .then(refreshedVideo => {
259 if (!refreshedVideo) return
261 // Only federate and notify after the torrent creation
262 Notifier.Instance.notifyOnNewVideoIfNeeded(refreshedVideo)
264 return retryTransactionWrapper(() => {
265 return sequelizeTypescript.transaction(t => federateVideoIfNeeded(refreshedVideo, true, t))
268 .catch(err => logger.error('Cannot federate or notify video creation %s', video.url, { err, ...lTags(video.uuid) }))