diff options
author | Chocobozzz <me@florianbigard.com> | 2023-04-21 14:55:10 +0200 |
---|---|---|
committer | Chocobozzz <chocobozzz@cpy.re> | 2023-05-09 08:57:34 +0200 |
commit | 0c9668f77901e7540e2c7045eb0f2974a4842a69 (patch) | |
tree | 226d3dd1565b0bb56588897af3b8530e6216e96b /server/middlewares/validators | |
parent | 6bcb854cdea8688a32240bc5719c7d139806e00b (diff) | |
download | PeerTube-0c9668f77901e7540e2c7045eb0f2974a4842a69.tar.gz PeerTube-0c9668f77901e7540e2c7045eb0f2974a4842a69.tar.zst PeerTube-0c9668f77901e7540e2c7045eb0f2974a4842a69.zip |
Implement remote runner jobs in server
Move ffmpeg functions to @shared
Diffstat (limited to 'server/middlewares/validators')
-rw-r--r-- | server/middlewares/validators/config.ts | 2 | ||||
-rw-r--r-- | server/middlewares/validators/runners/index.ts | 3 | ||||
-rw-r--r-- | server/middlewares/validators/runners/job-files.ts | 27 | ||||
-rw-r--r-- | server/middlewares/validators/runners/jobs.ts | 156 | ||||
-rw-r--r-- | server/middlewares/validators/runners/registration-token.ts | 37 | ||||
-rw-r--r-- | server/middlewares/validators/runners/runners.ts | 95 | ||||
-rw-r--r-- | server/middlewares/validators/sort.ts | 4 | ||||
-rw-r--r-- | server/middlewares/validators/videos/video-live.ts | 9 | ||||
-rw-r--r-- | server/middlewares/validators/videos/video-studio.ts | 2 | ||||
-rw-r--r-- | server/middlewares/validators/videos/videos.ts | 2 |
10 files changed, 335 insertions, 2 deletions
diff --git a/server/middlewares/validators/config.ts b/server/middlewares/validators/config.ts index 4a9d1cb54..b3e7e5011 100644 --- a/server/middlewares/validators/config.ts +++ b/server/middlewares/validators/config.ts | |||
@@ -54,6 +54,7 @@ const customConfigUpdateValidator = [ | |||
54 | body('transcoding.resolutions.1080p').isBoolean(), | 54 | body('transcoding.resolutions.1080p').isBoolean(), |
55 | body('transcoding.resolutions.1440p').isBoolean(), | 55 | body('transcoding.resolutions.1440p').isBoolean(), |
56 | body('transcoding.resolutions.2160p').isBoolean(), | 56 | body('transcoding.resolutions.2160p').isBoolean(), |
57 | body('transcoding.remoteRunners.enabled').isBoolean(), | ||
57 | 58 | ||
58 | body('transcoding.alwaysTranscodeOriginalResolution').isBoolean(), | 59 | body('transcoding.alwaysTranscodeOriginalResolution').isBoolean(), |
59 | 60 | ||
@@ -97,6 +98,7 @@ const customConfigUpdateValidator = [ | |||
97 | body('live.transcoding.resolutions.1440p').isBoolean(), | 98 | body('live.transcoding.resolutions.1440p').isBoolean(), |
98 | body('live.transcoding.resolutions.2160p').isBoolean(), | 99 | body('live.transcoding.resolutions.2160p').isBoolean(), |
99 | body('live.transcoding.alwaysTranscodeOriginalResolution').isBoolean(), | 100 | body('live.transcoding.alwaysTranscodeOriginalResolution').isBoolean(), |
101 | body('live.transcoding.remoteRunners.enabled').isBoolean(), | ||
100 | 102 | ||
101 | body('search.remoteUri.users').isBoolean(), | 103 | body('search.remoteUri.users').isBoolean(), |
102 | body('search.remoteUri.anonymous').isBoolean(), | 104 | body('search.remoteUri.anonymous').isBoolean(), |
diff --git a/server/middlewares/validators/runners/index.ts b/server/middlewares/validators/runners/index.ts new file mode 100644 index 000000000..9a9629a80 --- /dev/null +++ b/server/middlewares/validators/runners/index.ts | |||
@@ -0,0 +1,3 @@ | |||
1 | export * from './jobs' | ||
2 | export * from './registration-token' | ||
3 | export * from './runners' | ||
diff --git a/server/middlewares/validators/runners/job-files.ts b/server/middlewares/validators/runners/job-files.ts new file mode 100644 index 000000000..56afa39aa --- /dev/null +++ b/server/middlewares/validators/runners/job-files.ts | |||
@@ -0,0 +1,27 @@ | |||
1 | import express from 'express' | ||
2 | import { HttpStatusCode } from '@shared/models' | ||
3 | import { areValidationErrors, doesVideoExist, isValidVideoIdParam } from '../shared' | ||
4 | |||
5 | const tags = [ 'runner' ] | ||
6 | |||
7 | export const runnerJobGetVideoTranscodingFileValidator = [ | ||
8 | isValidVideoIdParam('videoId'), | ||
9 | |||
10 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
11 | if (areValidationErrors(req, res)) return | ||
12 | |||
13 | if (!await doesVideoExist(req.params.videoId, res, 'all')) return | ||
14 | |||
15 | const runnerJob = res.locals.runnerJob | ||
16 | |||
17 | if (runnerJob.privatePayload.videoUUID !== res.locals.videoAll.uuid) { | ||
18 | return res.fail({ | ||
19 | status: HttpStatusCode.FORBIDDEN_403, | ||
20 | message: 'Job is not associated to this video', | ||
21 | tags: [ ...tags, res.locals.videoAll.uuid ] | ||
22 | }) | ||
23 | } | ||
24 | |||
25 | return next() | ||
26 | } | ||
27 | ] | ||
diff --git a/server/middlewares/validators/runners/jobs.ts b/server/middlewares/validators/runners/jobs.ts new file mode 100644 index 000000000..8cb87e946 --- /dev/null +++ b/server/middlewares/validators/runners/jobs.ts | |||
@@ -0,0 +1,156 @@ | |||
1 | import express from 'express' | ||
2 | import { body, param } from 'express-validator' | ||
3 | import { isUUIDValid } from '@server/helpers/custom-validators/misc' | ||
4 | import { | ||
5 | isRunnerJobAbortReasonValid, | ||
6 | isRunnerJobErrorMessageValid, | ||
7 | isRunnerJobProgressValid, | ||
8 | isRunnerJobSuccessPayloadValid, | ||
9 | isRunnerJobTokenValid, | ||
10 | isRunnerJobUpdatePayloadValid | ||
11 | } from '@server/helpers/custom-validators/runners/jobs' | ||
12 | import { isRunnerTokenValid } from '@server/helpers/custom-validators/runners/runners' | ||
13 | import { cleanUpReqFiles } from '@server/helpers/express-utils' | ||
14 | import { RunnerJobModel } from '@server/models/runner/runner-job' | ||
15 | import { HttpStatusCode, RunnerJobState, RunnerJobSuccessBody, RunnerJobUpdateBody, ServerErrorCode } from '@shared/models' | ||
16 | import { areValidationErrors } from '../shared' | ||
17 | |||
18 | const tags = [ 'runner' ] | ||
19 | |||
20 | export const acceptRunnerJobValidator = [ | ||
21 | (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
22 | if (res.locals.runnerJob.state !== RunnerJobState.PENDING) { | ||
23 | return res.fail({ | ||
24 | status: HttpStatusCode.BAD_REQUEST_400, | ||
25 | message: 'This runner job is not in pending state', | ||
26 | tags | ||
27 | }) | ||
28 | } | ||
29 | |||
30 | return next() | ||
31 | } | ||
32 | ] | ||
33 | |||
34 | export const abortRunnerJobValidator = [ | ||
35 | body('reason').custom(isRunnerJobAbortReasonValid), | ||
36 | |||
37 | (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
38 | if (areValidationErrors(req, res, { tags })) return | ||
39 | |||
40 | return next() | ||
41 | } | ||
42 | ] | ||
43 | |||
44 | export const updateRunnerJobValidator = [ | ||
45 | body('progress').optional().custom(isRunnerJobProgressValid), | ||
46 | |||
47 | (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
48 | if (areValidationErrors(req, res, { tags })) return cleanUpReqFiles(req) | ||
49 | |||
50 | const body = req.body as RunnerJobUpdateBody | ||
51 | |||
52 | if (isRunnerJobUpdatePayloadValid(body.payload, res.locals.runnerJob.type, req.files) !== true) { | ||
53 | cleanUpReqFiles(req) | ||
54 | |||
55 | return res.fail({ | ||
56 | status: HttpStatusCode.BAD_REQUEST_400, | ||
57 | message: 'Payload is invalid', | ||
58 | tags | ||
59 | }) | ||
60 | } | ||
61 | |||
62 | return next() | ||
63 | } | ||
64 | ] | ||
65 | |||
66 | export const errorRunnerJobValidator = [ | ||
67 | body('message').custom(isRunnerJobErrorMessageValid), | ||
68 | |||
69 | (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
70 | if (areValidationErrors(req, res, { tags })) return | ||
71 | |||
72 | return next() | ||
73 | } | ||
74 | ] | ||
75 | |||
76 | export const successRunnerJobValidator = [ | ||
77 | (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
78 | const body = req.body as RunnerJobSuccessBody | ||
79 | |||
80 | if (isRunnerJobSuccessPayloadValid(body.payload, res.locals.runnerJob.type, req.files) !== true) { | ||
81 | cleanUpReqFiles(req) | ||
82 | |||
83 | return res.fail({ | ||
84 | status: HttpStatusCode.BAD_REQUEST_400, | ||
85 | message: 'Payload is invalid', | ||
86 | tags | ||
87 | }) | ||
88 | } | ||
89 | |||
90 | return next() | ||
91 | } | ||
92 | ] | ||
93 | |||
94 | export const runnerJobGetValidator = [ | ||
95 | param('jobUUID').custom(isUUIDValid), | ||
96 | |||
97 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
98 | if (areValidationErrors(req, res, { tags })) return | ||
99 | |||
100 | const runnerJob = await RunnerJobModel.loadWithRunner(req.params.jobUUID) | ||
101 | |||
102 | if (!runnerJob) { | ||
103 | return res.fail({ | ||
104 | status: HttpStatusCode.NOT_FOUND_404, | ||
105 | message: 'Unknown runner job', | ||
106 | tags | ||
107 | }) | ||
108 | } | ||
109 | |||
110 | res.locals.runnerJob = runnerJob | ||
111 | |||
112 | return next() | ||
113 | } | ||
114 | ] | ||
115 | |||
116 | export const jobOfRunnerGetValidator = [ | ||
117 | param('jobUUID').custom(isUUIDValid), | ||
118 | |||
119 | body('runnerToken').custom(isRunnerTokenValid), | ||
120 | body('jobToken').custom(isRunnerJobTokenValid), | ||
121 | |||
122 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
123 | if (areValidationErrors(req, res, { tags })) return cleanUpReqFiles(req) | ||
124 | |||
125 | const runnerJob = await RunnerJobModel.loadByRunnerAndJobTokensWithRunner({ | ||
126 | uuid: req.params.jobUUID, | ||
127 | runnerToken: req.body.runnerToken, | ||
128 | jobToken: req.body.jobToken | ||
129 | }) | ||
130 | |||
131 | if (!runnerJob) { | ||
132 | cleanUpReqFiles(req) | ||
133 | |||
134 | return res.fail({ | ||
135 | status: HttpStatusCode.NOT_FOUND_404, | ||
136 | message: 'Unknown runner job', | ||
137 | tags | ||
138 | }) | ||
139 | } | ||
140 | |||
141 | if (runnerJob.state !== RunnerJobState.PROCESSING) { | ||
142 | cleanUpReqFiles(req) | ||
143 | |||
144 | return res.fail({ | ||
145 | status: HttpStatusCode.BAD_REQUEST_400, | ||
146 | type: ServerErrorCode.RUNNER_JOB_NOT_IN_PROCESSING_STATE, | ||
147 | message: 'Job is not in "processing" state', | ||
148 | tags | ||
149 | }) | ||
150 | } | ||
151 | |||
152 | res.locals.runnerJob = runnerJob | ||
153 | |||
154 | return next() | ||
155 | } | ||
156 | ] | ||
diff --git a/server/middlewares/validators/runners/registration-token.ts b/server/middlewares/validators/runners/registration-token.ts new file mode 100644 index 000000000..cc31d4a7e --- /dev/null +++ b/server/middlewares/validators/runners/registration-token.ts | |||
@@ -0,0 +1,37 @@ | |||
1 | import express from 'express' | ||
2 | import { param } from 'express-validator' | ||
3 | import { isIdValid } from '@server/helpers/custom-validators/misc' | ||
4 | import { RunnerRegistrationTokenModel } from '@server/models/runner/runner-registration-token' | ||
5 | import { forceNumber } from '@shared/core-utils' | ||
6 | import { HttpStatusCode } from '@shared/models' | ||
7 | import { areValidationErrors } from '../shared/utils' | ||
8 | |||
9 | const tags = [ 'runner' ] | ||
10 | |||
11 | const deleteRegistrationTokenValidator = [ | ||
12 | param('id').custom(isIdValid), | ||
13 | |||
14 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
15 | if (areValidationErrors(req, res, { tags })) return | ||
16 | |||
17 | const registrationToken = await RunnerRegistrationTokenModel.load(forceNumber(req.params.id)) | ||
18 | |||
19 | if (!registrationToken) { | ||
20 | return res.fail({ | ||
21 | status: HttpStatusCode.NOT_FOUND_404, | ||
22 | message: 'Registration token not found', | ||
23 | tags | ||
24 | }) | ||
25 | } | ||
26 | |||
27 | res.locals.runnerRegistrationToken = registrationToken | ||
28 | |||
29 | return next() | ||
30 | } | ||
31 | ] | ||
32 | |||
33 | // --------------------------------------------------------------------------- | ||
34 | |||
35 | export { | ||
36 | deleteRegistrationTokenValidator | ||
37 | } | ||
diff --git a/server/middlewares/validators/runners/runners.ts b/server/middlewares/validators/runners/runners.ts new file mode 100644 index 000000000..71a1275d2 --- /dev/null +++ b/server/middlewares/validators/runners/runners.ts | |||
@@ -0,0 +1,95 @@ | |||
1 | import express from 'express' | ||
2 | import { body, param } from 'express-validator' | ||
3 | import { isIdValid } from '@server/helpers/custom-validators/misc' | ||
4 | import { | ||
5 | isRunnerDescriptionValid, | ||
6 | isRunnerNameValid, | ||
7 | isRunnerRegistrationTokenValid, | ||
8 | isRunnerTokenValid | ||
9 | } from '@server/helpers/custom-validators/runners/runners' | ||
10 | import { RunnerModel } from '@server/models/runner/runner' | ||
11 | import { RunnerRegistrationTokenModel } from '@server/models/runner/runner-registration-token' | ||
12 | import { forceNumber } from '@shared/core-utils' | ||
13 | import { HttpStatusCode, RegisterRunnerBody, ServerErrorCode } from '@shared/models' | ||
14 | import { areValidationErrors } from '../shared/utils' | ||
15 | |||
16 | const tags = [ 'runner' ] | ||
17 | |||
18 | const registerRunnerValidator = [ | ||
19 | body('registrationToken').custom(isRunnerRegistrationTokenValid), | ||
20 | body('name').custom(isRunnerNameValid), | ||
21 | body('description').optional().custom(isRunnerDescriptionValid), | ||
22 | |||
23 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
24 | if (areValidationErrors(req, res, { tags })) return | ||
25 | |||
26 | const body: RegisterRunnerBody = req.body | ||
27 | |||
28 | const runnerRegistrationToken = await RunnerRegistrationTokenModel.loadByRegistrationToken(body.registrationToken) | ||
29 | |||
30 | if (!runnerRegistrationToken) { | ||
31 | return res.fail({ | ||
32 | status: HttpStatusCode.NOT_FOUND_404, | ||
33 | message: 'Registration token is invalid', | ||
34 | tags | ||
35 | }) | ||
36 | } | ||
37 | |||
38 | res.locals.runnerRegistrationToken = runnerRegistrationToken | ||
39 | |||
40 | return next() | ||
41 | } | ||
42 | ] | ||
43 | |||
44 | const deleteRunnerValidator = [ | ||
45 | param('runnerId').custom(isIdValid), | ||
46 | |||
47 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
48 | if (areValidationErrors(req, res, { tags })) return | ||
49 | |||
50 | const runner = await RunnerModel.load(forceNumber(req.params.runnerId)) | ||
51 | |||
52 | if (!runner) { | ||
53 | return res.fail({ | ||
54 | status: HttpStatusCode.NOT_FOUND_404, | ||
55 | message: 'Runner not found', | ||
56 | tags | ||
57 | }) | ||
58 | } | ||
59 | |||
60 | res.locals.runner = runner | ||
61 | |||
62 | return next() | ||
63 | } | ||
64 | ] | ||
65 | |||
66 | const getRunnerFromTokenValidator = [ | ||
67 | body('runnerToken').custom(isRunnerTokenValid), | ||
68 | |||
69 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
70 | if (areValidationErrors(req, res, { tags })) return | ||
71 | |||
72 | const runner = await RunnerModel.loadByToken(req.body.runnerToken) | ||
73 | |||
74 | if (!runner) { | ||
75 | return res.fail({ | ||
76 | status: HttpStatusCode.NOT_FOUND_404, | ||
77 | message: 'Unknown runner token', | ||
78 | type: ServerErrorCode.UNKNOWN_RUNNER_TOKEN, | ||
79 | tags | ||
80 | }) | ||
81 | } | ||
82 | |||
83 | res.locals.runner = runner | ||
84 | |||
85 | return next() | ||
86 | } | ||
87 | ] | ||
88 | |||
89 | // --------------------------------------------------------------------------- | ||
90 | |||
91 | export { | ||
92 | registerRunnerValidator, | ||
93 | deleteRunnerValidator, | ||
94 | getRunnerFromTokenValidator | ||
95 | } | ||
diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts index e6cc46317..959f663ac 100644 --- a/server/middlewares/validators/sort.ts +++ b/server/middlewares/validators/sort.ts | |||
@@ -34,6 +34,10 @@ export const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COL | |||
34 | 34 | ||
35 | export const userRegistrationsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_REGISTRATIONS) | 35 | export const userRegistrationsSortValidator = checkSortFactory(SORTABLE_COLUMNS.USER_REGISTRATIONS) |
36 | 36 | ||
37 | export const runnersSortValidator = checkSortFactory(SORTABLE_COLUMNS.RUNNERS) | ||
38 | export const runnerRegistrationTokensSortValidator = checkSortFactory(SORTABLE_COLUMNS.RUNNER_REGISTRATION_TOKENS) | ||
39 | export const runnerJobsSortValidator = checkSortFactory(SORTABLE_COLUMNS.RUNNER_JOBS) | ||
40 | |||
37 | // --------------------------------------------------------------------------- | 41 | // --------------------------------------------------------------------------- |
38 | 42 | ||
39 | function checkSortFactory (columns: string[], tags: string[] = []) { | 43 | function checkSortFactory (columns: string[], tags: string[] = []) { |
diff --git a/server/middlewares/validators/videos/video-live.ts b/server/middlewares/validators/videos/video-live.ts index e80fe1593..2aff831a8 100644 --- a/server/middlewares/validators/videos/video-live.ts +++ b/server/middlewares/validators/videos/video-live.ts | |||
@@ -115,6 +115,15 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([ | |||
115 | }) | 115 | }) |
116 | } | 116 | } |
117 | 117 | ||
118 | if (body.saveReplay && !body.replaySettings?.privacy) { | ||
119 | cleanUpReqFiles(req) | ||
120 | |||
121 | return res.fail({ | ||
122 | status: HttpStatusCode.BAD_REQUEST_400, | ||
123 | message: 'Live replay is enabled but privacy replay setting is missing' | ||
124 | }) | ||
125 | } | ||
126 | |||
118 | const user = res.locals.oauth.token.User | 127 | const user = res.locals.oauth.token.User |
119 | if (!await doesVideoChannelOfAccountExist(body.channelId, user, res)) return cleanUpReqFiles(req) | 128 | if (!await doesVideoChannelOfAccountExist(body.channelId, user, res)) return cleanUpReqFiles(req) |
120 | 129 | ||
diff --git a/server/middlewares/validators/videos/video-studio.ts b/server/middlewares/validators/videos/video-studio.ts index b3e2d8101..4397e887e 100644 --- a/server/middlewares/validators/videos/video-studio.ts +++ b/server/middlewares/validators/videos/video-studio.ts | |||
@@ -10,7 +10,7 @@ import { | |||
10 | import { cleanUpReqFiles } from '@server/helpers/express-utils' | 10 | import { cleanUpReqFiles } from '@server/helpers/express-utils' |
11 | import { CONFIG } from '@server/initializers/config' | 11 | import { CONFIG } from '@server/initializers/config' |
12 | import { approximateIntroOutroAdditionalSize, getTaskFile } from '@server/lib/video-studio' | 12 | import { approximateIntroOutroAdditionalSize, getTaskFile } from '@server/lib/video-studio' |
13 | import { isAudioFile } from '@shared/extra-utils' | 13 | import { isAudioFile } from '@shared/ffmpeg' |
14 | import { HttpStatusCode, UserRight, VideoState, VideoStudioCreateEdition, VideoStudioTask } from '@shared/models' | 14 | import { HttpStatusCode, UserRight, VideoState, VideoStudioCreateEdition, VideoStudioTask } from '@shared/models' |
15 | import { areValidationErrors, checkUserCanManageVideo, checkUserQuota, doesVideoExist } from '../shared' | 15 | import { areValidationErrors, checkUserCanManageVideo, checkUserQuota, doesVideoExist } from '../shared' |
16 | 16 | ||
diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts index d3014e8e7..794e1d4f1 100644 --- a/server/middlewares/validators/videos/videos.ts +++ b/server/middlewares/validators/videos/videos.ts | |||
@@ -7,6 +7,7 @@ import { getServerActor } from '@server/models/application/application' | |||
7 | import { ExpressPromiseHandler } from '@server/types/express-handler' | 7 | import { ExpressPromiseHandler } from '@server/types/express-handler' |
8 | import { MUserAccountId, MVideoFullLight } from '@server/types/models' | 8 | import { MUserAccountId, MVideoFullLight } from '@server/types/models' |
9 | import { arrayify, getAllPrivacies } from '@shared/core-utils' | 9 | import { arrayify, getAllPrivacies } from '@shared/core-utils' |
10 | import { getVideoStreamDuration } from '@shared/ffmpeg' | ||
10 | import { HttpStatusCode, ServerErrorCode, UserRight, VideoInclude, VideoState } from '@shared/models' | 11 | import { HttpStatusCode, ServerErrorCode, UserRight, VideoInclude, VideoState } from '@shared/models' |
11 | import { | 12 | import { |
12 | exists, | 13 | exists, |
@@ -37,7 +38,6 @@ import { | |||
37 | isVideoSupportValid | 38 | isVideoSupportValid |
38 | } from '../../../helpers/custom-validators/videos' | 39 | } from '../../../helpers/custom-validators/videos' |
39 | import { cleanUpReqFiles } from '../../../helpers/express-utils' | 40 | import { cleanUpReqFiles } from '../../../helpers/express-utils' |
40 | import { getVideoStreamDuration } from '../../../helpers/ffmpeg' | ||
41 | import { logger } from '../../../helpers/logger' | 41 | import { logger } from '../../../helpers/logger' |
42 | import { deleteFileAndCatch } from '../../../helpers/utils' | 42 | import { deleteFileAndCatch } from '../../../helpers/utils' |
43 | import { getVideoWithAttributes } from '../../../helpers/video' | 43 | import { getVideoWithAttributes } from '../../../helpers/video' |