]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/middlewares/validators/videos/videos.ts
Merge branch 'release/4.2.0' into develop
[github/Chocobozzz/PeerTube.git] / server / middlewares / validators / videos / videos.ts
1 import express from 'express'
2 import { body, header, param, query, ValidationChain } from 'express-validator'
3 import { isTestInstance } from '@server/helpers/core-utils'
4 import { getResumableUploadPath } from '@server/helpers/upload'
5 import { Redis } from '@server/lib/redis'
6 import { getServerActor } from '@server/models/application/application'
7 import { ExpressPromiseHandler } from '@server/types/express-handler'
8 import { MUserAccountId, MVideoFullLight } from '@server/types/models'
9 import { getAllPrivacies } from '@shared/core-utils'
10 import { HttpStatusCode, ServerErrorCode, UserRight, VideoInclude } from '@shared/models'
11 import {
12 exists,
13 isBooleanValid,
14 isDateValid,
15 isFileValid,
16 isIdValid,
17 toArray,
18 toBooleanOrNull,
19 toIntOrNull,
20 toValueOrNull
21 } from '../../../helpers/custom-validators/misc'
22 import { isBooleanBothQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search'
23 import {
24 areVideoTagsValid,
25 isScheduleVideoUpdatePrivacyValid,
26 isVideoCategoryValid,
27 isVideoDescriptionValid,
28 isVideoFileMimeTypeValid,
29 isVideoFileSizeValid,
30 isVideoFilterValid,
31 isVideoImageValid,
32 isVideoIncludeValid,
33 isVideoLanguageValid,
34 isVideoLicenceValid,
35 isVideoNameValid,
36 isVideoOriginallyPublishedAtValid,
37 isVideoPrivacyValid,
38 isVideoSupportValid
39 } from '../../../helpers/custom-validators/videos'
40 import { cleanUpReqFiles } from '../../../helpers/express-utils'
41 import { getVideoStreamDuration } from '../../../helpers/ffmpeg'
42 import { logger } from '../../../helpers/logger'
43 import { deleteFileAndCatch } from '../../../helpers/utils'
44 import { getVideoWithAttributes } from '../../../helpers/video'
45 import { CONFIG } from '../../../initializers/config'
46 import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants'
47 import { isLocalVideoAccepted } from '../../../lib/moderation'
48 import { Hooks } from '../../../lib/plugins/hooks'
49 import { VideoModel } from '../../../models/video/video'
50 import {
51 areValidationErrors,
52 checkCanSeeVideo,
53 checkUserCanManageVideo,
54 checkUserQuota,
55 doesVideoChannelOfAccountExist,
56 doesVideoExist,
57 doesVideoFileOfVideoExist,
58 isValidVideoIdParam
59 } from '../shared'
60
61 const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
62 body('videofile')
63 .custom((_, { req }) => isFileValid({ files: req.files, field: 'videofile', mimeTypeRegex: null, maxSize: null }))
64 .withMessage('Should have a file'),
65 body('name')
66 .trim()
67 .custom(isVideoNameValid).withMessage(
68 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
69 ),
70 body('channelId')
71 .customSanitizer(toIntOrNull)
72 .custom(isIdValid).withMessage('Should have correct video channel id'),
73
74 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
75 logger.debug('Checking videosAdd parameters', { parameters: req.body, files: req.files })
76
77 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
78
79 const videoFile: express.VideoUploadFile = req.files['videofile'][0]
80 const user = res.locals.oauth.token.User
81
82 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFile.size, files: req.files })) {
83 return cleanUpReqFiles(req)
84 }
85
86 try {
87 if (!videoFile.duration) await addDurationToVideo(videoFile)
88 } catch (err) {
89 logger.error('Invalid input file in videosAddLegacyValidator.', { err })
90
91 res.fail({
92 status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
93 message: 'Video file unreadable.'
94 })
95 return cleanUpReqFiles(req)
96 }
97
98 if (!await isVideoAccepted(req, res, videoFile)) return cleanUpReqFiles(req)
99
100 return next()
101 }
102 ])
103
104 /**
105 * Gets called after the last PUT request
106 */
107 const videosAddResumableValidator = [
108 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
109 const user = res.locals.oauth.token.User
110 const body: express.CustomUploadXFile<express.UploadXFileMetadata> = req.body
111 const file = { ...body, duration: undefined, path: getResumableUploadPath(body.name), filename: body.metadata.filename }
112 const cleanup = () => deleteFileAndCatch(file.path)
113
114 const uploadId = req.query.upload_id
115 const sessionExists = await Redis.Instance.doesUploadSessionExist(uploadId)
116
117 if (sessionExists) {
118 const sessionResponse = await Redis.Instance.getUploadSession(uploadId)
119
120 if (!sessionResponse) {
121 res.setHeader('Retry-After', 300) // ask to retry after 5 min, knowing the upload_id is kept for up to 15 min after completion
122
123 return res.fail({
124 status: HttpStatusCode.SERVICE_UNAVAILABLE_503,
125 message: 'The upload is already being processed'
126 })
127 }
128
129 if (isTestInstance()) {
130 res.setHeader('x-resumable-upload-cached', 'true')
131 }
132
133 return res.json(sessionResponse)
134 }
135
136 await Redis.Instance.setUploadSession(uploadId)
137
138 if (!await doesVideoChannelOfAccountExist(file.metadata.channelId, user, res)) return cleanup()
139
140 try {
141 if (!file.duration) await addDurationToVideo(file)
142 } catch (err) {
143 logger.error('Invalid input file in videosAddResumableValidator.', { err })
144
145 res.fail({
146 status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
147 message: 'Video file unreadable.'
148 })
149 return cleanup()
150 }
151
152 if (!await isVideoAccepted(req, res, file)) return cleanup()
153
154 res.locals.videoFileResumable = { ...file, originalname: file.filename }
155
156 return next()
157 }
158 ]
159
160 /**
161 * File is created in POST initialisation, and its body is saved as a 'metadata' field is saved by uploadx for later use.
162 * see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/uploadx.ts
163 *
164 * Uploadx doesn't use next() until the upload completes, so this middleware has to be placed before uploadx
165 * see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/base-handler.ts
166 *
167 */
168 const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([
169 body('filename')
170 .isString()
171 .exists()
172 .withMessage('Should have a valid filename'),
173 body('name')
174 .trim()
175 .custom(isVideoNameValid).withMessage(
176 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
177 ),
178 body('channelId')
179 .customSanitizer(toIntOrNull)
180 .custom(isIdValid).withMessage('Should have correct video channel id'),
181
182 header('x-upload-content-length')
183 .isNumeric()
184 .exists()
185 .withMessage('Should specify the file length'),
186 header('x-upload-content-type')
187 .isString()
188 .exists()
189 .withMessage('Should specify the file mimetype'),
190
191 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
192 const videoFileMetadata = {
193 mimetype: req.headers['x-upload-content-type'] as string,
194 size: +req.headers['x-upload-content-length'],
195 originalname: req.body.filename
196 }
197
198 const user = res.locals.oauth.token.User
199 const cleanup = () => cleanUpReqFiles(req)
200
201 logger.debug('Checking videosAddResumableInitValidator parameters and headers', {
202 parameters: req.body,
203 headers: req.headers,
204 files: req.files
205 })
206
207 if (areValidationErrors(req, res)) return cleanup()
208
209 const files = { videofile: [ videoFileMetadata ] }
210 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFileMetadata.size, files })) return cleanup()
211
212 // multer required unsetting the Content-Type, now we can set it for node-uploadx
213 req.headers['content-type'] = 'application/json; charset=utf-8'
214 // place previewfile in metadata so that uploadx saves it in .META
215 if (req.files?.['previewfile']) req.body.previewfile = req.files['previewfile']
216
217 return next()
218 }
219 ])
220
221 const videosUpdateValidator = getCommonVideoEditAttributes().concat([
222 isValidVideoIdParam('id'),
223
224 body('name')
225 .optional()
226 .trim()
227 .custom(isVideoNameValid).withMessage(
228 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
229 ),
230 body('channelId')
231 .optional()
232 .customSanitizer(toIntOrNull)
233 .custom(isIdValid).withMessage('Should have correct video channel id'),
234
235 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
236 logger.debug('Checking videosUpdate parameters', { parameters: req.body })
237
238 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
239 if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
240 if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req)
241
242 // Check if the user who did the request is able to update the video
243 const user = res.locals.oauth.token.User
244 if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req)
245
246 if (req.body.channelId && !await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
247
248 return next()
249 }
250 ])
251
252 async function checkVideoFollowConstraints (req: express.Request, res: express.Response, next: express.NextFunction) {
253 const video = getVideoWithAttributes(res)
254
255 // Anybody can watch local videos
256 if (video.isOwned() === true) return next()
257
258 // Logged user
259 if (res.locals.oauth) {
260 // Users can search or watch remote videos
261 if (CONFIG.SEARCH.REMOTE_URI.USERS === true) return next()
262 }
263
264 // Anybody can search or watch remote videos
265 if (CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true) return next()
266
267 // Check our instance follows an actor that shared this video
268 const serverActor = await getServerActor()
269 if (await VideoModel.checkVideoHasInstanceFollow(video.id, serverActor.id) === true) return next()
270
271 return res.fail({
272 status: HttpStatusCode.FORBIDDEN_403,
273 message: 'Cannot get this video regarding follow constraints',
274 type: ServerErrorCode.DOES_NOT_RESPECT_FOLLOW_CONSTRAINTS,
275 data: {
276 originUrl: video.url
277 }
278 })
279 }
280
281 const videosCustomGetValidator = (
282 fetchType: 'for-api' | 'all' | 'only-video' | 'only-immutable-attributes',
283 authenticateInQuery = false
284 ) => {
285 return [
286 isValidVideoIdParam('id'),
287
288 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
289 logger.debug('Checking videosGet parameters', { parameters: req.params })
290
291 if (areValidationErrors(req, res)) return
292 if (!await doesVideoExist(req.params.id, res, fetchType)) return
293
294 // Controllers does not need to check video rights
295 if (fetchType === 'only-immutable-attributes') return next()
296
297 const video = getVideoWithAttributes(res) as MVideoFullLight
298
299 if (!await checkCanSeeVideo({ req, res, video, paramId: req.params.id, authenticateInQuery })) return
300
301 return next()
302 }
303 ]
304 }
305
306 const videosGetValidator = videosCustomGetValidator('all')
307 const videosDownloadValidator = videosCustomGetValidator('all', true)
308
309 const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([
310 isValidVideoIdParam('id'),
311
312 param('videoFileId')
313 .custom(isIdValid).not().isEmpty().withMessage('Should have a valid videoFileId'),
314
315 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
316 logger.debug('Checking videoFileMetadataGet parameters', { parameters: req.params })
317
318 if (areValidationErrors(req, res)) return
319 if (!await doesVideoFileOfVideoExist(+req.params.videoFileId, req.params.id, res)) return
320
321 return next()
322 }
323 ])
324
325 const videosRemoveValidator = [
326 isValidVideoIdParam('id'),
327
328 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
329 logger.debug('Checking videosRemove parameters', { parameters: req.params })
330
331 if (areValidationErrors(req, res)) return
332 if (!await doesVideoExist(req.params.id, res)) return
333
334 // Check if the user who did the request is able to delete the video
335 if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.REMOVE_ANY_VIDEO, res)) return
336
337 return next()
338 }
339 ]
340
341 const videosOverviewValidator = [
342 query('page')
343 .optional()
344 .isInt({ min: 1, max: OVERVIEWS.VIDEOS.SAMPLES_COUNT })
345 .withMessage('Should have a valid pagination'),
346
347 (req: express.Request, res: express.Response, next: express.NextFunction) => {
348 if (areValidationErrors(req, res)) return
349
350 return next()
351 }
352 ]
353
354 function getCommonVideoEditAttributes () {
355 return [
356 body('thumbnailfile')
357 .custom((value, { req }) => isVideoImageValid(req.files, 'thumbnailfile')).withMessage(
358 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
359 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
360 ),
361 body('previewfile')
362 .custom((value, { req }) => isVideoImageValid(req.files, 'previewfile')).withMessage(
363 'This preview file is not supported or too large. Please, make sure it is of the following type: ' +
364 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
365 ),
366
367 body('category')
368 .optional()
369 .customSanitizer(toIntOrNull)
370 .custom(isVideoCategoryValid).withMessage('Should have a valid category'),
371 body('licence')
372 .optional()
373 .customSanitizer(toIntOrNull)
374 .custom(isVideoLicenceValid).withMessage('Should have a valid licence'),
375 body('language')
376 .optional()
377 .customSanitizer(toValueOrNull)
378 .custom(isVideoLanguageValid).withMessage('Should have a valid language'),
379 body('nsfw')
380 .optional()
381 .customSanitizer(toBooleanOrNull)
382 .custom(isBooleanValid).withMessage('Should have a valid NSFW attribute'),
383 body('waitTranscoding')
384 .optional()
385 .customSanitizer(toBooleanOrNull)
386 .custom(isBooleanValid).withMessage('Should have a valid wait transcoding attribute'),
387 body('privacy')
388 .optional()
389 .customSanitizer(toValueOrNull)
390 .custom(isVideoPrivacyValid).withMessage('Should have correct video privacy'),
391 body('description')
392 .optional()
393 .customSanitizer(toValueOrNull)
394 .custom(isVideoDescriptionValid).withMessage('Should have a valid description'),
395 body('support')
396 .optional()
397 .customSanitizer(toValueOrNull)
398 .custom(isVideoSupportValid).withMessage('Should have a valid support text'),
399 body('tags')
400 .optional()
401 .customSanitizer(toValueOrNull)
402 .custom(areVideoTagsValid)
403 .withMessage(
404 `Should have an array of up to ${CONSTRAINTS_FIELDS.VIDEOS.TAGS.max} tags between ` +
405 `${CONSTRAINTS_FIELDS.VIDEOS.TAG.min} and ${CONSTRAINTS_FIELDS.VIDEOS.TAG.max} characters each`
406 ),
407 body('commentsEnabled')
408 .optional()
409 .customSanitizer(toBooleanOrNull)
410 .custom(isBooleanValid).withMessage('Should have comments enabled boolean'),
411 body('downloadEnabled')
412 .optional()
413 .customSanitizer(toBooleanOrNull)
414 .custom(isBooleanValid).withMessage('Should have downloading enabled boolean'),
415 body('originallyPublishedAt')
416 .optional()
417 .customSanitizer(toValueOrNull)
418 .custom(isVideoOriginallyPublishedAtValid).withMessage('Should have a valid original publication date'),
419 body('scheduleUpdate')
420 .optional()
421 .customSanitizer(toValueOrNull),
422 body('scheduleUpdate.updateAt')
423 .optional()
424 .custom(isDateValid).withMessage('Should have a schedule update date that conforms to ISO 8601'),
425 body('scheduleUpdate.privacy')
426 .optional()
427 .customSanitizer(toIntOrNull)
428 .custom(isScheduleVideoUpdatePrivacyValid).withMessage('Should have correct schedule update privacy')
429 ] as (ValidationChain | ExpressPromiseHandler)[]
430 }
431
432 const commonVideosFiltersValidator = [
433 query('categoryOneOf')
434 .optional()
435 .customSanitizer(toArray)
436 .custom(isNumberArray).withMessage('Should have a valid one of category array'),
437 query('licenceOneOf')
438 .optional()
439 .customSanitizer(toArray)
440 .custom(isNumberArray).withMessage('Should have a valid one of licence array'),
441 query('languageOneOf')
442 .optional()
443 .customSanitizer(toArray)
444 .custom(isStringArray).withMessage('Should have a valid one of language array'),
445 query('privacyOneOf')
446 .optional()
447 .customSanitizer(toArray)
448 .custom(isNumberArray).withMessage('Should have a valid one of privacy array'),
449 query('tagsOneOf')
450 .optional()
451 .customSanitizer(toArray)
452 .custom(isStringArray).withMessage('Should have a valid one of tags array'),
453 query('tagsAllOf')
454 .optional()
455 .customSanitizer(toArray)
456 .custom(isStringArray).withMessage('Should have a valid all of tags array'),
457 query('nsfw')
458 .optional()
459 .custom(isBooleanBothQueryValid).withMessage('Should have a valid NSFW attribute'),
460 query('isLive')
461 .optional()
462 .customSanitizer(toBooleanOrNull)
463 .custom(isBooleanValid).withMessage('Should have a valid live boolean'),
464 query('filter')
465 .optional()
466 .custom(isVideoFilterValid).withMessage('Should have a valid filter attribute'),
467 query('include')
468 .optional()
469 .custom(isVideoIncludeValid).withMessage('Should have a valid include attribute'),
470 query('isLocal')
471 .optional()
472 .customSanitizer(toBooleanOrNull)
473 .custom(isBooleanValid).withMessage('Should have a valid local boolean'),
474 query('hasHLSFiles')
475 .optional()
476 .customSanitizer(toBooleanOrNull)
477 .custom(isBooleanValid).withMessage('Should have a valid has hls boolean'),
478 query('hasWebtorrentFiles')
479 .optional()
480 .customSanitizer(toBooleanOrNull)
481 .custom(isBooleanValid).withMessage('Should have a valid has webtorrent boolean'),
482 query('skipCount')
483 .optional()
484 .customSanitizer(toBooleanOrNull)
485 .custom(isBooleanValid).withMessage('Should have a valid skip count boolean'),
486 query('search')
487 .optional()
488 .custom(exists).withMessage('Should have a valid search'),
489
490 (req: express.Request, res: express.Response, next: express.NextFunction) => {
491 logger.debug('Checking commons video filters query', { parameters: req.query })
492
493 if (areValidationErrors(req, res)) return
494
495 // FIXME: deprecated in 4.0, to remove
496 {
497 if (req.query.filter === 'all-local') {
498 req.query.include = VideoInclude.NOT_PUBLISHED_STATE
499 req.query.isLocal = true
500 req.query.privacyOneOf = getAllPrivacies()
501 } else if (req.query.filter === 'all') {
502 req.query.include = VideoInclude.NOT_PUBLISHED_STATE
503 req.query.privacyOneOf = getAllPrivacies()
504 } else if (req.query.filter === 'local') {
505 req.query.isLocal = true
506 }
507
508 req.query.filter = undefined
509 }
510
511 const user = res.locals.oauth?.token.User
512
513 if ((!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) !== true)) {
514 if (req.query.include || req.query.privacyOneOf) {
515 return res.fail({
516 status: HttpStatusCode.UNAUTHORIZED_401,
517 message: 'You are not allowed to see all videos.'
518 })
519 }
520 }
521
522 return next()
523 }
524 ]
525
526 // ---------------------------------------------------------------------------
527
528 export {
529 videosAddLegacyValidator,
530 videosAddResumableValidator,
531 videosAddResumableInitValidator,
532
533 videosUpdateValidator,
534 videosGetValidator,
535 videoFileMetadataGetValidator,
536 videosDownloadValidator,
537 checkVideoFollowConstraints,
538 videosCustomGetValidator,
539 videosRemoveValidator,
540
541 getCommonVideoEditAttributes,
542
543 commonVideosFiltersValidator,
544
545 videosOverviewValidator
546 }
547
548 // ---------------------------------------------------------------------------
549
550 function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) {
551 if (req.body.scheduleUpdate) {
552 if (!req.body.scheduleUpdate.updateAt) {
553 logger.warn('Invalid parameters: scheduleUpdate.updateAt is mandatory.')
554
555 res.fail({ message: 'Schedule update at is mandatory.' })
556 return true
557 }
558 }
559
560 return false
561 }
562
563 async function commonVideoChecksPass (parameters: {
564 req: express.Request
565 res: express.Response
566 user: MUserAccountId
567 videoFileSize: number
568 files: express.UploadFilesForCheck
569 }): Promise<boolean> {
570 const { req, res, user, videoFileSize, files } = parameters
571
572 if (areErrorsInScheduleUpdate(req, res)) return false
573
574 if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return false
575
576 if (!isVideoFileMimeTypeValid(files)) {
577 res.fail({
578 status: HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415,
579 message: 'This file is not supported. Please, make sure it is of the following type: ' +
580 CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
581 })
582 return false
583 }
584
585 if (!isVideoFileSizeValid(videoFileSize.toString())) {
586 res.fail({
587 status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
588 message: 'This file is too large. It exceeds the maximum file size authorized.',
589 type: ServerErrorCode.MAX_FILE_SIZE_REACHED
590 })
591 return false
592 }
593
594 if (await checkUserQuota(user, videoFileSize, res) === false) return false
595
596 return true
597 }
598
599 export async function isVideoAccepted (
600 req: express.Request,
601 res: express.Response,
602 videoFile: express.VideoUploadFile
603 ) {
604 // Check we accept this video
605 const acceptParameters = {
606 videoBody: req.body,
607 videoFile,
608 user: res.locals.oauth.token.User
609 }
610 const acceptedResult = await Hooks.wrapFun(
611 isLocalVideoAccepted,
612 acceptParameters,
613 'filter:api.video.upload.accept.result'
614 )
615
616 if (!acceptedResult || acceptedResult.accepted !== true) {
617 logger.info('Refused local video.', { acceptedResult, acceptParameters })
618 res.fail({
619 status: HttpStatusCode.FORBIDDEN_403,
620 message: acceptedResult.errorMessage || 'Refused local video'
621 })
622 return false
623 }
624
625 return true
626 }
627
628 async function addDurationToVideo (videoFile: { path: string, duration?: number }) {
629 const duration = await getVideoStreamDuration(videoFile.path)
630
631 // FFmpeg may not be able to guess video duration
632 // For example with m2v files: https://trac.ffmpeg.org/ticket/9726#comment:2
633 if (isNaN(duration)) videoFile.duration = 0
634 else videoFile.duration = duration
635 }