upload.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. import express from 'express'
  2. import { move } from 'fs-extra'
  3. import { basename } from 'path'
  4. import { getLowercaseExtension } from '@server/helpers/core-utils'
  5. import { deleteResumableUploadMetaFile, getResumableUploadPath } from '@server/helpers/upload'
  6. import { uuidToShort } from '@server/helpers/uuid'
  7. import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
  8. import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url'
  9. import { generateWebTorrentVideoFilename } from '@server/lib/paths'
  10. import {
  11. addMoveToObjectStorageJob,
  12. addOptimizeOrMergeAudioJob,
  13. buildLocalVideoFromReq,
  14. buildVideoThumbnailsFromReq,
  15. setVideoTags
  16. } from '@server/lib/video'
  17. import { VideoPathManager } from '@server/lib/video-path-manager'
  18. import { buildNextVideoState } from '@server/lib/video-state'
  19. import { openapiOperationDoc } from '@server/middlewares/doc'
  20. import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
  21. import { uploadx } from '@uploadx/core'
  22. import { VideoCreate, VideoState } from '../../../../shared'
  23. import { HttpStatusCode } from '../../../../shared/models'
  24. import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
  25. import { retryTransactionWrapper } from '../../../helpers/database-utils'
  26. import { createReqFiles } from '../../../helpers/express-utils'
  27. import { getMetadataFromFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffprobe-utils'
  28. import { logger, loggerTagsFactory } from '../../../helpers/logger'
  29. import { CONFIG } from '../../../initializers/config'
  30. import { DEFAULT_AUDIO_RESOLUTION, MIMETYPES } from '../../../initializers/constants'
  31. import { sequelizeTypescript } from '../../../initializers/database'
  32. import { federateVideoIfNeeded } from '../../../lib/activitypub/videos'
  33. import { Notifier } from '../../../lib/notifier'
  34. import { Hooks } from '../../../lib/plugins/hooks'
  35. import { generateVideoMiniature } from '../../../lib/thumbnail'
  36. import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
  37. import {
  38. asyncMiddleware,
  39. asyncRetryTransactionMiddleware,
  40. authenticate,
  41. videosAddLegacyValidator,
  42. videosAddResumableInitValidator,
  43. videosAddResumableValidator
  44. } from '../../../middlewares'
  45. import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
  46. import { VideoModel } from '../../../models/video/video'
  47. import { VideoFileModel } from '../../../models/video/video-file'
  48. const lTags = loggerTagsFactory('api', 'video')
  49. const auditLogger = auditLoggerFactory('videos')
  50. const uploadRouter = express.Router()
  51. const uploadxMiddleware = uploadx.upload({ directory: getResumableUploadPath() })
  52. const reqVideoFileAdd = createReqFiles(
  53. [ 'videofile', 'thumbnailfile', 'previewfile' ],
  54. Object.assign({}, MIMETYPES.VIDEO.MIMETYPE_EXT, MIMETYPES.IMAGE.MIMETYPE_EXT),
  55. {
  56. videofile: CONFIG.STORAGE.TMP_DIR,
  57. thumbnailfile: CONFIG.STORAGE.TMP_DIR,
  58. previewfile: CONFIG.STORAGE.TMP_DIR
  59. }
  60. )
  61. const reqVideoFileAddResumable = createReqFiles(
  62. [ 'thumbnailfile', 'previewfile' ],
  63. MIMETYPES.IMAGE.MIMETYPE_EXT,
  64. {
  65. thumbnailfile: getResumableUploadPath(),
  66. previewfile: getResumableUploadPath()
  67. }
  68. )
  69. uploadRouter.post('/upload',
  70. openapiOperationDoc({ operationId: 'uploadLegacy' }),
  71. authenticate,
  72. reqVideoFileAdd,
  73. asyncMiddleware(videosAddLegacyValidator),
  74. asyncRetryTransactionMiddleware(addVideoLegacy)
  75. )
  76. uploadRouter.post('/upload-resumable',
  77. openapiOperationDoc({ operationId: 'uploadResumableInit' }),
  78. authenticate,
  79. reqVideoFileAddResumable,
  80. asyncMiddleware(videosAddResumableInitValidator),
  81. uploadxMiddleware
  82. )
  83. uploadRouter.delete('/upload-resumable',
  84. authenticate,
  85. uploadxMiddleware
  86. )
  87. uploadRouter.put('/upload-resumable',
  88. openapiOperationDoc({ operationId: 'uploadResumable' }),
  89. authenticate,
  90. uploadxMiddleware, // uploadx doesn't use call next() before the file upload completes
  91. asyncMiddleware(videosAddResumableValidator),
  92. asyncMiddleware(addVideoResumable)
  93. )
  94. // ---------------------------------------------------------------------------
  95. export {
  96. uploadRouter
  97. }
  98. // ---------------------------------------------------------------------------
  99. export async function addVideoLegacy (req: express.Request, res: express.Response) {
  100. // Uploading the video could be long
  101. // Set timeout to 10 minutes, as Express's default is 2 minutes
  102. req.setTimeout(1000 * 60 * 10, () => {
  103. logger.error('Video upload has timed out.')
  104. return res.fail({
  105. status: HttpStatusCode.REQUEST_TIMEOUT_408,
  106. message: 'Video upload has timed out.'
  107. })
  108. })
  109. const videoPhysicalFile = req.files['videofile'][0]
  110. const videoInfo: VideoCreate = req.body
  111. const files = req.files
  112. return addVideo({ res, videoPhysicalFile, videoInfo, files })
  113. }
  114. export async function addVideoResumable (_req: express.Request, res: express.Response) {
  115. const videoPhysicalFile = res.locals.videoFileResumable
  116. const videoInfo = videoPhysicalFile.metadata
  117. const files = { previewfile: videoInfo.previewfile }
  118. // Don't need the meta file anymore
  119. await deleteResumableUploadMetaFile(videoPhysicalFile.path)
  120. return addVideo({ res, videoPhysicalFile, videoInfo, files })
  121. }
  122. async function addVideo (options: {
  123. res: express.Response
  124. videoPhysicalFile: express.VideoUploadFile
  125. videoInfo: VideoCreate
  126. files: express.UploadFiles
  127. }) {
  128. const { res, videoPhysicalFile, videoInfo, files } = options
  129. const videoChannel = res.locals.videoChannel
  130. const user = res.locals.oauth.token.User
  131. const videoData = buildLocalVideoFromReq(videoInfo, videoChannel.id)
  132. videoData.state = buildNextVideoState()
  133. videoData.duration = videoPhysicalFile.duration // duration was added by a previous middleware
  134. const video = new VideoModel(videoData) as MVideoFullLight
  135. video.VideoChannel = videoChannel
  136. video.url = getLocalVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object
  137. const videoFile = await buildNewFile(videoPhysicalFile)
  138. // Move physical file
  139. const destination = VideoPathManager.Instance.getFSVideoFileOutputPath(video, videoFile)
  140. await move(videoPhysicalFile.path, destination)
  141. // This is important in case if there is another attempt in the retry process
  142. videoPhysicalFile.filename = basename(destination)
  143. videoPhysicalFile.path = destination
  144. const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({
  145. video,
  146. files,
  147. fallback: type => generateVideoMiniature({ video, videoFile, type })
  148. })
  149. const { videoCreated } = await sequelizeTypescript.transaction(async t => {
  150. const sequelizeOptions = { transaction: t }
  151. const videoCreated = await video.save(sequelizeOptions) as MVideoFullLight
  152. await videoCreated.addAndSaveThumbnail(thumbnailModel, t)
  153. await videoCreated.addAndSaveThumbnail(previewModel, t)
  154. // Do not forget to add video channel information to the created video
  155. videoCreated.VideoChannel = res.locals.videoChannel
  156. videoFile.videoId = video.id
  157. await videoFile.save(sequelizeOptions)
  158. video.VideoFiles = [ videoFile ]
  159. await setVideoTags({ video, tags: videoInfo.tags, transaction: t })
  160. // Schedule an update in the future?
  161. if (videoInfo.scheduleUpdate) {
  162. await ScheduleVideoUpdateModel.create({
  163. videoId: video.id,
  164. updateAt: new Date(videoInfo.scheduleUpdate.updateAt),
  165. privacy: videoInfo.scheduleUpdate.privacy || null
  166. }, sequelizeOptions)
  167. }
  168. // Channel has a new content, set as updated
  169. await videoCreated.VideoChannel.setAsUpdated(t)
  170. await autoBlacklistVideoIfNeeded({
  171. video,
  172. user,
  173. isRemote: false,
  174. isNew: true,
  175. transaction: t
  176. })
  177. auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON()))
  178. logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid, lTags(videoCreated.uuid))
  179. return { videoCreated }
  180. })
  181. createTorrentFederate(video, videoFile)
  182. .then(() => {
  183. if (video.state === VideoState.TO_MOVE_TO_EXTERNAL_STORAGE) {
  184. return addMoveToObjectStorageJob(video)
  185. }
  186. if (video.state === VideoState.TO_TRANSCODE) {
  187. return addOptimizeOrMergeAudioJob(videoCreated, videoFile, user)
  188. }
  189. })
  190. .catch(err => logger.error('Cannot add optimize/merge audio job for %s.', videoCreated.uuid, { err, ...lTags(videoCreated.uuid) }))
  191. Hooks.runAction('action:api.video.uploaded', { video: videoCreated })
  192. return res.json({
  193. video: {
  194. id: videoCreated.id,
  195. shortUUID: uuidToShort(videoCreated.uuid),
  196. uuid: videoCreated.uuid
  197. }
  198. })
  199. }
  200. async function buildNewFile (videoPhysicalFile: express.VideoUploadFile) {
  201. const videoFile = new VideoFileModel({
  202. extname: getLowercaseExtension(videoPhysicalFile.filename),
  203. size: videoPhysicalFile.size,
  204. videoStreamingPlaylistId: null,
  205. metadata: await getMetadataFromFile(videoPhysicalFile.path)
  206. })
  207. if (videoFile.isAudio()) {
  208. videoFile.resolution = DEFAULT_AUDIO_RESOLUTION
  209. } else {
  210. videoFile.fps = await getVideoFileFPS(videoPhysicalFile.path)
  211. videoFile.resolution = (await getVideoFileResolution(videoPhysicalFile.path)).resolution
  212. }
  213. videoFile.filename = generateWebTorrentVideoFilename(videoFile.resolution, videoFile.extname)
  214. return videoFile
  215. }
  216. async function createTorrentAndSetInfoHashAsync (video: MVideo, fileArg: MVideoFile) {
  217. await createTorrentAndSetInfoHash(video, fileArg)
  218. // Refresh videoFile because the createTorrentAndSetInfoHash could be long
  219. const refreshedFile = await VideoFileModel.loadWithVideo(fileArg.id)
  220. // File does not exist anymore, remove the generated torrent
  221. if (!refreshedFile) return fileArg.removeTorrent()
  222. refreshedFile.infoHash = fileArg.infoHash
  223. refreshedFile.torrentFilename = fileArg.torrentFilename
  224. return refreshedFile.save()
  225. }
  226. function createTorrentFederate (video: MVideoFullLight, videoFile: MVideoFile) {
  227. // Create the torrent file in async way because it could be long
  228. return createTorrentAndSetInfoHashAsync(video, videoFile)
  229. .catch(err => logger.error('Cannot create torrent file for video %s', video.url, { err, ...lTags(video.uuid) }))
  230. .then(() => VideoModel.loadAndPopulateAccountAndServerAndTags(video.id))
  231. .then(refreshedVideo => {
  232. if (!refreshedVideo) return
  233. // Only federate and notify after the torrent creation
  234. Notifier.Instance.notifyOnNewVideoIfNeeded(refreshedVideo)
  235. return retryTransactionWrapper(() => {
  236. return sequelizeTypescript.transaction(t => federateVideoIfNeeded(refreshedVideo, true, t))
  237. })
  238. })
  239. .catch(err => logger.error('Cannot federate or notify video creation %s', video.url, { err, ...lTags(video.uuid) }))
  240. }