aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/controllers/api/videos
diff options
context:
space:
mode:
Diffstat (limited to 'server/controllers/api/videos')
-rw-r--r--server/controllers/api/videos/blacklist.ts10
-rw-r--r--server/controllers/api/videos/comment.ts7
-rw-r--r--server/controllers/api/videos/import.ts189
-rw-r--r--server/controllers/api/videos/index.ts454
-rw-r--r--server/controllers/api/videos/live.ts13
-rw-r--r--server/controllers/api/videos/ownership.ts12
-rw-r--r--server/controllers/api/videos/update.ts193
-rw-r--r--server/controllers/api/videos/upload.ts278
-rw-r--r--server/controllers/api/videos/watching.ts11
9 files changed, 672 insertions, 495 deletions
diff --git a/server/controllers/api/videos/blacklist.ts b/server/controllers/api/videos/blacklist.ts
index fa8448c86..530e17965 100644
--- a/server/controllers/api/videos/blacklist.ts
+++ b/server/controllers/api/videos/blacklist.ts
@@ -9,6 +9,7 @@ import {
9 authenticate, 9 authenticate,
10 blacklistSortValidator, 10 blacklistSortValidator,
11 ensureUserHasRight, 11 ensureUserHasRight,
12 openapiOperationDoc,
12 paginationValidator, 13 paginationValidator,
13 setBlacklistSort, 14 setBlacklistSort,
14 setDefaultPagination, 15 setDefaultPagination,
@@ -23,6 +24,7 @@ import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-c
23const blacklistRouter = express.Router() 24const blacklistRouter = express.Router()
24 25
25blacklistRouter.post('/:videoId/blacklist', 26blacklistRouter.post('/:videoId/blacklist',
27 openapiOperationDoc({ operationId: 'addVideoBlock' }),
26 authenticate, 28 authenticate,
27 ensureUserHasRight(UserRight.MANAGE_VIDEO_BLACKLIST), 29 ensureUserHasRight(UserRight.MANAGE_VIDEO_BLACKLIST),
28 asyncMiddleware(videosBlacklistAddValidator), 30 asyncMiddleware(videosBlacklistAddValidator),
@@ -30,6 +32,7 @@ blacklistRouter.post('/:videoId/blacklist',
30) 32)
31 33
32blacklistRouter.get('/blacklist', 34blacklistRouter.get('/blacklist',
35 openapiOperationDoc({ operationId: 'getVideoBlocks' }),
33 authenticate, 36 authenticate,
34 ensureUserHasRight(UserRight.MANAGE_VIDEO_BLACKLIST), 37 ensureUserHasRight(UserRight.MANAGE_VIDEO_BLACKLIST),
35 paginationValidator, 38 paginationValidator,
@@ -48,6 +51,7 @@ blacklistRouter.put('/:videoId/blacklist',
48) 51)
49 52
50blacklistRouter.delete('/:videoId/blacklist', 53blacklistRouter.delete('/:videoId/blacklist',
54 openapiOperationDoc({ operationId: 'delVideoBlock' }),
51 authenticate, 55 authenticate,
52 ensureUserHasRight(UserRight.MANAGE_VIDEO_BLACKLIST), 56 ensureUserHasRight(UserRight.MANAGE_VIDEO_BLACKLIST),
53 asyncMiddleware(videosBlacklistRemoveValidator), 57 asyncMiddleware(videosBlacklistRemoveValidator),
@@ -70,7 +74,7 @@ async function addVideoToBlacklistController (req: express.Request, res: express
70 74
71 logger.info('Video %s blacklisted.', videoInstance.uuid) 75 logger.info('Video %s blacklisted.', videoInstance.uuid)
72 76
73 return res.type('json').sendStatus(HttpStatusCode.NO_CONTENT_204) 77 return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
74} 78}
75 79
76async function updateVideoBlacklistController (req: express.Request, res: express.Response) { 80async function updateVideoBlacklistController (req: express.Request, res: express.Response) {
@@ -82,7 +86,7 @@ async function updateVideoBlacklistController (req: express.Request, res: expres
82 return videoBlacklist.save({ transaction: t }) 86 return videoBlacklist.save({ transaction: t })
83 }) 87 })
84 88
85 return res.type('json').sendStatus(HttpStatusCode.NO_CONTENT_204) 89 return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
86} 90}
87 91
88async function listBlacklist (req: express.Request, res: express.Response) { 92async function listBlacklist (req: express.Request, res: express.Response) {
@@ -105,5 +109,5 @@ async function removeVideoFromBlacklistController (req: express.Request, res: ex
105 109
106 logger.info('Video %s removed from blacklist.', video.uuid) 110 logger.info('Video %s removed from blacklist.', video.uuid)
107 111
108 return res.type('json').sendStatus(HttpStatusCode.NO_CONTENT_204) 112 return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
109} 113}
diff --git a/server/controllers/api/videos/comment.ts b/server/controllers/api/videos/comment.ts
index f1f53d354..e6f28c1cb 100644
--- a/server/controllers/api/videos/comment.ts
+++ b/server/controllers/api/videos/comment.ts
@@ -1,7 +1,7 @@
1import * as express from 'express' 1import * as express from 'express'
2import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' 2import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
3import { ResultList, ThreadsResultList, UserRight } from '../../../../shared/models' 3import { ResultList, ThreadsResultList, UserRight } from '../../../../shared/models'
4import { VideoCommentCreate } from '../../../../shared/models/videos/video-comment.model' 4import { VideoCommentCreate } from '../../../../shared/models/videos/comment/video-comment.model'
5import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger' 5import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger'
6import { getFormattedObjects } from '../../../helpers/utils' 6import { getFormattedObjects } from '../../../helpers/utils'
7import { sequelizeTypescript } from '../../../initializers/database' 7import { sequelizeTypescript } from '../../../initializers/database'
@@ -166,7 +166,10 @@ async function listVideoThreadComments (req: express.Request, res: express.Respo
166 } 166 }
167 167
168 if (resultList.data.length === 0) { 168 if (resultList.data.length === 0) {
169 return res.sendStatus(HttpStatusCode.NOT_FOUND_404) 169 return res.fail({
170 status: HttpStatusCode.NOT_FOUND_404,
171 message: 'No comments were found'
172 })
170 } 173 }
171 174
172 return res.json(buildFormattedCommentTree(resultList)) 175 return res.json(buildFormattedCommentTree(resultList))
diff --git a/server/controllers/api/videos/import.ts b/server/controllers/api/videos/import.ts
index 3b9b887e2..de9a5308a 100644
--- a/server/controllers/api/videos/import.ts
+++ b/server/controllers/api/videos/import.ts
@@ -3,7 +3,9 @@ import { move, readFile } from 'fs-extra'
3import * as magnetUtil from 'magnet-uri' 3import * as magnetUtil from 'magnet-uri'
4import * as parseTorrent from 'parse-torrent' 4import * as parseTorrent from 'parse-torrent'
5import { join } from 'path' 5import { join } from 'path'
6import { ServerConfigManager } from '@server/lib/server-config-manager'
6import { setVideoTags } from '@server/lib/video' 7import { setVideoTags } from '@server/lib/video'
8import { FilteredModelAttributes } from '@server/types'
7import { 9import {
8 MChannelAccountDefault, 10 MChannelAccountDefault,
9 MThumbnail, 11 MThumbnail,
@@ -14,23 +16,22 @@ import {
14 MVideoThumbnail, 16 MVideoThumbnail,
15 MVideoWithBlacklistLight 17 MVideoWithBlacklistLight
16} from '@server/types/models' 18} from '@server/types/models'
17import { MVideoImport, MVideoImportFormattable } from '@server/types/models/video/video-import' 19import { MVideoImportFormattable } from '@server/types/models/video/video-import'
18import { VideoImportCreate, VideoImportState, VideoPrivacy, VideoState } from '../../../../shared' 20import { ServerErrorCode, VideoImportCreate, VideoImportState, VideoPrivacy, VideoState } from '../../../../shared'
19import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
20import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type' 21import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type'
21import { auditLoggerFactory, getAuditIdFromRes, VideoImportAuditView } from '../../../helpers/audit-logger' 22import { auditLoggerFactory, getAuditIdFromRes, VideoImportAuditView } from '../../../helpers/audit-logger'
22import { moveAndProcessCaptionFile } from '../../../helpers/captions-utils' 23import { moveAndProcessCaptionFile } from '../../../helpers/captions-utils'
23import { isArray } from '../../../helpers/custom-validators/misc' 24import { isArray } from '../../../helpers/custom-validators/misc'
24import { createReqFiles } from '../../../helpers/express-utils' 25import { cleanUpReqFiles, createReqFiles } from '../../../helpers/express-utils'
25import { logger } from '../../../helpers/logger' 26import { logger } from '../../../helpers/logger'
26import { getSecureTorrentName } from '../../../helpers/utils' 27import { getSecureTorrentName } from '../../../helpers/utils'
27import { getYoutubeDLInfo, getYoutubeDLSubs, YoutubeDLInfo } from '../../../helpers/youtube-dl' 28import { YoutubeDL, YoutubeDLInfo } from '../../../helpers/youtube-dl'
28import { CONFIG } from '../../../initializers/config' 29import { CONFIG } from '../../../initializers/config'
29import { MIMETYPES } from '../../../initializers/constants' 30import { MIMETYPES } from '../../../initializers/constants'
30import { sequelizeTypescript } from '../../../initializers/database' 31import { sequelizeTypescript } from '../../../initializers/database'
31import { getLocalVideoActivityPubUrl } from '../../../lib/activitypub/url' 32import { getLocalVideoActivityPubUrl } from '../../../lib/activitypub/url'
32import { JobQueue } from '../../../lib/job-queue/job-queue' 33import { JobQueue } from '../../../lib/job-queue/job-queue'
33import { createVideoMiniatureFromExisting, createVideoMiniatureFromUrl } from '../../../lib/thumbnail' 34import { updateVideoMiniatureFromExisting, updateVideoMiniatureFromUrl } from '../../../lib/thumbnail'
34import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist' 35import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
35import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoImportAddValidator } from '../../../middlewares' 36import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoImportAddValidator } from '../../../middlewares'
36import { VideoModel } from '../../../models/video/video' 37import { VideoModel } from '../../../models/video/video'
@@ -81,22 +82,15 @@ async function addTorrentImport (req: express.Request, res: express.Response, to
81 let magnetUri: string 82 let magnetUri: string
82 83
83 if (torrentfile) { 84 if (torrentfile) {
84 torrentName = torrentfile.originalname 85 const result = await processTorrentOrAbortRequest(req, res, torrentfile)
86 if (!result) return
85 87
86 // Rename the torrent to a secured name 88 videoName = result.name
87 const newTorrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, getSecureTorrentName(torrentName)) 89 torrentName = result.torrentName
88 await move(torrentfile.path, newTorrentPath)
89 torrentfile.path = newTorrentPath
90
91 const buf = await readFile(torrentfile.path)
92 const parsedTorrent = parseTorrent(buf)
93
94 videoName = isArray(parsedTorrent.name) ? parsedTorrent.name[0] : parsedTorrent.name as string
95 } else { 90 } else {
96 magnetUri = body.magnetUri 91 const result = processMagnetURI(body)
97 92 magnetUri = result.magnetUri
98 const parsed = magnetUtil.decode(magnetUri) 93 videoName = result.name
99 videoName = isArray(parsed.name) ? parsed.name[0] : parsed.name as string
100 } 94 }
101 95
102 const video = buildVideo(res.locals.videoChannel.id, body, { name: videoName }) 96 const video = buildVideo(res.locals.videoChannel.id, body, { name: videoName })
@@ -104,26 +98,26 @@ async function addTorrentImport (req: express.Request, res: express.Response, to
104 const thumbnailModel = await processThumbnail(req, video) 98 const thumbnailModel = await processThumbnail(req, video)
105 const previewModel = await processPreview(req, video) 99 const previewModel = await processPreview(req, video)
106 100
107 const tags = body.tags || undefined
108 const videoImportAttributes = {
109 magnetUri,
110 torrentName,
111 state: VideoImportState.PENDING,
112 userId: user.id
113 }
114 const videoImport = await insertIntoDB({ 101 const videoImport = await insertIntoDB({
115 video, 102 video,
116 thumbnailModel, 103 thumbnailModel,
117 previewModel, 104 previewModel,
118 videoChannel: res.locals.videoChannel, 105 videoChannel: res.locals.videoChannel,
119 tags, 106 tags: body.tags || undefined,
120 videoImportAttributes, 107 user,
121 user 108 videoImportAttributes: {
109 magnetUri,
110 torrentName,
111 state: VideoImportState.PENDING,
112 userId: user.id
113 }
122 }) 114 })
123 115
124 // Create job to import the video 116 // Create job to import the video
125 const payload = { 117 const payload = {
126 type: torrentfile ? 'torrent-file' as 'torrent-file' : 'magnet-uri' as 'magnet-uri', 118 type: torrentfile
119 ? 'torrent-file' as 'torrent-file'
120 : 'magnet-uri' as 'magnet-uri',
127 videoImportId: videoImport.id, 121 videoImportId: videoImport.id,
128 magnetUri 122 magnetUri
129 } 123 }
@@ -139,17 +133,21 @@ async function addYoutubeDLImport (req: express.Request, res: express.Response)
139 const targetUrl = body.targetUrl 133 const targetUrl = body.targetUrl
140 const user = res.locals.oauth.token.User 134 const user = res.locals.oauth.token.User
141 135
136 const youtubeDL = new YoutubeDL(targetUrl, ServerConfigManager.Instance.getEnabledResolutions('vod'))
137
142 // Get video infos 138 // Get video infos
143 let youtubeDLInfo: YoutubeDLInfo 139 let youtubeDLInfo: YoutubeDLInfo
144 try { 140 try {
145 youtubeDLInfo = await getYoutubeDLInfo(targetUrl) 141 youtubeDLInfo = await youtubeDL.getYoutubeDLInfo()
146 } catch (err) { 142 } catch (err) {
147 logger.info('Cannot fetch information from import for URL %s.', targetUrl, { err }) 143 logger.info('Cannot fetch information from import for URL %s.', targetUrl, { err })
148 144
149 return res.status(HttpStatusCode.BAD_REQUEST_400) 145 return res.fail({
150 .json({ 146 message: 'Cannot fetch remote information of this URL.',
151 error: 'Cannot fetch remote information of this URL.' 147 data: {
152 }) 148 targetUrl
149 }
150 })
153 } 151 }
154 152
155 const video = buildVideo(res.locals.videoChannel.id, body, youtubeDLInfo) 153 const video = buildVideo(res.locals.videoChannel.id, body, youtubeDLInfo)
@@ -170,45 +168,22 @@ async function addYoutubeDLImport (req: express.Request, res: express.Response)
170 previewModel = await processPreviewFromUrl(youtubeDLInfo.thumbnailUrl, video) 168 previewModel = await processPreviewFromUrl(youtubeDLInfo.thumbnailUrl, video)
171 } 169 }
172 170
173 const tags = body.tags || youtubeDLInfo.tags
174 const videoImportAttributes = {
175 targetUrl,
176 state: VideoImportState.PENDING,
177 userId: user.id
178 }
179 const videoImport = await insertIntoDB({ 171 const videoImport = await insertIntoDB({
180 video, 172 video,
181 thumbnailModel, 173 thumbnailModel,
182 previewModel, 174 previewModel,
183 videoChannel: res.locals.videoChannel, 175 videoChannel: res.locals.videoChannel,
184 tags, 176 tags: body.tags || youtubeDLInfo.tags,
185 videoImportAttributes, 177 user,
186 user 178 videoImportAttributes: {
179 targetUrl,
180 state: VideoImportState.PENDING,
181 userId: user.id
182 }
187 }) 183 })
188 184
189 // Get video subtitles 185 // Get video subtitles
190 try { 186 await processYoutubeSubtitles(youtubeDL, targetUrl, video.id)
191 const subtitles = await getYoutubeDLSubs(targetUrl)
192
193 logger.info('Will create %s subtitles from youtube import %s.', subtitles.length, targetUrl)
194
195 for (const subtitle of subtitles) {
196 const videoCaption = new VideoCaptionModel({
197 videoId: video.id,
198 language: subtitle.language,
199 filename: VideoCaptionModel.generateCaptionName(subtitle.language)
200 }) as MVideoCaption
201
202 // Move physical file
203 await moveAndProcessCaptionFile(subtitle, videoCaption)
204
205 await sequelizeTypescript.transaction(async t => {
206 await VideoCaptionModel.insertOrReplaceLanguage(videoCaption, t)
207 })
208 }
209 } catch (err) {
210 logger.warn('Cannot get video subtitles.', { err })
211 }
212 187
213 // Create job to import the video 188 // Create job to import the video
214 const payload = { 189 const payload = {
@@ -240,7 +215,9 @@ function buildVideo (channelId: number, body: VideoImportCreate, importData: You
240 privacy: body.privacy || VideoPrivacy.PRIVATE, 215 privacy: body.privacy || VideoPrivacy.PRIVATE,
241 duration: 0, // duration will be set by the import job 216 duration: 0, // duration will be set by the import job
242 channelId: channelId, 217 channelId: channelId,
243 originallyPublishedAt: body.originallyPublishedAt || importData.originallyPublishedAt 218 originallyPublishedAt: body.originallyPublishedAt
219 ? new Date(body.originallyPublishedAt)
220 : importData.originallyPublishedAt
244 } 221 }
245 const video = new VideoModel(videoData) 222 const video = new VideoModel(videoData)
246 video.url = getLocalVideoActivityPubUrl(video) 223 video.url = getLocalVideoActivityPubUrl(video)
@@ -253,7 +230,7 @@ async function processThumbnail (req: express.Request, video: MVideoThumbnail) {
253 if (thumbnailField) { 230 if (thumbnailField) {
254 const thumbnailPhysicalFile = thumbnailField[0] 231 const thumbnailPhysicalFile = thumbnailField[0]
255 232
256 return createVideoMiniatureFromExisting({ 233 return updateVideoMiniatureFromExisting({
257 inputPath: thumbnailPhysicalFile.path, 234 inputPath: thumbnailPhysicalFile.path,
258 video, 235 video,
259 type: ThumbnailType.MINIATURE, 236 type: ThumbnailType.MINIATURE,
@@ -269,7 +246,7 @@ async function processPreview (req: express.Request, video: MVideoThumbnail): Pr
269 if (previewField) { 246 if (previewField) {
270 const previewPhysicalFile = previewField[0] 247 const previewPhysicalFile = previewField[0]
271 248
272 return createVideoMiniatureFromExisting({ 249 return updateVideoMiniatureFromExisting({
273 inputPath: previewPhysicalFile.path, 250 inputPath: previewPhysicalFile.path,
274 video, 251 video,
275 type: ThumbnailType.PREVIEW, 252 type: ThumbnailType.PREVIEW,
@@ -282,7 +259,7 @@ async function processPreview (req: express.Request, video: MVideoThumbnail): Pr
282 259
283async function processThumbnailFromUrl (url: string, video: MVideoThumbnail) { 260async function processThumbnailFromUrl (url: string, video: MVideoThumbnail) {
284 try { 261 try {
285 return createVideoMiniatureFromUrl({ downloadUrl: url, video, type: ThumbnailType.MINIATURE }) 262 return updateVideoMiniatureFromUrl({ downloadUrl: url, video, type: ThumbnailType.MINIATURE })
286 } catch (err) { 263 } catch (err) {
287 logger.warn('Cannot generate video thumbnail %s for %s.', url, video.url, { err }) 264 logger.warn('Cannot generate video thumbnail %s for %s.', url, video.url, { err })
288 return undefined 265 return undefined
@@ -291,7 +268,7 @@ async function processThumbnailFromUrl (url: string, video: MVideoThumbnail) {
291 268
292async function processPreviewFromUrl (url: string, video: MVideoThumbnail) { 269async function processPreviewFromUrl (url: string, video: MVideoThumbnail) {
293 try { 270 try {
294 return createVideoMiniatureFromUrl({ downloadUrl: url, video, type: ThumbnailType.PREVIEW }) 271 return updateVideoMiniatureFromUrl({ downloadUrl: url, video, type: ThumbnailType.PREVIEW })
295 } catch (err) { 272 } catch (err) {
296 logger.warn('Cannot generate video preview %s for %s.', url, video.url, { err }) 273 logger.warn('Cannot generate video preview %s for %s.', url, video.url, { err })
297 return undefined 274 return undefined
@@ -304,7 +281,7 @@ async function insertIntoDB (parameters: {
304 previewModel: MThumbnail 281 previewModel: MThumbnail
305 videoChannel: MChannelAccountDefault 282 videoChannel: MChannelAccountDefault
306 tags: string[] 283 tags: string[]
307 videoImportAttributes: Partial<MVideoImport> 284 videoImportAttributes: FilteredModelAttributes<VideoImportModel>
308 user: MUser 285 user: MUser
309}): Promise<MVideoImportFormattable> { 286}): Promise<MVideoImportFormattable> {
310 const { video, thumbnailModel, previewModel, videoChannel, tags, videoImportAttributes, user } = parameters 287 const { video, thumbnailModel, previewModel, videoChannel, tags, videoImportAttributes, user } = parameters
@@ -342,3 +319,69 @@ async function insertIntoDB (parameters: {
342 319
343 return videoImport 320 return videoImport
344} 321}
322
323async function processTorrentOrAbortRequest (req: express.Request, res: express.Response, torrentfile: Express.Multer.File) {
324 const torrentName = torrentfile.originalname
325
326 // Rename the torrent to a secured name
327 const newTorrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, getSecureTorrentName(torrentName))
328 await move(torrentfile.path, newTorrentPath, { overwrite: true })
329 torrentfile.path = newTorrentPath
330
331 const buf = await readFile(torrentfile.path)
332 const parsedTorrent = parseTorrent(buf) as parseTorrent.Instance
333
334 if (parsedTorrent.files.length !== 1) {
335 cleanUpReqFiles(req)
336
337 res.fail({
338 type: ServerErrorCode.INCORRECT_FILES_IN_TORRENT,
339 message: 'Torrents with only 1 file are supported.'
340 })
341 return undefined
342 }
343
344 return {
345 name: extractNameFromArray(parsedTorrent.name),
346 torrentName
347 }
348}
349
350function processMagnetURI (body: VideoImportCreate) {
351 const magnetUri = body.magnetUri
352 const parsed = magnetUtil.decode(magnetUri)
353
354 return {
355 name: extractNameFromArray(parsed.name),
356 magnetUri
357 }
358}
359
360function extractNameFromArray (name: string | string[]) {
361 return isArray(name) ? name[0] : name
362}
363
364async function processYoutubeSubtitles (youtubeDL: YoutubeDL, targetUrl: string, videoId: number) {
365 try {
366 const subtitles = await youtubeDL.getYoutubeDLSubs()
367
368 logger.info('Will create %s subtitles from youtube import %s.', subtitles.length, targetUrl)
369
370 for (const subtitle of subtitles) {
371 const videoCaption = new VideoCaptionModel({
372 videoId,
373 language: subtitle.language,
374 filename: VideoCaptionModel.generateCaptionName(subtitle.language)
375 }) as MVideoCaption
376
377 // Move physical file
378 await moveAndProcessCaptionFile(subtitle, videoCaption)
379
380 await sequelizeTypescript.transaction(async t => {
381 await VideoCaptionModel.insertOrReplaceLanguage(videoCaption, t)
382 })
383 }
384 } catch (err) {
385 logger.warn('Cannot get video subtitles.', { err })
386 }
387}
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts
index c32626d30..74b100e59 100644
--- a/server/controllers/api/videos/index.ts
+++ b/server/controllers/api/videos/index.ts
@@ -1,43 +1,22 @@
1import * as express from 'express' 1import * as express from 'express'
2import { move } from 'fs-extra'
3import { extname } from 'path'
4import toInt from 'validator/lib/toInt' 2import toInt from 'validator/lib/toInt'
5import { deleteResumableUploadMetaFile, getResumableUploadPath } from '@server/helpers/upload' 3import { doJSONRequest } from '@server/helpers/requests'
6import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' 4import { LiveManager } from '@server/lib/live'
7import { changeVideoChannelShare } from '@server/lib/activitypub/share' 5import { openapiOperationDoc } from '@server/middlewares/doc'
8import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url'
9import { LiveManager } from '@server/lib/live-manager'
10import { addOptimizeOrMergeAudioJob, buildLocalVideoFromReq, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
11import { generateVideoFilename, getVideoFilePath } from '@server/lib/video-paths'
12import { getServerActor } from '@server/models/application/application' 6import { getServerActor } from '@server/models/application/application'
13import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models' 7import { MVideoAccountLight } from '@server/types/models'
14import { uploadx } from '@uploadx/core' 8import { VideosCommonQuery } from '../../../../shared'
15import { VideoCreate, VideosCommonQuery, VideoState, VideoUpdate } from '../../../../shared'
16import { HttpStatusCode } from '../../../../shared/core-utils/miscs' 9import { HttpStatusCode } from '../../../../shared/core-utils/miscs'
17import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' 10import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
18import { resetSequelizeInstance, retryTransactionWrapper } from '../../../helpers/database-utils' 11import { buildNSFWFilter, getCountVideos } from '../../../helpers/express-utils'
19import { buildNSFWFilter, createReqFiles, getCountVideos } from '../../../helpers/express-utils' 12import { logger } from '../../../helpers/logger'
20import { getMetadataFromFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffprobe-utils'
21import { logger, loggerTagsFactory } from '../../../helpers/logger'
22import { getFormattedObjects } from '../../../helpers/utils' 13import { getFormattedObjects } from '../../../helpers/utils'
23import { CONFIG } from '../../../initializers/config' 14import { REMOTE_SCHEME, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES } from '../../../initializers/constants'
24import {
25 DEFAULT_AUDIO_RESOLUTION,
26 MIMETYPES,
27 VIDEO_CATEGORIES,
28 VIDEO_LANGUAGES,
29 VIDEO_LICENCES,
30 VIDEO_PRIVACIES
31} from '../../../initializers/constants'
32import { sequelizeTypescript } from '../../../initializers/database' 15import { sequelizeTypescript } from '../../../initializers/database'
33import { sendView } from '../../../lib/activitypub/send/send-view' 16import { sendView } from '../../../lib/activitypub/send/send-view'
34import { federateVideoIfNeeded, fetchRemoteVideoDescription } from '../../../lib/activitypub/videos'
35import { JobQueue } from '../../../lib/job-queue' 17import { JobQueue } from '../../../lib/job-queue'
36import { Notifier } from '../../../lib/notifier'
37import { Hooks } from '../../../lib/plugins/hooks' 18import { Hooks } from '../../../lib/plugins/hooks'
38import { Redis } from '../../../lib/redis' 19import { Redis } from '../../../lib/redis'
39import { generateVideoMiniature } from '../../../lib/thumbnail'
40import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
41import { 20import {
42 asyncMiddleware, 21 asyncMiddleware,
43 asyncRetryTransactionMiddleware, 22 asyncRetryTransactionMiddleware,
@@ -49,16 +28,11 @@ import {
49 setDefaultPagination, 28 setDefaultPagination,
50 setDefaultVideosSort, 29 setDefaultVideosSort,
51 videoFileMetadataGetValidator, 30 videoFileMetadataGetValidator,
52 videosAddLegacyValidator,
53 videosAddResumableInitValidator,
54 videosAddResumableValidator,
55 videosCustomGetValidator, 31 videosCustomGetValidator,
56 videosGetValidator, 32 videosGetValidator,
57 videosRemoveValidator, 33 videosRemoveValidator,
58 videosSortValidator, 34 videosSortValidator
59 videosUpdateValidator
60} from '../../../middlewares' 35} from '../../../middlewares'
61import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
62import { VideoModel } from '../../../models/video/video' 36import { VideoModel } from '../../../models/video/video'
63import { VideoFileModel } from '../../../models/video/video-file' 37import { VideoFileModel } from '../../../models/video/video-file'
64import { blacklistRouter } from './blacklist' 38import { blacklistRouter } from './blacklist'
@@ -68,40 +42,12 @@ import { videoImportsRouter } from './import'
68import { liveRouter } from './live' 42import { liveRouter } from './live'
69import { ownershipVideoRouter } from './ownership' 43import { ownershipVideoRouter } from './ownership'
70import { rateVideoRouter } from './rate' 44import { rateVideoRouter } from './rate'
45import { updateRouter } from './update'
46import { uploadRouter } from './upload'
71import { watchingRouter } from './watching' 47import { watchingRouter } from './watching'
72 48
73const lTags = loggerTagsFactory('api', 'video')
74const auditLogger = auditLoggerFactory('videos') 49const auditLogger = auditLoggerFactory('videos')
75const videosRouter = express.Router() 50const videosRouter = express.Router()
76const uploadxMiddleware = uploadx.upload({ directory: getResumableUploadPath() })
77
78const reqVideoFileAdd = createReqFiles(
79 [ 'videofile', 'thumbnailfile', 'previewfile' ],
80 Object.assign({}, MIMETYPES.VIDEO.MIMETYPE_EXT, MIMETYPES.IMAGE.MIMETYPE_EXT),
81 {
82 videofile: CONFIG.STORAGE.TMP_DIR,
83 thumbnailfile: CONFIG.STORAGE.TMP_DIR,
84 previewfile: CONFIG.STORAGE.TMP_DIR
85 }
86)
87
88const reqVideoFileAddResumable = createReqFiles(
89 [ 'thumbnailfile', 'previewfile' ],
90 MIMETYPES.IMAGE.MIMETYPE_EXT,
91 {
92 thumbnailfile: getResumableUploadPath(),
93 previewfile: getResumableUploadPath()
94 }
95)
96
97const reqVideoFileUpdate = createReqFiles(
98 [ 'thumbnailfile', 'previewfile' ],
99 MIMETYPES.IMAGE.MIMETYPE_EXT,
100 {
101 thumbnailfile: CONFIG.STORAGE.TMP_DIR,
102 previewfile: CONFIG.STORAGE.TMP_DIR
103 }
104)
105 51
106videosRouter.use('/', blacklistRouter) 52videosRouter.use('/', blacklistRouter)
107videosRouter.use('/', rateVideoRouter) 53videosRouter.use('/', rateVideoRouter)
@@ -111,13 +57,28 @@ videosRouter.use('/', videoImportsRouter)
111videosRouter.use('/', ownershipVideoRouter) 57videosRouter.use('/', ownershipVideoRouter)
112videosRouter.use('/', watchingRouter) 58videosRouter.use('/', watchingRouter)
113videosRouter.use('/', liveRouter) 59videosRouter.use('/', liveRouter)
60videosRouter.use('/', uploadRouter)
61videosRouter.use('/', updateRouter)
114 62
115videosRouter.get('/categories', listVideoCategories) 63videosRouter.get('/categories',
116videosRouter.get('/licences', listVideoLicences) 64 openapiOperationDoc({ operationId: 'getCategories' }),
117videosRouter.get('/languages', listVideoLanguages) 65 listVideoCategories
118videosRouter.get('/privacies', listVideoPrivacies) 66)
67videosRouter.get('/licences',
68 openapiOperationDoc({ operationId: 'getLicences' }),
69 listVideoLicences
70)
71videosRouter.get('/languages',
72 openapiOperationDoc({ operationId: 'getLanguages' }),
73 listVideoLanguages
74)
75videosRouter.get('/privacies',
76 openapiOperationDoc({ operationId: 'getPrivacies' }),
77 listVideoPrivacies
78)
119 79
120videosRouter.get('/', 80videosRouter.get('/',
81 openapiOperationDoc({ operationId: 'getVideos' }),
121 paginationValidator, 82 paginationValidator,
122 videosSortValidator, 83 videosSortValidator,
123 setDefaultVideosSort, 84 setDefaultVideosSort,
@@ -127,40 +88,8 @@ videosRouter.get('/',
127 asyncMiddleware(listVideos) 88 asyncMiddleware(listVideos)
128) 89)
129 90
130videosRouter.post('/upload',
131 authenticate,
132 reqVideoFileAdd,
133 asyncMiddleware(videosAddLegacyValidator),
134 asyncRetryTransactionMiddleware(addVideoLegacy)
135)
136
137videosRouter.post('/upload-resumable',
138 authenticate,
139 reqVideoFileAddResumable,
140 asyncMiddleware(videosAddResumableInitValidator),
141 uploadxMiddleware
142)
143
144videosRouter.delete('/upload-resumable',
145 authenticate,
146 uploadxMiddleware
147)
148
149videosRouter.put('/upload-resumable',
150 authenticate,
151 uploadxMiddleware, // uploadx doesn't use call next() before the file upload completes
152 asyncMiddleware(videosAddResumableValidator),
153 asyncMiddleware(addVideoResumable)
154)
155
156videosRouter.put('/:id',
157 authenticate,
158 reqVideoFileUpdate,
159 asyncMiddleware(videosUpdateValidator),
160 asyncRetryTransactionMiddleware(updateVideo)
161)
162
163videosRouter.get('/:id/description', 91videosRouter.get('/:id/description',
92 openapiOperationDoc({ operationId: 'getVideoDesc' }),
164 asyncMiddleware(videosGetValidator), 93 asyncMiddleware(videosGetValidator),
165 asyncMiddleware(getVideoDescription) 94 asyncMiddleware(getVideoDescription)
166) 95)
@@ -169,17 +98,20 @@ videosRouter.get('/:id/metadata/:videoFileId',
169 asyncMiddleware(getVideoFileMetadata) 98 asyncMiddleware(getVideoFileMetadata)
170) 99)
171videosRouter.get('/:id', 100videosRouter.get('/:id',
101 openapiOperationDoc({ operationId: 'getVideo' }),
172 optionalAuthenticate, 102 optionalAuthenticate,
173 asyncMiddleware(videosCustomGetValidator('only-video-with-rights')), 103 asyncMiddleware(videosCustomGetValidator('for-api')),
174 asyncMiddleware(checkVideoFollowConstraints), 104 asyncMiddleware(checkVideoFollowConstraints),
175 asyncMiddleware(getVideo) 105 asyncMiddleware(getVideo)
176) 106)
177videosRouter.post('/:id/views', 107videosRouter.post('/:id/views',
108 openapiOperationDoc({ operationId: 'addView' }),
178 asyncMiddleware(videosCustomGetValidator('only-immutable-attributes')), 109 asyncMiddleware(videosCustomGetValidator('only-immutable-attributes')),
179 asyncMiddleware(viewVideo) 110 asyncMiddleware(viewVideo)
180) 111)
181 112
182videosRouter.delete('/:id', 113videosRouter.delete('/:id',
114 openapiOperationDoc({ operationId: 'delVideo' }),
183 authenticate, 115 authenticate,
184 asyncMiddleware(videosRemoveValidator), 116 asyncMiddleware(videosRemoveValidator),
185 asyncRetryTransactionMiddleware(removeVideo) 117 asyncRetryTransactionMiddleware(removeVideo)
@@ -209,287 +141,8 @@ function listVideoPrivacies (_req: express.Request, res: express.Response) {
209 res.json(VIDEO_PRIVACIES) 141 res.json(VIDEO_PRIVACIES)
210} 142}
211 143
212async function addVideoLegacy (req: express.Request, res: express.Response) { 144async function getVideo (_req: express.Request, res: express.Response) {
213 // Uploading the video could be long 145 const video = res.locals.videoAPI
214 // Set timeout to 10 minutes, as Express's default is 2 minutes
215 req.setTimeout(1000 * 60 * 10, () => {
216 logger.error('Upload video has timed out.')
217 return res.sendStatus(HttpStatusCode.REQUEST_TIMEOUT_408)
218 })
219
220 const videoPhysicalFile = req.files['videofile'][0]
221 const videoInfo: VideoCreate = req.body
222 const files = req.files
223
224 return addVideo({ res, videoPhysicalFile, videoInfo, files })
225}
226
227async function addVideoResumable (_req: express.Request, res: express.Response) {
228 const videoPhysicalFile = res.locals.videoFileResumable
229 const videoInfo = videoPhysicalFile.metadata
230 const files = { previewfile: videoInfo.previewfile }
231
232 // Don't need the meta file anymore
233 await deleteResumableUploadMetaFile(videoPhysicalFile.path)
234
235 return addVideo({ res, videoPhysicalFile, videoInfo, files })
236}
237
238async function addVideo (options: {
239 res: express.Response
240 videoPhysicalFile: express.VideoUploadFile
241 videoInfo: VideoCreate
242 files: express.UploadFiles
243}) {
244 const { res, videoPhysicalFile, videoInfo, files } = options
245 const videoChannel = res.locals.videoChannel
246 const user = res.locals.oauth.token.User
247
248 const videoData = buildLocalVideoFromReq(videoInfo, videoChannel.id)
249
250 videoData.state = CONFIG.TRANSCODING.ENABLED
251 ? VideoState.TO_TRANSCODE
252 : VideoState.PUBLISHED
253
254 videoData.duration = videoPhysicalFile.duration // duration was added by a previous middleware
255
256 const video = new VideoModel(videoData) as MVideoFullLight
257 video.VideoChannel = videoChannel
258 video.url = getLocalVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object
259
260 const videoFile = new VideoFileModel({
261 extname: extname(videoPhysicalFile.filename),
262 size: videoPhysicalFile.size,
263 videoStreamingPlaylistId: null,
264 metadata: await getMetadataFromFile(videoPhysicalFile.path)
265 })
266
267 if (videoFile.isAudio()) {
268 videoFile.resolution = DEFAULT_AUDIO_RESOLUTION
269 } else {
270 videoFile.fps = await getVideoFileFPS(videoPhysicalFile.path)
271 videoFile.resolution = (await getVideoFileResolution(videoPhysicalFile.path)).videoFileResolution
272 }
273
274 videoFile.filename = generateVideoFilename(video, false, videoFile.resolution, videoFile.extname)
275
276 // Move physical file
277 const destination = getVideoFilePath(video, videoFile)
278 await move(videoPhysicalFile.path, destination)
279 // This is important in case if there is another attempt in the retry process
280 videoPhysicalFile.filename = getVideoFilePath(video, videoFile)
281 videoPhysicalFile.path = destination
282
283 const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({
284 video,
285 files,
286 fallback: type => generateVideoMiniature({ video, videoFile, type })
287 })
288
289 const { videoCreated } = await sequelizeTypescript.transaction(async t => {
290 const sequelizeOptions = { transaction: t }
291
292 const videoCreated = await video.save(sequelizeOptions) as MVideoFullLight
293
294 await videoCreated.addAndSaveThumbnail(thumbnailModel, t)
295 await videoCreated.addAndSaveThumbnail(previewModel, t)
296
297 // Do not forget to add video channel information to the created video
298 videoCreated.VideoChannel = res.locals.videoChannel
299
300 videoFile.videoId = video.id
301 await videoFile.save(sequelizeOptions)
302
303 video.VideoFiles = [ videoFile ]
304
305 await setVideoTags({ video, tags: videoInfo.tags, transaction: t })
306
307 // Schedule an update in the future?
308 if (videoInfo.scheduleUpdate) {
309 await ScheduleVideoUpdateModel.create({
310 videoId: video.id,
311 updateAt: videoInfo.scheduleUpdate.updateAt,
312 privacy: videoInfo.scheduleUpdate.privacy || null
313 }, { transaction: t })
314 }
315
316 // Channel has a new content, set as updated
317 await videoCreated.VideoChannel.setAsUpdated(t)
318
319 await autoBlacklistVideoIfNeeded({
320 video,
321 user,
322 isRemote: false,
323 isNew: true,
324 transaction: t
325 })
326
327 auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON()))
328 logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid, lTags(videoCreated.uuid))
329
330 return { videoCreated }
331 })
332
333 // Create the torrent file in async way because it could be long
334 createTorrentAndSetInfoHashAsync(video, videoFile)
335 .catch(err => logger.error('Cannot create torrent file for video %s', video.url, { err, ...lTags(video.uuid) }))
336 .then(() => VideoModel.loadAndPopulateAccountAndServerAndTags(video.id))
337 .then(refreshedVideo => {
338 if (!refreshedVideo) return
339
340 // Only federate and notify after the torrent creation
341 Notifier.Instance.notifyOnNewVideoIfNeeded(refreshedVideo)
342
343 return retryTransactionWrapper(() => {
344 return sequelizeTypescript.transaction(t => federateVideoIfNeeded(refreshedVideo, true, t))
345 })
346 })
347 .catch(err => logger.error('Cannot federate or notify video creation %s', video.url, { err, ...lTags(video.uuid) }))
348
349 if (video.state === VideoState.TO_TRANSCODE) {
350 await addOptimizeOrMergeAudioJob(videoCreated, videoFile, user)
351 }
352
353 Hooks.runAction('action:api.video.uploaded', { video: videoCreated })
354
355 return res.json({
356 video: {
357 id: videoCreated.id,
358 uuid: videoCreated.uuid
359 }
360 })
361}
362
363async function updateVideo (req: express.Request, res: express.Response) {
364 const videoInstance = res.locals.videoAll
365 const videoFieldsSave = videoInstance.toJSON()
366 const oldVideoAuditView = new VideoAuditView(videoInstance.toFormattedDetailsJSON())
367 const videoInfoToUpdate: VideoUpdate = req.body
368
369 const wasConfidentialVideo = videoInstance.isConfidential()
370 const hadPrivacyForFederation = videoInstance.hasPrivacyForFederation()
371
372 const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({
373 video: videoInstance,
374 files: req.files,
375 fallback: () => Promise.resolve(undefined),
376 automaticallyGenerated: false
377 })
378
379 try {
380 const videoInstanceUpdated = await sequelizeTypescript.transaction(async t => {
381 const sequelizeOptions = { transaction: t }
382 const oldVideoChannel = videoInstance.VideoChannel
383
384 if (videoInfoToUpdate.name !== undefined) videoInstance.name = videoInfoToUpdate.name
385 if (videoInfoToUpdate.category !== undefined) videoInstance.category = videoInfoToUpdate.category
386 if (videoInfoToUpdate.licence !== undefined) videoInstance.licence = videoInfoToUpdate.licence
387 if (videoInfoToUpdate.language !== undefined) videoInstance.language = videoInfoToUpdate.language
388 if (videoInfoToUpdate.nsfw !== undefined) videoInstance.nsfw = videoInfoToUpdate.nsfw
389 if (videoInfoToUpdate.waitTranscoding !== undefined) videoInstance.waitTranscoding = videoInfoToUpdate.waitTranscoding
390 if (videoInfoToUpdate.support !== undefined) videoInstance.support = videoInfoToUpdate.support
391 if (videoInfoToUpdate.description !== undefined) videoInstance.description = videoInfoToUpdate.description
392 if (videoInfoToUpdate.commentsEnabled !== undefined) videoInstance.commentsEnabled = videoInfoToUpdate.commentsEnabled
393 if (videoInfoToUpdate.downloadEnabled !== undefined) videoInstance.downloadEnabled = videoInfoToUpdate.downloadEnabled
394
395 if (videoInfoToUpdate.originallyPublishedAt !== undefined && videoInfoToUpdate.originallyPublishedAt !== null) {
396 videoInstance.originallyPublishedAt = new Date(videoInfoToUpdate.originallyPublishedAt)
397 }
398
399 let isNewVideo = false
400 if (videoInfoToUpdate.privacy !== undefined) {
401 isNewVideo = videoInstance.isNewVideo(videoInfoToUpdate.privacy)
402
403 const newPrivacy = parseInt(videoInfoToUpdate.privacy.toString(), 10)
404 videoInstance.setPrivacy(newPrivacy)
405
406 // Unfederate the video if the new privacy is not compatible with federation
407 if (hadPrivacyForFederation && !videoInstance.hasPrivacyForFederation()) {
408 await VideoModel.sendDelete(videoInstance, { transaction: t })
409 }
410 }
411
412 const videoInstanceUpdated = await videoInstance.save(sequelizeOptions) as MVideoFullLight
413
414 if (thumbnailModel) await videoInstanceUpdated.addAndSaveThumbnail(thumbnailModel, t)
415 if (previewModel) await videoInstanceUpdated.addAndSaveThumbnail(previewModel, t)
416
417 // Video tags update?
418 if (videoInfoToUpdate.tags !== undefined) {
419 await setVideoTags({
420 video: videoInstanceUpdated,
421 tags: videoInfoToUpdate.tags,
422 transaction: t
423 })
424 }
425
426 // Video channel update?
427 if (res.locals.videoChannel && videoInstanceUpdated.channelId !== res.locals.videoChannel.id) {
428 await videoInstanceUpdated.$set('VideoChannel', res.locals.videoChannel, { transaction: t })
429 videoInstanceUpdated.VideoChannel = res.locals.videoChannel
430
431 if (hadPrivacyForFederation === true) await changeVideoChannelShare(videoInstanceUpdated, oldVideoChannel, t)
432 }
433
434 // Schedule an update in the future?
435 if (videoInfoToUpdate.scheduleUpdate) {
436 await ScheduleVideoUpdateModel.upsert({
437 videoId: videoInstanceUpdated.id,
438 updateAt: videoInfoToUpdate.scheduleUpdate.updateAt,
439 privacy: videoInfoToUpdate.scheduleUpdate.privacy || null
440 }, { transaction: t })
441 } else if (videoInfoToUpdate.scheduleUpdate === null) {
442 await ScheduleVideoUpdateModel.deleteByVideoId(videoInstanceUpdated.id, t)
443 }
444
445 await autoBlacklistVideoIfNeeded({
446 video: videoInstanceUpdated,
447 user: res.locals.oauth.token.User,
448 isRemote: false,
449 isNew: false,
450 transaction: t
451 })
452
453 await federateVideoIfNeeded(videoInstanceUpdated, isNewVideo, t)
454
455 auditLogger.update(
456 getAuditIdFromRes(res),
457 new VideoAuditView(videoInstanceUpdated.toFormattedDetailsJSON()),
458 oldVideoAuditView
459 )
460 logger.info('Video with name %s and uuid %s updated.', videoInstance.name, videoInstance.uuid, lTags(videoInstance.uuid))
461
462 return videoInstanceUpdated
463 })
464
465 if (wasConfidentialVideo) {
466 Notifier.Instance.notifyOnNewVideoIfNeeded(videoInstanceUpdated)
467 }
468
469 Hooks.runAction('action:api.video.updated', { video: videoInstanceUpdated, body: req.body })
470 } catch (err) {
471 // Force fields we want to update
472 // If the transaction is retried, sequelize will think the object has not changed
473 // So it will skip the SQL request, even if the last one was ROLLBACKed!
474 resetSequelizeInstance(videoInstance, videoFieldsSave)
475
476 throw err
477 }
478
479 return res.type('json')
480 .status(HttpStatusCode.NO_CONTENT_204)
481 .end()
482}
483
484async function getVideo (req: express.Request, res: express.Response) {
485 // We need more attributes
486 const userId: number = res.locals.oauth ? res.locals.oauth.token.User.id : null
487
488 const video = await Hooks.wrapPromiseFun(
489 VideoModel.loadForGetAPI,
490 { id: res.locals.onlyVideoWithRights.id, userId },
491 'filter:api.video.get.result'
492 )
493 146
494 if (video.isOutdated()) { 147 if (video.isOutdated()) {
495 JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video', url: video.url } }) 148 JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video', url: video.url } })
@@ -505,7 +158,7 @@ async function viewVideo (req: express.Request, res: express.Response) {
505 const exists = await Redis.Instance.doesVideoIPViewExist(ip, immutableVideoAttrs.uuid) 158 const exists = await Redis.Instance.doesVideoIPViewExist(ip, immutableVideoAttrs.uuid)
506 if (exists) { 159 if (exists) {
507 logger.debug('View for ip %s and video %s already exists.', ip, immutableVideoAttrs.uuid) 160 logger.debug('View for ip %s and video %s already exists.', ip, immutableVideoAttrs.uuid)
508 return res.sendStatus(HttpStatusCode.NO_CONTENT_204) 161 return res.status(HttpStatusCode.NO_CONTENT_204).end()
509 } 162 }
510 163
511 const video = await VideoModel.load(immutableVideoAttrs.id) 164 const video = await VideoModel.load(immutableVideoAttrs.id)
@@ -538,18 +191,15 @@ async function viewVideo (req: express.Request, res: express.Response) {
538 191
539 Hooks.runAction('action:api.video.viewed', { video, ip }) 192 Hooks.runAction('action:api.video.viewed', { video, ip })
540 193
541 return res.sendStatus(HttpStatusCode.NO_CONTENT_204) 194 return res.status(HttpStatusCode.NO_CONTENT_204).end()
542} 195}
543 196
544async function getVideoDescription (req: express.Request, res: express.Response) { 197async function getVideoDescription (req: express.Request, res: express.Response) {
545 const videoInstance = res.locals.videoAll 198 const videoInstance = res.locals.videoAll
546 let description = ''
547 199
548 if (videoInstance.isOwned()) { 200 const description = videoInstance.isOwned()
549 description = videoInstance.description 201 ? videoInstance.description
550 } else { 202 : await fetchRemoteVideoDescription(videoInstance)
551 description = await fetchRemoteVideoDescription(videoInstance)
552 }
553 203
554 return res.json({ description }) 204 return res.json({ description })
555} 205}
@@ -591,7 +241,7 @@ async function listVideos (req: express.Request, res: express.Response) {
591 return res.json(getFormattedObjects(resultList.data, resultList.total)) 241 return res.json(getFormattedObjects(resultList.data, resultList.total))
592} 242}
593 243
594async function removeVideo (req: express.Request, res: express.Response) { 244async function removeVideo (_req: express.Request, res: express.Response) {
595 const videoInstance = res.locals.videoAll 245 const videoInstance = res.locals.videoAll
596 246
597 await sequelizeTypescript.transaction(async t => { 247 await sequelizeTypescript.transaction(async t => {
@@ -608,16 +258,14 @@ async function removeVideo (req: express.Request, res: express.Response) {
608 .end() 258 .end()
609} 259}
610 260
611async function createTorrentAndSetInfoHashAsync (video: MVideo, fileArg: MVideoFile) { 261// ---------------------------------------------------------------------------
612 await createTorrentAndSetInfoHash(video, fileArg)
613
614 // Refresh videoFile because the createTorrentAndSetInfoHash could be long
615 const refreshedFile = await VideoFileModel.loadWithVideo(fileArg.id)
616 // File does not exist anymore, remove the generated torrent
617 if (!refreshedFile) return fileArg.removeTorrent()
618 262
619 refreshedFile.infoHash = fileArg.infoHash 263// FIXME: Should not exist, we rely on specific API
620 refreshedFile.torrentFilename = fileArg.torrentFilename 264async function fetchRemoteVideoDescription (video: MVideoAccountLight) {
265 const host = video.VideoChannel.Account.Actor.Server.host
266 const path = video.getDescriptionAPIPath()
267 const url = REMOTE_SCHEME.HTTP + '://' + host + path
621 268
622 return refreshedFile.save() 269 const { body } = await doJSONRequest<any>(url)
270 return body.description || ''
623} 271}
diff --git a/server/controllers/api/videos/live.ts b/server/controllers/api/videos/live.ts
index 04d2494ce..d8c51c2d4 100644
--- a/server/controllers/api/videos/live.ts
+++ b/server/controllers/api/videos/live.ts
@@ -1,6 +1,6 @@
1import * as express from 'express' 1import * as express from 'express'
2import { v4 as uuidv4 } from 'uuid'
3import { createReqFiles } from '@server/helpers/express-utils' 2import { createReqFiles } from '@server/helpers/express-utils'
3import { buildUUID, uuidToShort } from '@server/helpers/uuid'
4import { CONFIG } from '@server/initializers/config' 4import { CONFIG } from '@server/initializers/config'
5import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants' 5import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants'
6import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url' 6import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url'
@@ -11,12 +11,12 @@ import { videoLiveAddValidator, videoLiveGetValidator, videoLiveUpdateValidator
11import { VideoLiveModel } from '@server/models/video/video-live' 11import { VideoLiveModel } from '@server/models/video/video-live'
12import { MVideoDetails, MVideoFullLight } from '@server/types/models' 12import { MVideoDetails, MVideoFullLight } from '@server/types/models'
13import { LiveVideoCreate, LiveVideoUpdate, VideoState } from '../../../../shared' 13import { LiveVideoCreate, LiveVideoUpdate, VideoState } from '../../../../shared'
14import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
14import { logger } from '../../../helpers/logger' 15import { logger } from '../../../helpers/logger'
15import { sequelizeTypescript } from '../../../initializers/database' 16import { sequelizeTypescript } from '../../../initializers/database'
16import { createVideoMiniatureFromExisting } from '../../../lib/thumbnail' 17import { updateVideoMiniatureFromExisting } from '../../../lib/thumbnail'
17import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares' 18import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares'
18import { VideoModel } from '../../../models/video/video' 19import { VideoModel } from '../../../models/video/video'
19import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
20 20
21const liveRouter = express.Router() 21const liveRouter = express.Router()
22 22
@@ -76,7 +76,7 @@ async function updateLiveVideo (req: express.Request, res: express.Response) {
76 76
77 await federateVideoIfNeeded(video, false) 77 await federateVideoIfNeeded(video, false)
78 78
79 return res.sendStatus(HttpStatusCode.NO_CONTENT_204) 79 return res.status(HttpStatusCode.NO_CONTENT_204).end()
80} 80}
81 81
82async function addLiveVideo (req: express.Request, res: express.Response) { 82async function addLiveVideo (req: express.Request, res: express.Response) {
@@ -94,13 +94,13 @@ async function addLiveVideo (req: express.Request, res: express.Response) {
94 const videoLive = new VideoLiveModel() 94 const videoLive = new VideoLiveModel()
95 videoLive.saveReplay = videoInfo.saveReplay || false 95 videoLive.saveReplay = videoInfo.saveReplay || false
96 videoLive.permanentLive = videoInfo.permanentLive || false 96 videoLive.permanentLive = videoInfo.permanentLive || false
97 videoLive.streamKey = uuidv4() 97 videoLive.streamKey = buildUUID()
98 98
99 const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({ 99 const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({
100 video, 100 video,
101 files: req.files, 101 files: req.files,
102 fallback: type => { 102 fallback: type => {
103 return createVideoMiniatureFromExisting({ 103 return updateVideoMiniatureFromExisting({
104 inputPath: ASSETS_PATH.DEFAULT_LIVE_BACKGROUND, 104 inputPath: ASSETS_PATH.DEFAULT_LIVE_BACKGROUND,
105 video, 105 video,
106 type, 106 type,
@@ -138,6 +138,7 @@ async function addLiveVideo (req: express.Request, res: express.Response) {
138 return res.json({ 138 return res.json({
139 video: { 139 video: {
140 id: videoCreated.id, 140 id: videoCreated.id,
141 shortUUID: uuidToShort(videoCreated.uuid),
141 uuid: videoCreated.uuid 142 uuid: videoCreated.uuid
142 } 143 }
143 }) 144 })
diff --git a/server/controllers/api/videos/ownership.ts b/server/controllers/api/videos/ownership.ts
index a85d7c30b..1bb96e046 100644
--- a/server/controllers/api/videos/ownership.ts
+++ b/server/controllers/api/videos/ownership.ts
@@ -99,15 +99,15 @@ async function listVideoOwnership (req: express.Request, res: express.Response)
99 return res.json(getFormattedObjects(resultList.data, resultList.total)) 99 return res.json(getFormattedObjects(resultList.data, resultList.total))
100} 100}
101 101
102async function acceptOwnership (req: express.Request, res: express.Response) { 102function acceptOwnership (req: express.Request, res: express.Response) {
103 return sequelizeTypescript.transaction(async t => { 103 return sequelizeTypescript.transaction(async t => {
104 const videoChangeOwnership = res.locals.videoChangeOwnership 104 const videoChangeOwnership = res.locals.videoChangeOwnership
105 const channel = res.locals.videoChannel 105 const channel = res.locals.videoChannel
106 106
107 // We need more attributes for federation 107 // We need more attributes for federation
108 const targetVideo = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoChangeOwnership.Video.id) 108 const targetVideo = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoChangeOwnership.Video.id, t)
109 109
110 const oldVideoChannel = await VideoChannelModel.loadAndPopulateAccount(targetVideo.channelId) 110 const oldVideoChannel = await VideoChannelModel.loadAndPopulateAccount(targetVideo.channelId, t)
111 111
112 targetVideo.channelId = channel.id 112 targetVideo.channelId = channel.id
113 113
@@ -122,17 +122,17 @@ async function acceptOwnership (req: express.Request, res: express.Response) {
122 videoChangeOwnership.status = VideoChangeOwnershipStatus.ACCEPTED 122 videoChangeOwnership.status = VideoChangeOwnershipStatus.ACCEPTED
123 await videoChangeOwnership.save({ transaction: t }) 123 await videoChangeOwnership.save({ transaction: t })
124 124
125 return res.sendStatus(HttpStatusCode.NO_CONTENT_204) 125 return res.status(HttpStatusCode.NO_CONTENT_204).end()
126 }) 126 })
127} 127}
128 128
129async function refuseOwnership (req: express.Request, res: express.Response) { 129function refuseOwnership (req: express.Request, res: express.Response) {
130 return sequelizeTypescript.transaction(async t => { 130 return sequelizeTypescript.transaction(async t => {
131 const videoChangeOwnership = res.locals.videoChangeOwnership 131 const videoChangeOwnership = res.locals.videoChangeOwnership
132 132
133 videoChangeOwnership.status = VideoChangeOwnershipStatus.REFUSED 133 videoChangeOwnership.status = VideoChangeOwnershipStatus.REFUSED
134 await videoChangeOwnership.save({ transaction: t }) 134 await videoChangeOwnership.save({ transaction: t })
135 135
136 return res.sendStatus(HttpStatusCode.NO_CONTENT_204) 136 return res.status(HttpStatusCode.NO_CONTENT_204).end()
137 }) 137 })
138} 138}
diff --git a/server/controllers/api/videos/update.ts b/server/controllers/api/videos/update.ts
new file mode 100644
index 000000000..8affe71c6
--- /dev/null
+++ b/server/controllers/api/videos/update.ts
@@ -0,0 +1,193 @@
1import * as express from 'express'
2import { Transaction } from 'sequelize/types'
3import { changeVideoChannelShare } from '@server/lib/activitypub/share'
4import { buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
5import { FilteredModelAttributes } from '@server/types'
6import { MVideoFullLight } from '@server/types/models'
7import { VideoUpdate } from '../../../../shared'
8import { HttpStatusCode } from '../../../../shared/core-utils/miscs'
9import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
10import { resetSequelizeInstance } from '../../../helpers/database-utils'
11import { createReqFiles } from '../../../helpers/express-utils'
12import { logger, loggerTagsFactory } from '../../../helpers/logger'
13import { CONFIG } from '../../../initializers/config'
14import { MIMETYPES } from '../../../initializers/constants'
15import { sequelizeTypescript } from '../../../initializers/database'
16import { federateVideoIfNeeded } from '../../../lib/activitypub/videos'
17import { Notifier } from '../../../lib/notifier'
18import { Hooks } from '../../../lib/plugins/hooks'
19import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
20import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares'
21import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
22import { VideoModel } from '../../../models/video/video'
23import { openapiOperationDoc } from '@server/middlewares/doc'
24
25const lTags = loggerTagsFactory('api', 'video')
26const auditLogger = auditLoggerFactory('videos')
27const updateRouter = express.Router()
28
29const reqVideoFileUpdate = createReqFiles(
30 [ 'thumbnailfile', 'previewfile' ],
31 MIMETYPES.IMAGE.MIMETYPE_EXT,
32 {
33 thumbnailfile: CONFIG.STORAGE.TMP_DIR,
34 previewfile: CONFIG.STORAGE.TMP_DIR
35 }
36)
37
38updateRouter.put('/:id',
39 openapiOperationDoc({ operationId: 'putVideo' }),
40 authenticate,
41 reqVideoFileUpdate,
42 asyncMiddleware(videosUpdateValidator),
43 asyncRetryTransactionMiddleware(updateVideo)
44)
45
46// ---------------------------------------------------------------------------
47
48export {
49 updateRouter
50}
51
52// ---------------------------------------------------------------------------
53
54export async function updateVideo (req: express.Request, res: express.Response) {
55 const videoInstance = res.locals.videoAll
56 const videoFieldsSave = videoInstance.toJSON()
57 const oldVideoAuditView = new VideoAuditView(videoInstance.toFormattedDetailsJSON())
58 const videoInfoToUpdate: VideoUpdate = req.body
59
60 const wasConfidentialVideo = videoInstance.isConfidential()
61 const hadPrivacyForFederation = videoInstance.hasPrivacyForFederation()
62
63 const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({
64 video: videoInstance,
65 files: req.files,
66 fallback: () => Promise.resolve(undefined),
67 automaticallyGenerated: false
68 })
69
70 try {
71 const videoInstanceUpdated = await sequelizeTypescript.transaction(async t => {
72 const sequelizeOptions = { transaction: t }
73 const oldVideoChannel = videoInstance.VideoChannel
74
75 const keysToUpdate: (keyof VideoUpdate & FilteredModelAttributes<VideoModel>)[] = [
76 'name',
77 'category',
78 'licence',
79 'language',
80 'nsfw',
81 'waitTranscoding',
82 'support',
83 'description',
84 'commentsEnabled',
85 'downloadEnabled'
86 ]
87
88 for (const key of keysToUpdate) {
89 if (videoInfoToUpdate[key] !== undefined) videoInstance.set(key, videoInfoToUpdate[key])
90 }
91
92 if (videoInfoToUpdate.originallyPublishedAt !== undefined && videoInfoToUpdate.originallyPublishedAt !== null) {
93 videoInstance.originallyPublishedAt = new Date(videoInfoToUpdate.originallyPublishedAt)
94 }
95
96 // Privacy update?
97 let isNewVideo = false
98 if (videoInfoToUpdate.privacy !== undefined) {
99 isNewVideo = await updateVideoPrivacy({ videoInstance, videoInfoToUpdate, hadPrivacyForFederation, transaction: t })
100 }
101
102 const videoInstanceUpdated = await videoInstance.save(sequelizeOptions) as MVideoFullLight
103
104 // Thumbnail & preview updates?
105 if (thumbnailModel) await videoInstanceUpdated.addAndSaveThumbnail(thumbnailModel, t)
106 if (previewModel) await videoInstanceUpdated.addAndSaveThumbnail(previewModel, t)
107
108 // Video tags update?
109 if (videoInfoToUpdate.tags !== undefined) {
110 await setVideoTags({ video: videoInstanceUpdated, tags: videoInfoToUpdate.tags, transaction: t })
111 }
112
113 // Video channel update?
114 if (res.locals.videoChannel && videoInstanceUpdated.channelId !== res.locals.videoChannel.id) {
115 await videoInstanceUpdated.$set('VideoChannel', res.locals.videoChannel, { transaction: t })
116 videoInstanceUpdated.VideoChannel = res.locals.videoChannel
117
118 if (hadPrivacyForFederation === true) await changeVideoChannelShare(videoInstanceUpdated, oldVideoChannel, t)
119 }
120
121 // Schedule an update in the future?
122 await updateSchedule(videoInstanceUpdated, videoInfoToUpdate, t)
123
124 await autoBlacklistVideoIfNeeded({
125 video: videoInstanceUpdated,
126 user: res.locals.oauth.token.User,
127 isRemote: false,
128 isNew: false,
129 transaction: t
130 })
131
132 await federateVideoIfNeeded(videoInstanceUpdated, isNewVideo, t)
133
134 auditLogger.update(
135 getAuditIdFromRes(res),
136 new VideoAuditView(videoInstanceUpdated.toFormattedDetailsJSON()),
137 oldVideoAuditView
138 )
139 logger.info('Video with name %s and uuid %s updated.', videoInstance.name, videoInstance.uuid, lTags(videoInstance.uuid))
140
141 return videoInstanceUpdated
142 })
143
144 if (wasConfidentialVideo) {
145 Notifier.Instance.notifyOnNewVideoIfNeeded(videoInstanceUpdated)
146 }
147
148 Hooks.runAction('action:api.video.updated', { video: videoInstanceUpdated, body: req.body })
149 } catch (err) {
150 // Force fields we want to update
151 // If the transaction is retried, sequelize will think the object has not changed
152 // So it will skip the SQL request, even if the last one was ROLLBACKed!
153 resetSequelizeInstance(videoInstance, videoFieldsSave)
154
155 throw err
156 }
157
158 return res.type('json')
159 .status(HttpStatusCode.NO_CONTENT_204)
160 .end()
161}
162
163async function updateVideoPrivacy (options: {
164 videoInstance: MVideoFullLight
165 videoInfoToUpdate: VideoUpdate
166 hadPrivacyForFederation: boolean
167 transaction: Transaction
168}) {
169 const { videoInstance, videoInfoToUpdate, hadPrivacyForFederation, transaction } = options
170 const isNewVideo = videoInstance.isNewVideo(videoInfoToUpdate.privacy)
171
172 const newPrivacy = parseInt(videoInfoToUpdate.privacy.toString(), 10)
173 videoInstance.setPrivacy(newPrivacy)
174
175 // Unfederate the video if the new privacy is not compatible with federation
176 if (hadPrivacyForFederation && !videoInstance.hasPrivacyForFederation()) {
177 await VideoModel.sendDelete(videoInstance, { transaction })
178 }
179
180 return isNewVideo
181}
182
183function updateSchedule (videoInstance: MVideoFullLight, videoInfoToUpdate: VideoUpdate, transaction: Transaction) {
184 if (videoInfoToUpdate.scheduleUpdate) {
185 return ScheduleVideoUpdateModel.upsert({
186 videoId: videoInstance.id,
187 updateAt: new Date(videoInfoToUpdate.scheduleUpdate.updateAt),
188 privacy: videoInfoToUpdate.scheduleUpdate.privacy || null
189 }, { transaction })
190 } else if (videoInfoToUpdate.scheduleUpdate === null) {
191 return ScheduleVideoUpdateModel.deleteByVideoId(videoInstance.id, transaction)
192 }
193}
diff --git a/server/controllers/api/videos/upload.ts b/server/controllers/api/videos/upload.ts
new file mode 100644
index 000000000..bcd21ac99
--- /dev/null
+++ b/server/controllers/api/videos/upload.ts
@@ -0,0 +1,278 @@
1import * as express from 'express'
2import { move } from 'fs-extra'
3import { getLowercaseExtension } from '@server/helpers/core-utils'
4import { deleteResumableUploadMetaFile, getResumableUploadPath } from '@server/helpers/upload'
5import { uuidToShort } from '@server/helpers/uuid'
6import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
7import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url'
8import { addOptimizeOrMergeAudioJob, buildLocalVideoFromReq, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
9import { generateVideoFilename, getVideoFilePath } from '@server/lib/video-paths'
10import { openapiOperationDoc } from '@server/middlewares/doc'
11import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
12import { uploadx } from '@uploadx/core'
13import { VideoCreate, VideoState } from '../../../../shared'
14import { HttpStatusCode } from '../../../../shared/core-utils/miscs'
15import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
16import { retryTransactionWrapper } from '../../../helpers/database-utils'
17import { createReqFiles } from '../../../helpers/express-utils'
18import { getMetadataFromFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffprobe-utils'
19import { logger, loggerTagsFactory } from '../../../helpers/logger'
20import { CONFIG } from '../../../initializers/config'
21import { DEFAULT_AUDIO_RESOLUTION, MIMETYPES } from '../../../initializers/constants'
22import { sequelizeTypescript } from '../../../initializers/database'
23import { federateVideoIfNeeded } from '../../../lib/activitypub/videos'
24import { Notifier } from '../../../lib/notifier'
25import { Hooks } from '../../../lib/plugins/hooks'
26import { generateVideoMiniature } from '../../../lib/thumbnail'
27import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
28import {
29 asyncMiddleware,
30 asyncRetryTransactionMiddleware,
31 authenticate,
32 videosAddLegacyValidator,
33 videosAddResumableInitValidator,
34 videosAddResumableValidator
35} from '../../../middlewares'
36import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
37import { VideoModel } from '../../../models/video/video'
38import { VideoFileModel } from '../../../models/video/video-file'
39
40const lTags = loggerTagsFactory('api', 'video')
41const auditLogger = auditLoggerFactory('videos')
42const uploadRouter = express.Router()
43const uploadxMiddleware = uploadx.upload({ directory: getResumableUploadPath() })
44
45const reqVideoFileAdd = createReqFiles(
46 [ 'videofile', 'thumbnailfile', 'previewfile' ],
47 Object.assign({}, MIMETYPES.VIDEO.MIMETYPE_EXT, MIMETYPES.IMAGE.MIMETYPE_EXT),
48 {
49 videofile: CONFIG.STORAGE.TMP_DIR,
50 thumbnailfile: CONFIG.STORAGE.TMP_DIR,
51 previewfile: CONFIG.STORAGE.TMP_DIR
52 }
53)
54
55const reqVideoFileAddResumable = createReqFiles(
56 [ 'thumbnailfile', 'previewfile' ],
57 MIMETYPES.IMAGE.MIMETYPE_EXT,
58 {
59 thumbnailfile: getResumableUploadPath(),
60 previewfile: getResumableUploadPath()
61 }
62)
63
64uploadRouter.post('/upload',
65 openapiOperationDoc({ operationId: 'uploadLegacy' }),
66 authenticate,
67 reqVideoFileAdd,
68 asyncMiddleware(videosAddLegacyValidator),
69 asyncRetryTransactionMiddleware(addVideoLegacy)
70)
71
72uploadRouter.post('/upload-resumable',
73 openapiOperationDoc({ operationId: 'uploadResumableInit' }),
74 authenticate,
75 reqVideoFileAddResumable,
76 asyncMiddleware(videosAddResumableInitValidator),
77 uploadxMiddleware
78)
79
80uploadRouter.delete('/upload-resumable',
81 authenticate,
82 uploadxMiddleware
83)
84
85uploadRouter.put('/upload-resumable',
86 openapiOperationDoc({ operationId: 'uploadResumable' }),
87 authenticate,
88 uploadxMiddleware, // uploadx doesn't use call next() before the file upload completes
89 asyncMiddleware(videosAddResumableValidator),
90 asyncMiddleware(addVideoResumable)
91)
92
93// ---------------------------------------------------------------------------
94
95export {
96 uploadRouter
97}
98
99// ---------------------------------------------------------------------------
100
101export async function addVideoLegacy (req: express.Request, res: express.Response) {
102 // Uploading the video could be long
103 // Set timeout to 10 minutes, as Express's default is 2 minutes
104 req.setTimeout(1000 * 60 * 10, () => {
105 logger.error('Video upload has timed out.')
106 return res.fail({
107 status: HttpStatusCode.REQUEST_TIMEOUT_408,
108 message: 'Video upload has timed out.'
109 })
110 })
111
112 const videoPhysicalFile = req.files['videofile'][0]
113 const videoInfo: VideoCreate = req.body
114 const files = req.files
115
116 return addVideo({ res, videoPhysicalFile, videoInfo, files })
117}
118
119export async function addVideoResumable (_req: express.Request, res: express.Response) {
120 const videoPhysicalFile = res.locals.videoFileResumable
121 const videoInfo = videoPhysicalFile.metadata
122 const files = { previewfile: videoInfo.previewfile }
123
124 // Don't need the meta file anymore
125 await deleteResumableUploadMetaFile(videoPhysicalFile.path)
126
127 return addVideo({ res, videoPhysicalFile, videoInfo, files })
128}
129
130async function addVideo (options: {
131 res: express.Response
132 videoPhysicalFile: express.VideoUploadFile
133 videoInfo: VideoCreate
134 files: express.UploadFiles
135}) {
136 const { res, videoPhysicalFile, videoInfo, files } = options
137 const videoChannel = res.locals.videoChannel
138 const user = res.locals.oauth.token.User
139
140 const videoData = buildLocalVideoFromReq(videoInfo, videoChannel.id)
141
142 videoData.state = CONFIG.TRANSCODING.ENABLED
143 ? VideoState.TO_TRANSCODE
144 : VideoState.PUBLISHED
145
146 videoData.duration = videoPhysicalFile.duration // duration was added by a previous middleware
147
148 const video = new VideoModel(videoData) as MVideoFullLight
149 video.VideoChannel = videoChannel
150 video.url = getLocalVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object
151
152 const videoFile = await buildNewFile(video, videoPhysicalFile)
153
154 // Move physical file
155 const destination = getVideoFilePath(video, videoFile)
156 await move(videoPhysicalFile.path, destination)
157 // This is important in case if there is another attempt in the retry process
158 videoPhysicalFile.filename = getVideoFilePath(video, videoFile)
159 videoPhysicalFile.path = destination
160
161 const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({
162 video,
163 files,
164 fallback: type => generateVideoMiniature({ video, videoFile, type })
165 })
166
167 const { videoCreated } = await sequelizeTypescript.transaction(async t => {
168 const sequelizeOptions = { transaction: t }
169
170 const videoCreated = await video.save(sequelizeOptions) as MVideoFullLight
171
172 await videoCreated.addAndSaveThumbnail(thumbnailModel, t)
173 await videoCreated.addAndSaveThumbnail(previewModel, t)
174
175 // Do not forget to add video channel information to the created video
176 videoCreated.VideoChannel = res.locals.videoChannel
177
178 videoFile.videoId = video.id
179 await videoFile.save(sequelizeOptions)
180
181 video.VideoFiles = [ videoFile ]
182
183 await setVideoTags({ video, tags: videoInfo.tags, transaction: t })
184
185 // Schedule an update in the future?
186 if (videoInfo.scheduleUpdate) {
187 await ScheduleVideoUpdateModel.create({
188 videoId: video.id,
189 updateAt: new Date(videoInfo.scheduleUpdate.updateAt),
190 privacy: videoInfo.scheduleUpdate.privacy || null
191 }, sequelizeOptions)
192 }
193
194 // Channel has a new content, set as updated
195 await videoCreated.VideoChannel.setAsUpdated(t)
196
197 await autoBlacklistVideoIfNeeded({
198 video,
199 user,
200 isRemote: false,
201 isNew: true,
202 transaction: t
203 })
204
205 auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON()))
206 logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid, lTags(videoCreated.uuid))
207
208 return { videoCreated }
209 })
210
211 createTorrentFederate(video, videoFile)
212
213 if (video.state === VideoState.TO_TRANSCODE) {
214 await addOptimizeOrMergeAudioJob(videoCreated, videoFile, user)
215 }
216
217 Hooks.runAction('action:api.video.uploaded', { video: videoCreated })
218
219 return res.json({
220 video: {
221 id: videoCreated.id,
222 shortUUID: uuidToShort(videoCreated.uuid),
223 uuid: videoCreated.uuid
224 }
225 })
226}
227
228async function buildNewFile (video: MVideo, videoPhysicalFile: express.VideoUploadFile) {
229 const videoFile = new VideoFileModel({
230 extname: getLowercaseExtension(videoPhysicalFile.filename),
231 size: videoPhysicalFile.size,
232 videoStreamingPlaylistId: null,
233 metadata: await getMetadataFromFile(videoPhysicalFile.path)
234 })
235
236 if (videoFile.isAudio()) {
237 videoFile.resolution = DEFAULT_AUDIO_RESOLUTION
238 } else {
239 videoFile.fps = await getVideoFileFPS(videoPhysicalFile.path)
240 videoFile.resolution = (await getVideoFileResolution(videoPhysicalFile.path)).videoFileResolution
241 }
242
243 videoFile.filename = generateVideoFilename(video, false, videoFile.resolution, videoFile.extname)
244
245 return videoFile
246}
247
248async function createTorrentAndSetInfoHashAsync (video: MVideo, fileArg: MVideoFile) {
249 await createTorrentAndSetInfoHash(video, fileArg)
250
251 // Refresh videoFile because the createTorrentAndSetInfoHash could be long
252 const refreshedFile = await VideoFileModel.loadWithVideo(fileArg.id)
253 // File does not exist anymore, remove the generated torrent
254 if (!refreshedFile) return fileArg.removeTorrent()
255
256 refreshedFile.infoHash = fileArg.infoHash
257 refreshedFile.torrentFilename = fileArg.torrentFilename
258
259 return refreshedFile.save()
260}
261
262function createTorrentFederate (video: MVideoFullLight, videoFile: MVideoFile): void {
263 // Create the torrent file in async way because it could be long
264 createTorrentAndSetInfoHashAsync(video, videoFile)
265 .catch(err => logger.error('Cannot create torrent file for video %s', video.url, { err, ...lTags(video.uuid) }))
266 .then(() => VideoModel.loadAndPopulateAccountAndServerAndTags(video.id))
267 .then(refreshedVideo => {
268 if (!refreshedVideo) return
269
270 // Only federate and notify after the torrent creation
271 Notifier.Instance.notifyOnNewVideoIfNeeded(refreshedVideo)
272
273 return retryTransactionWrapper(() => {
274 return sequelizeTypescript.transaction(t => federateVideoIfNeeded(refreshedVideo, true, t))
275 })
276 })
277 .catch(err => logger.error('Cannot federate or notify video creation %s', video.url, { err, ...lTags(video.uuid) }))
278}
diff --git a/server/controllers/api/videos/watching.ts b/server/controllers/api/videos/watching.ts
index 627f12aa9..8b15525aa 100644
--- a/server/controllers/api/videos/watching.ts
+++ b/server/controllers/api/videos/watching.ts
@@ -1,12 +1,19 @@
1import * as express from 'express' 1import * as express from 'express'
2import { UserWatchingVideo } from '../../../../shared' 2import { UserWatchingVideo } from '../../../../shared'
3import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoWatchingValidator } from '../../../middlewares' 3import {
4import { UserVideoHistoryModel } from '../../../models/account/user-video-history' 4 asyncMiddleware,
5 asyncRetryTransactionMiddleware,
6 authenticate,
7 openapiOperationDoc,
8 videoWatchingValidator
9} from '../../../middlewares'
10import { UserVideoHistoryModel } from '../../../models/user/user-video-history'
5import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' 11import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
6 12
7const watchingRouter = express.Router() 13const watchingRouter = express.Router()
8 14
9watchingRouter.put('/:videoId/watching', 15watchingRouter.put('/:videoId/watching',
16 openapiOperationDoc({ operationId: 'setProgress' }),
10 authenticate, 17 authenticate,
11 asyncMiddleware(videoWatchingValidator), 18 asyncMiddleware(videoWatchingValidator),
12 asyncRetryTransactionMiddleware(userWatchVideo) 19 asyncRetryTransactionMiddleware(userWatchVideo)