diff options
Diffstat (limited to 'server/controllers/api/videos')
-rw-r--r-- | server/controllers/api/videos/blacklist.ts | 10 | ||||
-rw-r--r-- | server/controllers/api/videos/comment.ts | 7 | ||||
-rw-r--r-- | server/controllers/api/videos/import.ts | 189 | ||||
-rw-r--r-- | server/controllers/api/videos/index.ts | 454 | ||||
-rw-r--r-- | server/controllers/api/videos/live.ts | 13 | ||||
-rw-r--r-- | server/controllers/api/videos/ownership.ts | 12 | ||||
-rw-r--r-- | server/controllers/api/videos/update.ts | 193 | ||||
-rw-r--r-- | server/controllers/api/videos/upload.ts | 278 | ||||
-rw-r--r-- | server/controllers/api/videos/watching.ts | 11 |
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 | |||
23 | const blacklistRouter = express.Router() | 24 | const blacklistRouter = express.Router() |
24 | 25 | ||
25 | blacklistRouter.post('/:videoId/blacklist', | 26 | blacklistRouter.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 | ||
32 | blacklistRouter.get('/blacklist', | 34 | blacklistRouter.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 | ||
50 | blacklistRouter.delete('/:videoId/blacklist', | 53 | blacklistRouter.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 | ||
76 | async function updateVideoBlacklistController (req: express.Request, res: express.Response) { | 80 | async 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 | ||
88 | async function listBlacklist (req: express.Request, res: express.Response) { | 92 | async 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 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' | 2 | import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' |
3 | import { ResultList, ThreadsResultList, UserRight } from '../../../../shared/models' | 3 | import { ResultList, ThreadsResultList, UserRight } from '../../../../shared/models' |
4 | import { VideoCommentCreate } from '../../../../shared/models/videos/video-comment.model' | 4 | import { VideoCommentCreate } from '../../../../shared/models/videos/comment/video-comment.model' |
5 | import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger' | 5 | import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger' |
6 | import { getFormattedObjects } from '../../../helpers/utils' | 6 | import { getFormattedObjects } from '../../../helpers/utils' |
7 | import { sequelizeTypescript } from '../../../initializers/database' | 7 | import { 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' | |||
3 | import * as magnetUtil from 'magnet-uri' | 3 | import * as magnetUtil from 'magnet-uri' |
4 | import * as parseTorrent from 'parse-torrent' | 4 | import * as parseTorrent from 'parse-torrent' |
5 | import { join } from 'path' | 5 | import { join } from 'path' |
6 | import { ServerConfigManager } from '@server/lib/server-config-manager' | ||
6 | import { setVideoTags } from '@server/lib/video' | 7 | import { setVideoTags } from '@server/lib/video' |
8 | import { FilteredModelAttributes } from '@server/types' | ||
7 | import { | 9 | import { |
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' |
17 | import { MVideoImport, MVideoImportFormattable } from '@server/types/models/video/video-import' | 19 | import { MVideoImportFormattable } from '@server/types/models/video/video-import' |
18 | import { VideoImportCreate, VideoImportState, VideoPrivacy, VideoState } from '../../../../shared' | 20 | import { ServerErrorCode, VideoImportCreate, VideoImportState, VideoPrivacy, VideoState } from '../../../../shared' |
19 | import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' | ||
20 | import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type' | 21 | import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type' |
21 | import { auditLoggerFactory, getAuditIdFromRes, VideoImportAuditView } from '../../../helpers/audit-logger' | 22 | import { auditLoggerFactory, getAuditIdFromRes, VideoImportAuditView } from '../../../helpers/audit-logger' |
22 | import { moveAndProcessCaptionFile } from '../../../helpers/captions-utils' | 23 | import { moveAndProcessCaptionFile } from '../../../helpers/captions-utils' |
23 | import { isArray } from '../../../helpers/custom-validators/misc' | 24 | import { isArray } from '../../../helpers/custom-validators/misc' |
24 | import { createReqFiles } from '../../../helpers/express-utils' | 25 | import { cleanUpReqFiles, createReqFiles } from '../../../helpers/express-utils' |
25 | import { logger } from '../../../helpers/logger' | 26 | import { logger } from '../../../helpers/logger' |
26 | import { getSecureTorrentName } from '../../../helpers/utils' | 27 | import { getSecureTorrentName } from '../../../helpers/utils' |
27 | import { getYoutubeDLInfo, getYoutubeDLSubs, YoutubeDLInfo } from '../../../helpers/youtube-dl' | 28 | import { YoutubeDL, YoutubeDLInfo } from '../../../helpers/youtube-dl' |
28 | import { CONFIG } from '../../../initializers/config' | 29 | import { CONFIG } from '../../../initializers/config' |
29 | import { MIMETYPES } from '../../../initializers/constants' | 30 | import { MIMETYPES } from '../../../initializers/constants' |
30 | import { sequelizeTypescript } from '../../../initializers/database' | 31 | import { sequelizeTypescript } from '../../../initializers/database' |
31 | import { getLocalVideoActivityPubUrl } from '../../../lib/activitypub/url' | 32 | import { getLocalVideoActivityPubUrl } from '../../../lib/activitypub/url' |
32 | import { JobQueue } from '../../../lib/job-queue/job-queue' | 33 | import { JobQueue } from '../../../lib/job-queue/job-queue' |
33 | import { createVideoMiniatureFromExisting, createVideoMiniatureFromUrl } from '../../../lib/thumbnail' | 34 | import { updateVideoMiniatureFromExisting, updateVideoMiniatureFromUrl } from '../../../lib/thumbnail' |
34 | import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist' | 35 | import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist' |
35 | import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoImportAddValidator } from '../../../middlewares' | 36 | import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoImportAddValidator } from '../../../middlewares' |
36 | import { VideoModel } from '../../../models/video/video' | 37 | import { 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 | ||
283 | async function processThumbnailFromUrl (url: string, video: MVideoThumbnail) { | 260 | async 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 | ||
292 | async function processPreviewFromUrl (url: string, video: MVideoThumbnail) { | 269 | async 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 | |||
323 | async 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 | |||
350 | function 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 | |||
360 | function extractNameFromArray (name: string | string[]) { | ||
361 | return isArray(name) ? name[0] : name | ||
362 | } | ||
363 | |||
364 | async 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 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { move } from 'fs-extra' | ||
3 | import { extname } from 'path' | ||
4 | import toInt from 'validator/lib/toInt' | 2 | import toInt from 'validator/lib/toInt' |
5 | import { deleteResumableUploadMetaFile, getResumableUploadPath } from '@server/helpers/upload' | 3 | import { doJSONRequest } from '@server/helpers/requests' |
6 | import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' | 4 | import { LiveManager } from '@server/lib/live' |
7 | import { changeVideoChannelShare } from '@server/lib/activitypub/share' | 5 | import { openapiOperationDoc } from '@server/middlewares/doc' |
8 | import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url' | ||
9 | import { LiveManager } from '@server/lib/live-manager' | ||
10 | import { addOptimizeOrMergeAudioJob, buildLocalVideoFromReq, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video' | ||
11 | import { generateVideoFilename, getVideoFilePath } from '@server/lib/video-paths' | ||
12 | import { getServerActor } from '@server/models/application/application' | 6 | import { getServerActor } from '@server/models/application/application' |
13 | import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models' | 7 | import { MVideoAccountLight } from '@server/types/models' |
14 | import { uploadx } from '@uploadx/core' | 8 | import { VideosCommonQuery } from '../../../../shared' |
15 | import { VideoCreate, VideosCommonQuery, VideoState, VideoUpdate } from '../../../../shared' | ||
16 | import { HttpStatusCode } from '../../../../shared/core-utils/miscs' | 9 | import { HttpStatusCode } from '../../../../shared/core-utils/miscs' |
17 | import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' | 10 | import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' |
18 | import { resetSequelizeInstance, retryTransactionWrapper } from '../../../helpers/database-utils' | 11 | import { buildNSFWFilter, getCountVideos } from '../../../helpers/express-utils' |
19 | import { buildNSFWFilter, createReqFiles, getCountVideos } from '../../../helpers/express-utils' | 12 | import { logger } from '../../../helpers/logger' |
20 | import { getMetadataFromFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffprobe-utils' | ||
21 | import { logger, loggerTagsFactory } from '../../../helpers/logger' | ||
22 | import { getFormattedObjects } from '../../../helpers/utils' | 13 | import { getFormattedObjects } from '../../../helpers/utils' |
23 | import { CONFIG } from '../../../initializers/config' | 14 | import { REMOTE_SCHEME, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES } from '../../../initializers/constants' |
24 | import { | ||
25 | DEFAULT_AUDIO_RESOLUTION, | ||
26 | MIMETYPES, | ||
27 | VIDEO_CATEGORIES, | ||
28 | VIDEO_LANGUAGES, | ||
29 | VIDEO_LICENCES, | ||
30 | VIDEO_PRIVACIES | ||
31 | } from '../../../initializers/constants' | ||
32 | import { sequelizeTypescript } from '../../../initializers/database' | 15 | import { sequelizeTypescript } from '../../../initializers/database' |
33 | import { sendView } from '../../../lib/activitypub/send/send-view' | 16 | import { sendView } from '../../../lib/activitypub/send/send-view' |
34 | import { federateVideoIfNeeded, fetchRemoteVideoDescription } from '../../../lib/activitypub/videos' | ||
35 | import { JobQueue } from '../../../lib/job-queue' | 17 | import { JobQueue } from '../../../lib/job-queue' |
36 | import { Notifier } from '../../../lib/notifier' | ||
37 | import { Hooks } from '../../../lib/plugins/hooks' | 18 | import { Hooks } from '../../../lib/plugins/hooks' |
38 | import { Redis } from '../../../lib/redis' | 19 | import { Redis } from '../../../lib/redis' |
39 | import { generateVideoMiniature } from '../../../lib/thumbnail' | ||
40 | import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist' | ||
41 | import { | 20 | import { |
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' |
61 | import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' | ||
62 | import { VideoModel } from '../../../models/video/video' | 36 | import { VideoModel } from '../../../models/video/video' |
63 | import { VideoFileModel } from '../../../models/video/video-file' | 37 | import { VideoFileModel } from '../../../models/video/video-file' |
64 | import { blacklistRouter } from './blacklist' | 38 | import { blacklistRouter } from './blacklist' |
@@ -68,40 +42,12 @@ import { videoImportsRouter } from './import' | |||
68 | import { liveRouter } from './live' | 42 | import { liveRouter } from './live' |
69 | import { ownershipVideoRouter } from './ownership' | 43 | import { ownershipVideoRouter } from './ownership' |
70 | import { rateVideoRouter } from './rate' | 44 | import { rateVideoRouter } from './rate' |
45 | import { updateRouter } from './update' | ||
46 | import { uploadRouter } from './upload' | ||
71 | import { watchingRouter } from './watching' | 47 | import { watchingRouter } from './watching' |
72 | 48 | ||
73 | const lTags = loggerTagsFactory('api', 'video') | ||
74 | const auditLogger = auditLoggerFactory('videos') | 49 | const auditLogger = auditLoggerFactory('videos') |
75 | const videosRouter = express.Router() | 50 | const videosRouter = express.Router() |
76 | const uploadxMiddleware = uploadx.upload({ directory: getResumableUploadPath() }) | ||
77 | |||
78 | const 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 | |||
88 | const reqVideoFileAddResumable = createReqFiles( | ||
89 | [ 'thumbnailfile', 'previewfile' ], | ||
90 | MIMETYPES.IMAGE.MIMETYPE_EXT, | ||
91 | { | ||
92 | thumbnailfile: getResumableUploadPath(), | ||
93 | previewfile: getResumableUploadPath() | ||
94 | } | ||
95 | ) | ||
96 | |||
97 | const 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 | ||
106 | videosRouter.use('/', blacklistRouter) | 52 | videosRouter.use('/', blacklistRouter) |
107 | videosRouter.use('/', rateVideoRouter) | 53 | videosRouter.use('/', rateVideoRouter) |
@@ -111,13 +57,28 @@ videosRouter.use('/', videoImportsRouter) | |||
111 | videosRouter.use('/', ownershipVideoRouter) | 57 | videosRouter.use('/', ownershipVideoRouter) |
112 | videosRouter.use('/', watchingRouter) | 58 | videosRouter.use('/', watchingRouter) |
113 | videosRouter.use('/', liveRouter) | 59 | videosRouter.use('/', liveRouter) |
60 | videosRouter.use('/', uploadRouter) | ||
61 | videosRouter.use('/', updateRouter) | ||
114 | 62 | ||
115 | videosRouter.get('/categories', listVideoCategories) | 63 | videosRouter.get('/categories', |
116 | videosRouter.get('/licences', listVideoLicences) | 64 | openapiOperationDoc({ operationId: 'getCategories' }), |
117 | videosRouter.get('/languages', listVideoLanguages) | 65 | listVideoCategories |
118 | videosRouter.get('/privacies', listVideoPrivacies) | 66 | ) |
67 | videosRouter.get('/licences', | ||
68 | openapiOperationDoc({ operationId: 'getLicences' }), | ||
69 | listVideoLicences | ||
70 | ) | ||
71 | videosRouter.get('/languages', | ||
72 | openapiOperationDoc({ operationId: 'getLanguages' }), | ||
73 | listVideoLanguages | ||
74 | ) | ||
75 | videosRouter.get('/privacies', | ||
76 | openapiOperationDoc({ operationId: 'getPrivacies' }), | ||
77 | listVideoPrivacies | ||
78 | ) | ||
119 | 79 | ||
120 | videosRouter.get('/', | 80 | videosRouter.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 | ||
130 | videosRouter.post('/upload', | ||
131 | authenticate, | ||
132 | reqVideoFileAdd, | ||
133 | asyncMiddleware(videosAddLegacyValidator), | ||
134 | asyncRetryTransactionMiddleware(addVideoLegacy) | ||
135 | ) | ||
136 | |||
137 | videosRouter.post('/upload-resumable', | ||
138 | authenticate, | ||
139 | reqVideoFileAddResumable, | ||
140 | asyncMiddleware(videosAddResumableInitValidator), | ||
141 | uploadxMiddleware | ||
142 | ) | ||
143 | |||
144 | videosRouter.delete('/upload-resumable', | ||
145 | authenticate, | ||
146 | uploadxMiddleware | ||
147 | ) | ||
148 | |||
149 | videosRouter.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 | |||
156 | videosRouter.put('/:id', | ||
157 | authenticate, | ||
158 | reqVideoFileUpdate, | ||
159 | asyncMiddleware(videosUpdateValidator), | ||
160 | asyncRetryTransactionMiddleware(updateVideo) | ||
161 | ) | ||
162 | |||
163 | videosRouter.get('/:id/description', | 91 | videosRouter.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 | ) |
171 | videosRouter.get('/:id', | 100 | videosRouter.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 | ) |
177 | videosRouter.post('/:id/views', | 107 | videosRouter.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 | ||
182 | videosRouter.delete('/:id', | 113 | videosRouter.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 | ||
212 | async function addVideoLegacy (req: express.Request, res: express.Response) { | 144 | async 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 | |||
227 | async 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 | |||
238 | async 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 | |||
363 | async 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 | |||
484 | async 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 | ||
544 | async function getVideoDescription (req: express.Request, res: express.Response) { | 197 | async 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 | ||
594 | async function removeVideo (req: express.Request, res: express.Response) { | 244 | async 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 | ||
611 | async 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 | 264 | async 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 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { v4 as uuidv4 } from 'uuid' | ||
3 | import { createReqFiles } from '@server/helpers/express-utils' | 2 | import { createReqFiles } from '@server/helpers/express-utils' |
3 | import { buildUUID, uuidToShort } from '@server/helpers/uuid' | ||
4 | import { CONFIG } from '@server/initializers/config' | 4 | import { CONFIG } from '@server/initializers/config' |
5 | import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants' | 5 | import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants' |
6 | import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url' | 6 | import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url' |
@@ -11,12 +11,12 @@ import { videoLiveAddValidator, videoLiveGetValidator, videoLiveUpdateValidator | |||
11 | import { VideoLiveModel } from '@server/models/video/video-live' | 11 | import { VideoLiveModel } from '@server/models/video/video-live' |
12 | import { MVideoDetails, MVideoFullLight } from '@server/types/models' | 12 | import { MVideoDetails, MVideoFullLight } from '@server/types/models' |
13 | import { LiveVideoCreate, LiveVideoUpdate, VideoState } from '../../../../shared' | 13 | import { LiveVideoCreate, LiveVideoUpdate, VideoState } from '../../../../shared' |
14 | import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' | ||
14 | import { logger } from '../../../helpers/logger' | 15 | import { logger } from '../../../helpers/logger' |
15 | import { sequelizeTypescript } from '../../../initializers/database' | 16 | import { sequelizeTypescript } from '../../../initializers/database' |
16 | import { createVideoMiniatureFromExisting } from '../../../lib/thumbnail' | 17 | import { updateVideoMiniatureFromExisting } from '../../../lib/thumbnail' |
17 | import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares' | 18 | import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares' |
18 | import { VideoModel } from '../../../models/video/video' | 19 | import { VideoModel } from '../../../models/video/video' |
19 | import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' | ||
20 | 20 | ||
21 | const liveRouter = express.Router() | 21 | const 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 | ||
82 | async function addLiveVideo (req: express.Request, res: express.Response) { | 82 | async 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 | ||
102 | async function acceptOwnership (req: express.Request, res: express.Response) { | 102 | function 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 | ||
129 | async function refuseOwnership (req: express.Request, res: express.Response) { | 129 | function 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 @@ | |||
1 | import * as express from 'express' | ||
2 | import { Transaction } from 'sequelize/types' | ||
3 | import { changeVideoChannelShare } from '@server/lib/activitypub/share' | ||
4 | import { buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video' | ||
5 | import { FilteredModelAttributes } from '@server/types' | ||
6 | import { MVideoFullLight } from '@server/types/models' | ||
7 | import { VideoUpdate } from '../../../../shared' | ||
8 | import { HttpStatusCode } from '../../../../shared/core-utils/miscs' | ||
9 | import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' | ||
10 | import { resetSequelizeInstance } from '../../../helpers/database-utils' | ||
11 | import { createReqFiles } from '../../../helpers/express-utils' | ||
12 | import { logger, loggerTagsFactory } from '../../../helpers/logger' | ||
13 | import { CONFIG } from '../../../initializers/config' | ||
14 | import { MIMETYPES } from '../../../initializers/constants' | ||
15 | import { sequelizeTypescript } from '../../../initializers/database' | ||
16 | import { federateVideoIfNeeded } from '../../../lib/activitypub/videos' | ||
17 | import { Notifier } from '../../../lib/notifier' | ||
18 | import { Hooks } from '../../../lib/plugins/hooks' | ||
19 | import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist' | ||
20 | import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares' | ||
21 | import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' | ||
22 | import { VideoModel } from '../../../models/video/video' | ||
23 | import { openapiOperationDoc } from '@server/middlewares/doc' | ||
24 | |||
25 | const lTags = loggerTagsFactory('api', 'video') | ||
26 | const auditLogger = auditLoggerFactory('videos') | ||
27 | const updateRouter = express.Router() | ||
28 | |||
29 | const 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 | |||
38 | updateRouter.put('/:id', | ||
39 | openapiOperationDoc({ operationId: 'putVideo' }), | ||
40 | authenticate, | ||
41 | reqVideoFileUpdate, | ||
42 | asyncMiddleware(videosUpdateValidator), | ||
43 | asyncRetryTransactionMiddleware(updateVideo) | ||
44 | ) | ||
45 | |||
46 | // --------------------------------------------------------------------------- | ||
47 | |||
48 | export { | ||
49 | updateRouter | ||
50 | } | ||
51 | |||
52 | // --------------------------------------------------------------------------- | ||
53 | |||
54 | export 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 | |||
163 | async 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 | |||
183 | function 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 @@ | |||
1 | import * as express from 'express' | ||
2 | import { move } from 'fs-extra' | ||
3 | import { getLowercaseExtension } from '@server/helpers/core-utils' | ||
4 | import { deleteResumableUploadMetaFile, getResumableUploadPath } from '@server/helpers/upload' | ||
5 | import { uuidToShort } from '@server/helpers/uuid' | ||
6 | import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' | ||
7 | import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url' | ||
8 | import { addOptimizeOrMergeAudioJob, buildLocalVideoFromReq, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video' | ||
9 | import { generateVideoFilename, getVideoFilePath } from '@server/lib/video-paths' | ||
10 | import { openapiOperationDoc } from '@server/middlewares/doc' | ||
11 | import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models' | ||
12 | import { uploadx } from '@uploadx/core' | ||
13 | import { VideoCreate, VideoState } from '../../../../shared' | ||
14 | import { HttpStatusCode } from '../../../../shared/core-utils/miscs' | ||
15 | import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' | ||
16 | import { retryTransactionWrapper } from '../../../helpers/database-utils' | ||
17 | import { createReqFiles } from '../../../helpers/express-utils' | ||
18 | import { getMetadataFromFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffprobe-utils' | ||
19 | import { logger, loggerTagsFactory } from '../../../helpers/logger' | ||
20 | import { CONFIG } from '../../../initializers/config' | ||
21 | import { DEFAULT_AUDIO_RESOLUTION, MIMETYPES } from '../../../initializers/constants' | ||
22 | import { sequelizeTypescript } from '../../../initializers/database' | ||
23 | import { federateVideoIfNeeded } from '../../../lib/activitypub/videos' | ||
24 | import { Notifier } from '../../../lib/notifier' | ||
25 | import { Hooks } from '../../../lib/plugins/hooks' | ||
26 | import { generateVideoMiniature } from '../../../lib/thumbnail' | ||
27 | import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist' | ||
28 | import { | ||
29 | asyncMiddleware, | ||
30 | asyncRetryTransactionMiddleware, | ||
31 | authenticate, | ||
32 | videosAddLegacyValidator, | ||
33 | videosAddResumableInitValidator, | ||
34 | videosAddResumableValidator | ||
35 | } from '../../../middlewares' | ||
36 | import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' | ||
37 | import { VideoModel } from '../../../models/video/video' | ||
38 | import { VideoFileModel } from '../../../models/video/video-file' | ||
39 | |||
40 | const lTags = loggerTagsFactory('api', 'video') | ||
41 | const auditLogger = auditLoggerFactory('videos') | ||
42 | const uploadRouter = express.Router() | ||
43 | const uploadxMiddleware = uploadx.upload({ directory: getResumableUploadPath() }) | ||
44 | |||
45 | const 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 | |||
55 | const reqVideoFileAddResumable = createReqFiles( | ||
56 | [ 'thumbnailfile', 'previewfile' ], | ||
57 | MIMETYPES.IMAGE.MIMETYPE_EXT, | ||
58 | { | ||
59 | thumbnailfile: getResumableUploadPath(), | ||
60 | previewfile: getResumableUploadPath() | ||
61 | } | ||
62 | ) | ||
63 | |||
64 | uploadRouter.post('/upload', | ||
65 | openapiOperationDoc({ operationId: 'uploadLegacy' }), | ||
66 | authenticate, | ||
67 | reqVideoFileAdd, | ||
68 | asyncMiddleware(videosAddLegacyValidator), | ||
69 | asyncRetryTransactionMiddleware(addVideoLegacy) | ||
70 | ) | ||
71 | |||
72 | uploadRouter.post('/upload-resumable', | ||
73 | openapiOperationDoc({ operationId: 'uploadResumableInit' }), | ||
74 | authenticate, | ||
75 | reqVideoFileAddResumable, | ||
76 | asyncMiddleware(videosAddResumableInitValidator), | ||
77 | uploadxMiddleware | ||
78 | ) | ||
79 | |||
80 | uploadRouter.delete('/upload-resumable', | ||
81 | authenticate, | ||
82 | uploadxMiddleware | ||
83 | ) | ||
84 | |||
85 | uploadRouter.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 | |||
95 | export { | ||
96 | uploadRouter | ||
97 | } | ||
98 | |||
99 | // --------------------------------------------------------------------------- | ||
100 | |||
101 | export 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 | |||
119 | export 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 | |||
130 | async 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 | |||
228 | async 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 | |||
248 | async 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 | |||
262 | function 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 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { UserWatchingVideo } from '../../../../shared' | 2 | import { UserWatchingVideo } from '../../../../shared' |
3 | import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoWatchingValidator } from '../../../middlewares' | 3 | import { |
4 | import { UserVideoHistoryModel } from '../../../models/account/user-video-history' | 4 | asyncMiddleware, |
5 | asyncRetryTransactionMiddleware, | ||
6 | authenticate, | ||
7 | openapiOperationDoc, | ||
8 | videoWatchingValidator | ||
9 | } from '../../../middlewares' | ||
10 | import { UserVideoHistoryModel } from '../../../models/user/user-video-history' | ||
5 | import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' | 11 | import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' |
6 | 12 | ||
7 | const watchingRouter = express.Router() | 13 | const watchingRouter = express.Router() |
8 | 14 | ||
9 | watchingRouter.put('/:videoId/watching', | 15 | watchingRouter.put('/:videoId/watching', |
16 | openapiOperationDoc({ operationId: 'setProgress' }), | ||
10 | authenticate, | 17 | authenticate, |
11 | asyncMiddleware(videoWatchingValidator), | 18 | asyncMiddleware(videoWatchingValidator), |
12 | asyncRetryTransactionMiddleware(userWatchVideo) | 19 | asyncRetryTransactionMiddleware(userWatchVideo) |