aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2019-05-21 10:05:12 +0200
committerChocobozzz <me@florianbigard.com>2019-05-21 10:05:12 +0200
commit73b3aa6429dfb2e31628fa09a479dce318289d7d (patch)
tree88cf5c7c49ba89c18633a4a64a4acfc8d40b4a50
parentfd822c1c699fb89bb1c3218e047e1d842bc1ba1a (diff)
parent618750486ee2732e0ad3525349e4d42f29e1803e (diff)
downloadPeerTube-73b3aa6429dfb2e31628fa09a479dce318289d7d.tar.gz
PeerTube-73b3aa6429dfb2e31628fa09a479dce318289d7d.tar.zst
PeerTube-73b3aa6429dfb2e31628fa09a479dce318289d7d.zip
Merge branch 'feature/audio-upload' into develop
-rw-r--r--.travis.yml4
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html8
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts1
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html10
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts2
-rw-r--r--client/src/app/+my-account/my-account-videos/my-account-videos.component.html2
-rw-r--r--client/src/app/shared/buttons/button.component.scss13
-rw-r--r--client/src/app/shared/buttons/button.component.ts2
-rw-r--r--client/src/app/shared/buttons/delete-button.component.html2
-rw-r--r--client/src/app/shared/buttons/edit-button.component.html2
-rw-r--r--client/src/app/shared/forms/reactive-file.component.html7
-rw-r--r--client/src/app/shared/forms/reactive-file.component.scss10
-rw-r--r--client/src/app/shared/forms/reactive-file.component.ts2
-rw-r--r--client/src/app/shared/images/image-upload.component.html9
-rw-r--r--client/src/app/shared/images/image-upload.component.scss18
-rw-r--r--client/src/app/shared/images/preview-upload.component.html13
-rw-r--r--client/src/app/shared/images/preview-upload.component.scss27
-rw-r--r--client/src/app/shared/images/preview-upload.component.ts (renamed from client/src/app/shared/images/image-upload.component.ts)17
-rw-r--r--client/src/app/shared/shared.module.ts6
-rw-r--r--client/src/app/shared/video/video-edit.model.ts5
-rw-r--r--client/src/app/videos/+video-edit/shared/video-edit.component.html14
-rw-r--r--client/src/app/videos/+video-edit/shared/video-edit.component.ts1
-rw-r--r--client/src/app/videos/+video-edit/video-add-components/video-upload.component.html21
-rw-r--r--client/src/app/videos/+video-edit/video-add-components/video-upload.component.scss17
-rw-r--r--client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts106
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.ts8
-rw-r--r--client/src/assets/player/peertube-player-manager.ts13
-rw-r--r--config/default.yaml2
-rw-r--r--config/production.yaml.example2
-rw-r--r--config/test-2.yaml1
-rw-r--r--config/test.yaml1
-rwxr-xr-xscripts/create-transcoding-job.ts13
-rw-r--r--server/assets/default-audio-background.jpgbin0 -> 14048 bytes
-rw-r--r--server/controllers/api/config.ts1
-rw-r--r--server/controllers/api/videos/index.ts43
-rw-r--r--server/controllers/static.ts2
-rw-r--r--server/helpers/express-utils.ts13
-rw-r--r--server/helpers/ffmpeg-utils.ts154
-rw-r--r--server/initializers/config.ts1
-rw-r--r--server/initializers/constants.ts68
-rw-r--r--server/lib/files-cache/videos-preview-cache.ts2
-rw-r--r--server/lib/job-queue/handlers/video-file-import.ts4
-rw-r--r--server/lib/job-queue/handlers/video-import.ts1
-rw-r--r--server/lib/job-queue/handlers/video-transcoding.ts56
-rw-r--r--server/lib/thumbnail.ts8
-rw-r--r--server/lib/video-transcoding.ts88
-rw-r--r--server/models/video/thumbnail.ts8
-rw-r--r--server/models/video/video-file.ts5
-rw-r--r--server/tests/api/check-params/config.ts1
-rw-r--r--server/tests/api/server/config.ts14
-rw-r--r--server/tests/api/server/jobs.ts2
-rw-r--r--server/tests/api/travis-2.sh2
-rw-r--r--server/tests/api/videos/multiple-servers.ts4
-rw-r--r--server/tests/api/videos/services.ts4
-rw-r--r--server/tests/api/videos/video-hls.ts45
-rw-r--r--server/tests/api/videos/video-transcoder.ts51
-rw-r--r--server/tests/fixtures/preview.jpgbin4215 -> 6868 bytes
-rw-r--r--server/tests/fixtures/sample.oggbin0 -> 105243 bytes
-rw-r--r--server/tests/fixtures/video_short1-preview.webm.jpgbin10181 -> 22654 bytes
-rw-r--r--shared/extra-utils/server/config.ts1
-rw-r--r--shared/models/server/custom-config.model.ts1
61 files changed, 646 insertions, 292 deletions
diff --git a/.travis.yml b/.travis.yml
index 5fa41fb43..8b3ec94d9 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -29,8 +29,8 @@ install:
29 - CC=gcc-4.9 CXX=g++-4.9 yarn install 29 - CC=gcc-4.9 CXX=g++-4.9 yarn install
30 30
31before_script: 31before_script:
32 - wget --no-check-certificate "https://download.cpy.re/ffmpeg/ffmpeg-release-4.0.2-64bit-static.tar.xz" 32 - wget --no-check-certificate "https://download.cpy.re/ffmpeg/ffmpeg-release-4.0.3-64bit-static.tar.xz"
33 - tar xf ffmpeg-release-4.0.2-64bit-static.tar.xz 33 - tar xf ffmpeg-release-4.0.3-64bit-static.tar.xz
34 - mkdir -p $HOME/bin 34 - mkdir -p $HOME/bin
35 - cp ffmpeg-*/{ffmpeg,ffprobe} $HOME/bin 35 - cp ffmpeg-*/{ffmpeg,ffprobe} $HOME/bin
36 - export PATH=$HOME/bin:$PATH 36 - export PATH=$HOME/bin:$PATH
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
index 637484622..44fc6dc26 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
@@ -287,6 +287,14 @@
287 </div> 287 </div>
288 288
289 <div class="form-group"> 289 <div class="form-group">
290 <my-peertube-checkbox
291 inputName="transcodingAllowAudioFiles" formControlName="allowAudioFiles"
292 i18n-labelText labelText="Allow audio files upload"
293 i18n-helpHtml helpHtml="Allow your users to upload audio files that will be merged with the preview file on upload"
294 ></my-peertube-checkbox>
295 </div>
296
297 <div class="form-group">
290 <label i18n for="transcodingThreads">Transcoding threads</label> 298 <label i18n for="transcodingThreads">Transcoding threads</label>
291 <div class="peertube-select-container"> 299 <div class="peertube-select-container">
292 <select id="transcodingThreads" formControlName="threads"> 300 <select id="transcodingThreads" formControlName="threads">
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
index e64750713..c238a6c81 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
@@ -116,6 +116,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
116 enabled: null, 116 enabled: null,
117 threads: this.customConfigValidatorsService.TRANSCODING_THREADS, 117 threads: this.customConfigValidatorsService.TRANSCODING_THREADS,
118 allowAdditionalExtensions: null, 118 allowAdditionalExtensions: null,
119 allowAudioFiles: null,
119 resolutions: {} 120 resolutions: {}
120 }, 121 },
121 autoBlacklist: { 122 autoBlacklist: {
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html
index 303fc46f7..82321459f 100644
--- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html
@@ -57,10 +57,12 @@
57 </div> 57 </div>
58 58
59 <div class="form-group"> 59 <div class="form-group">
60 <my-image-upload 60 <label i18n>Playlist thumbnail</label>
61 i18n-inputLabel inputLabel="Upload thumbnail" inputName="thumbnailfile" formControlName="thumbnailfile" 61
62 previewWidth="200px" previewHeight="110px" 62 <my-preview-upload
63 ></my-image-upload> 63 i18n-inputLabel inputLabel="Edit" inputName="thumbnailfile" formControlName="thumbnailfile"
64 previewWidth="223px" previewHeight="122px"
65 ></my-preview-upload>
64 </div> 66 </div>
65 </div> 67 </div>
66 </div> 68 </div>
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts
index fbfb4c8f7..81dd9a75f 100644
--- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts
@@ -1,6 +1,4 @@
1import { FormReactive } from '@app/shared' 1import { FormReactive } from '@app/shared'
2import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
3import { ServerService } from '@app/core'
4import { VideoPlaylist } from '@shared/models/videos/playlist/video-playlist.model' 2import { VideoPlaylist } from '@shared/models/videos/playlist/video-playlist.model'
5 3
6export abstract class MyAccountVideoPlaylistEdit extends FormReactive { 4export abstract class MyAccountVideoPlaylistEdit extends FormReactive {
diff --git a/client/src/app/+my-account/my-account-videos/my-account-videos.component.html b/client/src/app/+my-account/my-account-videos/my-account-videos.component.html
index d7993fdc2..38b48f1d6 100644
--- a/client/src/app/+my-account/my-account-videos/my-account-videos.component.html
+++ b/client/src/app/+my-account/my-account-videos/my-account-videos.component.html
@@ -19,7 +19,7 @@
19 <my-edit-button [routerLink]="[ '/videos', 'update', video.uuid ]"></my-edit-button> 19 <my-edit-button [routerLink]="[ '/videos', 'update', video.uuid ]"></my-edit-button>
20 20
21 <my-button i18n-label label="Change ownership" 21 <my-button i18n-label label="Change ownership"
22 className="action-button-change-ownership" 22 className="action-button-change-ownership grey-button"
23 icon="im-with-her" 23 icon="im-with-her"
24 (click)="changeOwnership($event, video)" 24 (click)="changeOwnership($event, video)"
25 ></my-button> 25 ></my-button>
diff --git a/client/src/app/shared/buttons/button.component.scss b/client/src/app/shared/buttons/button.component.scss
index 04199a2a9..99d7f51c1 100644
--- a/client/src/app/shared/buttons/button.component.scss
+++ b/client/src/app/shared/buttons/button.component.scss
@@ -5,16 +5,9 @@
5 @include peertube-button-link; 5 @include peertube-button-link;
6 @include button-with-icon(21px, 0, -2px); 6 @include button-with-icon(21px, 0, -2px);
7 7
8 font-weight: $font-semibold; 8 // FIXME: Firefox does not apply global .orange-button icon color
9 color: $grey-foreground-color; 9 &.orange-button {
10 background-color: $grey-background-color; 10 @include apply-svg-color(#fff)
11
12 &:hover {
13 background-color: $grey-background-hover-color;
14 }
15
16 my-global-icon {
17 @include apply-svg-color($grey-foreground-color);
18 } 11 }
19} 12}
20 13
diff --git a/client/src/app/shared/buttons/button.component.ts b/client/src/app/shared/buttons/button.component.ts
index c2b69d31a..cf334e8d5 100644
--- a/client/src/app/shared/buttons/button.component.ts
+++ b/client/src/app/shared/buttons/button.component.ts
@@ -9,7 +9,7 @@ import { GlobalIconName } from '@app/shared/images/global-icon.component'
9 9
10export class ButtonComponent { 10export class ButtonComponent {
11 @Input() label = '' 11 @Input() label = ''
12 @Input() className: string = undefined 12 @Input() className = 'grey-button'
13 @Input() icon: GlobalIconName = undefined 13 @Input() icon: GlobalIconName = undefined
14 @Input() title: string = undefined 14 @Input() title: string = undefined
15 15
diff --git a/client/src/app/shared/buttons/delete-button.component.html b/client/src/app/shared/buttons/delete-button.component.html
index 4d12a84c0..d278e7015 100644
--- a/client/src/app/shared/buttons/delete-button.component.html
+++ b/client/src/app/shared/buttons/delete-button.component.html
@@ -1,4 +1,4 @@
1<span class="action-button action-button-delete" [title]="getTitle()" role="button"> 1<span class="action-button action-button-delete grey-button" [title]="getTitle()" role="button">
2 <my-global-icon iconName="delete"></my-global-icon> 2 <my-global-icon iconName="delete"></my-global-icon>
3 3
4 <span class="button-label" *ngIf="label">{{ label }}</span> 4 <span class="button-label" *ngIf="label">{{ label }}</span>
diff --git a/client/src/app/shared/buttons/edit-button.component.html b/client/src/app/shared/buttons/edit-button.component.html
index da3addbae..3d7cd4780 100644
--- a/client/src/app/shared/buttons/edit-button.component.html
+++ b/client/src/app/shared/buttons/edit-button.component.html
@@ -1,4 +1,4 @@
1<a class="action-button action-button-edit" [routerLink]="routerLink" i18n-title title="Edit"> 1<a class="action-button action-button-edit grey-button" [routerLink]="routerLink" i18n-title title="Edit">
2 <my-global-icon iconName="edit"></my-global-icon> 2 <my-global-icon iconName="edit"></my-global-icon>
3 3
4 <span class="button-label" *ngIf="label">{{ label }}</span> 4 <span class="button-label" *ngIf="label">{{ label }}</span>
diff --git a/client/src/app/shared/forms/reactive-file.component.html b/client/src/app/shared/forms/reactive-file.component.html
index 7d691059d..f6bf5f9ae 100644
--- a/client/src/app/shared/forms/reactive-file.component.html
+++ b/client/src/app/shared/forms/reactive-file.component.html
@@ -1,6 +1,9 @@
1<div class="root"> 1<div class="root">
2 <div class="button-file"> 2 <div class="button-file" [ngClass]="{ 'with-icon': !!icon }">
3 <my-global-icon *ngIf="icon" [iconName]="icon"></my-global-icon>
4
3 <span>{{ inputLabel }}</span> 5 <span>{{ inputLabel }}</span>
6
4 <input 7 <input
5 type="file" 8 type="file"
6 [name]="inputName" [id]="inputName" [accept]="extensions" 9 [name]="inputName" [id]="inputName" [accept]="extensions"
@@ -8,7 +11,5 @@
8 /> 11 />
9 </div> 12 </div>
10 13
11 <div i18n class="file-constraints">(extensions: {{ allowedExtensionsMessage }}, max size: {{ maxFileSize | bytes }})</div>
12
13 <div class="filename" *ngIf="displayFilename === true && filename">{{ filename }}</div> 14 <div class="filename" *ngIf="displayFilename === true && filename">{{ filename }}</div>
14</div> 15</div>
diff --git a/client/src/app/shared/forms/reactive-file.component.scss b/client/src/app/shared/forms/reactive-file.component.scss
index d89844264..84c23c1d6 100644
--- a/client/src/app/shared/forms/reactive-file.component.scss
+++ b/client/src/app/shared/forms/reactive-file.component.scss
@@ -8,13 +8,11 @@
8 8
9 .button-file { 9 .button-file {
10 @include peertube-button-file(auto); 10 @include peertube-button-file(auto);
11 @include grey-button;
11 12
12 min-width: 190px; 13 &.with-icon {
13 } 14 @include button-with-icon;
14 15 }
15 .file-constraints {
16 margin-left: 5px;
17 font-size: 13px;
18 } 16 }
19 17
20 .filename { 18 .filename {
diff --git a/client/src/app/shared/forms/reactive-file.component.ts b/client/src/app/shared/forms/reactive-file.component.ts
index f60c38e8d..b7a821d4f 100644
--- a/client/src/app/shared/forms/reactive-file.component.ts
+++ b/client/src/app/shared/forms/reactive-file.component.ts
@@ -2,6 +2,7 @@ import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@ang
2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' 2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3import { Notifier } from '@app/core' 3import { Notifier } from '@app/core'
4import { I18n } from '@ngx-translate/i18n-polyfill' 4import { I18n } from '@ngx-translate/i18n-polyfill'
5import { GlobalIconName } from '@app/shared/images/global-icon.component'
5 6
6@Component({ 7@Component({
7 selector: 'my-reactive-file', 8 selector: 'my-reactive-file',
@@ -21,6 +22,7 @@ export class ReactiveFileComponent implements OnInit, ControlValueAccessor {
21 @Input() extensions: string[] = [] 22 @Input() extensions: string[] = []
22 @Input() maxFileSize: number 23 @Input() maxFileSize: number
23 @Input() displayFilename = false 24 @Input() displayFilename = false
25 @Input() icon: GlobalIconName
24 26
25 @Output() fileChanged = new EventEmitter<Blob>() 27 @Output() fileChanged = new EventEmitter<Blob>()
26 28
diff --git a/client/src/app/shared/images/image-upload.component.html b/client/src/app/shared/images/image-upload.component.html
deleted file mode 100644
index c09c862c4..000000000
--- a/client/src/app/shared/images/image-upload.component.html
+++ /dev/null
@@ -1,9 +0,0 @@
1<div class="root">
2 <my-reactive-file
3 [inputName]="inputName" [inputLabel]="inputLabel" [extensions]="videoImageExtensions" [maxFileSize]="maxVideoImageSize"
4 (fileChanged)="onFileChanged($event)"
5 ></my-reactive-file>
6
7 <img *ngIf="imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" [src]="imageSrc" class="preview" />
8 <div *ngIf="!imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" class="preview no-image"></div>
9</div>
diff --git a/client/src/app/shared/images/image-upload.component.scss b/client/src/app/shared/images/image-upload.component.scss
deleted file mode 100644
index b63963bca..000000000
--- a/client/src/app/shared/images/image-upload.component.scss
+++ /dev/null
@@ -1,18 +0,0 @@
1@import '_variables';
2@import '_mixins';
3
4.root {
5 height: auto;
6 display: flex;
7 align-items: center;
8
9 .preview {
10 border: 2px solid grey;
11 border-radius: 4px;
12 margin-left: 50px;
13
14 &.no-image {
15 background-color: #ececec;
16 }
17 }
18}
diff --git a/client/src/app/shared/images/preview-upload.component.html b/client/src/app/shared/images/preview-upload.component.html
new file mode 100644
index 000000000..5e1d5211b
--- /dev/null
+++ b/client/src/app/shared/images/preview-upload.component.html
@@ -0,0 +1,13 @@
1<div class="root">
2 <div class="preview-container">
3 <my-reactive-file
4 [inputName]="inputName" [inputLabel]="inputLabel" [extensions]="videoImageExtensions" [maxFileSize]="maxVideoImageSize"
5 icon="edit" (fileChanged)="onFileChanged($event)"
6 ></my-reactive-file>
7
8 <img *ngIf="imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" [src]="imageSrc" class="preview" />
9 <div *ngIf="!imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" class="preview no-image"></div>
10 </div>
11
12 <div i18n class="file-constraints">(extensions: {{ allowedExtensionsMessage }}, max size: {{ maxVideoImageSize | bytes }})</div>
13</div>
diff --git a/client/src/app/shared/images/preview-upload.component.scss b/client/src/app/shared/images/preview-upload.component.scss
new file mode 100644
index 000000000..257060239
--- /dev/null
+++ b/client/src/app/shared/images/preview-upload.component.scss
@@ -0,0 +1,27 @@
1@import '_variables';
2@import '_mixins';
3
4.root {
5 height: auto;
6 display: flex;
7 flex-direction: column;
8
9 .preview-container {
10 position: relative;
11
12 my-reactive-file {
13 position: absolute;
14 bottom: 10px;
15 left: 10px;
16 }
17
18 .preview {
19 border: 2px solid grey;
20 border-radius: 4px;
21
22 &.no-image {
23 background-color: #ececec;
24 }
25 }
26 }
27}
diff --git a/client/src/app/shared/images/image-upload.component.ts b/client/src/app/shared/images/preview-upload.component.ts
index 2da1592ff..44b78866e 100644
--- a/client/src/app/shared/images/image-upload.component.ts
+++ b/client/src/app/shared/images/preview-upload.component.ts
@@ -1,27 +1,28 @@
1import { Component, forwardRef, Input } from '@angular/core' 1import { Component, forwardRef, Input, OnInit } from '@angular/core'
2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' 2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser' 3import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'
4import { ServerService } from '@app/core' 4import { ServerService } from '@app/core'
5 5
6@Component({ 6@Component({
7 selector: 'my-image-upload', 7 selector: 'my-preview-upload',
8 styleUrls: [ './image-upload.component.scss' ], 8 styleUrls: [ './preview-upload.component.scss' ],
9 templateUrl: './image-upload.component.html', 9 templateUrl: './preview-upload.component.html',
10 providers: [ 10 providers: [
11 { 11 {
12 provide: NG_VALUE_ACCESSOR, 12 provide: NG_VALUE_ACCESSOR,
13 useExisting: forwardRef(() => ImageUploadComponent), 13 useExisting: forwardRef(() => PreviewUploadComponent),
14 multi: true 14 multi: true
15 } 15 }
16 ] 16 ]
17}) 17})
18export class ImageUploadComponent implements ControlValueAccessor { 18export class PreviewUploadComponent implements OnInit, ControlValueAccessor {
19 @Input() inputLabel: string 19 @Input() inputLabel: string
20 @Input() inputName: string 20 @Input() inputName: string
21 @Input() previewWidth: string 21 @Input() previewWidth: string
22 @Input() previewHeight: string 22 @Input() previewHeight: string
23 23
24 imageSrc: SafeResourceUrl 24 imageSrc: SafeResourceUrl
25 allowedExtensionsMessage = ''
25 26
26 private file: File 27 private file: File
27 28
@@ -38,6 +39,10 @@ export class ImageUploadComponent implements ControlValueAccessor {
38 return this.serverService.getConfig().video.image.size.max 39 return this.serverService.getConfig().video.image.size.max
39 } 40 }
40 41
42 ngOnInit () {
43 this.allowedExtensionsMessage = this.videoImageExtensions.join(', ')
44 }
45
41 onFileChanged (file: File) { 46 onFileChanged (file: File) {
42 this.file = file 47 this.file = file
43 48
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts
index ded65653f..39f1a69e2 100644
--- a/client/src/app/shared/shared.module.ts
+++ b/client/src/app/shared/shared.module.ts
@@ -69,7 +69,7 @@ import { HtmlRendererService, LinkifierService, MarkdownService } from '@app/sha
69import { ConfirmComponent } from '@app/shared/confirm/confirm.component' 69import { ConfirmComponent } from '@app/shared/confirm/confirm.component'
70import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component' 70import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component'
71import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' 71import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
72import { ImageUploadComponent } from '@app/shared/images/image-upload.component' 72import { PreviewUploadComponent } from '@app/shared/images/preview-upload.component'
73import { GlobalIconComponent } from '@app/shared/images/global-icon.component' 73import { GlobalIconComponent } from '@app/shared/images/global-icon.component'
74import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/video-playlist-miniature.component' 74import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/video-playlist-miniature.component'
75import { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component' 75import { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component'
@@ -154,7 +154,7 @@ import { ClipboardModule } from 'ngx-clipboard'
154 ConfirmComponent, 154 ConfirmComponent,
155 155
156 GlobalIconComponent, 156 GlobalIconComponent,
157 ImageUploadComponent 157 PreviewUploadComponent
158 ], 158 ],
159 159
160 exports: [ 160 exports: [
@@ -218,7 +218,7 @@ import { ClipboardModule } from 'ngx-clipboard'
218 ConfirmComponent, 218 ConfirmComponent,
219 219
220 GlobalIconComponent, 220 GlobalIconComponent,
221 ImageUploadComponent, 221 PreviewUploadComponent,
222 222
223 NumberFormatterPipe, 223 NumberFormatterPipe,
224 ObjectLengthPipe, 224 ObjectLengthPipe,
diff --git a/client/src/app/shared/video/video-edit.model.ts b/client/src/app/shared/video/video-edit.model.ts
index 1f633d427..67d8e7711 100644
--- a/client/src/app/shared/video/video-edit.model.ts
+++ b/client/src/app/shared/video/video-edit.model.ts
@@ -85,6 +85,11 @@ export class VideoEdit implements VideoUpdate {
85 const originallyPublishedAt = new Date(values['originallyPublishedAt']) 85 const originallyPublishedAt = new Date(values['originallyPublishedAt'])
86 this.originallyPublishedAt = originallyPublishedAt.toISOString() 86 this.originallyPublishedAt = originallyPublishedAt.toISOString()
87 } 87 }
88
89 // Use the same file than the preview for the thumbnail
90 if (this.previewfile) {
91 this.thumbnailfile = this.previewfile
92 }
88 } 93 }
89 94
90 toFormPatch () { 95 toFormPatch () {
diff --git a/client/src/app/videos/+video-edit/shared/video-edit.component.html b/client/src/app/videos/+video-edit/shared/video-edit.component.html
index 99695204d..28572d611 100644
--- a/client/src/app/videos/+video-edit/shared/video-edit.component.html
+++ b/client/src/app/videos/+video-edit/shared/video-edit.component.html
@@ -187,18 +187,14 @@
187 <ng-template ngbTabContent> 187 <ng-template ngbTabContent>
188 <div class="row advanced-settings"> 188 <div class="row advanced-settings">
189 <div class="col-md-12 col-xl-8"> 189 <div class="col-md-12 col-xl-8">
190 <div class="form-group">
191 <my-image-upload
192 i18n-inputLabel inputLabel="Upload thumbnail" inputName="thumbnailfile" formControlName="thumbnailfile"
193 previewWidth="200px" previewHeight="110px"
194 ></my-image-upload>
195 </div>
196 190
197 <div class="form-group"> 191 <div class="form-group">
198 <my-image-upload 192 <label i18n for="previewfile">Video preview</label>
199 i18n-inputLabel inputLabel="Upload preview" inputName="previewfile" formControlName="previewfile" 193
194 <my-preview-upload
195 i18n-inputLabel inputLabel="Edit" inputName="previewfile" formControlName="previewfile"
200 previewWidth="360px" previewHeight="200px" 196 previewWidth="360px" previewHeight="200px"
201 ></my-image-upload> 197 ></my-preview-upload>
202 </div> 198 </div>
203 199
204 <div class="form-group"> 200 <div class="form-group">
diff --git a/client/src/app/videos/+video-edit/shared/video-edit.component.ts b/client/src/app/videos/+video-edit/shared/video-edit.component.ts
index c80efd802..95d397b52 100644
--- a/client/src/app/videos/+video-edit/shared/video-edit.component.ts
+++ b/client/src/app/videos/+video-edit/shared/video-edit.component.ts
@@ -100,7 +100,6 @@ export class VideoEditComponent implements OnInit, OnDestroy {
100 language: this.videoValidatorsService.VIDEO_LANGUAGE, 100 language: this.videoValidatorsService.VIDEO_LANGUAGE,
101 description: this.videoValidatorsService.VIDEO_DESCRIPTION, 101 description: this.videoValidatorsService.VIDEO_DESCRIPTION,
102 tags: null, 102 tags: null,
103 thumbnailfile: null,
104 previewfile: null, 103 previewfile: null,
105 support: this.videoValidatorsService.VIDEO_SUPPORT, 104 support: this.videoValidatorsService.VIDEO_SUPPORT,
106 schedulePublicationAt: this.videoValidatorsService.VIDEO_SCHEDULE_PUBLICATION_AT, 105 schedulePublicationAt: this.videoValidatorsService.VIDEO_SCHEDULE_PUBLICATION_AT,
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.html b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.html
index 536769d2f..3247a2bd6 100644
--- a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.html
+++ b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.html
@@ -26,6 +26,27 @@
26 </select> 26 </select>
27 </div> 27 </div>
28 </div> 28 </div>
29
30 <ng-container *ngIf="isUploadingAudioFile">
31 <div class="form-group audio-preview">
32 <label i18n for="previewfileUpload">Video background image</label>
33
34 <div i18n class="audio-image-info">
35 Image that will be merged with your audio file.
36 <br />
37 The chosen image will be definitive and cannot be modified.
38 </div>
39
40 <my-preview-upload
41 i18n-inputLabel inputLabel="Edit" inputName="previewfileUpload" [(ngModel)]="previewfileUpload"
42 previewWidth="360px" previewHeight="200px"
43 ></my-preview-upload>
44 </div>
45
46 <div class="form-group upload-audio-button">
47 <my-button className="orange-button" i18n-label [label]="getAudioUploadLabel()" icon="upload" (click)="uploadFirstStep(true)"></my-button>
48 </div>
49 </ng-container>
29 </div> 50 </div>
30</div> 51</div>
31 52
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.scss b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.scss
index 8adf8f169..684342f09 100644
--- a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.scss
+++ b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.scss
@@ -1,9 +1,20 @@
1@import 'variables'; 1@import 'variables';
2@import 'mixins'; 2@import 'mixins';
3 3
4.first-step-block .form-group-channel { 4.first-step-block {
5 margin-bottom: 20px; 5
6 margin-top: 35px; 6 .form-group-channel {
7 margin-bottom: 20px;
8 margin-top: 35px;
9 }
10
11 .audio-image-info {
12 margin-bottom: 10px;
13 }
14
15 .audio-preview {
16 margin: 30px 0;
17 }
7} 18}
8 19
9.upload-progress-cancel { 20.upload-progress-cancel {
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts
index d6d4bad21..73de25c59 100644
--- a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts
+++ b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts
@@ -35,8 +35,10 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
35 userVideoQuotaUsed = 0 35 userVideoQuotaUsed = 0
36 userVideoQuotaUsedDaily = 0 36 userVideoQuotaUsedDaily = 0
37 37
38 isUploadingAudioFile = false
38 isUploadingVideo = false 39 isUploadingVideo = false
39 isUpdatingVideo = false 40 isUpdatingVideo = false
41
40 videoUploaded = false 42 videoUploaded = false
41 videoUploadObservable: Subscription = null 43 videoUploadObservable: Subscription = null
42 videoUploadPercents = 0 44 videoUploadPercents = 0
@@ -44,7 +46,9 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
44 id: 0, 46 id: 0,
45 uuid: '' 47 uuid: ''
46 } 48 }
49
47 waitTranscodingEnabled = true 50 waitTranscodingEnabled = true
51 previewfileUpload: File
48 52
49 error: string 53 error: string
50 54
@@ -100,6 +104,17 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
100 } 104 }
101 } 105 }
102 106
107 getVideoFile () {
108 return this.videofileInput.nativeElement.files[0]
109 }
110
111 getAudioUploadLabel () {
112 const videofile = this.getVideoFile()
113 if (!videofile) return this.i18n('Upload')
114
115 return this.i18n('Upload {{videofileName}}', { videofileName: videofile.name })
116 }
117
103 fileChange () { 118 fileChange () {
104 this.uploadFirstStep() 119 this.uploadFirstStep()
105 } 120 }
@@ -114,38 +129,15 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
114 } 129 }
115 } 130 }
116 131
117 uploadFirstStep () { 132 uploadFirstStep (clickedOnButton = false) {
118 const videofile = this.videofileInput.nativeElement.files[0] 133 const videofile = this.getVideoFile()
119 if (!videofile) return 134 if (!videofile) return
120 135
121 // Check global user quota 136 if (!this.checkGlobalUserQuota(videofile)) return
122 const bytePipes = new BytesPipe() 137 if (!this.checkDailyUserQuota(videofile)) return
123 const videoQuota = this.authService.getUser().videoQuota
124 if (videoQuota !== -1 && (this.userVideoQuotaUsed + videofile.size) > videoQuota) {
125 const msg = this.i18n(
126 'Your video quota is exceeded with this video (video size: {{videoSize}}, used: {{videoQuotaUsed}}, quota: {{videoQuota}})',
127 {
128 videoSize: bytePipes.transform(videofile.size, 0),
129 videoQuotaUsed: bytePipes.transform(this.userVideoQuotaUsed, 0),
130 videoQuota: bytePipes.transform(videoQuota, 0)
131 }
132 )
133 this.notifier.error(msg)
134 return
135 }
136 138
137 // Check daily user quota 139 if (clickedOnButton === false && this.isAudioFile(videofile.name)) {
138 const videoQuotaDaily = this.authService.getUser().videoQuotaDaily 140 this.isUploadingAudioFile = true
139 if (videoQuotaDaily !== -1 && (this.userVideoQuotaUsedDaily + videofile.size) > videoQuotaDaily) {
140 const msg = this.i18n(
141 'Your daily video quota is exceeded with this video (video size: {{videoSize}}, used: {{quotaUsedDaily}}, quota: {{quotaDaily}})',
142 {
143 videoSize: bytePipes.transform(videofile.size, 0),
144 quotaUsedDaily: bytePipes.transform(this.userVideoQuotaUsedDaily, 0),
145 quotaDaily: bytePipes.transform(videoQuotaDaily, 0)
146 }
147 )
148 this.notifier.error(msg)
149 return 141 return
150 } 142 }
151 143
@@ -180,6 +172,11 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
180 formData.append('channelId', '' + channelId) 172 formData.append('channelId', '' + channelId)
181 formData.append('videofile', videofile) 173 formData.append('videofile', videofile)
182 174
175 if (this.previewfileUpload) {
176 formData.append('previewfile', this.previewfileUpload)
177 formData.append('thumbnailfile', this.previewfileUpload)
178 }
179
183 this.isUploadingVideo = true 180 this.isUploadingVideo = true
184 this.firstStepDone.emit(name) 181 this.firstStepDone.emit(name)
185 182
@@ -187,7 +184,8 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
187 name, 184 name,
188 privacy, 185 privacy,
189 nsfw, 186 nsfw,
190 channelId 187 channelId,
188 previewfile: this.previewfileUpload
191 }) 189 })
192 190
193 this.explainedVideoPrivacies = this.videoService.explainedPrivacyLabels(this.videoPrivacies) 191 this.explainedVideoPrivacies = this.videoService.explainedPrivacyLabels(this.videoPrivacies)
@@ -251,4 +249,52 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
251 } 249 }
252 ) 250 )
253 } 251 }
252
253 private checkGlobalUserQuota (videofile: File) {
254 const bytePipes = new BytesPipe()
255
256 // Check global user quota
257 const videoQuota = this.authService.getUser().videoQuota
258 if (videoQuota !== -1 && (this.userVideoQuotaUsed + videofile.size) > videoQuota) {
259 const msg = this.i18n(
260 'Your video quota is exceeded with this video (video size: {{videoSize}}, used: {{videoQuotaUsed}}, quota: {{videoQuota}})',
261 {
262 videoSize: bytePipes.transform(videofile.size, 0),
263 videoQuotaUsed: bytePipes.transform(this.userVideoQuotaUsed, 0),
264 videoQuota: bytePipes.transform(videoQuota, 0)
265 }
266 )
267 this.notifier.error(msg)
268
269 return false
270 }
271
272 return true
273 }
274
275 private checkDailyUserQuota (videofile: File) {
276 const bytePipes = new BytesPipe()
277
278 // Check daily user quota
279 const videoQuotaDaily = this.authService.getUser().videoQuotaDaily
280 if (videoQuotaDaily !== -1 && (this.userVideoQuotaUsedDaily + videofile.size) > videoQuotaDaily) {
281 const msg = this.i18n(
282 'Your daily video quota is exceeded with this video (video size: {{videoSize}}, used: {{quotaUsedDaily}}, quota: {{quotaDaily}})',
283 {
284 videoSize: bytePipes.transform(videofile.size, 0),
285 quotaUsedDaily: bytePipes.transform(this.userVideoQuotaUsedDaily, 0),
286 quotaDaily: bytePipes.transform(videoQuotaDaily, 0)
287 }
288 )
289 this.notifier.error(msg)
290
291 return false
292 }
293
294 return true
295 }
296
297 private isAudioFile (filename: string) {
298 return filename.endsWith('.mp3') || filename.endsWith('.flac') || filename.endsWith('.ogg')
299 }
254} 300}
diff --git a/client/src/app/videos/+video-watch/video-watch.component.ts b/client/src/app/videos/+video-watch/video-watch.component.ts
index 631504eab..55109dc32 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/videos/+video-watch/video-watch.component.ts
@@ -545,8 +545,12 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
545 private flushPlayer () { 545 private flushPlayer () {
546 // Remove player if it exists 546 // Remove player if it exists
547 if (this.player) { 547 if (this.player) {
548 this.player.dispose() 548 try {
549 this.player = undefined 549 this.player.dispose()
550 this.player = undefined
551 } catch (err) {
552 console.error('Cannot dispose player.', err)
553 }
550 } 554 }
551 } 555 }
552 556
diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts
index 6cdd54372..31cbc7dfd 100644
--- a/client/src/assets/player/peertube-player-manager.ts
+++ b/client/src/assets/player/peertube-player-manager.ts
@@ -117,8 +117,17 @@ export class PeertubePlayerManager {
117 videojs(options.common.playerElement, videojsOptions, function (this: any) { 117 videojs(options.common.playerElement, videojsOptions, function (this: any) {
118 const player = this 118 const player = this
119 119
120 player.tech_.one('error', () => self.maybeFallbackToWebTorrent(mode, player, options)) 120 let alreadyFallback = false
121 player.one('error', () => self.maybeFallbackToWebTorrent(mode, player, options)) 121
122 player.tech_.one('error', () => {
123 if (!alreadyFallback) self.maybeFallbackToWebTorrent(mode, player, options)
124 alreadyFallback = true
125 })
126
127 player.one('error', () => {
128 if (!alreadyFallback) self.maybeFallbackToWebTorrent(mode, player, options)
129 alreadyFallback = true
130 })
122 131
123 self.addContextMenu(mode, player, options.common.embedUrl) 132 self.addContextMenu(mode, player, options.common.embedUrl)
124 133
diff --git a/config/default.yaml b/config/default.yaml
index 27952d048..fcbbf17e8 100644
--- a/config/default.yaml
+++ b/config/default.yaml
@@ -180,6 +180,8 @@ transcoding:
180 enabled: true 180 enabled: true
181 # Allow your users to upload .mkv, .mov, .avi, .flv videos 181 # Allow your users to upload .mkv, .mov, .avi, .flv videos
182 allow_additional_extensions: true 182 allow_additional_extensions: true
183 # If a user uploads an audio file, PeerTube will create a video by merging the preview file and the audio file
184 allow_audio_files: true
183 threads: 1 185 threads: 1
184 resolutions: # Only created if the original video has a higher resolution, uses more storage! 186 resolutions: # Only created if the original video has a higher resolution, uses more storage!
185 240p: false 187 240p: false
diff --git a/config/production.yaml.example b/config/production.yaml.example
index f84e15670..0ab99ac45 100644
--- a/config/production.yaml.example
+++ b/config/production.yaml.example
@@ -188,6 +188,8 @@ transcoding:
188 enabled: true 188 enabled: true
189 # Allow your users to upload .mkv, .mov, .avi, .flv videos 189 # Allow your users to upload .mkv, .mov, .avi, .flv videos
190 allow_additional_extensions: true 190 allow_additional_extensions: true
191 # If a user uploads an audio file, PeerTube will create a video by merging the preview file and the audio file
192 allow_audio_files: true
191 threads: 1 193 threads: 1
192 resolutions: # Only created if the original video has a higher resolution, uses more storage! 194 resolutions: # Only created if the original video has a higher resolution, uses more storage!
193 240p: false 195 240p: false
diff --git a/config/test-2.yaml b/config/test-2.yaml
index a5515afa4..de7300366 100644
--- a/config/test-2.yaml
+++ b/config/test-2.yaml
@@ -31,3 +31,4 @@ signup:
31transcoding: 31transcoding:
32 enabled: true 32 enabled: true
33 allow_additional_extensions: true 33 allow_additional_extensions: true
34 allow_audio_files: true
diff --git a/config/test.yaml b/config/test.yaml
index 682530840..7dabe433c 100644
--- a/config/test.yaml
+++ b/config/test.yaml
@@ -55,6 +55,7 @@ signup:
55transcoding: 55transcoding:
56 enabled: true 56 enabled: true
57 allow_additional_extensions: false 57 allow_additional_extensions: false
58 allow_audio_files: false
58 threads: 2 59 threads: 2
59 resolutions: 60 resolutions:
60 240p: true 61 240p: true
diff --git a/scripts/create-transcoding-job.ts b/scripts/create-transcoding-job.ts
index 4a677eacb..2b7cb5177 100755
--- a/scripts/create-transcoding-job.ts
+++ b/scripts/create-transcoding-job.ts
@@ -2,6 +2,7 @@ import * as program from 'commander'
2import { VideoModel } from '../server/models/video/video' 2import { VideoModel } from '../server/models/video/video'
3import { initDatabaseModels } from '../server/initializers' 3import { initDatabaseModels } from '../server/initializers'
4import { JobQueue } from '../server/lib/job-queue' 4import { JobQueue } from '../server/lib/job-queue'
5import { VideoTranscodingPayload } from '../server/lib/job-queue/handlers/video-transcoding'
5 6
6program 7program
7 .option('-v, --video [videoUUID]', 'Video UUID') 8 .option('-v, --video [videoUUID]', 'Video UUID')
@@ -31,15 +32,9 @@ async function run () {
31 const video = await VideoModel.loadByUUIDWithFile(program['video']) 32 const video = await VideoModel.loadByUUIDWithFile(program['video'])
32 if (!video) throw new Error('Video not found.') 33 if (!video) throw new Error('Video not found.')
33 34
34 const dataInput = { 35 const dataInput: VideoTranscodingPayload = program.resolution !== undefined
35 videoUUID: video.uuid, 36 ? { type: 'new-resolution' as 'new-resolution', videoUUID: video.uuid, isNewVideo: false, resolution: program.resolution }
36 isNewVideo: false, 37 : { type: 'optimize' as 'optimize', videoUUID: video.uuid, isNewVideo: false }
37 resolution: undefined
38 }
39
40 if (program.resolution !== undefined) {
41 dataInput.resolution = program.resolution
42 }
43 38
44 await JobQueue.Instance.init() 39 await JobQueue.Instance.init()
45 await JobQueue.Instance.createJob({ type: 'video-transcoding', payload: dataInput }) 40 await JobQueue.Instance.createJob({ type: 'video-transcoding', payload: dataInput })
diff --git a/server/assets/default-audio-background.jpg b/server/assets/default-audio-background.jpg
new file mode 100644
index 000000000..a19173eac
--- /dev/null
+++ b/server/assets/default-audio-background.jpg
Binary files differ
diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts
index 40012c03b..d9ce6a153 100644
--- a/server/controllers/api/config.ts
+++ b/server/controllers/api/config.ts
@@ -255,6 +255,7 @@ function customConfig (): CustomConfig {
255 transcoding: { 255 transcoding: {
256 enabled: CONFIG.TRANSCODING.ENABLED, 256 enabled: CONFIG.TRANSCODING.ENABLED,
257 allowAdditionalExtensions: CONFIG.TRANSCODING.ALLOW_ADDITIONAL_EXTENSIONS, 257 allowAdditionalExtensions: CONFIG.TRANSCODING.ALLOW_ADDITIONAL_EXTENSIONS,
258 allowAudioFiles: CONFIG.TRANSCODING.ALLOW_AUDIO_FILES,
258 threads: CONFIG.TRANSCODING.THREADS, 259 threads: CONFIG.TRANSCODING.THREADS,
259 resolutions: { 260 resolutions: {
260 '240p': CONFIG.TRANSCODING.RESOLUTIONS[ '240p' ], 261 '240p': CONFIG.TRANSCODING.RESOLUTIONS[ '240p' ],
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts
index 1a18a8ae8..40a2c972b 100644
--- a/server/controllers/api/videos/index.ts
+++ b/server/controllers/api/videos/index.ts
@@ -6,7 +6,14 @@ import { logger } from '../../../helpers/logger'
6import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' 6import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
7import { getFormattedObjects, getServerActor } from '../../../helpers/utils' 7import { getFormattedObjects, getServerActor } from '../../../helpers/utils'
8import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist' 8import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
9import { MIMETYPES, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES } from '../../../initializers/constants' 9import {
10 DEFAULT_AUDIO_RESOLUTION,
11 MIMETYPES,
12 VIDEO_CATEGORIES,
13 VIDEO_LANGUAGES,
14 VIDEO_LICENCES,
15 VIDEO_PRIVACIES
16} from '../../../initializers/constants'
10import { 17import {
11 changeVideoChannelShare, 18 changeVideoChannelShare,
12 federateVideoIfNeeded, 19 federateVideoIfNeeded,
@@ -54,6 +61,7 @@ import { CONFIG } from '../../../initializers/config'
54import { sequelizeTypescript } from '../../../initializers/database' 61import { sequelizeTypescript } from '../../../initializers/database'
55import { createVideoMiniatureFromExisting, generateVideoMiniature } from '../../../lib/thumbnail' 62import { createVideoMiniatureFromExisting, generateVideoMiniature } from '../../../lib/thumbnail'
56import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type' 63import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type'
64import { VideoTranscodingPayload } from '../../../lib/job-queue/handlers/video-transcoding'
57 65
58const auditLogger = auditLoggerFactory('videos') 66const auditLogger = auditLoggerFactory('videos')
59const videosRouter = express.Router() 67const videosRouter = express.Router()
@@ -191,18 +199,19 @@ async function addVideo (req: express.Request, res: express.Response) {
191 const video = new VideoModel(videoData) 199 const video = new VideoModel(videoData)
192 video.url = getVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object 200 video.url = getVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object
193 201
194 // Build the file object
195 const { videoFileResolution } = await getVideoFileResolution(videoPhysicalFile.path)
196 const fps = await getVideoFileFPS(videoPhysicalFile.path)
197
198 const videoFileData = { 202 const videoFileData = {
199 extname: extname(videoPhysicalFile.filename), 203 extname: extname(videoPhysicalFile.filename),
200 resolution: videoFileResolution, 204 size: videoPhysicalFile.size
201 size: videoPhysicalFile.size,
202 fps
203 } 205 }
204 const videoFile = new VideoFileModel(videoFileData) 206 const videoFile = new VideoFileModel(videoFileData)
205 207
208 if (!videoFile.isAudio()) {
209 videoFile.fps = await getVideoFileFPS(videoPhysicalFile.path)
210 videoFile.resolution = (await getVideoFileResolution(videoPhysicalFile.path)).videoFileResolution
211 } else {
212 videoFile.resolution = DEFAULT_AUDIO_RESOLUTION
213 }
214
206 // Move physical file 215 // Move physical file
207 const videoDir = CONFIG.STORAGE.VIDEOS_DIR 216 const videoDir = CONFIG.STORAGE.VIDEOS_DIR
208 const destination = join(videoDir, video.getVideoFilename(videoFile)) 217 const destination = join(videoDir, video.getVideoFilename(videoFile))
@@ -279,9 +288,21 @@ async function addVideo (req: express.Request, res: express.Response) {
279 288
280 if (video.state === VideoState.TO_TRANSCODE) { 289 if (video.state === VideoState.TO_TRANSCODE) {
281 // Put uuid because we don't have id auto incremented for now 290 // Put uuid because we don't have id auto incremented for now
282 const dataInput = { 291 let dataInput: VideoTranscodingPayload
283 videoUUID: videoCreated.uuid, 292
284 isNewVideo: true 293 if (videoFile.isAudio()) {
294 dataInput = {
295 type: 'merge-audio' as 'merge-audio',
296 resolution: DEFAULT_AUDIO_RESOLUTION,
297 videoUUID: videoCreated.uuid,
298 isNewVideo: true
299 }
300 } else {
301 dataInput = {
302 type: 'optimize' as 'optimize',
303 videoUUID: videoCreated.uuid,
304 isNewVideo: true
305 }
285 } 306 }
286 307
287 await JobQueue.Instance.createJob({ type: 'video-transcoding', payload: dataInput }) 308 await JobQueue.Instance.createJob({ type: 'video-transcoding', payload: dataInput })
diff --git a/server/controllers/static.ts b/server/controllers/static.ts
index 05019fcc2..d57dba6ce 100644
--- a/server/controllers/static.ts
+++ b/server/controllers/static.ts
@@ -181,7 +181,7 @@ async function getVideoCaption (req: express.Request, res: express.Response) {
181 return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE }) 181 return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE })
182} 182}
183 183
184async function generateNodeinfo (req: express.Request, res: express.Response, next: express.NextFunction) { 184async function generateNodeinfo (req: express.Request, res: express.Response) {
185 const { totalVideos } = await VideoModel.getStats() 185 const { totalVideos } = await VideoModel.getStats()
186 const { totalLocalVideoComments } = await VideoCommentModel.getStats() 186 const { totalLocalVideoComments } = await VideoCommentModel.getStats()
187 const { totalUsers } = await UserModel.getStats() 187 const { totalUsers } = await UserModel.getStats()
diff --git a/server/helpers/express-utils.ts b/server/helpers/express-utils.ts
index e0a1d56a5..00f3f198b 100644
--- a/server/helpers/express-utils.ts
+++ b/server/helpers/express-utils.ts
@@ -74,7 +74,18 @@ function createReqFiles (
74 }, 74 },
75 75
76 filename: async (req, file, cb) => { 76 filename: async (req, file, cb) => {
77 const extension = mimeTypes[ file.mimetype ] || extname(file.originalname) 77 let extension: string
78 const fileExtension = extname(file.originalname)
79 const extensionFromMimetype = mimeTypes[ file.mimetype ]
80
81 // Take the file extension if we don't understand the mime type
82 // We have the OGG/OGV exception too because firefox sends a bad mime type when sending an OGG file
83 if (fileExtension === '.ogg' || fileExtension === '.ogv' || !extensionFromMimetype) {
84 extension = fileExtension
85 } else {
86 extension = extensionFromMimetype
87 }
88
78 let randomString = '' 89 let randomString = ''
79 90
80 try { 91 try {
diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts
index 2fdf34cb7..c180da832 100644
--- a/server/helpers/ffmpeg-utils.ts
+++ b/server/helpers/ffmpeg-utils.ts
@@ -117,37 +117,50 @@ async function generateImageFromVideoFile (fromPath: string, folder: string, ima
117 } 117 }
118} 118}
119 119
120type TranscodeOptions = { 120type TranscodeOptionsType = 'hls' | 'quick-transcode' | 'video' | 'merge-audio'
121
122interface BaseTranscodeOptions {
123 type: TranscodeOptionsType
121 inputPath: string 124 inputPath: string
122 outputPath: string 125 outputPath: string
123 resolution: VideoResolution 126 resolution: VideoResolution
124 isPortraitMode?: boolean 127 isPortraitMode?: boolean
125 doQuickTranscode?: Boolean 128}
126 129
127 hlsPlaylist?: { 130interface HLSTranscodeOptions extends BaseTranscodeOptions {
131 type: 'hls'
132 hlsPlaylist: {
128 videoFilename: string 133 videoFilename: string
129 } 134 }
130} 135}
131 136
137interface QuickTranscodeOptions extends BaseTranscodeOptions {
138 type: 'quick-transcode'
139}
140
141interface VideoTranscodeOptions extends BaseTranscodeOptions {
142 type: 'video'
143}
144
145interface MergeAudioTranscodeOptions extends BaseTranscodeOptions {
146 type: 'merge-audio'
147 audioPath: string
148}
149
150type TranscodeOptions = HLSTranscodeOptions | VideoTranscodeOptions | MergeAudioTranscodeOptions | QuickTranscodeOptions
151
132function transcode (options: TranscodeOptions) { 152function transcode (options: TranscodeOptions) {
133 return new Promise<void>(async (res, rej) => { 153 return new Promise<void>(async (res, rej) => {
134 try { 154 try {
135 let command = ffmpeg(options.inputPath, { niceness: FFMPEG_NICE.TRANSCODING }) 155 let command = ffmpeg(options.inputPath, { niceness: FFMPEG_NICE.TRANSCODING })
136 .output(options.outputPath) 156 .output(options.outputPath)
137 157
138 if (options.doQuickTranscode) { 158 if (options.type === 'quick-transcode') {
139 if (options.hlsPlaylist) { 159 command = await buildQuickTranscodeCommand(command)
140 throw(Error("Quick transcode and HLS can't be used at the same time")) 160 } else if (options.type === 'hls') {
141 }
142
143 command
144 .format('mp4')
145 .addOption('-c:v copy')
146 .addOption('-c:a copy')
147 .outputOption('-map_metadata -1') // strip all metadata
148 .outputOption('-movflags faststart')
149 } else if (options.hlsPlaylist) {
150 command = await buildHLSCommand(command, options) 161 command = await buildHLSCommand(command, options)
162 } else if (options.type === 'merge-audio') {
163 command = await buildAudioMergeCommand(command, options)
151 } else { 164 } else {
152 command = await buildx264Command(command, options) 165 command = await buildx264Command(command, options)
153 } 166 }
@@ -163,7 +176,7 @@ function transcode (options: TranscodeOptions) {
163 return rej(err) 176 return rej(err)
164 }) 177 })
165 .on('end', () => { 178 .on('end', () => {
166 return onTranscodingSuccess(options) 179 return fixHLSPlaylistIfNeeded(options)
167 .then(() => res()) 180 .then(() => res())
168 .catch(err => rej(err)) 181 .catch(err => rej(err))
169 }) 182 })
@@ -205,6 +218,8 @@ export {
205 getVideoFileResolution, 218 getVideoFileResolution,
206 getDurationFromVideoFile, 219 getDurationFromVideoFile,
207 generateImageFromVideoFile, 220 generateImageFromVideoFile,
221 TranscodeOptions,
222 TranscodeOptionsType,
208 transcode, 223 transcode,
209 getVideoFileFPS, 224 getVideoFileFPS,
210 computeResolutionsToTranscode, 225 computeResolutionsToTranscode,
@@ -215,7 +230,7 @@ export {
215 230
216// --------------------------------------------------------------------------- 231// ---------------------------------------------------------------------------
217 232
218async function buildx264Command (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) { 233async function buildx264Command (command: ffmpeg.FfmpegCommand, options: VideoTranscodeOptions) {
219 let fps = await getVideoFileFPS(options.inputPath) 234 let fps = await getVideoFileFPS(options.inputPath)
220 // On small/medium resolutions, limit FPS 235 // On small/medium resolutions, limit FPS
221 if ( 236 if (
@@ -226,7 +241,7 @@ async function buildx264Command (command: ffmpeg.FfmpegCommand, options: Transco
226 fps = VIDEO_TRANSCODING_FPS.AVERAGE 241 fps = VIDEO_TRANSCODING_FPS.AVERAGE
227 } 242 }
228 243
229 command = await presetH264(command, options.resolution, fps) 244 command = await presetH264(command, options.inputPath, options.resolution, fps)
230 245
231 if (options.resolution !== undefined) { 246 if (options.resolution !== undefined) {
232 // '?x720' or '720x?' for example 247 // '?x720' or '720x?' for example
@@ -245,7 +260,29 @@ async function buildx264Command (command: ffmpeg.FfmpegCommand, options: Transco
245 return command 260 return command
246} 261}
247 262
248async function buildHLSCommand (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) { 263async function buildAudioMergeCommand (command: ffmpeg.FfmpegCommand, options: MergeAudioTranscodeOptions) {
264 command = command.loop(undefined)
265
266 command = await presetH264VeryFast(command, options.audioPath, options.resolution)
267
268 command = command.input(options.audioPath)
269 .videoFilter('scale=trunc(iw/2)*2:trunc(ih/2)*2') // Avoid "height not divisible by 2" error
270 .outputOption('-tune stillimage')
271 .outputOption('-shortest')
272
273 return command
274}
275
276async function buildQuickTranscodeCommand (command: ffmpeg.FfmpegCommand) {
277 command = await presetCopy(command)
278
279 command = command.outputOption('-map_metadata -1') // strip all metadata
280 .outputOption('-movflags faststart')
281
282 return command
283}
284
285async function buildHLSCommand (command: ffmpeg.FfmpegCommand, options: HLSTranscodeOptions) {
249 const videoPath = getHLSVideoPath(options) 286 const videoPath = getHLSVideoPath(options)
250 287
251 command = await presetCopy(command) 288 command = await presetCopy(command)
@@ -261,19 +298,19 @@ async function buildHLSCommand (command: ffmpeg.FfmpegCommand, options: Transcod
261 return command 298 return command
262} 299}
263 300
264function getHLSVideoPath (options: TranscodeOptions) { 301function getHLSVideoPath (options: HLSTranscodeOptions) {
265 return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}` 302 return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
266} 303}
267 304
268async function onTranscodingSuccess (options: TranscodeOptions) { 305async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) {
269 if (!options.hlsPlaylist) return 306 if (options.type !== 'hls') return
270 307
271 // Fix wrong mapping with some ffmpeg versions
272 const fileContent = await readFile(options.outputPath) 308 const fileContent = await readFile(options.outputPath)
273 309
274 const videoFileName = options.hlsPlaylist.videoFilename 310 const videoFileName = options.hlsPlaylist.videoFilename
275 const videoFilePath = getHLSVideoPath(options) 311 const videoFilePath = getHLSVideoPath(options)
276 312
313 // Fix wrong mapping with some ffmpeg versions
277 const newContent = fileContent.toString() 314 const newContent = fileContent.toString()
278 .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`) 315 .replace(`#EXT-X-MAP:URI="${videoFilePath}",`, `#EXT-X-MAP:URI="${videoFileName}",`)
279 316
@@ -300,44 +337,27 @@ function getVideoStreamFromFile (path: string) {
300 * and quality. Superfast and ultrafast will give you better 337 * and quality. Superfast and ultrafast will give you better
301 * performance, but then quality is noticeably worse. 338 * performance, but then quality is noticeably worse.
302 */ 339 */
303async function presetH264VeryFast (command: ffmpeg.FfmpegCommand, resolution: VideoResolution, fps: number): Promise<ffmpeg.FfmpegCommand> { 340async function presetH264VeryFast (command: ffmpeg.FfmpegCommand, input: string, resolution: VideoResolution, fps?: number) {
304 let localCommand = await presetH264(command, resolution, fps) 341 let localCommand = await presetH264(command, input, resolution, fps)
342
305 localCommand = localCommand.outputOption('-preset:v veryfast') 343 localCommand = localCommand.outputOption('-preset:v veryfast')
306 .outputOption([ '--aq-mode=2', '--aq-strength=1.3' ]) 344
307 /* 345 /*
308 MAIN reference: https://slhck.info/video/2017/03/01/rate-control.html 346 MAIN reference: https://slhck.info/video/2017/03/01/rate-control.html
309 Our target situation is closer to a livestream than a stream, 347 Our target situation is closer to a livestream than a stream,
310 since we want to reduce as much a possible the encoding burden, 348 since we want to reduce as much a possible the encoding burden,
311 altough not to the point of a livestream where there is a hard 349 although not to the point of a livestream where there is a hard
312 constraint on the frames per second to be encoded. 350 constraint on the frames per second to be encoded.
313
314 why '--aq-mode=2 --aq-strength=1.3' instead of '-profile:v main'?
315 Make up for most of the loss of grain and macroblocking
316 with less computing power.
317 */ 351 */
318 352
319 return localCommand 353 return localCommand
320} 354}
321 355
322/** 356/**
323 * A preset optimised for a stillimage audio video
324 */
325async function presetStillImageWithAudio (
326 command: ffmpeg.FfmpegCommand,
327 resolution: VideoResolution,
328 fps: number
329): Promise<ffmpeg.FfmpegCommand> {
330 let localCommand = await presetH264VeryFast(command, resolution, fps)
331 localCommand = localCommand.outputOption('-tune stillimage')
332
333 return localCommand
334}
335
336/**
337 * A toolbox to play with audio 357 * A toolbox to play with audio
338 */ 358 */
339namespace audio { 359namespace audio {
340 export const get = (option: ffmpeg.FfmpegCommand | string) => { 360 export const get = (option: string) => {
341 // without position, ffprobe considers the last input only 361 // without position, ffprobe considers the last input only
342 // we make it consider the first input only 362 // we make it consider the first input only
343 // if you pass a file path to pos, then ffprobe acts on that file directly 363 // if you pass a file path to pos, then ffprobe acts on that file directly
@@ -359,11 +379,7 @@ namespace audio {
359 return res({ absolutePath: data.format.filename }) 379 return res({ absolutePath: data.format.filename })
360 } 380 }
361 381
362 if (typeof option === 'string') { 382 return ffmpeg.ffprobe(option, parseFfprobe)
363 return ffmpeg.ffprobe(option, parseFfprobe)
364 }
365
366 return option.ffprobe(parseFfprobe)
367 }) 383 })
368 } 384 }
369 385
@@ -405,7 +421,7 @@ namespace audio {
405 * As for the audio, quality '5' is the highest and ensures 96-112kbps/channel 421 * As for the audio, quality '5' is the highest and ensures 96-112kbps/channel
406 * See https://trac.ffmpeg.org/wiki/Encode/AAC#fdk_vbr 422 * See https://trac.ffmpeg.org/wiki/Encode/AAC#fdk_vbr
407 */ 423 */
408async function presetH264 (command: ffmpeg.FfmpegCommand, resolution: VideoResolution, fps: number): Promise<ffmpeg.FfmpegCommand> { 424async function presetH264 (command: ffmpeg.FfmpegCommand, input: string, resolution: VideoResolution, fps?: number) {
409 let localCommand = command 425 let localCommand = command
410 .format('mp4') 426 .format('mp4')
411 .videoCodec('libx264') 427 .videoCodec('libx264')
@@ -416,7 +432,7 @@ async function presetH264 (command: ffmpeg.FfmpegCommand, resolution: VideoResol
416 .outputOption('-map_metadata -1') // strip all metadata 432 .outputOption('-map_metadata -1') // strip all metadata
417 .outputOption('-movflags faststart') 433 .outputOption('-movflags faststart')
418 434
419 const parsedAudio = await audio.get(localCommand) 435 const parsedAudio = await audio.get(input)
420 436
421 if (!parsedAudio.audioStream) { 437 if (!parsedAudio.audioStream) {
422 localCommand = localCommand.noAudio() 438 localCommand = localCommand.noAudio()
@@ -425,28 +441,30 @@ async function presetH264 (command: ffmpeg.FfmpegCommand, resolution: VideoResol
425 .audioCodec('libfdk_aac') 441 .audioCodec('libfdk_aac')
426 .audioQuality(5) 442 .audioQuality(5)
427 } else { 443 } else {
428 // we try to reduce the ceiling bitrate by making rough correspondances of bitrates 444 // we try to reduce the ceiling bitrate by making rough matches of bitrates
429 // of course this is far from perfect, but it might save some space in the end 445 // of course this is far from perfect, but it might save some space in the end
446 localCommand = localCommand.audioCodec('aac')
447
430 const audioCodecName = parsedAudio.audioStream[ 'codec_name' ] 448 const audioCodecName = parsedAudio.audioStream[ 'codec_name' ]
431 let bitrate: number
432 if (audio.bitrate[ audioCodecName ]) {
433 localCommand = localCommand.audioCodec('aac')
434 449
435 bitrate = audio.bitrate[ audioCodecName ](parsedAudio.audioStream[ 'bit_rate' ]) 450 if (audio.bitrate[ audioCodecName ]) {
451 const bitrate = audio.bitrate[ audioCodecName ](parsedAudio.audioStream[ 'bit_rate' ])
436 if (bitrate !== undefined && bitrate !== -1) localCommand = localCommand.audioBitrate(bitrate) 452 if (bitrate !== undefined && bitrate !== -1) localCommand = localCommand.audioBitrate(bitrate)
437 } 453 }
438 } 454 }
439 455
440 // Constrained Encoding (VBV) 456 if (fps) {
441 // https://slhck.info/video/2017/03/01/rate-control.html 457 // Constrained Encoding (VBV)
442 // https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate 458 // https://slhck.info/video/2017/03/01/rate-control.html
443 const targetBitrate = getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS) 459 // https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate
444 localCommand = localCommand.outputOptions([`-maxrate ${ targetBitrate }`, `-bufsize ${ targetBitrate * 2 }`]) 460 const targetBitrate = getTargetBitrate(resolution, fps, VIDEO_TRANSCODING_FPS)
445 461 localCommand = localCommand.outputOptions([ `-maxrate ${targetBitrate}`, `-bufsize ${targetBitrate * 2}` ])
446 // Keyframe interval of 2 seconds for faster seeking and resolution switching. 462
447 // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html 463 // Keyframe interval of 2 seconds for faster seeking and resolution switching.
448 // https://superuser.com/a/908325 464 // https://streaminglearningcenter.com/blogs/whats-the-right-keyframe-interval.html
449 localCommand = localCommand.outputOption(`-g ${ fps * 2 }`) 465 // https://superuser.com/a/908325
466 localCommand = localCommand.outputOption(`-g ${fps * 2}`)
467 }
450 468
451 return localCommand 469 return localCommand
452} 470}
diff --git a/server/initializers/config.ts b/server/initializers/config.ts
index 723755c45..2be300a57 100644
--- a/server/initializers/config.ts
+++ b/server/initializers/config.ts
@@ -148,6 +148,7 @@ const CONFIG = {
148 TRANSCODING: { 148 TRANSCODING: {
149 get ENABLED () { return config.get<boolean>('transcoding.enabled') }, 149 get ENABLED () { return config.get<boolean>('transcoding.enabled') },
150 get ALLOW_ADDITIONAL_EXTENSIONS () { return config.get<boolean>('transcoding.allow_additional_extensions') }, 150 get ALLOW_ADDITIONAL_EXTENSIONS () { return config.get<boolean>('transcoding.allow_additional_extensions') },
151 get ALLOW_AUDIO_FILES () { return config.get<boolean>('transcoding.allow_audio_files') },
151 get THREADS () { return config.get<number>('transcoding.threads') }, 152 get THREADS () { return config.get<number>('transcoding.threads') },
152 RESOLUTIONS: { 153 RESOLUTIONS: {
153 get '240p' () { return config.get<boolean>('transcoding.resolutions.240p') }, 154 get '240p' () { return config.get<boolean>('transcoding.resolutions.240p') },
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 62778ae58..8a11101ff 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -1,10 +1,10 @@
1import { join } from 'path' 1import { join } from 'path'
2import { JobType, VideoRateType, VideoState } from '../../shared/models' 2import { JobType, VideoRateType, VideoResolution, VideoState } from '../../shared/models'
3import { ActivityPubActorType } from '../../shared/models/activitypub' 3import { ActivityPubActorType } from '../../shared/models/activitypub'
4import { FollowState } from '../../shared/models/actors' 4import { FollowState } from '../../shared/models/actors'
5import { VideoAbuseState, VideoImportState, VideoPrivacy, VideoTranscodingFPS } from '../../shared/models/videos' 5import { VideoAbuseState, VideoImportState, VideoPrivacy, VideoTranscodingFPS } from '../../shared/models/videos'
6// Do not use barrels, remain constants as independent as possible 6// Do not use barrels, remain constants as independent as possible
7import { isTestInstance, sanitizeHost, sanitizeUrl } from '../helpers/core-utils' 7import { isTestInstance, sanitizeHost, sanitizeUrl, root } from '../helpers/core-utils'
8import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type' 8import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type'
9import { invert } from 'lodash' 9import { invert } from 'lodash'
10import { CronRepeatOptions, EveryRepeatOptions } from 'bull' 10import { CronRepeatOptions, EveryRepeatOptions } from 'bull'
@@ -228,7 +228,7 @@ let CONSTRAINTS_FIELDS = {
228 max: 2 * 1024 * 1024 // 2MB 228 max: 2 * 1024 * 1024 // 2MB
229 } 229 }
230 }, 230 },
231 EXTNAME: buildVideosExtname(), 231 EXTNAME: [] as string[],
232 INFO_HASH: { min: 40, max: 40 }, // Length, info hash is 20 bytes length but we represent it in hexadecimal so 20 * 2 232 INFO_HASH: { min: 40, max: 40 }, // Length, info hash is 20 bytes length but we represent it in hexadecimal so 20 * 2
233 DURATION: { min: 0 }, // Number 233 DURATION: { min: 0 }, // Number
234 TAGS: { min: 0, max: 5 }, // Number of total tags 234 TAGS: { min: 0, max: 5 }, // Number of total tags
@@ -300,6 +300,8 @@ const VIDEO_TRANSCODING_FPS: VideoTranscodingFPS = {
300 KEEP_ORIGIN_FPS_RESOLUTION_MIN: 720 // We keep the original FPS on high resolutions (720 minimum) 300 KEEP_ORIGIN_FPS_RESOLUTION_MIN: 720 // We keep the original FPS on high resolutions (720 minimum)
301} 301}
302 302
303const DEFAULT_AUDIO_RESOLUTION = VideoResolution.H_480P
304
303const VIDEO_RATE_TYPES: { [ id: string ]: VideoRateType } = { 305const VIDEO_RATE_TYPES: { [ id: string ]: VideoRateType } = {
304 LIKE: 'like', 306 LIKE: 'like',
305 DISLIKE: 'dislike' 307 DISLIKE: 'dislike'
@@ -380,8 +382,18 @@ const VIDEO_PLAYLIST_TYPES = {
380} 382}
381 383
382const MIMETYPES = { 384const MIMETYPES = {
385 AUDIO: {
386 MIMETYPE_EXT: {
387 'audio/mpeg': '.mp3',
388 'audio/mp3': '.mp3',
389 'application/ogg': '.ogg',
390 'audio/ogg': '.ogg',
391 'audio/flac': '.flac'
392 },
393 EXT_MIMETYPE: null as { [ id: string ]: string }
394 },
383 VIDEO: { 395 VIDEO: {
384 MIMETYPE_EXT: buildVideoMimetypeExt(), 396 MIMETYPE_EXT: null as { [ id: string ]: string },
385 EXT_MIMETYPE: null as { [ id: string ]: string } 397 EXT_MIMETYPE: null as { [ id: string ]: string }
386 }, 398 },
387 IMAGE: { 399 IMAGE: {
@@ -403,7 +415,7 @@ const MIMETYPES = {
403 } 415 }
404 } 416 }
405} 417}
406MIMETYPES.VIDEO.EXT_MIMETYPE = invert(MIMETYPES.VIDEO.MIMETYPE_EXT) 418MIMETYPES.AUDIO.EXT_MIMETYPE = invert(MIMETYPES.AUDIO.MIMETYPE_EXT)
407 419
408// --------------------------------------------------------------------------- 420// ---------------------------------------------------------------------------
409 421
@@ -429,7 +441,7 @@ const ACTIVITY_PUB = {
429 COLLECTION_ITEMS_PER_PAGE: 10, 441 COLLECTION_ITEMS_PER_PAGE: 10,
430 FETCH_PAGE_LIMIT: 100, 442 FETCH_PAGE_LIMIT: 100,
431 URL_MIME_TYPES: { 443 URL_MIME_TYPES: {
432 VIDEO: Object.keys(MIMETYPES.VIDEO.MIMETYPE_EXT), 444 VIDEO: [] as string[],
433 TORRENT: [ 'application/x-bittorrent' ], 445 TORRENT: [ 'application/x-bittorrent' ],
434 MAGNET: [ 'application/x-bittorrent;x-scheme-handler/magnet' ] 446 MAGNET: [ 'application/x-bittorrent;x-scheme-handler/magnet' ]
435 }, 447 },
@@ -497,8 +509,8 @@ const THUMBNAILS_SIZE = {
497 height: 122 509 height: 122
498} 510}
499const PREVIEWS_SIZE = { 511const PREVIEWS_SIZE = {
500 width: 560, 512 width: 850,
501 height: 315 513 height: 480
502} 514}
503const AVATARS_SIZE = { 515const AVATARS_SIZE = {
504 width: 120, 516 width: 120,
@@ -543,6 +555,10 @@ const REDUNDANCY = {
543 555
544const ACCEPT_HEADERS = [ 'html', 'application/json' ].concat(ACTIVITY_PUB.POTENTIAL_ACCEPT_HEADERS) 556const ACCEPT_HEADERS = [ 'html', 'application/json' ].concat(ACTIVITY_PUB.POTENTIAL_ACCEPT_HEADERS)
545 557
558const ASSETS_PATH = {
559 DEFAULT_AUDIO_BACKGROUND: join(root(), 'server', 'assets', 'default-audio-background.jpg')
560}
561
546// --------------------------------------------------------------------------- 562// ---------------------------------------------------------------------------
547 563
548const CUSTOM_HTML_TAG_COMMENTS = { 564const CUSTOM_HTML_TAG_COMMENTS = {
@@ -612,6 +628,7 @@ if (isTestInstance() === true) {
612} 628}
613 629
614updateWebserverUrls() 630updateWebserverUrls()
631updateWebserverConfig()
615 632
616registerConfigChangedHandler(() => { 633registerConfigChangedHandler(() => {
617 updateWebserverUrls() 634 updateWebserverUrls()
@@ -681,12 +698,14 @@ export {
681 RATES_LIMIT, 698 RATES_LIMIT,
682 MIMETYPES, 699 MIMETYPES,
683 CRAWL_REQUEST_CONCURRENCY, 700 CRAWL_REQUEST_CONCURRENCY,
701 DEFAULT_AUDIO_RESOLUTION,
684 JOB_COMPLETED_LIFETIME, 702 JOB_COMPLETED_LIFETIME,
685 HTTP_SIGNATURE, 703 HTTP_SIGNATURE,
686 VIDEO_IMPORT_STATES, 704 VIDEO_IMPORT_STATES,
687 VIDEO_VIEW_LIFETIME, 705 VIDEO_VIEW_LIFETIME,
688 CONTACT_FORM_LIFETIME, 706 CONTACT_FORM_LIFETIME,
689 VIDEO_PLAYLIST_PRIVACIES, 707 VIDEO_PLAYLIST_PRIVACIES,
708 ASSETS_PATH,
690 loadLanguages, 709 loadLanguages,
691 buildLanguages 710 buildLanguages
692} 711}
@@ -700,15 +719,21 @@ function buildVideoMimetypeExt () {
700 'video/mp4': '.mp4' 719 'video/mp4': '.mp4'
701 } 720 }
702 721
703 if (CONFIG.TRANSCODING.ENABLED && CONFIG.TRANSCODING.ALLOW_ADDITIONAL_EXTENSIONS) { 722 if (CONFIG.TRANSCODING.ENABLED) {
704 Object.assign(data, { 723 if (CONFIG.TRANSCODING.ALLOW_ADDITIONAL_EXTENSIONS) {
705 'video/quicktime': '.mov', 724 Object.assign(data, {
706 'video/x-msvideo': '.avi', 725 'video/quicktime': '.mov',
707 'video/x-flv': '.flv', 726 'video/x-msvideo': '.avi',
708 'video/x-matroska': '.mkv', 727 'video/x-flv': '.flv',
709 'application/octet-stream': '.mkv', 728 'video/x-matroska': '.mkv',
710 'video/avi': '.avi' 729 'application/octet-stream': '.mkv',
711 }) 730 'video/avi': '.avi'
731 })
732 }
733
734 if (CONFIG.TRANSCODING.ALLOW_AUDIO_FILES) {
735 Object.assign(data, MIMETYPES.AUDIO.MIMETYPE_EXT)
736 }
712 } 737 }
713 738
714 return data 739 return data
@@ -724,16 +749,15 @@ function updateWebserverUrls () {
724} 749}
725 750
726function updateWebserverConfig () { 751function updateWebserverConfig () {
727 CONSTRAINTS_FIELDS.VIDEOS.EXTNAME = buildVideosExtname()
728
729 MIMETYPES.VIDEO.MIMETYPE_EXT = buildVideoMimetypeExt() 752 MIMETYPES.VIDEO.MIMETYPE_EXT = buildVideoMimetypeExt()
730 MIMETYPES.VIDEO.EXT_MIMETYPE = invert(MIMETYPES.VIDEO.MIMETYPE_EXT) 753 MIMETYPES.VIDEO.EXT_MIMETYPE = invert(MIMETYPES.VIDEO.MIMETYPE_EXT)
754 ACTIVITY_PUB.URL_MIME_TYPES.VIDEO = Object.keys(MIMETYPES.VIDEO.MIMETYPE_EXT)
755
756 CONSTRAINTS_FIELDS.VIDEOS.EXTNAME = buildVideosExtname()
731} 757}
732 758
733function buildVideosExtname () { 759function buildVideosExtname () {
734 return CONFIG.TRANSCODING.ENABLED && CONFIG.TRANSCODING.ALLOW_ADDITIONAL_EXTENSIONS 760 return Object.keys(MIMETYPES.VIDEO.EXT_MIMETYPE)
735 ? [ '.mp4', '.ogv', '.webm', '.mkv', '.mov', '.avi', '.flv' ]
736 : [ '.mp4', '.ogv', '.webm' ]
737} 761}
738 762
739function loadLanguages () { 763function loadLanguages () {
diff --git a/server/lib/files-cache/videos-preview-cache.ts b/server/lib/files-cache/videos-preview-cache.ts
index 14be7f24a..a68619d07 100644
--- a/server/lib/files-cache/videos-preview-cache.ts
+++ b/server/lib/files-cache/videos-preview-cache.ts
@@ -21,7 +21,7 @@ class VideosPreviewCache extends AbstractVideoStaticFileCache <string> {
21 const video = await VideoModel.loadByUUIDWithFile(videoUUID) 21 const video = await VideoModel.loadByUUIDWithFile(videoUUID)
22 if (!video) return undefined 22 if (!video) return undefined
23 23
24 if (video.isOwned()) return { isOwned: true, path: join(CONFIG.STORAGE.PREVIEWS_DIR, video.getPreview().filename) } 24 if (video.isOwned()) return { isOwned: true, path: video.getPreview().getPath() }
25 25
26 return this.loadRemoteFile(videoUUID) 26 return this.loadRemoteFile(videoUUID)
27 } 27 }
diff --git a/server/lib/job-queue/handlers/video-file-import.ts b/server/lib/job-queue/handlers/video-file-import.ts
index 921d9a083..8cacb0ef3 100644
--- a/server/lib/job-queue/handlers/video-file-import.ts
+++ b/server/lib/job-queue/handlers/video-file-import.ts
@@ -1,7 +1,7 @@
1import * as Bull from 'bull' 1import * as Bull from 'bull'
2import { logger } from '../../../helpers/logger' 2import { logger } from '../../../helpers/logger'
3import { VideoModel } from '../../../models/video/video' 3import { VideoModel } from '../../../models/video/video'
4import { publishVideoIfNeeded } from './video-transcoding' 4import { publishNewResolutionIfNeeded } from './video-transcoding'
5import { getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffmpeg-utils' 5import { getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffmpeg-utils'
6import { copy, stat } from 'fs-extra' 6import { copy, stat } from 'fs-extra'
7import { VideoFileModel } from '../../../models/video/video-file' 7import { VideoFileModel } from '../../../models/video/video-file'
@@ -25,7 +25,7 @@ async function processVideoFileImport (job: Bull.Job) {
25 25
26 await updateVideoFile(video, payload.filePath) 26 await updateVideoFile(video, payload.filePath)
27 27
28 await publishVideoIfNeeded(video) 28 await publishNewResolutionIfNeeded(video)
29 return video 29 return video
30} 30}
31 31
diff --git a/server/lib/job-queue/handlers/video-import.ts b/server/lib/job-queue/handlers/video-import.ts
index 1650916a6..50e159245 100644
--- a/server/lib/job-queue/handlers/video-import.ts
+++ b/server/lib/job-queue/handlers/video-import.ts
@@ -209,6 +209,7 @@ async function processFile (downloader: () => Promise<string>, videoImport: Vide
209 if (videoImportUpdated.Video.state === VideoState.TO_TRANSCODE) { 209 if (videoImportUpdated.Video.state === VideoState.TO_TRANSCODE) {
210 // Put uuid because we don't have id auto incremented for now 210 // Put uuid because we don't have id auto incremented for now
211 const dataInput = { 211 const dataInput = {
212 type: 'optimize' as 'optimize',
212 videoUUID: videoImportUpdated.Video.uuid, 213 videoUUID: videoImportUpdated.Video.uuid,
213 isNewVideo: true 214 isNewVideo: true
214 } 215 }
diff --git a/server/lib/job-queue/handlers/video-transcoding.ts b/server/lib/job-queue/handlers/video-transcoding.ts
index 48cac517e..e9b84ecd6 100644
--- a/server/lib/job-queue/handlers/video-transcoding.ts
+++ b/server/lib/job-queue/handlers/video-transcoding.ts
@@ -8,18 +8,39 @@ import { retryTransactionWrapper } from '../../../helpers/database-utils'
8import { sequelizeTypescript } from '../../../initializers' 8import { sequelizeTypescript } from '../../../initializers'
9import * as Bluebird from 'bluebird' 9import * as Bluebird from 'bluebird'
10import { computeResolutionsToTranscode } from '../../../helpers/ffmpeg-utils' 10import { computeResolutionsToTranscode } from '../../../helpers/ffmpeg-utils'
11import { generateHlsPlaylist, optimizeVideofile, transcodeOriginalVideofile } from '../../video-transcoding' 11import { generateHlsPlaylist, optimizeVideofile, transcodeOriginalVideofile, mergeAudioVideofile } from '../../video-transcoding'
12import { Notifier } from '../../notifier' 12import { Notifier } from '../../notifier'
13import { CONFIG } from '../../../initializers/config' 13import { CONFIG } from '../../../initializers/config'
14 14
15export type VideoTranscodingPayload = { 15interface BaseTranscodingPayload {
16 videoUUID: string 16 videoUUID: string
17 resolution?: VideoResolution
18 isNewVideo?: boolean 17 isNewVideo?: boolean
18}
19
20interface HLSTranscodingPayload extends BaseTranscodingPayload {
21 type: 'hls'
22 isPortraitMode?: boolean
23 resolution: VideoResolution
24}
25
26interface NewResolutionTranscodingPayload extends BaseTranscodingPayload {
27 type: 'new-resolution'
19 isPortraitMode?: boolean 28 isPortraitMode?: boolean
20 generateHlsPlaylist?: boolean 29 resolution: VideoResolution
30}
31
32interface MergeAudioTranscodingPayload extends BaseTranscodingPayload {
33 type: 'merge-audio'
34 resolution: VideoResolution
35}
36
37interface OptimizeTranscodingPayload extends BaseTranscodingPayload {
38 type: 'optimize'
21} 39}
22 40
41export type VideoTranscodingPayload = HLSTranscodingPayload | NewResolutionTranscodingPayload
42 | OptimizeTranscodingPayload | MergeAudioTranscodingPayload
43
23async function processVideoTranscoding (job: Bull.Job) { 44async function processVideoTranscoding (job: Bull.Job) {
24 const payload = job.data as VideoTranscodingPayload 45 const payload = job.data as VideoTranscodingPayload
25 logger.info('Processing video file in job %d.', job.id) 46 logger.info('Processing video file in job %d.', job.id)
@@ -31,14 +52,18 @@ async function processVideoTranscoding (job: Bull.Job) {
31 return undefined 52 return undefined
32 } 53 }
33 54
34 if (payload.generateHlsPlaylist) { 55 if (payload.type === 'hls') {
35 await generateHlsPlaylist(video, payload.resolution, payload.isPortraitMode || false) 56 await generateHlsPlaylist(video, payload.resolution, payload.isPortraitMode || false)
36 57
37 await retryTransactionWrapper(onHlsPlaylistGenerationSuccess, video) 58 await retryTransactionWrapper(onHlsPlaylistGenerationSuccess, video)
38 } else if (payload.resolution) { // Transcoding in other resolution 59 } else if (payload.type === 'new-resolution') {
39 await transcodeOriginalVideofile(video, payload.resolution, payload.isPortraitMode || false) 60 await transcodeOriginalVideofile(video, payload.resolution, payload.isPortraitMode || false)
40 61
41 await retryTransactionWrapper(publishVideoIfNeeded, video, payload) 62 await retryTransactionWrapper(publishNewResolutionIfNeeded, video, payload)
63 } else if (payload.type === 'merge-audio') {
64 await mergeAudioVideofile(video, payload.resolution)
65
66 await retryTransactionWrapper(publishNewResolutionIfNeeded, video, payload)
42 } else { 67 } else {
43 await optimizeVideofile(video) 68 await optimizeVideofile(video)
44 69
@@ -62,7 +87,7 @@ async function onHlsPlaylistGenerationSuccess (video: VideoModel) {
62 }) 87 })
63} 88}
64 89
65async function publishVideoIfNeeded (video: VideoModel, payload?: VideoTranscodingPayload) { 90async function publishNewResolutionIfNeeded (video: VideoModel, payload?: NewResolutionTranscodingPayload | MergeAudioTranscodingPayload) {
66 const { videoDatabase, videoPublished } = await sequelizeTypescript.transaction(async t => { 91 const { videoDatabase, videoPublished } = await sequelizeTypescript.transaction(async t => {
67 // Maybe the video changed in database, refresh it 92 // Maybe the video changed in database, refresh it
68 let videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t) 93 let videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.uuid, t)
@@ -94,7 +119,7 @@ async function publishVideoIfNeeded (video: VideoModel, payload?: VideoTranscodi
94 await createHlsJobIfEnabled(payload) 119 await createHlsJobIfEnabled(payload)
95} 120}
96 121
97async function onVideoFileOptimizerSuccess (videoArg: VideoModel, payload: VideoTranscodingPayload) { 122async function onVideoFileOptimizerSuccess (videoArg: VideoModel, payload: OptimizeTranscodingPayload) {
98 if (videoArg === undefined) return undefined 123 if (videoArg === undefined) return undefined
99 124
100 // Outside the transaction (IO on disk) 125 // Outside the transaction (IO on disk)
@@ -120,6 +145,7 @@ async function onVideoFileOptimizerSuccess (videoArg: VideoModel, payload: Video
120 145
121 for (const resolution of resolutionsEnabled) { 146 for (const resolution of resolutionsEnabled) {
122 const dataInput = { 147 const dataInput = {
148 type: 'new-resolution' as 'new-resolution',
123 videoUUID: videoDatabase.uuid, 149 videoUUID: videoDatabase.uuid,
124 resolution 150 resolution
125 } 151 }
@@ -149,27 +175,27 @@ async function onVideoFileOptimizerSuccess (videoArg: VideoModel, payload: Video
149 if (payload.isNewVideo) Notifier.Instance.notifyOnNewVideo(videoDatabase) 175 if (payload.isNewVideo) Notifier.Instance.notifyOnNewVideo(videoDatabase)
150 if (videoPublished) Notifier.Instance.notifyOnVideoPublishedAfterTranscoding(videoDatabase) 176 if (videoPublished) Notifier.Instance.notifyOnVideoPublishedAfterTranscoding(videoDatabase)
151 177
152 await createHlsJobIfEnabled(Object.assign({}, payload, { resolution: videoDatabase.getOriginalFile().resolution })) 178 const hlsPayload = Object.assign({}, payload, { resolution: videoDatabase.getOriginalFile().resolution })
179 await createHlsJobIfEnabled(hlsPayload)
153} 180}
154 181
155// --------------------------------------------------------------------------- 182// ---------------------------------------------------------------------------
156 183
157export { 184export {
158 processVideoTranscoding, 185 processVideoTranscoding,
159 publishVideoIfNeeded 186 publishNewResolutionIfNeeded
160} 187}
161 188
162// --------------------------------------------------------------------------- 189// ---------------------------------------------------------------------------
163 190
164function createHlsJobIfEnabled (payload?: VideoTranscodingPayload) { 191function createHlsJobIfEnabled (payload?: { videoUUID: string, resolution: number, isPortraitMode?: boolean }) {
165 // Generate HLS playlist? 192 // Generate HLS playlist?
166 if (payload && CONFIG.TRANSCODING.HLS.ENABLED) { 193 if (payload && CONFIG.TRANSCODING.HLS.ENABLED) {
167 const hlsTranscodingPayload = { 194 const hlsTranscodingPayload = {
195 type: 'hls' as 'hls',
168 videoUUID: payload.videoUUID, 196 videoUUID: payload.videoUUID,
169 resolution: payload.resolution, 197 resolution: payload.resolution,
170 isPortraitMode: payload.isPortraitMode, 198 isPortraitMode: payload.isPortraitMode
171
172 generateHlsPlaylist: true
173 } 199 }
174 200
175 return JobQueue.Instance.createJob({ type: 'video-transcoding', payload: hlsTranscodingPayload }) 201 return JobQueue.Instance.createJob({ type: 'video-transcoding', payload: hlsTranscodingPayload })
diff --git a/server/lib/thumbnail.ts b/server/lib/thumbnail.ts
index 950b14c3b..18bdcded4 100644
--- a/server/lib/thumbnail.ts
+++ b/server/lib/thumbnail.ts
@@ -1,7 +1,7 @@
1import { VideoFileModel } from '../models/video/video-file' 1import { VideoFileModel } from '../models/video/video-file'
2import { generateImageFromVideoFile } from '../helpers/ffmpeg-utils' 2import { generateImageFromVideoFile } from '../helpers/ffmpeg-utils'
3import { CONFIG } from '../initializers/config' 3import { CONFIG } from '../initializers/config'
4import { PREVIEWS_SIZE, THUMBNAILS_SIZE } from '../initializers/constants' 4import { PREVIEWS_SIZE, THUMBNAILS_SIZE, ASSETS_PATH } from '../initializers/constants'
5import { VideoModel } from '../models/video/video' 5import { VideoModel } from '../models/video/video'
6import { ThumbnailModel } from '../models/video/thumbnail' 6import { ThumbnailModel } from '../models/video/thumbnail'
7import { ThumbnailType } from '../../shared/models/videos/thumbnail.type' 7import { ThumbnailType } from '../../shared/models/videos/thumbnail.type'
@@ -45,8 +45,10 @@ function createVideoMiniatureFromExisting (inputPath: string, video: VideoModel,
45function generateVideoMiniature (video: VideoModel, videoFile: VideoFileModel, type: ThumbnailType) { 45function generateVideoMiniature (video: VideoModel, videoFile: VideoFileModel, type: ThumbnailType) {
46 const input = video.getVideoFilePath(videoFile) 46 const input = video.getVideoFilePath(videoFile)
47 47
48 const { filename, basePath, height, width, existingThumbnail } = buildMetadataFromVideo(video, type) 48 const { filename, basePath, height, width, existingThumbnail, outputPath } = buildMetadataFromVideo(video, type)
49 const thumbnailCreator = () => generateImageFromVideoFile(input, basePath, filename, { height, width }) 49 const thumbnailCreator = videoFile.isAudio()
50 ? () => processImage(ASSETS_PATH.DEFAULT_AUDIO_BACKGROUND, outputPath, { width, height }, true)
51 : () => generateImageFromVideoFile(input, basePath, filename, { height, width })
50 52
51 return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail }) 53 return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail })
52} 54}
diff --git a/server/lib/video-transcoding.ts b/server/lib/video-transcoding.ts
index d6b6b251a..8d786e0ef 100644
--- a/server/lib/video-transcoding.ts
+++ b/server/lib/video-transcoding.ts
@@ -1,6 +1,6 @@
1import { HLS_STREAMING_PLAYLIST_DIRECTORY, P2P_MEDIA_LOADER_PEER_VERSION, WEBSERVER } from '../initializers/constants' 1import { HLS_STREAMING_PLAYLIST_DIRECTORY, P2P_MEDIA_LOADER_PEER_VERSION, WEBSERVER } from '../initializers/constants'
2import { join } from 'path' 2import { join } from 'path'
3import { getVideoFileFPS, transcode, canDoQuickTranscode } from '../helpers/ffmpeg-utils' 3import { canDoQuickTranscode, getVideoFileFPS, transcode, TranscodeOptions, TranscodeOptionsType } from '../helpers/ffmpeg-utils'
4import { ensureDir, move, remove, stat } from 'fs-extra' 4import { ensureDir, move, remove, stat } from 'fs-extra'
5import { logger } from '../helpers/logger' 5import { logger } from '../helpers/logger'
6import { VideoResolution } from '../../shared/models/videos' 6import { VideoResolution } from '../../shared/models/videos'
@@ -23,13 +23,15 @@ async function optimizeVideofile (video: VideoModel, inputVideoFileArg?: VideoFi
23 const videoInputPath = join(videosDirectory, video.getVideoFilename(inputVideoFile)) 23 const videoInputPath = join(videosDirectory, video.getVideoFilename(inputVideoFile))
24 const videoTranscodedPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname) 24 const videoTranscodedPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname)
25 25
26 const doQuickTranscode = await(canDoQuickTranscode(videoInputPath)) 26 const transcodeType: TranscodeOptionsType = await canDoQuickTranscode(videoInputPath)
27 ? 'quick-transcode'
28 : 'video'
27 29
28 const transcodeOptions = { 30 const transcodeOptions: TranscodeOptions = {
31 type: transcodeType as any, // FIXME: typing issue
29 inputPath: videoInputPath, 32 inputPath: videoInputPath,
30 outputPath: videoTranscodedPath, 33 outputPath: videoTranscodedPath,
31 resolution: inputVideoFile.resolution, 34 resolution: inputVideoFile.resolution
32 doQuickTranscode
33 } 35 }
34 36
35 // Could be very long! 37 // Could be very long!
@@ -39,19 +41,11 @@ async function optimizeVideofile (video: VideoModel, inputVideoFileArg?: VideoFi
39 await remove(videoInputPath) 41 await remove(videoInputPath)
40 42
41 // Important to do this before getVideoFilename() to take in account the new file extension 43 // Important to do this before getVideoFilename() to take in account the new file extension
42 inputVideoFile.set('extname', newExtname) 44 inputVideoFile.extname = newExtname
43
44 const stats = await stat(videoTranscodedPath)
45 const fps = await getVideoFileFPS(videoTranscodedPath)
46 45
47 const videoOutputPath = video.getVideoFilePath(inputVideoFile) 46 const videoOutputPath = video.getVideoFilePath(inputVideoFile)
48 await move(videoTranscodedPath, videoOutputPath)
49 47
50 inputVideoFile.set('size', stats.size) 48 await onVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, videoOutputPath)
51 inputVideoFile.set('fps', fps)
52
53 await video.createTorrentAndSetInfoHash(inputVideoFile)
54 await inputVideoFile.save()
55 } catch (err) { 49 } catch (err) {
56 // Auto destruction... 50 // Auto destruction...
57 video.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', { err })) 51 video.destroy().catch(err => logger.error('Cannot destruct video after transcoding failure.', { err }))
@@ -81,6 +75,7 @@ async function transcodeOriginalVideofile (video: VideoModel, resolution: VideoR
81 const videoTranscodedPath = join(transcodeDirectory, video.getVideoFilename(newVideoFile)) 75 const videoTranscodedPath = join(transcodeDirectory, video.getVideoFilename(newVideoFile))
82 76
83 const transcodeOptions = { 77 const transcodeOptions = {
78 type: 'video' as 'video',
84 inputPath: videoInputPath, 79 inputPath: videoInputPath,
85 outputPath: videoTranscodedPath, 80 outputPath: videoTranscodedPath,
86 resolution, 81 resolution,
@@ -89,19 +84,37 @@ async function transcodeOriginalVideofile (video: VideoModel, resolution: VideoR
89 84
90 await transcode(transcodeOptions) 85 await transcode(transcodeOptions)
91 86
92 const stats = await stat(videoTranscodedPath) 87 return onVideoFileTranscoding(video, newVideoFile, videoTranscodedPath, videoOutputPath)
93 const fps = await getVideoFileFPS(videoTranscodedPath) 88}
89
90async function mergeAudioVideofile (video: VideoModel, resolution: VideoResolution) {
91 const videosDirectory = CONFIG.STORAGE.VIDEOS_DIR
92 const transcodeDirectory = CONFIG.STORAGE.TMP_DIR
93 const newExtname = '.mp4'
94
95 const inputVideoFile = video.getOriginalFile()
94 96
95 await move(videoTranscodedPath, videoOutputPath) 97 const audioInputPath = join(videosDirectory, video.getVideoFilename(video.getOriginalFile()))
98 const videoTranscodedPath = join(transcodeDirectory, video.id + '-transcoded' + newExtname)
96 99
97 newVideoFile.set('size', stats.size) 100 const transcodeOptions = {
98 newVideoFile.set('fps', fps) 101 type: 'merge-audio' as 'merge-audio',
102 inputPath: video.getPreview().getPath(),
103 outputPath: videoTranscodedPath,
104 audioPath: audioInputPath,
105 resolution
106 }
99 107
100 await video.createTorrentAndSetInfoHash(newVideoFile) 108 await transcode(transcodeOptions)
101 109
102 await newVideoFile.save() 110 await remove(audioInputPath)
103 111
104 video.VideoFiles.push(newVideoFile) 112 // Important to do this before getVideoFilename() to take in account the new file extension
113 inputVideoFile.extname = newExtname
114
115 const videoOutputPath = video.getVideoFilePath(inputVideoFile)
116
117 return onVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, videoOutputPath)
105} 118}
106 119
107async function generateHlsPlaylist (video: VideoModel, resolution: VideoResolution, isPortraitMode: boolean) { 120async function generateHlsPlaylist (video: VideoModel, resolution: VideoResolution, isPortraitMode: boolean) {
@@ -112,6 +125,7 @@ async function generateHlsPlaylist (video: VideoModel, resolution: VideoResoluti
112 const outputPath = join(baseHlsDirectory, VideoStreamingPlaylistModel.getHlsPlaylistFilename(resolution)) 125 const outputPath = join(baseHlsDirectory, VideoStreamingPlaylistModel.getHlsPlaylistFilename(resolution))
113 126
114 const transcodeOptions = { 127 const transcodeOptions = {
128 type: 'hls' as 'hls',
115 inputPath: videoInputPath, 129 inputPath: videoInputPath,
116 outputPath, 130 outputPath,
117 resolution, 131 resolution,
@@ -140,8 +154,34 @@ async function generateHlsPlaylist (video: VideoModel, resolution: VideoResoluti
140 }) 154 })
141} 155}
142 156
157// ---------------------------------------------------------------------------
158
143export { 159export {
144 generateHlsPlaylist, 160 generateHlsPlaylist,
145 optimizeVideofile, 161 optimizeVideofile,
146 transcodeOriginalVideofile 162 transcodeOriginalVideofile,
163 mergeAudioVideofile
164}
165
166// ---------------------------------------------------------------------------
167
168async function onVideoFileTranscoding (video: VideoModel, videoFile: VideoFileModel, transcodingPath: string, outputPath: string) {
169 const stats = await stat(transcodingPath)
170 const fps = await getVideoFileFPS(transcodingPath)
171
172 await move(transcodingPath, outputPath)
173
174 videoFile.set('size', stats.size)
175 videoFile.set('fps', fps)
176
177 await video.createTorrentAndSetInfoHash(videoFile)
178
179 const updatedVideoFile = await videoFile.save()
180
181 // Add it if this is a new created file
182 if (video.VideoFiles.some(f => f.id === videoFile.id) === false) {
183 video.VideoFiles.push(updatedVideoFile)
184 }
185
186 return video
147} 187}
diff --git a/server/models/video/thumbnail.ts b/server/models/video/thumbnail.ts
index 206e9a3d6..8faf0adba 100644
--- a/server/models/video/thumbnail.ts
+++ b/server/models/video/thumbnail.ts
@@ -107,10 +107,12 @@ export class ThumbnailModel extends Model<ThumbnailModel> {
107 return WEBSERVER.URL + staticPath + this.filename 107 return WEBSERVER.URL + staticPath + this.filename
108 } 108 }
109 109
110 removeThumbnail () { 110 getPath () {
111 const directory = ThumbnailModel.types[this.type].directory 111 const directory = ThumbnailModel.types[this.type].directory
112 const thumbnailPath = join(directory, this.filename) 112 return join(directory, this.filename)
113 }
113 114
114 return remove(thumbnailPath) 115 removeThumbnail () {
116 return remove(this.getPath())
115 } 117 }
116} 118}
diff --git a/server/models/video/video-file.ts b/server/models/video/video-file.ts
index 2203a7aba..05c490759 100644
--- a/server/models/video/video-file.ts
+++ b/server/models/video/video-file.ts
@@ -24,6 +24,7 @@ import { VideoModel } from './video'
24import { VideoRedundancyModel } from '../redundancy/video-redundancy' 24import { VideoRedundancyModel } from '../redundancy/video-redundancy'
25import { VideoStreamingPlaylistModel } from './video-streaming-playlist' 25import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
26import { FindOptions, QueryTypes, Transaction } from 'sequelize' 26import { FindOptions, QueryTypes, Transaction } from 'sequelize'
27import { MIMETYPES } from '../../initializers/constants'
27 28
28@Table({ 29@Table({
29 tableName: 'videoFile', 30 tableName: 'videoFile',
@@ -161,6 +162,10 @@ export class VideoFileModel extends Model<VideoFileModel> {
161 })) 162 }))
162 } 163 }
163 164
165 isAudio () {
166 return !!MIMETYPES.AUDIO.EXT_MIMETYPE[this.extname]
167 }
168
164 hasSameUniqueKeysThan (other: VideoFileModel) { 169 hasSameUniqueKeysThan (other: VideoFileModel) {
165 return this.fps === other.fps && 170 return this.fps === other.fps &&
166 this.resolution === other.resolution && 171 this.resolution === other.resolution &&
diff --git a/server/tests/api/check-params/config.ts b/server/tests/api/check-params/config.ts
index 2a2ec606a..8155e11ab 100644
--- a/server/tests/api/check-params/config.ts
+++ b/server/tests/api/check-params/config.ts
@@ -59,6 +59,7 @@ describe('Test config API validators', function () {
59 transcoding: { 59 transcoding: {
60 enabled: true, 60 enabled: true,
61 allowAdditionalExtensions: true, 61 allowAdditionalExtensions: true,
62 allowAudioFiles: true,
62 threads: 1, 63 threads: 1,
63 resolutions: { 64 resolutions: {
64 '240p': false, 65 '240p': false,
diff --git a/server/tests/api/server/config.ts b/server/tests/api/server/config.ts
index ca389b7b6..8ea21158a 100644
--- a/server/tests/api/server/config.ts
+++ b/server/tests/api/server/config.ts
@@ -15,7 +15,7 @@ import {
15 registerUser, 15 registerUser,
16 reRunServer, ServerInfo, 16 reRunServer, ServerInfo,
17 setAccessTokensToServers, 17 setAccessTokensToServers,
18 updateCustomConfig 18 updateCustomConfig, uploadVideo
19} from '../../../../shared/extra-utils' 19} from '../../../../shared/extra-utils'
20import { ServerConfig } from '../../../../shared/models' 20import { ServerConfig } from '../../../../shared/models'
21 21
@@ -52,6 +52,7 @@ function checkInitialConfig (server: ServerInfo, data: CustomConfig) {
52 expect(data.user.videoQuotaDaily).to.equal(-1) 52 expect(data.user.videoQuotaDaily).to.equal(-1)
53 expect(data.transcoding.enabled).to.be.false 53 expect(data.transcoding.enabled).to.be.false
54 expect(data.transcoding.allowAdditionalExtensions).to.be.false 54 expect(data.transcoding.allowAdditionalExtensions).to.be.false
55 expect(data.transcoding.allowAudioFiles).to.be.false
55 expect(data.transcoding.threads).to.equal(2) 56 expect(data.transcoding.threads).to.equal(2)
56 expect(data.transcoding.resolutions['240p']).to.be.true 57 expect(data.transcoding.resolutions['240p']).to.be.true
57 expect(data.transcoding.resolutions['360p']).to.be.true 58 expect(data.transcoding.resolutions['360p']).to.be.true
@@ -102,6 +103,7 @@ function checkUpdatedConfig (data: CustomConfig) {
102 expect(data.transcoding.enabled).to.be.true 103 expect(data.transcoding.enabled).to.be.true
103 expect(data.transcoding.threads).to.equal(1) 104 expect(data.transcoding.threads).to.equal(1)
104 expect(data.transcoding.allowAdditionalExtensions).to.be.true 105 expect(data.transcoding.allowAdditionalExtensions).to.be.true
106 expect(data.transcoding.allowAudioFiles).to.be.true
105 expect(data.transcoding.resolutions['240p']).to.be.false 107 expect(data.transcoding.resolutions['240p']).to.be.false
106 expect(data.transcoding.resolutions['360p']).to.be.true 108 expect(data.transcoding.resolutions['360p']).to.be.true
107 expect(data.transcoding.resolutions['480p']).to.be.true 109 expect(data.transcoding.resolutions['480p']).to.be.true
@@ -158,6 +160,9 @@ describe('Test config', function () {
158 expect(data.video.file.extensions).to.contain('.webm') 160 expect(data.video.file.extensions).to.contain('.webm')
159 expect(data.video.file.extensions).to.contain('.ogv') 161 expect(data.video.file.extensions).to.contain('.ogv')
160 162
163 await uploadVideo(server.url, server.accessToken, { fixture: 'video_short.mkv' }, 400)
164 await uploadVideo(server.url, server.accessToken, { fixture: 'sample.ogg' }, 400)
165
161 expect(data.contactForm.enabled).to.be.true 166 expect(data.contactForm.enabled).to.be.true
162 }) 167 })
163 168
@@ -215,6 +220,7 @@ describe('Test config', function () {
215 transcoding: { 220 transcoding: {
216 enabled: true, 221 enabled: true,
217 allowAdditionalExtensions: true, 222 allowAdditionalExtensions: true,
223 allowAudioFiles: true,
218 threads: 1, 224 threads: 1,
219 resolutions: { 225 resolutions: {
220 '240p': false, 226 '240p': false,
@@ -269,6 +275,12 @@ describe('Test config', function () {
269 expect(data.video.file.extensions).to.contain('.ogv') 275 expect(data.video.file.extensions).to.contain('.ogv')
270 expect(data.video.file.extensions).to.contain('.flv') 276 expect(data.video.file.extensions).to.contain('.flv')
271 expect(data.video.file.extensions).to.contain('.mkv') 277 expect(data.video.file.extensions).to.contain('.mkv')
278 expect(data.video.file.extensions).to.contain('.mp3')
279 expect(data.video.file.extensions).to.contain('.ogg')
280 expect(data.video.file.extensions).to.contain('.flac')
281
282 await uploadVideo(server.url, server.accessToken, { fixture: 'video_short.mkv' }, 200)
283 await uploadVideo(server.url, server.accessToken, { fixture: 'sample.ogg' }, 200)
272 }) 284 })
273 285
274 it('Should have the configuration updated after a restart', async function () { 286 it('Should have the configuration updated after a restart', async function () {
diff --git a/server/tests/api/server/jobs.ts b/server/tests/api/server/jobs.ts
index 634654626..3ab2fe120 100644
--- a/server/tests/api/server/jobs.ts
+++ b/server/tests/api/server/jobs.ts
@@ -26,7 +26,7 @@ describe('Test jobs', function () {
26 }) 26 })
27 27
28 it('Should create some jobs', async function () { 28 it('Should create some jobs', async function () {
29 this.timeout(30000) 29 this.timeout(60000)
30 30
31 await uploadVideo(servers[1].url, servers[1].accessToken, { name: 'video1' }) 31 await uploadVideo(servers[1].url, servers[1].accessToken, { name: 'video1' })
32 await uploadVideo(servers[1].url, servers[1].accessToken, { name: 'video2' }) 32 await uploadVideo(servers[1].url, servers[1].accessToken, { name: 'video2' })
diff --git a/server/tests/api/travis-2.sh b/server/tests/api/travis-2.sh
index 82c1864b4..ba7a061b0 100644
--- a/server/tests/api/travis-2.sh
+++ b/server/tests/api/travis-2.sh
@@ -5,5 +5,5 @@ set -eu
5serverFiles=$(find server/tests/api/server -type f | grep -v index.ts | xargs echo) 5serverFiles=$(find server/tests/api/server -type f | grep -v index.ts | xargs echo)
6usersFiles=$(find server/tests/api/users -type f | grep -v index.ts | xargs echo) 6usersFiles=$(find server/tests/api/users -type f | grep -v index.ts | xargs echo)
7 7
8MOCHA_PARALLEL=true mocha-parallel-tests --max-parallel $1 --timeout 5000 --exit --require ts-node/register --bail \ 8MOCHA_PARALLEL=true mocha --timeout 5000 --exit --require ts-node/register --bail \
9 $serverFiles $usersFiles 9 $serverFiles $usersFiles
diff --git a/server/tests/api/videos/multiple-servers.ts b/server/tests/api/videos/multiple-servers.ts
index 09b461200..e9625e5f7 100644
--- a/server/tests/api/videos/multiple-servers.ts
+++ b/server/tests/api/videos/multiple-servers.ts
@@ -215,7 +215,7 @@ describe('Test multiple servers', function () {
215 files: [ 215 files: [
216 { 216 {
217 resolution: 240, 217 resolution: 240,
218 size: 187000 218 size: 189000
219 }, 219 },
220 { 220 {
221 resolution: 360, 221 resolution: 360,
@@ -223,7 +223,7 @@ describe('Test multiple servers', function () {
223 }, 223 },
224 { 224 {
225 resolution: 480, 225 resolution: 480,
226 size: 383000 226 size: 384000
227 }, 227 },
228 { 228 {
229 resolution: 720, 229 resolution: 720,
diff --git a/server/tests/api/videos/services.ts b/server/tests/api/videos/services.ts
index 38e232e5f..17172331f 100644
--- a/server/tests/api/videos/services.ts
+++ b/server/tests/api/videos/services.ts
@@ -41,8 +41,8 @@ describe('Test services', function () {
41 expect(res.body.width).to.equal(560) 41 expect(res.body.width).to.equal(560)
42 expect(res.body.height).to.equal(315) 42 expect(res.body.height).to.equal(315)
43 expect(res.body.thumbnail_url).to.equal(expectedThumbnailUrl) 43 expect(res.body.thumbnail_url).to.equal(expectedThumbnailUrl)
44 expect(res.body.thumbnail_width).to.equal(560) 44 expect(res.body.thumbnail_width).to.equal(850)
45 expect(res.body.thumbnail_height).to.equal(315) 45 expect(res.body.thumbnail_height).to.equal(480)
46 }) 46 })
47 47
48 it('Should have a valid oEmbed response with small max height query', async function () { 48 it('Should have a valid oEmbed response with small max height query', async function () {
diff --git a/server/tests/api/videos/video-hls.ts b/server/tests/api/videos/video-hls.ts
index 504c50dee..39178bb1a 100644
--- a/server/tests/api/videos/video-hls.ts
+++ b/server/tests/api/videos/video-hls.ts
@@ -21,12 +21,11 @@ import {
21import { VideoDetails } from '../../../../shared/models/videos' 21import { VideoDetails } from '../../../../shared/models/videos'
22import { VideoStreamingPlaylistType } from '../../../../shared/models/videos/video-streaming-playlist.type' 22import { VideoStreamingPlaylistType } from '../../../../shared/models/videos/video-streaming-playlist.type'
23import { join } from 'path' 23import { join } from 'path'
24import { DEFAULT_AUDIO_RESOLUTION } from '../../../initializers/constants'
24 25
25const expect = chai.expect 26const expect = chai.expect
26 27
27async function checkHlsPlaylist (servers: ServerInfo[], videoUUID: string) { 28async function checkHlsPlaylist (servers: ServerInfo[], videoUUID: string, resolutions = [ 240, 360, 480, 720 ]) {
28 const resolutions = [ 240, 360, 480, 720 ]
29
30 for (const server of servers) { 29 for (const server of servers) {
31 const res = await getVideo(server.url, videoUUID) 30 const res = await getVideo(server.url, videoUUID)
32 const videoDetails: VideoDetails = res.body 31 const videoDetails: VideoDetails = res.body
@@ -41,9 +40,8 @@ async function checkHlsPlaylist (servers: ServerInfo[], videoUUID: string) {
41 40
42 const masterPlaylist = res2.text 41 const masterPlaylist = res2.text
43 42
44 expect(masterPlaylist).to.contain('#EXT-X-STREAM-INF:BANDWIDTH=55472,RESOLUTION=640x360,FRAME-RATE=25')
45
46 for (const resolution of resolutions) { 43 for (const resolution of resolutions) {
44 expect(masterPlaylist).to.match(new RegExp('#EXT-X-STREAM-INF:BANDWIDTH=\\d+,RESOLUTION=\\d+x' + resolution + ',FRAME-RATE=\\d+'))
47 expect(masterPlaylist).to.contain(`${resolution}.m3u8`) 45 expect(masterPlaylist).to.contain(`${resolution}.m3u8`)
48 } 46 }
49 } 47 }
@@ -70,11 +68,21 @@ async function checkHlsPlaylist (servers: ServerInfo[], videoUUID: string) {
70describe('Test HLS videos', function () { 68describe('Test HLS videos', function () {
71 let servers: ServerInfo[] = [] 69 let servers: ServerInfo[] = []
72 let videoUUID = '' 70 let videoUUID = ''
71 let videoAudioUUID = ''
73 72
74 before(async function () { 73 before(async function () {
75 this.timeout(120000) 74 this.timeout(120000)
76 75
77 servers = await flushAndRunMultipleServers(2, { transcoding: { enabled: true, hls: { enabled: true } } }) 76 const configOverride = {
77 transcoding: {
78 enabled: true,
79 allow_audio_files: true,
80 hls: {
81 enabled: true
82 }
83 }
84 }
85 servers = await flushAndRunMultipleServers(2, configOverride)
78 86
79 // Get the access tokens 87 // Get the access tokens
80 await setAccessTokensToServers(servers) 88 await setAccessTokensToServers(servers)
@@ -86,17 +94,28 @@ describe('Test HLS videos', function () {
86 it('Should upload a video and transcode it to HLS', async function () { 94 it('Should upload a video and transcode it to HLS', async function () {
87 this.timeout(120000) 95 this.timeout(120000)
88 96
89 { 97 const res = await uploadVideo(servers[ 0 ].url, servers[ 0 ].accessToken, { name: 'video 1', fixture: 'video_short.webm' })
90 const res = await uploadVideo(servers[ 0 ].url, servers[ 0 ].accessToken, { name: 'video 1', fixture: 'video_short.webm' }) 98 videoUUID = res.body.video.uuid
91 videoUUID = res.body.video.uuid
92 }
93 99
94 await waitJobs(servers) 100 await waitJobs(servers)
95 101
96 await checkHlsPlaylist(servers, videoUUID) 102 await checkHlsPlaylist(servers, videoUUID)
97 }) 103 })
98 104
105 it('Should upload an audio file and transcode it to HLS', async function () {
106 this.timeout(120000)
107
108 const res = await uploadVideo(servers[ 0 ].url, servers[ 0 ].accessToken, { name: 'video audio', fixture: 'sample.ogg' })
109 videoAudioUUID = res.body.video.uuid
110
111 await waitJobs(servers)
112
113 await checkHlsPlaylist(servers, videoAudioUUID, [ DEFAULT_AUDIO_RESOLUTION ])
114 })
115
99 it('Should update the video', async function () { 116 it('Should update the video', async function () {
117 this.timeout(10000)
118
100 await updateVideo(servers[0].url, servers[0].accessToken, videoUUID, { name: 'video 1 updated' }) 119 await updateVideo(servers[0].url, servers[0].accessToken, videoUUID, { name: 'video 1 updated' })
101 120
102 await waitJobs(servers) 121 await waitJobs(servers)
@@ -104,13 +123,17 @@ describe('Test HLS videos', function () {
104 await checkHlsPlaylist(servers, videoUUID) 123 await checkHlsPlaylist(servers, videoUUID)
105 }) 124 })
106 125
107 it('Should delete the video', async function () { 126 it('Should delete videos', async function () {
127 this.timeout(10000)
128
108 await removeVideo(servers[0].url, servers[0].accessToken, videoUUID) 129 await removeVideo(servers[0].url, servers[0].accessToken, videoUUID)
130 await removeVideo(servers[0].url, servers[0].accessToken, videoAudioUUID)
109 131
110 await waitJobs(servers) 132 await waitJobs(servers)
111 133
112 for (const server of servers) { 134 for (const server of servers) {
113 await getVideo(server.url, videoUUID, 404) 135 await getVideo(server.url, videoUUID, 404)
136 await getVideo(server.url, videoAudioUUID, 404)
114 } 137 }
115 }) 138 })
116 139
diff --git a/server/tests/api/videos/video-transcoder.ts b/server/tests/api/videos/video-transcoder.ts
index cfd0c8430..90ade1652 100644
--- a/server/tests/api/videos/video-transcoder.ts
+++ b/server/tests/api/videos/video-transcoder.ts
@@ -14,6 +14,7 @@ import {
14 getMyVideos, 14 getMyVideos,
15 getVideo, 15 getVideo,
16 getVideosList, 16 getVideosList,
17 makeGetRequest,
17 root, 18 root,
18 ServerInfo, 19 ServerInfo,
19 setAccessTokensToServers, 20 setAccessTokensToServers,
@@ -365,6 +366,56 @@ describe('Test video transcoding', function () {
365 expect(await canDoQuickTranscode(buildAbsoluteFixturePath('video_short.webm'))).to.be.false 366 expect(await canDoQuickTranscode(buildAbsoluteFixturePath('video_short.webm'))).to.be.false
366 }) 367 })
367 368
369 it('Should merge an audio file with the preview file', async function () {
370 this.timeout(60000)
371
372 const videoAttributesArg = { name: 'audio_with_preview', previewfile: 'preview.jpg', fixture: 'sample.ogg' }
373 await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, videoAttributesArg)
374
375 await waitJobs(servers)
376
377 for (const server of servers) {
378 const res = await getVideosList(server.url)
379
380 const video = res.body.data.find(v => v.name === 'audio_with_preview')
381 const res2 = await getVideo(server.url, video.id)
382 const videoDetails: VideoDetails = res2.body
383
384 expect(videoDetails.files).to.have.lengthOf(1)
385
386 await makeGetRequest({ url: server.url, path: videoDetails.thumbnailPath, statusCodeExpected: 200 })
387 await makeGetRequest({ url: server.url, path: videoDetails.previewPath, statusCodeExpected: 200 })
388
389 const magnetUri = videoDetails.files[ 0 ].magnetUri
390 expect(magnetUri).to.contain('.mp4')
391 }
392 })
393
394 it('Should upload an audio file and choose a default background image', async function () {
395 this.timeout(60000)
396
397 const videoAttributesArg = { name: 'audio_without_preview', fixture: 'sample.ogg' }
398 await uploadVideo(servers[ 1 ].url, servers[ 1 ].accessToken, videoAttributesArg)
399
400 await waitJobs(servers)
401
402 for (const server of servers) {
403 const res = await getVideosList(server.url)
404
405 const video = res.body.data.find(v => v.name === 'audio_without_preview')
406 const res2 = await getVideo(server.url, video.id)
407 const videoDetails = res2.body
408
409 expect(videoDetails.files).to.have.lengthOf(1)
410
411 await makeGetRequest({ url: server.url, path: videoDetails.thumbnailPath, statusCodeExpected: 200 })
412 await makeGetRequest({ url: server.url, path: videoDetails.previewPath, statusCodeExpected: 200 })
413
414 const magnetUri = videoDetails.files[ 0 ].magnetUri
415 expect(magnetUri).to.contain('.mp4')
416 }
417 })
418
368 after(async function () { 419 after(async function () {
369 await cleanupTests(servers) 420 await cleanupTests(servers)
370 }) 421 })
diff --git a/server/tests/fixtures/preview.jpg b/server/tests/fixtures/preview.jpg
index c40ece838..cb5692281 100644
--- a/server/tests/fixtures/preview.jpg
+++ b/server/tests/fixtures/preview.jpg
Binary files differ
diff --git a/server/tests/fixtures/sample.ogg b/server/tests/fixtures/sample.ogg
new file mode 100644
index 000000000..0d7f43eb7
--- /dev/null
+++ b/server/tests/fixtures/sample.ogg
Binary files differ
diff --git a/server/tests/fixtures/video_short1-preview.webm.jpg b/server/tests/fixtures/video_short1-preview.webm.jpg
index d2a068b78..157d3ca9a 100644
--- a/server/tests/fixtures/video_short1-preview.webm.jpg
+++ b/server/tests/fixtures/video_short1-preview.webm.jpg
Binary files differ
diff --git a/shared/extra-utils/server/config.ts b/shared/extra-utils/server/config.ts
index deb77e9c0..a5f5989e0 100644
--- a/shared/extra-utils/server/config.ts
+++ b/shared/extra-utils/server/config.ts
@@ -91,6 +91,7 @@ function updateCustomSubConfig (url: string, token: string, newConfig: any) {
91 transcoding: { 91 transcoding: {
92 enabled: true, 92 enabled: true,
93 allowAdditionalExtensions: true, 93 allowAdditionalExtensions: true,
94 allowAudioFiles: true,
94 threads: 1, 95 threads: 1,
95 resolutions: { 96 resolutions: {
96 '240p': false, 97 '240p': false,
diff --git a/shared/models/server/custom-config.model.ts b/shared/models/server/custom-config.model.ts
index ca52eff4b..4cc379b2a 100644
--- a/shared/models/server/custom-config.model.ts
+++ b/shared/models/server/custom-config.model.ts
@@ -54,6 +54,7 @@ export interface CustomConfig {
54 transcoding: { 54 transcoding: {
55 enabled: boolean 55 enabled: boolean
56 allowAdditionalExtensions: boolean 56 allowAdditionalExtensions: boolean
57 allowAudioFiles: boolean
57 threads: number 58 threads: number
58 resolutions: { 59 resolutions: {
59 '240p': boolean 60 '240p': boolean