Browse Source

Support additional video extensions

Chocobozzz 5 năm trước cách đây
mục cha
commit
14e2014acc
39 tập tin đã thay đổi với 268 bổ sung90 xóa
  1. 8 0
      client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
  2. 3 0
      client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
  3. 2 2
      client/src/app/shared/forms/peertube-checkbox.component.html
  4. 2 3
      client/src/app/shared/forms/peertube-checkbox.component.ts
  5. 1 0
      client/src/app/videos/+video-edit/shared/video-edit.component.html
  6. 1 0
      client/src/app/videos/+video-edit/shared/video-edit.component.ts
  7. 2 1
      client/src/app/videos/+video-edit/video-add-components/video-upload.component.html
  8. 9 0
      client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts
  9. 1 1
      client/src/app/videos/+video-edit/video-update.component.html
  10. 7 0
      client/src/app/videos/+video-edit/video-update.component.ts
  11. 2 0
      config/default.yaml
  12. 2 0
      config/production.yaml.example
  13. 1 0
      config/test-2.yaml
  14. 1 0
      config/test.yaml
  15. 4 1
      server/controllers/api/config.ts
  16. 2 2
      server/controllers/api/users/me.ts
  17. 2 2
      server/controllers/api/video-channel.ts
  18. 2 2
      server/controllers/api/videos/captions.ts
  19. 2 9
      server/controllers/api/videos/import.ts
  20. 5 7
      server/controllers/api/videos/index.ts
  21. 2 2
      server/helpers/custom-validators/video-captions.ts
  22. 2 2
      server/helpers/custom-validators/video-imports.ts
  23. 9 4
      server/helpers/custom-validators/videos.ts
  24. 1 1
      server/initializers/checker-before-init.ts
  25. 65 31
      server/initializers/constants.ts
  26. 49 0
      server/initializers/migrations/0295-video-file-extname.ts
  27. 3 4
      server/lib/activitypub/actor.ts
  28. 3 4
      server/lib/activitypub/videos.ts
  29. 3 3
      server/models/redundancy/video-redundancy.ts
  30. 3 1
      server/models/video/video-file.ts
  31. 3 3
      server/models/video/video-format-utils.ts
  32. 1 0
      server/tests/api/check-params/config.ts
  33. 6 1
      server/tests/api/check-params/videos.ts
  34. 28 2
      server/tests/api/server/config.ts
  35. 29 2
      server/tests/api/videos/video-transcoder.ts
  36. BIN
      server/tests/fixtures/video_short.avi
  37. BIN
      server/tests/fixtures/video_short.mkv
  38. 1 0
      shared/models/server/custom-config.model.ts
  39. 1 0
      shared/utils/server/config.ts

+ 8 - 0
client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html

@@ -219,6 +219,14 @@
 
         <ng-template [ngIf]="isTranscodingEnabled()">
 
+          <div class="form-group">
+            <my-peertube-checkbox
+              inputName="transcodingAllowAdditionalExtensions" formControlName="transcodingAllowAdditionalExtensions"
+              i18n-labelText labelText="Allow additional extensions"
+              i18n-helpHtml helpHtml="Allow your users to upload .mkv, .mov, .avi, .flv videos"
+            ></my-peertube-checkbox>
+          </div>
+
           <div class="form-group">
             <label i18n for="transcodingThreads">Transcoding threads</label>
             <div class="peertube-select-container">

+ 3 - 0
client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts

@@ -82,6 +82,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
       userVideoQuota: this.userValidatorsService.USER_VIDEO_QUOTA,
       userVideoQuotaDaily: this.userValidatorsService.USER_VIDEO_QUOTA_DAILY,
       transcodingThreads: this.customConfigValidatorsService.TRANSCODING_THREADS,
+      transcodingAllowAdditionalExtensions: null,
       transcodingEnabled: null,
       customizationJavascript: null,
       customizationCSS: null
@@ -163,6 +164,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
       },
       transcoding: {
         enabled: this.form.value['transcodingEnabled'],
+        allowAdditionalExtensions: this.form.value['transcodingAllowAdditionalExtensions'],
         threads: this.form.value['transcodingThreads'],
         resolutions: {
           '240p': this.form.value[this.getResolutionKey('240p')],
@@ -221,6 +223,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
       userVideoQuotaDaily: this.customConfig.user.videoQuotaDaily,
       transcodingThreads: this.customConfig.transcoding.threads,
       transcodingEnabled: this.customConfig.transcoding.enabled,
+      transcodingAllowAdditionalExtensions: this.customConfig.transcoding.allowAdditionalExtensions,
       customizationJavascript: this.customConfig.instance.customizations.javascript,
       customizationCSS: this.customConfig.instance.customizations.css,
       importVideosHttpEnabled: this.customConfig.import.videos.http.enabled,

+ 2 - 2
client/src/app/shared/forms/peertube-checkbox.component.html

@@ -1,10 +1,10 @@
 <div class="root">
   <label class="form-group-checkbox">
-    <input type="checkbox" [(ngModel)]="checked" (ngModelChange)="onModelChange()" [id]="inputName" [disabled]="isDisabled" />
+    <input type="checkbox" [(ngModel)]="checked" (ngModelChange)="onModelChange()" [id]="inputName" [disabled]="disabled" />
     <span role="checkbox" [attr.aria-checked]="checked"></span>
     <span *ngIf="labelText">{{ labelText }}</span>
     <span *ngIf="labelHtml" [innerHTML]="labelHtml"></span>
   </label>
 
   <my-help *ngIf="helpHtml" tooltipPlacement="top" helpType="custom" i18n-customHtml [customHtml]="helpHtml"></my-help>
-</div>
+</div>

+ 2 - 3
client/src/app/shared/forms/peertube-checkbox.component.ts

@@ -19,8 +19,7 @@ export class PeertubeCheckboxComponent implements ControlValueAccessor {
   @Input() labelText: string
   @Input() labelHtml: string
   @Input() helpHtml: string
-
-  isDisabled = false
+  @Input() disabled = false
 
   propagateChange = (_: any) => { /* empty */ }
 
@@ -41,6 +40,6 @@ export class PeertubeCheckboxComponent implements ControlValueAccessor {
   }
 
   setDisabledState (isDisabled: boolean) {
-    this.isDisabled = isDisabled
+    this.disabled = isDisabled
   }
 }

+ 1 - 0
client/src/app/videos/+video-edit/shared/video-edit.component.html

@@ -126,6 +126,7 @@
             ></my-peertube-checkbox>
 
             <my-peertube-checkbox
+              *ngIf="waitTranscodingEnabled"
               inputName="waitTranscoding" formControlName="waitTranscoding"
               i18n-labelText labelText="Wait transcoding before publishing the video"
               i18n-helpHtml helpHtml="If you decide not to wait for transcoding before publishing the video, it could be unplayable until transcoding ends."

+ 1 - 0
client/src/app/videos/+video-edit/shared/video-edit.component.ts

@@ -27,6 +27,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
   @Input() userVideoChannels: { id: number, label: string, support: string }[] = []
   @Input() schedulePublicationPossible = true
   @Input() videoCaptions: VideoCaptionEdit[] = []
+  @Input() waitTranscodingEnabled = true
 
   @ViewChild('videoCaptionAddModal') videoCaptionAddModal: VideoCaptionAddModalComponent
 

+ 2 - 1
client/src/app/videos/+video-edit/video-add-components/video-upload.component.html

@@ -6,7 +6,7 @@
       <span i18n>Select the file to upload</span>
       <input #videofileInput type="file" name="videofile" id="videofile" [accept]="videoExtensions" (change)="fileChange()" />
     </div>
-    <span class="button-file-extension">(.mp4, .webm, .ogv)</span>
+    <span class="button-file-extension">({{ videoExtensions }})</span>
 
     <div class="form-group form-group-channel">
       <label i18n for="first-step-channel">Channel</label>
@@ -47,6 +47,7 @@
   <my-video-edit
     [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions"
     [validationMessages]="validationMessages" [videoPrivacies]="videoPrivacies" [userVideoChannels]="userVideoChannels"
+    [waitTranscodingEnabled]="waitTranscodingEnabled"
   ></my-video-edit>
 
   <div class="submit-container">

+ 9 - 0
client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts

@@ -44,6 +44,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
     id: 0,
     uuid: ''
   }
+  waitTranscodingEnabled = true
 
   error: string
 
@@ -117,6 +118,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
     const videofile = this.videofileInput.nativeElement.files[0]
     if (!videofile) return
 
+    // Check global user quota
     const bytePipes = new BytesPipe()
     const videoQuota = this.authService.getUser().videoQuota
     if (videoQuota !== -1 && (this.userVideoQuotaUsed + videofile.size) > videoQuota) {
@@ -132,6 +134,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
       return
     }
 
+    // Check daily user quota
     const videoQuotaDaily = this.authService.getUser().videoQuotaDaily
     if (videoQuotaDaily !== -1 && (this.userVideoQuotaUsedDaily + videofile.size) > videoQuotaDaily) {
       const msg = this.i18n(
@@ -146,6 +149,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
       return
     }
 
+    // Build name field
     const nameWithoutExtension = videofile.name.replace(/\.[^/.]+$/, '')
     let name: string
 
@@ -153,6 +157,11 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
     if (nameWithoutExtension.length < 3) name = videofile.name
     else name = nameWithoutExtension
 
+    // Force user to wait transcoding for unsupported video types in web browsers
+    if (!videofile.name.endsWith('.mp4') && !videofile.name.endsWith('.webm') && !videofile.name.endsWith('.ogv')) {
+      this.waitTranscodingEnabled = false
+    }
+
     const privacy = this.firstStepPrivacyId.toString()
     const nsfw = false
     const waitTranscoding = true

+ 1 - 1
client/src/app/videos/+video-edit/video-update.component.html

@@ -8,7 +8,7 @@
     <my-video-edit
       [form]="form" [formErrors]="formErrors" [schedulePublicationPossible]="schedulePublicationPossible"
       [validationMessages]="validationMessages" [videoPrivacies]="videoPrivacies" [userVideoChannels]="userVideoChannels"
-      [videoCaptions]="videoCaptions"
+      [videoCaptions]="videoCaptions" [waitTranscodingEnabled]="waitTranscodingEnabled"
     ></my-video-edit>
 
     <div class="submit-container">

+ 7 - 0
client/src/app/videos/+video-edit/video-update.component.ts

@@ -12,6 +12,7 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
 import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
 import { VideoCaptionService } from '@app/shared/video-caption'
 import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model'
+import { VideoDetails } from '@app/shared/video/video-details.model'
 
 @Component({
   selector: 'my-videos-update',
@@ -26,6 +27,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
   userVideoChannels: { id: number, label: string, support: string }[] = []
   schedulePublicationPossible = false
   videoCaptions: VideoCaptionEdit[] = []
+  waitTranscodingEnabled = true
 
   private updateDone = false
 
@@ -65,6 +67,11 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
 
           this.videoPrivacies = this.videoService.explainedPrivacyLabels(this.videoPrivacies)
 
+          const videoFiles = (video as VideoDetails).files
+          if (videoFiles.length > 1) { // Already transcoded
+            this.waitTranscodingEnabled = false
+          }
+
           // FIXME: Angular does not detect the change inside this subscription, so use the patched setTimeout
           setTimeout(() => this.hydrateFormFromVideo())
         },

+ 2 - 0
config/default.yaml

@@ -124,6 +124,8 @@ user:
 # Please, do not disable transcoding since many uploaded videos will not work
 transcoding:
   enabled: true
+  # Allow your users to upload .mkv, .mov, .avi, .flv videos
+  allow_additional_extensions: true
   threads: 1
   resolutions: # Only created if the original video has a higher resolution, uses more storage!
     240p: false

+ 2 - 0
config/production.yaml.example

@@ -137,6 +137,8 @@ user:
 # Please, do not disable transcoding since many uploaded videos will not work
 transcoding:
   enabled: true
+  # Allow your users to upload .mkv, .mov, .avi, .flv videos
+  allow_additional_extensions: true
   threads: 1
   resolutions: # Only created if the original video has a higher resolution, uses more storage!
     240p: false

+ 1 - 0
config/test-2.yaml

@@ -29,3 +29,4 @@ signup:
 
 transcoding:
   enabled: true
+  allow_additional_extensions: true

+ 1 - 0
config/test.yaml

@@ -51,6 +51,7 @@ signup:
 
 transcoding:
   enabled: true
+  allow_additional_extensions: false
   threads: 2
   resolutions:
     240p: true

+ 4 - 1
server/controllers/api/config.ts

@@ -172,7 +172,8 @@ async function updateCustomConfig (req: express.Request, res: express.Response,
     'instance.defaultClientRoute',
     'instance.shortDescription',
     'cache.videoCaptions',
-    'signup.requiresEmailVerification'
+    'signup.requiresEmailVerification',
+    'transcoding.allowAdditionalExtensions'
   )
   toUpdateJSON.user['video_quota'] = toUpdate.user.videoQuota
   toUpdateJSON.user['video_quota_daily'] = toUpdate.user.videoQuotaDaily
@@ -180,6 +181,7 @@ async function updateCustomConfig (req: express.Request, res: express.Response,
   toUpdateJSON.instance['short_description'] = toUpdate.instance.shortDescription
   toUpdateJSON.instance['default_nsfw_policy'] = toUpdate.instance.defaultNSFWPolicy
   toUpdateJSON.signup['requires_email_verification'] = toUpdate.signup.requiresEmailVerification
+  toUpdateJSON.transcoding['allow_additional_extensions'] = toUpdate.transcoding.allowAdditionalExtensions
 
   await writeJSON(CONFIG.CUSTOM_FILE, toUpdateJSON, { spaces: 2 })
 
@@ -247,6 +249,7 @@ function customConfig (): CustomConfig {
     },
     transcoding: {
       enabled: CONFIG.TRANSCODING.ENABLED,
+      allowAdditionalExtensions: CONFIG.TRANSCODING.ALLOW_ADDITIONAL_EXTENSIONS,
       threads: CONFIG.TRANSCODING.THREADS,
       resolutions: {
         '240p': CONFIG.TRANSCODING.RESOLUTIONS[ '240p' ],

+ 2 - 2
server/controllers/api/users/me.ts

@@ -2,7 +2,7 @@ import * as express from 'express'
 import 'multer'
 import { UserUpdateMe, UserVideoRate as FormattedUserVideoRate } from '../../../../shared'
 import { getFormattedObjects } from '../../../helpers/utils'
-import { CONFIG, IMAGE_MIMETYPE_EXT, sequelizeTypescript } from '../../../initializers'
+import { CONFIG, MIMETYPES, sequelizeTypescript } from '../../../initializers'
 import { sendUpdateActor } from '../../../lib/activitypub/send'
 import {
   asyncMiddleware,
@@ -42,7 +42,7 @@ import { AccountModel } from '../../../models/account/account'
 
 const auditLogger = auditLoggerFactory('users-me')
 
-const reqAvatarFile = createReqFiles([ 'avatarfile' ], IMAGE_MIMETYPE_EXT, { avatarfile: CONFIG.STORAGE.TMP_DIR })
+const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { avatarfile: CONFIG.STORAGE.TMP_DIR })
 
 const meRouter = express.Router()
 

+ 2 - 2
server/controllers/api/video-channel.ts

@@ -22,7 +22,7 @@ import { createVideoChannel } from '../../lib/video-channel'
 import { buildNSFWFilter, createReqFiles, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
 import { setAsyncActorKeys } from '../../lib/activitypub'
 import { AccountModel } from '../../models/account/account'
-import { CONFIG, IMAGE_MIMETYPE_EXT, sequelizeTypescript } from '../../initializers'
+import { CONFIG, MIMETYPES, sequelizeTypescript } from '../../initializers'
 import { logger } from '../../helpers/logger'
 import { VideoModel } from '../../models/video/video'
 import { updateAvatarValidator } from '../../middlewares/validators/avatar'
@@ -32,7 +32,7 @@ import { resetSequelizeInstance } from '../../helpers/database-utils'
 import { UserModel } from '../../models/account/user'
 
 const auditLogger = auditLoggerFactory('channels')
-const reqAvatarFile = createReqFiles([ 'avatarfile' ], IMAGE_MIMETYPE_EXT, { avatarfile: CONFIG.STORAGE.TMP_DIR })
+const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { avatarfile: CONFIG.STORAGE.TMP_DIR })
 
 const videoChannelRouter = express.Router()
 

+ 2 - 2
server/controllers/api/videos/captions.ts

@@ -2,7 +2,7 @@ import * as express from 'express'
 import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares'
 import { addVideoCaptionValidator, deleteVideoCaptionValidator, listVideoCaptionsValidator } from '../../../middlewares/validators'
 import { createReqFiles } from '../../../helpers/express-utils'
-import { CONFIG, sequelizeTypescript, VIDEO_CAPTIONS_MIMETYPE_EXT } from '../../../initializers'
+import { CONFIG, MIMETYPES, sequelizeTypescript } from '../../../initializers'
 import { getFormattedObjects } from '../../../helpers/utils'
 import { VideoCaptionModel } from '../../../models/video/video-caption'
 import { VideoModel } from '../../../models/video/video'
@@ -12,7 +12,7 @@ import { moveAndProcessCaptionFile } from '../../../helpers/captions-utils'
 
 const reqVideoCaptionAdd = createReqFiles(
   [ 'captionfile' ],
-  VIDEO_CAPTIONS_MIMETYPE_EXT,
+  MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT,
   {
     captionfile: CONFIG.STORAGE.CAPTIONS_DIR
   }

+ 2 - 9
server/controllers/api/videos/import.ts

@@ -3,14 +3,7 @@ import * as magnetUtil from 'magnet-uri'
 import 'multer'
 import { auditLoggerFactory, getAuditIdFromRes, VideoImportAuditView } from '../../../helpers/audit-logger'
 import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoImportAddValidator } from '../../../middlewares'
-import {
-  CONFIG,
-  IMAGE_MIMETYPE_EXT,
-  PREVIEWS_SIZE,
-  sequelizeTypescript,
-  THUMBNAILS_SIZE,
-  TORRENT_MIMETYPE_EXT
-} from '../../../initializers'
+import { CONFIG, MIMETYPES, PREVIEWS_SIZE, sequelizeTypescript, THUMBNAILS_SIZE } from '../../../initializers'
 import { getYoutubeDLInfo, YoutubeDLInfo } from '../../../helpers/youtube-dl'
 import { createReqFiles } from '../../../helpers/express-utils'
 import { logger } from '../../../helpers/logger'
@@ -35,7 +28,7 @@ const videoImportsRouter = express.Router()
 
 const reqVideoFileImport = createReqFiles(
   [ 'thumbnailfile', 'previewfile', 'torrentfile' ],
-  Object.assign({}, TORRENT_MIMETYPE_EXT, IMAGE_MIMETYPE_EXT),
+  Object.assign({}, MIMETYPES.TORRENT.MIMETYPE_EXT, MIMETYPES.IMAGE.MIMETYPE_EXT),
   {
     thumbnailfile: CONFIG.STORAGE.TMP_DIR,
     previewfile: CONFIG.STORAGE.TMP_DIR,

+ 5 - 7
server/controllers/api/videos/index.ts

@@ -7,15 +7,13 @@ import { logger } from '../../../helpers/logger'
 import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
 import { getFormattedObjects, getServerActor } from '../../../helpers/utils'
 import {
-  CONFIG,
-  IMAGE_MIMETYPE_EXT,
+  CONFIG, MIMETYPES,
   PREVIEWS_SIZE,
   sequelizeTypescript,
   THUMBNAILS_SIZE,
   VIDEO_CATEGORIES,
   VIDEO_LANGUAGES,
   VIDEO_LICENCES,
-  VIDEO_MIMETYPE_EXT,
   VIDEO_PRIVACIES
 } from '../../../initializers'
 import {
@@ -57,7 +55,7 @@ import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-u
 import { videoCaptionsRouter } from './captions'
 import { videoImportsRouter } from './import'
 import { resetSequelizeInstance } from '../../../helpers/database-utils'
-import { rename } from 'fs-extra'
+import { move } from 'fs-extra'
 import { watchingRouter } from './watching'
 
 const auditLogger = auditLoggerFactory('videos')
@@ -65,7 +63,7 @@ const videosRouter = express.Router()
 
 const reqVideoFileAdd = createReqFiles(
   [ 'videofile', 'thumbnailfile', 'previewfile' ],
-  Object.assign({}, VIDEO_MIMETYPE_EXT, IMAGE_MIMETYPE_EXT),
+  Object.assign({}, MIMETYPES.VIDEO.MIMETYPE_EXT, MIMETYPES.IMAGE.MIMETYPE_EXT),
   {
     videofile: CONFIG.STORAGE.TMP_DIR,
     thumbnailfile: CONFIG.STORAGE.TMP_DIR,
@@ -74,7 +72,7 @@ const reqVideoFileAdd = createReqFiles(
 )
 const reqVideoFileUpdate = createReqFiles(
   [ 'thumbnailfile', 'previewfile' ],
-  IMAGE_MIMETYPE_EXT,
+  MIMETYPES.IMAGE.MIMETYPE_EXT,
   {
     thumbnailfile: CONFIG.STORAGE.TMP_DIR,
     previewfile: CONFIG.STORAGE.TMP_DIR
@@ -208,7 +206,7 @@ async function addVideo (req: express.Request, res: express.Response) {
   // Move physical file
   const videoDir = CONFIG.STORAGE.VIDEOS_DIR
   const destination = join(videoDir, video.getVideoFilename(videoFile))
-  await rename(videoPhysicalFile.path, destination)
+  await move(videoPhysicalFile.path, destination)
   // This is important in case if there is another attempt in the retry process
   videoPhysicalFile.filename = video.getVideoFilename(videoFile)
   videoPhysicalFile.path = destination

+ 2 - 2
server/helpers/custom-validators/video-captions.ts

@@ -1,4 +1,4 @@
-import { CONSTRAINTS_FIELDS, VIDEO_CAPTIONS_MIMETYPE_EXT, VIDEO_LANGUAGES } from '../../initializers'
+import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_LANGUAGES } from '../../initializers'
 import { exists, isFileValid } from './misc'
 import { Response } from 'express'
 import { VideoModel } from '../../models/video/video'
@@ -8,7 +8,7 @@ function isVideoCaptionLanguageValid (value: any) {
   return exists(value) && VIDEO_LANGUAGES[ value ] !== undefined
 }
 
-const videoCaptionTypes = Object.keys(VIDEO_CAPTIONS_MIMETYPE_EXT)
+const videoCaptionTypes = Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT)
                                 .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream ><
                                 .map(m => `(${m})`)
 const videoCaptionTypesRegex = videoCaptionTypes.join('|')

+ 2 - 2
server/helpers/custom-validators/video-imports.ts

@@ -1,7 +1,7 @@
 import 'express-validator'
 import 'multer'
 import * as validator from 'validator'
-import { CONSTRAINTS_FIELDS, TORRENT_MIMETYPE_EXT, VIDEO_IMPORT_STATES } from '../../initializers'
+import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_IMPORT_STATES } from '../../initializers'
 import { exists, isFileValid } from './misc'
 import * as express from 'express'
 import { VideoImportModel } from '../../models/video/video-import'
@@ -24,7 +24,7 @@ function isVideoImportStateValid (value: any) {
   return exists(value) && VIDEO_IMPORT_STATES[ value ] !== undefined
 }
 
-const videoTorrentImportTypes = Object.keys(TORRENT_MIMETYPE_EXT).map(m => `(${m})`)
+const videoTorrentImportTypes = Object.keys(MIMETYPES.TORRENT.MIMETYPE_EXT).map(m => `(${m})`)
 const videoTorrentImportRegex = videoTorrentImportTypes.join('|')
 function isVideoImportTorrentFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) {
   return isFileValid(files, videoTorrentImportRegex, 'torrentfile', CONSTRAINTS_FIELDS.VIDEO_IMPORTS.TORRENT_FILE.FILE_SIZE.max, true)

+ 9 - 4
server/helpers/custom-validators/videos.ts

@@ -5,10 +5,9 @@ import 'multer'
 import * as validator from 'validator'
 import { UserRight, VideoFilter, VideoPrivacy, VideoRateType } from '../../../shared'
 import {
-  CONSTRAINTS_FIELDS,
+  CONSTRAINTS_FIELDS, MIMETYPES,
   VIDEO_CATEGORIES,
   VIDEO_LICENCES,
-  VIDEO_MIMETYPE_EXT,
   VIDEO_PRIVACIES,
   VIDEO_RATE_TYPES,
   VIDEO_STATES
@@ -83,10 +82,15 @@ function isVideoRatingTypeValid (value: string) {
   return value === 'none' || values(VIDEO_RATE_TYPES).indexOf(value as VideoRateType) !== -1
 }
 
-const videoFileTypes = Object.keys(VIDEO_MIMETYPE_EXT).map(m => `(${m})`)
-const videoFileTypesRegex = videoFileTypes.join('|')
+function isVideoFileExtnameValid (value: string) {
+  return exists(value) && MIMETYPES.VIDEO.EXT_MIMETYPE[value] !== undefined
+}
 
 function isVideoFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) {
+  const videoFileTypesRegex = Object.keys(MIMETYPES.VIDEO.MIMETYPE_EXT)
+                               .map(m => `(${m})`)
+                               .join('|')
+
   return isFileValid(files, videoFileTypesRegex, 'videofile', null)
 }
 
@@ -221,6 +225,7 @@ export {
   isVideoStateValid,
   isVideoViewsValid,
   isVideoRatingTypeValid,
+  isVideoFileExtnameValid,
   isVideoDurationValid,
   isVideoTagValid,
   isVideoPrivacyValid,

+ 1 - 1
server/initializers/checker-before-init.ts

@@ -19,7 +19,7 @@ function checkMissedConfig () {
     'signup.enabled', 'signup.limit', 'signup.requires_email_verification',
     'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist',
     'redundancy.videos.strategies', 'redundancy.videos.check_interval',
-    'transcoding.enabled', 'transcoding.threads',
+    'transcoding.enabled', 'transcoding.threads', 'transcoding.allow_additional_extensions',
     'import.videos.http.enabled', 'import.videos.torrent.enabled',
     'trending.videos.interval_days',
     'instance.name', 'instance.short_description', 'instance.description', 'instance.terms', 'instance.default_client_route',

+ 65 - 31
server/initializers/constants.ts

@@ -16,7 +16,7 @@ let config: IConfig = require('config')
 
 // ---------------------------------------------------------------------------
 
-const LAST_MIGRATION_VERSION = 290
+const LAST_MIGRATION_VERSION = 295
 
 // ---------------------------------------------------------------------------
 
@@ -246,6 +246,7 @@ const CONFIG = {
   },
   TRANSCODING: {
     get ENABLED () { return config.get<boolean>('transcoding.enabled') },
+    get ALLOW_ADDITIONAL_EXTENSIONS () { return config.get<boolean>('transcoding.allow_additional_extensions') },
     get THREADS () { return config.get<number>('transcoding.threads') },
     RESOLUTIONS: {
       get '240p' () { return config.get<boolean>('transcoding.resolutions.240p') },
@@ -298,7 +299,7 @@ const CONFIG = {
 
 // ---------------------------------------------------------------------------
 
-const CONSTRAINTS_FIELDS = {
+let CONSTRAINTS_FIELDS = {
   USERS: {
     NAME: { min: 1, max: 50 }, // Length
     DESCRIPTION: { min: 3, max: 1000 }, // Length
@@ -357,7 +358,7 @@ const CONSTRAINTS_FIELDS = {
         max: 2 * 1024 * 1024 // 2MB
       }
     },
-    EXTNAME: [ '.mp4', '.ogv', '.webm' ],
+    EXTNAME: buildVideosExtname(),
     INFO_HASH: { min: 40, max: 40 }, // Length, info hash is 20 bytes length but we represent it in hexadecimal so 20 * 2
     DURATION: { min: 0 }, // Number
     TAGS: { min: 0, max: 5 }, // Number of total tags
@@ -480,27 +481,31 @@ const VIDEO_ABUSE_STATES = {
   [VideoAbuseState.ACCEPTED]: 'Accepted'
 }
 
-const VIDEO_MIMETYPE_EXT = {
-  'video/webm': '.webm',
-  'video/ogg': '.ogv',
-  'video/mp4': '.mp4'
-}
-const VIDEO_EXT_MIMETYPE = invert(VIDEO_MIMETYPE_EXT)
-
-const IMAGE_MIMETYPE_EXT = {
-  'image/png': '.png',
-  'image/jpg': '.jpg',
-  'image/jpeg': '.jpg'
-}
-
-const VIDEO_CAPTIONS_MIMETYPE_EXT = {
-  'text/vtt': '.vtt',
-  'application/x-subrip': '.srt'
-}
-
-const TORRENT_MIMETYPE_EXT = {
-  'application/x-bittorrent': '.torrent'
+const MIMETYPES = {
+  VIDEO: {
+    MIMETYPE_EXT: buildVideoMimetypeExt(),
+    EXT_MIMETYPE: null as { [ id: string ]: string }
+  },
+  IMAGE: {
+    MIMETYPE_EXT: {
+      'image/png': '.png',
+      'image/jpg': '.jpg',
+      'image/jpeg': '.jpg'
+    }
+  },
+  VIDEO_CAPTIONS: {
+    MIMETYPE_EXT: {
+      'text/vtt': '.vtt',
+      'application/x-subrip': '.srt'
+    }
+  },
+  TORRENT: {
+    MIMETYPE_EXT: {
+      'application/x-bittorrent': '.torrent'
+    }
+  }
 }
+MIMETYPES.VIDEO.EXT_MIMETYPE = invert(MIMETYPES.VIDEO.MIMETYPE_EXT)
 
 // ---------------------------------------------------------------------------
 
@@ -526,7 +531,7 @@ const ACTIVITY_PUB = {
   COLLECTION_ITEMS_PER_PAGE: 10,
   FETCH_PAGE_LIMIT: 100,
   URL_MIME_TYPES: {
-    VIDEO: Object.keys(VIDEO_MIMETYPE_EXT),
+    VIDEO: Object.keys(MIMETYPES.VIDEO.MIMETYPE_EXT),
     TORRENT: [ 'application/x-bittorrent' ],
     MAGNET: [ 'application/x-bittorrent;x-scheme-handler/magnet' ]
   },
@@ -685,13 +690,12 @@ if (isTestInstance() === true) {
   ROUTE_CACHE_LIFETIME.OVERVIEWS.VIDEOS = '0ms'
 }
 
-updateWebserverConfig()
+updateWebserverUrls()
 
 // ---------------------------------------------------------------------------
 
 export {
   API_VERSION,
-  VIDEO_CAPTIONS_MIMETYPE_EXT,
   AVATARS_SIZE,
   ACCEPT_HEADERS,
   BCRYPT_SALT_SIZE,
@@ -719,7 +723,6 @@ export {
   FEEDS,
   JOB_TTL,
   NSFW_POLICY_TYPES,
-  TORRENT_MIMETYPE_EXT,
   STATIC_MAX_AGE,
   STATIC_PATHS,
   VIDEO_IMPORT_TIMEOUT,
@@ -732,7 +735,6 @@ export {
   VIDEO_LICENCES,
   VIDEO_STATES,
   VIDEO_RATE_TYPES,
-  VIDEO_MIMETYPE_EXT,
   VIDEO_TRANSCODING_FPS,
   FFMPEG_NICE,
   VIDEO_ABUSE_STATES,
@@ -740,13 +742,12 @@ export {
   USER_PASSWORD_RESET_LIFETIME,
   MEMOIZE_TTL,
   USER_EMAIL_VERIFY_LIFETIME,
-  IMAGE_MIMETYPE_EXT,
   OVERVIEWS,
   SCHEDULER_INTERVALS_MS,
   REPEAT_JOBS,
   STATIC_DOWNLOAD_PATHS,
   RATES_LIMIT,
-  VIDEO_EXT_MIMETYPE,
+  MIMETYPES,
   CRAWL_REQUEST_CONCURRENCY,
   JOB_COMPLETED_LIFETIME,
   HTTP_SIGNATURE,
@@ -768,11 +769,43 @@ function getLocalConfigFilePath () {
   return join(dirname(configSources[ 0 ].name), filename + '.json')
 }
 
-function updateWebserverConfig () {
+function buildVideoMimetypeExt () {
+  const data = {
+    'video/webm': '.webm',
+    'video/ogg': '.ogv',
+    'video/mp4': '.mp4'
+  }
+
+  if (CONFIG.TRANSCODING.ENABLED && CONFIG.TRANSCODING.ALLOW_ADDITIONAL_EXTENSIONS) {
+    Object.assign(data, {
+      'video/quicktime': '.mov',
+      'video/x-msvideo': '.avi',
+      'video/x-flv': '.flv',
+      'video/x-matroska': '.mkv'
+    })
+  }
+
+  return data
+}
+
+function updateWebserverUrls () {
   CONFIG.WEBSERVER.URL = sanitizeUrl(CONFIG.WEBSERVER.SCHEME + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT)
   CONFIG.WEBSERVER.HOST = sanitizeHost(CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT, REMOTE_SCHEME.HTTP)
 }
 
+function updateWebserverConfig () {
+  CONSTRAINTS_FIELDS.VIDEOS.EXTNAME = buildVideosExtname()
+
+  MIMETYPES.VIDEO.MIMETYPE_EXT = buildVideoMimetypeExt()
+  MIMETYPES.VIDEO.EXT_MIMETYPE = invert(MIMETYPES.VIDEO.MIMETYPE_EXT)
+}
+
+function buildVideosExtname () {
+  return CONFIG.TRANSCODING.ENABLED && CONFIG.TRANSCODING.ALLOW_ADDITIONAL_EXTENSIONS
+    ? [ '.mp4', '.ogv', '.webm', '.mkv', '.mov', '.avi', '.flv' ]
+    : [ '.mp4', '.ogv', '.webm' ]
+}
+
 function buildVideosRedundancy (objs: any[]): VideosRedundancy[] {
   if (!objs) return []
 
@@ -854,4 +887,5 @@ export function reloadConfig () {
   config = require('config')
 
   updateWebserverConfig()
+  updateWebserverUrls()
 }

+ 49 - 0
server/initializers/migrations/0295-video-file-extname.ts

@@ -0,0 +1,49 @@
+import * as Sequelize from 'sequelize'
+
+async function up (utils: {
+  transaction: Sequelize.Transaction,
+  queryInterface: Sequelize.QueryInterface,
+  sequelize: Sequelize.Sequelize,
+  db: any
+}): Promise<void> {
+  {
+    await utils.queryInterface.renameColumn('videoFile', 'extname', 'extname_old')
+  }
+
+  {
+    const data = {
+      type: Sequelize.STRING,
+      defaultValue: null,
+      allowNull: true
+    }
+
+    await utils.queryInterface.addColumn('videoFile', 'extname', data)
+  }
+
+  {
+    const query = 'UPDATE "videoFile" SET "extname" = "extname_old"::text'
+    await utils.sequelize.query(query)
+  }
+
+  {
+    const data = {
+      type: Sequelize.STRING,
+      defaultValue: null,
+      allowNull: false
+    }
+    await utils.queryInterface.changeColumn('videoFile', 'extname', data)
+  }
+
+  {
+    await utils.queryInterface.removeColumn('videoFile', 'extname_old')
+  }
+}
+
+function down (options) {
+  throw new Error('Not implemented.')
+}
+
+export {
+  up,
+  down
+}

+ 3 - 4
server/lib/activitypub/actor.ts

@@ -1,5 +1,4 @@
 import * as Bluebird from 'bluebird'
-import { join } from 'path'
 import { Transaction } from 'sequelize'
 import * as url from 'url'
 import * as uuidv4 from 'uuid/v4'
@@ -13,7 +12,7 @@ import { logger } from '../../helpers/logger'
 import { createPrivateAndPublicKeys } from '../../helpers/peertube-crypto'
 import { doRequest, downloadImage } from '../../helpers/requests'
 import { getUrlFromWebfinger } from '../../helpers/webfinger'
-import { AVATARS_SIZE, CONFIG, IMAGE_MIMETYPE_EXT, sequelizeTypescript } from '../../initializers'
+import { AVATARS_SIZE, CONFIG, MIMETYPES, sequelizeTypescript } from '../../initializers'
 import { AccountModel } from '../../models/account/account'
 import { ActorModel } from '../../models/activitypub/actor'
 import { AvatarModel } from '../../models/avatar/avatar'
@@ -172,10 +171,10 @@ async function fetchActorTotalItems (url: string) {
 
 async function fetchAvatarIfExists (actorJSON: ActivityPubActor) {
   if (
-    actorJSON.icon && actorJSON.icon.type === 'Image' && IMAGE_MIMETYPE_EXT[actorJSON.icon.mediaType] !== undefined &&
+    actorJSON.icon && actorJSON.icon.type === 'Image' && MIMETYPES.IMAGE.MIMETYPE_EXT[actorJSON.icon.mediaType] !== undefined &&
     isActivityPubUrlValid(actorJSON.icon.url)
   ) {
-    const extension = IMAGE_MIMETYPE_EXT[actorJSON.icon.mediaType]
+    const extension = MIMETYPES.IMAGE.MIMETYPE_EXT[actorJSON.icon.mediaType]
 
     const avatarName = uuidv4() + extension
     await downloadImage(actorJSON.icon.url, CONFIG.STORAGE.AVATARS_DIR, avatarName, AVATARS_SIZE)

+ 3 - 4
server/lib/activitypub/videos.ts

@@ -1,7 +1,6 @@
 import * as Bluebird from 'bluebird'
 import * as sequelize from 'sequelize'
 import * as magnetUtil from 'magnet-uri'
-import { join } from 'path'
 import * as request from 'request'
 import { ActivityIconObject, ActivityUrlObject, ActivityVideoUrlObject, VideoState } from '../../../shared/index'
 import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
@@ -11,7 +10,7 @@ import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos
 import { resetSequelizeInstance, retryTransactionWrapper } from '../../helpers/database-utils'
 import { logger } from '../../helpers/logger'
 import { doRequest, downloadImage } from '../../helpers/requests'
-import { ACTIVITY_PUB, CONFIG, REMOTE_SCHEME, sequelizeTypescript, THUMBNAILS_SIZE, VIDEO_MIMETYPE_EXT } from '../../initializers'
+import { ACTIVITY_PUB, CONFIG, MIMETYPES, REMOTE_SCHEME, sequelizeTypescript, THUMBNAILS_SIZE } from '../../initializers'
 import { ActorModel } from '../../models/activitypub/actor'
 import { TagModel } from '../../models/video/tag'
 import { VideoModel } from '../../models/video/video'
@@ -362,7 +361,7 @@ export {
 // ---------------------------------------------------------------------------
 
 function isActivityVideoUrlObject (url: ActivityUrlObject): url is ActivityVideoUrlObject {
-  const mimeTypes = Object.keys(VIDEO_MIMETYPE_EXT)
+  const mimeTypes = Object.keys(MIMETYPES.VIDEO.MIMETYPE_EXT)
 
   const urlMediaType = url.mediaType || url.mimeType
   return mimeTypes.indexOf(urlMediaType) !== -1 && urlMediaType.startsWith('video/')
@@ -490,7 +489,7 @@ function videoFileActivityUrlToDBAttributes (video: VideoModel, videoObject: Vid
 
     const mediaType = fileUrl.mediaType || fileUrl.mimeType
     const attribute = {
-      extname: VIDEO_MIMETYPE_EXT[ mediaType ],
+      extname: MIMETYPES.VIDEO.MIMETYPE_EXT[ mediaType ],
       infoHash: parsed.infoHash,
       resolution: fileUrl.height,
       size: fileUrl.size,

+ 3 - 3
server/models/redundancy/video-redundancy.ts

@@ -15,7 +15,7 @@ import {
 import { ActorModel } from '../activitypub/actor'
 import { getVideoSort, throwIfNotValid } from '../utils'
 import { isActivityPubUrlValid, isUrlValid } from '../../helpers/custom-validators/activitypub/misc'
-import { CONFIG, CONSTRAINTS_FIELDS, STATIC_PATHS, VIDEO_EXT_MIMETYPE } from '../../initializers'
+import { CONFIG, CONSTRAINTS_FIELDS, MIMETYPES } from '../../initializers'
 import { VideoFileModel } from '../video/video-file'
 import { getServerActor } from '../../helpers/utils'
 import { VideoModel } from '../video/video'
@@ -415,8 +415,8 @@ export class VideoRedundancyModel extends Model<VideoRedundancyModel> {
       expires: this.expiresOn.toISOString(),
       url: {
         type: 'Link',
-        mimeType: VIDEO_EXT_MIMETYPE[ this.VideoFile.extname ] as any,
-        mediaType: VIDEO_EXT_MIMETYPE[ this.VideoFile.extname ] as any,
+        mimeType: MIMETYPES.VIDEO.EXT_MIMETYPE[ this.VideoFile.extname ] as any,
+        mediaType: MIMETYPES.VIDEO.EXT_MIMETYPE[ this.VideoFile.extname ] as any,
         href: this.fileUrl,
         height: this.VideoFile.resolution,
         size: this.VideoFile.size,

+ 3 - 1
server/models/video/video-file.ts

@@ -14,6 +14,7 @@ import {
   UpdatedAt
 } from 'sequelize-typescript'
 import {
+  isVideoFileExtnameValid,
   isVideoFileInfoHashValid,
   isVideoFileResolutionValid,
   isVideoFileSizeValid,
@@ -58,7 +59,8 @@ export class VideoFileModel extends Model<VideoFileModel> {
   size: number
 
   @AllowNull(false)
-  @Column(DataType.ENUM(values(CONSTRAINTS_FIELDS.VIDEOS.EXTNAME)))
+  @Is('VideoFileExtname', value => throwIfNotValid(value, isVideoFileExtnameValid, 'extname'))
+  @Column
   extname: string
 
   @AllowNull(false)

+ 3 - 3
server/models/video/video-format-utils.ts

@@ -2,7 +2,7 @@ import { Video, VideoDetails, VideoFile } from '../../../shared/models/videos'
 import { VideoModel } from './video'
 import { VideoFileModel } from './video-file'
 import { ActivityUrlObject, VideoTorrentObject } from '../../../shared/models/activitypub/objects'
-import { CONFIG, THUMBNAILS_SIZE, VIDEO_EXT_MIMETYPE } from '../../initializers'
+import { CONFIG, MIMETYPES, THUMBNAILS_SIZE } from '../../initializers'
 import { VideoCaptionModel } from './video-caption'
 import {
   getVideoCommentsActivityPubUrl,
@@ -207,8 +207,8 @@ function videoModelToActivityPubObject (video: VideoModel): VideoTorrentObject {
   for (const file of video.VideoFiles) {
     url.push({
       type: 'Link',
-      mimeType: VIDEO_EXT_MIMETYPE[ file.extname ] as any,
-      mediaType: VIDEO_EXT_MIMETYPE[ file.extname ] as any,
+      mimeType: MIMETYPES.VIDEO.EXT_MIMETYPE[ file.extname ] as any,
+      mediaType: MIMETYPES.VIDEO.EXT_MIMETYPE[ file.extname ] as any,
       href: video.getVideoFileUrl(file, baseUrlHttp),
       height: file.resolution,
       size: file.size,

+ 1 - 0
server/tests/api/check-params/config.ts

@@ -54,6 +54,7 @@ describe('Test config API validators', function () {
     },
     transcoding: {
       enabled: true,
+      allowAdditionalExtensions: true,
       threads: 1,
       resolutions: {
         '240p': false,

+ 6 - 1
server/tests/api/check-params/videos.ts

@@ -320,10 +320,15 @@ describe('Test videos API validator', function () {
 
     it('Should fail without an incorrect input file', async function () {
       const fields = baseCorrectParams
-      const attaches = {
+      let attaches = {
         'videofile': join(__dirname, '..', '..', 'fixtures', 'video_short_fake.webm')
       }
       await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches })
+
+      attaches = {
+        'videofile': join(__dirname, '..', '..', 'fixtures', 'video_short.mkv')
+      }
+      await makeUploadRequest({ url: server.url, path: path + '/upload', token: server.accessToken, fields, attaches })
     })
 
     it('Should fail with an incorrect thumbnail file', async function () {

+ 28 - 2
server/tests/api/server/config.ts

@@ -17,6 +17,7 @@ import {
   setAccessTokensToServers,
   updateCustomConfig
 } from '../../../../shared/utils'
+import { ServerConfig } from '../../../../shared/models'
 
 const expect = chai.expect
 
@@ -43,6 +44,7 @@ function checkInitialConfig (data: CustomConfig) {
   expect(data.user.videoQuota).to.equal(5242880)
   expect(data.user.videoQuotaDaily).to.equal(-1)
   expect(data.transcoding.enabled).to.be.false
+  expect(data.transcoding.allowAdditionalExtensions).to.be.false
   expect(data.transcoding.threads).to.equal(2)
   expect(data.transcoding.resolutions['240p']).to.be.true
   expect(data.transcoding.resolutions['360p']).to.be.true
@@ -74,6 +76,7 @@ function checkUpdatedConfig (data: CustomConfig) {
   expect(data.user.videoQuotaDaily).to.equal(318742)
   expect(data.transcoding.enabled).to.be.true
   expect(data.transcoding.threads).to.equal(1)
+  expect(data.transcoding.allowAdditionalExtensions).to.be.true
   expect(data.transcoding.resolutions['240p']).to.be.false
   expect(data.transcoding.resolutions['360p']).to.be.true
   expect(data.transcoding.resolutions['480p']).to.be.true
@@ -96,7 +99,7 @@ describe('Test config', function () {
 
   it('Should have a correct config on a server with registration enabled', async function () {
     const res = await getConfig(server.url)
-    const data = res.body
+    const data: ServerConfig = res.body
 
     expect(data.signup.allowed).to.be.true
   })
@@ -111,11 +114,21 @@ describe('Test config', function () {
     ])
 
     const res = await getConfig(server.url)
-    const data = res.body
+    const data: ServerConfig = res.body
 
     expect(data.signup.allowed).to.be.false
   })
 
+  it('Should have the correct video allowed extensions', async function () {
+    const res = await getConfig(server.url)
+    const data: ServerConfig = res.body
+
+    expect(data.video.file.extensions).to.have.lengthOf(3)
+    expect(data.video.file.extensions).to.contain('.mp4')
+    expect(data.video.file.extensions).to.contain('.webm')
+    expect(data.video.file.extensions).to.contain('.ogv')
+  })
+
   it('Should get the customized configuration', async function () {
     const res = await getCustomConfig(server.url, server.accessToken)
     const data = res.body as CustomConfig
@@ -165,6 +178,7 @@ describe('Test config', function () {
       },
       transcoding: {
         enabled: true,
+        allowAdditionalExtensions: true,
         threads: 1,
         resolutions: {
           '240p': false,
@@ -193,6 +207,18 @@ describe('Test config', function () {
     checkUpdatedConfig(data)
   })
 
+  it('Should have the correct updated video allowed extensions', async function () {
+    const res = await getConfig(server.url)
+    const data: ServerConfig = res.body
+
+    expect(data.video.file.extensions).to.have.length.above(3)
+    expect(data.video.file.extensions).to.contain('.mp4')
+    expect(data.video.file.extensions).to.contain('.webm')
+    expect(data.video.file.extensions).to.contain('.ogv')
+    expect(data.video.file.extensions).to.contain('.flv')
+    expect(data.video.file.extensions).to.contain('.mkv')
+  })
+
   it('Should have the configuration updated after a restart', async function () {
     this.timeout(10000)
 

+ 29 - 2
server/tests/api/videos/video-transcoder.ts

@@ -20,9 +20,8 @@ import {
   uploadVideo,
   webtorrentAdd
 } from '../../../../shared/utils'
-import { join } from 'path'
+import { extname, join } from 'path'
 import { waitJobs } from '../../../../shared/utils/server/jobs'
-import { pathExists } from 'fs-extra'
 import { VIDEO_TRANSCODING_FPS } from '../../../../server/initializers/constants'
 
 const expect = chai.expect
@@ -322,6 +321,34 @@ describe('Test video transcoding', function () {
     }
   })
 
+  it('Should accept and transcode additional extensions', async function () {
+    this.timeout(300000)
+
+    for (const fixture of [ 'video_short.mkv', 'video_short.avi' ]) {
+      const videoAttributes = {
+        name: fixture,
+        fixture
+      }
+
+      await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, videoAttributes)
+
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const res = await getVideosList(server.url)
+
+        const video = res.body.data.find(v => v.name === videoAttributes.name)
+        const res2 = await getVideo(server.url, video.id)
+        const videoDetails = res2.body
+
+        expect(videoDetails.files).to.have.lengthOf(4)
+
+        const magnetUri = videoDetails.files[ 0 ].magnetUri
+        expect(magnetUri).to.contain('.mp4')
+      }
+    }
+  })
+
   after(async function () {
     killallServers(servers)
   })

BIN
server/tests/fixtures/video_short.avi


BIN
server/tests/fixtures/video_short.mkv


+ 1 - 0
shared/models/server/custom-config.model.ts

@@ -48,6 +48,7 @@ export interface CustomConfig {
 
   transcoding: {
     enabled: boolean
+    allowAdditionalExtensions: boolean
     threads: number
     resolutions: {
       '240p': boolean

+ 1 - 0
shared/utils/server/config.ts

@@ -86,6 +86,7 @@ function updateCustomSubConfig (url: string, token: string, newConfig: any) {
     },
     transcoding: {
       enabled: true,
+      allowAdditionalExtensions: true,
       threads: 1,
       resolutions: {
         '240p': false,