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