aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2018-08-02 15:34:09 +0200
committerChocobozzz <me@florianbigard.com>2018-08-06 11:19:16 +0200
commitfbad87b0472f574409f7aa3ae7f8b54927d0cdd6 (patch)
tree197b4209e75d57dabae7cdd6f2da5f765e427023
parent5e319fb7898fd0482c399cc3ae9dcfc20d274a58 (diff)
downloadPeerTube-fbad87b0472f574409f7aa3ae7f8b54927d0cdd6.tar.gz
PeerTube-fbad87b0472f574409f7aa3ae7f8b54927d0cdd6.tar.zst
PeerTube-fbad87b0472f574409f7aa3ae7f8b54927d0cdd6.zip
Add ability to import video with youtube-dl
-rw-r--r--client/package.json1
-rw-r--r--client/src/app/shared/shared.module.ts2
-rw-r--r--client/src/app/shared/video-import/index.ts1
-rw-r--r--client/src/app/shared/video-import/video-import.service.ts56
-rw-r--r--client/src/app/shared/video/video-edit.model.ts40
-rw-r--r--client/src/app/videos/+video-edit/video-add.component.html68
-rw-r--r--client/src/app/videos/+video-edit/video-add.component.scss117
-rw-r--r--client/src/app/videos/+video-edit/video-add.component.ts252
-rw-r--r--client/src/app/videos/+video-edit/video-add.module.ts6
-rw-r--r--client/src/app/videos/+video-edit/video-import.component.html55
-rw-r--r--client/src/app/videos/+video-edit/video-import.component.scss37
-rw-r--r--client/src/app/videos/+video-edit/video-import.component.ts161
-rw-r--r--client/src/app/videos/+video-edit/video-update.component.ts1
-rw-r--r--client/src/app/videos/+video-edit/video-upload.component.html58
-rw-r--r--client/src/app/videos/+video-edit/video-upload.component.scss85
-rw-r--r--client/src/app/videos/+video-edit/video-upload.component.ts251
-rw-r--r--config/default.yaml6
-rw-r--r--package.json7
-rw-r--r--server/controllers/api/videos/import.ts151
-rw-r--r--server/controllers/api/videos/index.ts3
-rw-r--r--server/helpers/custom-validators/activitypub/videos.ts2
-rw-r--r--server/helpers/custom-validators/video-imports.ts30
-rw-r--r--server/helpers/logger.ts2
-rw-r--r--server/helpers/youtube-dl.ts142
-rw-r--r--server/initializers/constants.ts18
-rw-r--r--server/initializers/database.ts4
-rw-r--r--server/lib/job-queue/handlers/video-import.ts129
-rw-r--r--server/lib/job-queue/job-queue.ts10
-rw-r--r--server/middlewares/validators/index.ts1
-rw-r--r--server/middlewares/validators/video-imports.ts51
-rw-r--r--server/middlewares/validators/videos.ts62
-rw-r--r--server/models/account/account.ts1
-rw-r--r--server/models/video/video-import.ts105
-rw-r--r--server/models/video/video.ts2
-rw-r--r--shared/models/server/job.model.ts3
-rw-r--r--shared/models/videos/index.ts4
-rw-r--r--shared/models/videos/video-import-create.model.ts6
-rw-r--r--shared/models/videos/video-import-state.enum.ts5
-rw-r--r--shared/models/videos/video-import-update.model.ts5
-rw-r--r--shared/models/videos/video-import.model.ts7
-rw-r--r--shared/models/videos/video-state.enum.ts3
-rw-r--r--tsconfig.json1
42 files changed, 1506 insertions, 445 deletions
diff --git a/client/package.json b/client/package.json
index aae7643c7..34305ee44 100644
--- a/client/package.json
+++ b/client/package.json
@@ -23,6 +23,7 @@
23 "ngx-extractor": "ngx-extractor" 23 "ngx-extractor": "ngx-extractor"
24 }, 24 },
25 "license": "GPLv3", 25 "license": "GPLv3",
26 "typings": "*.d.ts",
26 "resolutions": { 27 "resolutions": {
27 "video.js": "^7", 28 "video.js": "^7",
28 "webtorrent/create-torrent/junk": "^1", 29 "webtorrent/create-torrent/junk": "^1",
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts
index 99df61cdb..62ce97102 100644
--- a/client/src/app/shared/shared.module.ts
+++ b/client/src/app/shared/shared.module.ts
@@ -51,6 +51,7 @@ import { ScreenService } from '@app/shared/misc/screen.service'
51import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service' 51import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service'
52import { VideoCaptionService } from '@app/shared/video-caption' 52import { VideoCaptionService } from '@app/shared/video-caption'
53import { PeertubeCheckboxComponent } from '@app/shared/forms/peertube-checkbox.component' 53import { PeertubeCheckboxComponent } from '@app/shared/forms/peertube-checkbox.component'
54import { VideoImportService } from '@app/shared/video-import/video-import.service'
54 55
55@NgModule({ 56@NgModule({
56 imports: [ 57 imports: [
@@ -143,6 +144,7 @@ import { PeertubeCheckboxComponent } from '@app/shared/forms/peertube-checkbox.c
143 VideoCommentValidatorsService, 144 VideoCommentValidatorsService,
144 VideoValidatorsService, 145 VideoValidatorsService,
145 VideoCaptionsValidatorsService, 146 VideoCaptionsValidatorsService,
147 VideoImportService,
146 148
147 I18nPrimengCalendarService, 149 I18nPrimengCalendarService,
148 ScreenService, 150 ScreenService,
diff --git a/client/src/app/shared/video-import/index.ts b/client/src/app/shared/video-import/index.ts
new file mode 100644
index 000000000..9bb73ec2c
--- /dev/null
+++ b/client/src/app/shared/video-import/index.ts
@@ -0,0 +1 @@
export * from './video-import.service'
diff --git a/client/src/app/shared/video-import/video-import.service.ts b/client/src/app/shared/video-import/video-import.service.ts
new file mode 100644
index 000000000..b4709866a
--- /dev/null
+++ b/client/src/app/shared/video-import/video-import.service.ts
@@ -0,0 +1,56 @@
1import { catchError } from 'rxjs/operators'
2import { HttpClient } from '@angular/common/http'
3import { Injectable } from '@angular/core'
4import { Observable } from 'rxjs'
5import { VideoImport } from '../../../../../shared'
6import { environment } from '../../../environments/environment'
7import { RestExtractor, RestService } from '../rest'
8import { VideoImportCreate } from '../../../../../shared/models/videos/video-import-create.model'
9import { objectToFormData } from '@app/shared/misc/utils'
10import { VideoUpdate } from '../../../../../shared/models/videos'
11
12@Injectable()
13export class VideoImportService {
14 private static BASE_VIDEO_IMPORT_URL = environment.apiUrl + '/api/v1/videos/imports/'
15
16 constructor (
17 private authHttp: HttpClient,
18 private restService: RestService,
19 private restExtractor: RestExtractor
20 ) {}
21
22 importVideo (targetUrl: string, video: VideoUpdate): Observable<VideoImport> {
23 const url = VideoImportService.BASE_VIDEO_IMPORT_URL
24 const language = video.language || null
25 const licence = video.licence || null
26 const category = video.category || null
27 const description = video.description || null
28 const support = video.support || null
29 const scheduleUpdate = video.scheduleUpdate || null
30
31 const body: VideoImportCreate = {
32 targetUrl,
33
34 name: video.name,
35 category,
36 licence,
37 language,
38 support,
39 description,
40 channelId: video.channelId,
41 privacy: video.privacy,
42 tags: video.tags,
43 nsfw: video.nsfw,
44 waitTranscoding: video.waitTranscoding,
45 commentsEnabled: video.commentsEnabled,
46 thumbnailfile: video.thumbnailfile,
47 previewfile: video.previewfile,
48 scheduleUpdate
49 }
50
51 const data = objectToFormData(body)
52 return this.authHttp.post<VideoImport>(url, data)
53 .pipe(catchError(res => this.restExtractor.handleError(res)))
54 }
55
56}
diff --git a/client/src/app/shared/video/video-edit.model.ts b/client/src/app/shared/video/video-edit.model.ts
index 8562f8d25..0046be964 100644
--- a/client/src/app/shared/video/video-edit.model.ts
+++ b/client/src/app/shared/video/video-edit.model.ts
@@ -1,7 +1,7 @@
1import { VideoDetails } from './video-details.model'
2import { VideoPrivacy } from '../../../../../shared/models/videos/video-privacy.enum' 1import { VideoPrivacy } from '../../../../../shared/models/videos/video-privacy.enum'
3import { VideoUpdate } from '../../../../../shared/models/videos' 2import { VideoUpdate } from '../../../../../shared/models/videos'
4import { VideoScheduleUpdate } from '../../../../../shared/models/videos/video-schedule-update.model' 3import { VideoScheduleUpdate } from '../../../../../shared/models/videos/video-schedule-update.model'
4import { Video } from '../../../../../shared/models/videos/video.model'
5 5
6export class VideoEdit implements VideoUpdate { 6export class VideoEdit implements VideoUpdate {
7 static readonly SPECIAL_SCHEDULED_PRIVACY = -1 7 static readonly SPECIAL_SCHEDULED_PRIVACY = -1
@@ -26,26 +26,26 @@ export class VideoEdit implements VideoUpdate {
26 id?: number 26 id?: number
27 scheduleUpdate?: VideoScheduleUpdate 27 scheduleUpdate?: VideoScheduleUpdate
28 28
29 constructor (videoDetails?: VideoDetails) { 29 constructor (video?: Video & { tags: string[], commentsEnabled: boolean, support: string, thumbnailUrl: string, previewUrl: string }) {
30 if (videoDetails) { 30 if (video) {
31 this.id = videoDetails.id 31 this.id = video.id
32 this.uuid = videoDetails.uuid 32 this.uuid = video.uuid
33 this.category = videoDetails.category.id 33 this.category = video.category.id
34 this.licence = videoDetails.licence.id 34 this.licence = video.licence.id
35 this.language = videoDetails.language.id 35 this.language = video.language.id
36 this.description = videoDetails.description 36 this.description = video.description
37 this.name = videoDetails.name 37 this.name = video.name
38 this.tags = videoDetails.tags 38 this.tags = video.tags
39 this.nsfw = videoDetails.nsfw 39 this.nsfw = video.nsfw
40 this.commentsEnabled = videoDetails.commentsEnabled 40 this.commentsEnabled = video.commentsEnabled
41 this.waitTranscoding = videoDetails.waitTranscoding 41 this.waitTranscoding = video.waitTranscoding
42 this.channelId = videoDetails.channel.id 42 this.channelId = video.channel.id
43 this.privacy = videoDetails.privacy.id 43 this.privacy = video.privacy.id
44 this.support = videoDetails.support 44 this.support = video.support
45 this.thumbnailUrl = videoDetails.thumbnailUrl 45 this.thumbnailUrl = video.thumbnailUrl
46 this.previewUrl = videoDetails.previewUrl 46 this.previewUrl = video.previewUrl
47 47
48 this.scheduleUpdate = videoDetails.scheduledUpdate 48 this.scheduleUpdate = video.scheduledUpdate
49 } 49 }
50 } 50 }
51 51
diff --git a/client/src/app/videos/+video-edit/video-add.component.html b/client/src/app/videos/+video-edit/video-add.component.html
index 9c2c01c65..ed8d91c11 100644
--- a/client/src/app/videos/+video-edit/video-add.component.html
+++ b/client/src/app/videos/+video-edit/video-add.component.html
@@ -1,65 +1,17 @@
1<div class="margin-content"> 1<div class="margin-content">
2 <div class="title-page title-page-single"> 2 <div class="title-page title-page-single">
3 <ng-container *ngIf="!videoFileName" i18n>Upload your video</ng-container> 3 <ng-container *ngIf="secondStepType === 'import'" i18n>Import {{ videoName }}</ng-container>
4 <ng-container *ngIf="videoFileName" i18n>Upload {{ videoFileName }}</ng-container> 4 <ng-container *ngIf="secondStepType === 'upload'" i18n>Upload {{ videoName }}</ng-container>
5 </div> 5 </div>
6 6
7 <div *ngIf="!isUploadingVideo" class="upload-video-container"> 7 <tabset class="video-add-tabset root-tabset bootstrap" [ngClass]="{ 'hide-nav': secondStepType !== undefined }">
8 <div class="upload-video">
9 <div class="icon icon-upload"></div>
10 8
11 <div class="button-file"> 9 <tab i18n-heading heading="Upload your video">
12 <span i18n>Select the file to upload</span> 10 <my-video-upload #videoUpload (firstStepDone)="onFirstStepDone('upload', $event)"></my-video-upload>
13 <input #videofileInput type="file" name="videofile" id="videofile" [accept]="videoExtensions" (change)="fileChange()" /> 11 </tab>
14 </div>
15 <span class="button-file-extension">(.mp4, .webm, .ogv)</span>
16 12
17 <div class="form-group form-group-channel"> 13 <tab i18n-heading heading="Import your video">
18 <label i18n for="first-step-channel">Channel</label> 14 <my-video-import #videoImport (firstStepDone)="onFirstStepDone('import', $event)"></my-video-import>
19 <div class="peertube-select-container"> 15 </tab>
20 <select id="first-step-channel" [(ngModel)]="firstStepChannelId"> 16 </tabset>
21 <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option>
22 </select>
23 </div>
24 </div>
25
26 <div class="form-group">
27 <label i18n for="first-step-privacy">Privacy</label>
28 <div class="peertube-select-container">
29 <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId">
30 <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option>
31 <option [value]="SPECIAL_SCHEDULED_PRIVACY">Scheduled</option>
32 </select>
33 </div>
34 </div>
35 </div>
36 </div>
37
38 <div *ngIf="isUploadingVideo" class="upload-progress-cancel">
39 <p-progressBar
40 [value]="videoUploadPercents"
41 [ngClass]="{ processing: videoUploadPercents === 100 && videoUploaded === false }"
42 ></p-progressBar>
43 <input *ngIf="videoUploaded === false" type="button" value="Cancel" (click)="cancelUpload()" />
44 </div>
45
46 <!-- Hidden because we want to load the component -->
47 <form [hidden]="!isUploadingVideo" novalidate [formGroup]="form">
48 <my-video-edit
49 [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions"
50 [validationMessages]="validationMessages" [videoPrivacies]="videoPrivacies" [userVideoChannels]="userVideoChannels"
51 ></my-video-edit>
52
53 <div class="submit-container">
54 <div i18n *ngIf="videoUploaded === false" class="message-submit">Publish will be available when upload is finished</div>
55
56 <div class="submit-button"
57 (click)="updateSecondStep()"
58 [ngClass]="{ disabled: !form.valid || isUpdatingVideo === true || videoUploaded !== true }"
59 >
60 <span class="icon icon-validate"></span>
61 <input type="button" i18n-value value="Publish" />
62 </div>
63 </div>
64 </form>
65</div> 17</div>
diff --git a/client/src/app/videos/+video-edit/video-add.component.scss b/client/src/app/videos/+video-edit/video-add.component.scss
index c0b5f3d07..a811b9cf0 100644
--- a/client/src/app/videos/+video-edit/video-add.component.scss
+++ b/client/src/app/videos/+video-edit/video-add.component.scss
@@ -1,101 +1,54 @@
1@import '_variables'; 1@import '_variables';
2@import '_mixins'; 2@import '_mixins';
3 3
4.upload-video-container { 4$border-width: 3px;
5 border-radius: 3px; 5$border-type: solid;
6 background-color: #F7F7F7; 6$border-color: #EAEAEA;
7 border: 3px solid #EAEAEA;
8 width: 100%;
9 height: 440px;
10 margin-top: 40px;
11 display: flex;
12 justify-content: center;
13 align-items: center;
14 7
15 .peertube-select-container { 8$background-color: #F7F7F7;
16 @include peertube-select-container(190px);
17 }
18
19 .upload-video {
20 display: flex;
21 flex-direction: column;
22 align-items: center;
23
24 .form-group-channel {
25 margin-bottom: 20px;
26 }
27
28 .icon.icon-upload {
29 @include icon(90px);
30 margin-bottom: 25px;
31 cursor: default;
32
33 background-image: url('../../../assets/images/video/upload.svg');
34 }
35
36 .button-file {
37 @include peertube-button-file(auto);
38
39 min-width: 190px;
40 }
41 9
42 .button-file-extension { 10/deep/ tabset.root-tabset.video-add-tabset {
43 display: block; 11 &.hide-nav .nav {
44 font-size: 12px; 12 display: none !important;
45 margin-top: 5px;
46 }
47 }
48
49 .form-group-channel {
50 margin-top: 35px;
51 } 13 }
52}
53 14
54.upload-progress-cancel { 15 & > .nav {
55 display: flex;
56 margin-top: 25px;
57 margin-bottom: 40px;
58 16
59 p-progressBar { 17 border-bottom: $border-width $border-type $border-color;
60 flex-grow: 1; 18 margin: 0 !important;
61
62 /deep/ .ui-progressbar {
63 font-size: 15px !important;
64 color: #fff !important;
65 height: 30px !important;
66 line-height: 30px !important;
67 border-radius: 3px !important;
68 background-color: rgba(11, 204, 41, 0.16) !important;
69
70 .ui-progressbar-value {
71 background-color: #0BCC29 !important;
72 }
73 19
74 .ui-progressbar-label { 20 & > li {
75 text-align: left; 21 margin-bottom: -$border-width;
76 padding-left: 18px;
77 margin-top: 0 !important;
78 }
79 } 22 }
80 23
81 &.processing { 24 .nav-link {
82 /deep/ .ui-progressbar-label { 25 height: 40px !important;
83 // Same color as background to hide "100%" 26 padding: 0 30px !important;
84 color: rgba(11, 204, 41, 0.16) !important; 27 font-size: 15px;
28
29 &.active {
30 border: $border-width $border-type $border-color;
31 border-bottom: none;
32 background-color: $background-color !important;
85 33
86 &::before { 34 span {
87 content: 'Processing...'; 35 border-bottom: 2px solid #F1680D;
88 color: #fff; 36 font-weight: $font-bold;
89 } 37 }
90 } 38 }
91 } 39 }
92 } 40 }
93 41
94 input { 42 .upload-video-container {
95 @include peertube-button; 43 border: $border-width $border-type $border-color;
96 @include grey-button; 44 border-top: none;
97 45
98 margin-left: 10px; 46 background-color: $background-color;
47 border-radius: 3px;
48 width: 100%;
49 height: 440px;
50 display: flex;
51 justify-content: center;
52 align-items: center;
99 } 53 }
100} 54} \ No newline at end of file
101
diff --git a/client/src/app/videos/+video-edit/video-add.component.ts b/client/src/app/videos/+video-edit/video-add.component.ts
index 651ee8dd2..64071b40c 100644
--- a/client/src/app/videos/+video-edit/video-add.component.ts
+++ b/client/src/app/videos/+video-edit/video-add.component.ts
@@ -1,251 +1,29 @@
1import { HttpEventType, HttpResponse } from '@angular/common/http' 1import { Component, ViewChild } from '@angular/core'
2import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'
3import { Router } from '@angular/router'
4import { UserService } from '@app/shared'
5import { CanComponentDeactivate } from '@app/shared/guards/can-deactivate-guard.service' 2import { CanComponentDeactivate } from '@app/shared/guards/can-deactivate-guard.service'
6import { LoadingBarService } from '@ngx-loading-bar/core' 3import { VideoImportComponent } from '@app/videos/+video-edit/video-import.component'
7import { NotificationsService } from 'angular2-notifications' 4import { VideoUploadComponent } from '@app/videos/+video-edit/video-upload.component'
8import { BytesPipe } from 'ngx-pipes'
9import { Subscription } from 'rxjs'
10import { VideoConstant, VideoPrivacy } from '../../../../../shared/models/videos'
11import { AuthService, ServerService } from '../../core'
12import { FormReactive } from '../../shared'
13import { populateAsyncUserVideoChannels } from '../../shared/misc/utils'
14import { VideoEdit } from '../../shared/video/video-edit.model'
15import { VideoService } from '../../shared/video/video.service'
16import { I18n } from '@ngx-translate/i18n-polyfill'
17import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
18import { switchMap } from 'rxjs/operators'
19import { VideoCaptionService } from '@app/shared/video-caption'
20import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model'
21 5
22@Component({ 6@Component({
23 selector: 'my-videos-add', 7 selector: 'my-videos-add',
24 templateUrl: './video-add.component.html', 8 templateUrl: './video-add.component.html',
25 styleUrls: [ 9 styleUrls: [ './video-add.component.scss' ]
26 './shared/video-edit.component.scss',
27 './video-add.component.scss'
28 ]
29}) 10})
30export class VideoAddComponent extends FormReactive implements OnInit, OnDestroy, CanComponentDeactivate { 11export class VideoAddComponent implements CanComponentDeactivate {
31 @ViewChild('videofileInput') videofileInput 12 @ViewChild('videoUpload') videoUpload: VideoUploadComponent
13 @ViewChild('videoImport') videoImport: VideoImportComponent
32 14
33 // So that it can be accessed in the template 15 secondStepType: 'upload' | 'import'
34 readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY 16 videoName: string
35 17
36 isUploadingVideo = false 18 onFirstStepDone (type: 'upload' | 'import', videoName: string) {
37 isUpdatingVideo = false 19 this.secondStepType = type
38 videoUploaded = false 20 this.videoName = videoName
39 videoUploadObservable: Subscription = null
40 videoUploadPercents = 0
41 videoUploadedIds = {
42 id: 0,
43 uuid: ''
44 }
45 videoFileName: string
46
47 userVideoChannels: { id: number, label: string, support: string }[] = []
48 userVideoQuotaUsed = 0
49 videoPrivacies: VideoConstant<string>[] = []
50 firstStepPrivacyId = 0
51 firstStepChannelId = 0
52 videoCaptions: VideoCaptionEdit[] = []
53
54 constructor (
55 protected formValidatorService: FormValidatorService,
56 private router: Router,
57 private notificationsService: NotificationsService,
58 private authService: AuthService,
59 private userService: UserService,
60 private serverService: ServerService,
61 private videoService: VideoService,
62 private loadingBar: LoadingBarService,
63 private i18n: I18n,
64 private videoCaptionService: VideoCaptionService
65 ) {
66 super()
67 }
68
69 get videoExtensions () {
70 return this.serverService.getConfig().video.file.extensions.join(',')
71 }
72
73 ngOnInit () {
74 this.buildForm({})
75
76 populateAsyncUserVideoChannels(this.authService, this.userVideoChannels)
77 .then(() => this.firstStepChannelId = this.userVideoChannels[0].id)
78
79 this.userService.getMyVideoQuotaUsed()
80 .subscribe(data => this.userVideoQuotaUsed = data.videoQuotaUsed)
81
82 this.serverService.videoPrivaciesLoaded
83 .subscribe(
84 () => {
85 this.videoPrivacies = this.serverService.getVideoPrivacies()
86
87 // Public by default
88 this.firstStepPrivacyId = VideoPrivacy.PUBLIC
89 })
90 }
91
92 ngOnDestroy () {
93 if (this.videoUploadObservable) {
94 this.videoUploadObservable.unsubscribe()
95 }
96 } 21 }
97 22
98 canDeactivate () { 23 canDeactivate () {
99 let text = '' 24 if (this.secondStepType === 'upload') return this.videoUpload.canDeactivate()
100 25 if (this.secondStepType === 'import') return this.videoImport.canDeactivate()
101 if (this.videoUploaded === true) {
102 // FIXME: cannot concatenate strings inside i18n service :/
103 text = this.i18n('Your video was uploaded in your account and is private.') +
104 this.i18n('But associated data (tags, description...) will be lost, are you sure you want to leave this page?')
105 } else {
106 text = this.i18n('Your video is not uploaded yet, are you sure you want to leave this page?')
107 }
108
109 return {
110 canDeactivate: !this.isUploadingVideo,
111 text
112 }
113 }
114
115 fileChange () {
116 this.uploadFirstStep()
117 }
118
119 checkForm () {
120 this.forceCheck()
121
122 return this.form.valid
123 }
124
125 cancelUpload () {
126 if (this.videoUploadObservable !== null) {
127 this.videoUploadObservable.unsubscribe()
128 this.isUploadingVideo = false
129 this.videoUploadPercents = 0
130 this.videoUploadObservable = null
131 this.notificationsService.info(this.i18n('Info'), this.i18n('Upload cancelled'))
132 }
133 }
134
135 uploadFirstStep () {
136 const videofile = this.videofileInput.nativeElement.files[0] as File
137 if (!videofile) return
138
139 // Cannot upload videos > 8GB for now
140 if (videofile.size > 8 * 1024 * 1024 * 1024) {
141 this.notificationsService.error(this.i18n('Error'), this.i18n('We are sorry but PeerTube cannot handle videos > 8GB'))
142 return
143 }
144
145 const videoQuota = this.authService.getUser().videoQuota
146 if (videoQuota !== -1 && (this.userVideoQuotaUsed + videofile.size) > videoQuota) {
147 const bytePipes = new BytesPipe()
148
149 const msg = this.i18n(
150 'Your video quota is exceeded with this video (video size: {{ videoSize }}, used: {{ videoQuotaUsed }}, quota: {{ videoQuota }})',
151 {
152 videoSize: bytePipes.transform(videofile.size, 0),
153 videoQuotaUsed: bytePipes.transform(this.userVideoQuotaUsed, 0),
154 videoQuota: bytePipes.transform(videoQuota, 0)
155 }
156 )
157 this.notificationsService.error(this.i18n('Error'), msg)
158 return
159 }
160
161 this.videoFileName = videofile.name
162
163 const nameWithoutExtension = videofile.name.replace(/\.[^/.]+$/, '')
164 let name: string
165
166 // If the name of the file is very small, keep the extension
167 if (nameWithoutExtension.length < 3) name = videofile.name
168 else name = nameWithoutExtension
169
170 const privacy = this.firstStepPrivacyId.toString()
171 const nsfw = false
172 const waitTranscoding = true
173 const commentsEnabled = true
174 const channelId = this.firstStepChannelId.toString()
175
176 const formData = new FormData()
177 formData.append('name', name)
178 // Put the video "private" -> we are waiting the user validation of the second step
179 formData.append('privacy', VideoPrivacy.PRIVATE.toString())
180 formData.append('nsfw', '' + nsfw)
181 formData.append('commentsEnabled', '' + commentsEnabled)
182 formData.append('waitTranscoding', '' + waitTranscoding)
183 formData.append('channelId', '' + channelId)
184 formData.append('videofile', videofile)
185
186 this.isUploadingVideo = true
187 this.form.patchValue({
188 name,
189 privacy,
190 nsfw,
191 channelId
192 })
193
194 this.videoUploadObservable = this.videoService.uploadVideo(formData).subscribe(
195 event => {
196 if (event.type === HttpEventType.UploadProgress) {
197 this.videoUploadPercents = Math.round(100 * event.loaded / event.total)
198 } else if (event instanceof HttpResponse) {
199 this.videoUploaded = true
200
201 this.videoUploadedIds = event.body.video
202
203 this.videoUploadObservable = null
204 }
205 },
206
207 err => {
208 // Reset progress
209 this.isUploadingVideo = false
210 this.videoUploadPercents = 0
211 this.videoUploadObservable = null
212 this.notificationsService.error(this.i18n('Error'), err.message)
213 }
214 )
215 }
216
217 updateSecondStep () {
218 if (this.checkForm() === false) {
219 return
220 }
221
222 const video = new VideoEdit()
223 video.patch(this.form.value)
224 video.id = this.videoUploadedIds.id
225 video.uuid = this.videoUploadedIds.uuid
226
227 this.isUpdatingVideo = true
228 this.loadingBar.start()
229 this.videoService.updateVideo(video)
230 .pipe(
231 // Then update captions
232 switchMap(() => this.videoCaptionService.updateCaptions(video.id, this.videoCaptions))
233 )
234 .subscribe(
235 () => {
236 this.isUpdatingVideo = false
237 this.isUploadingVideo = false
238 this.loadingBar.complete()
239
240 this.notificationsService.success(this.i18n('Success'), this.i18n('Video published.'))
241 this.router.navigate([ '/videos/watch', video.uuid ])
242 },
243 26
244 err => { 27 return { canDeactivate: true }
245 this.isUpdatingVideo = false
246 this.notificationsService.error(this.i18n('Error'), err.message)
247 console.error(err)
248 }
249 )
250 } 28 }
251} 29}
diff --git a/client/src/app/videos/+video-edit/video-add.module.ts b/client/src/app/videos/+video-edit/video-add.module.ts
index 1bfedf251..91f544971 100644
--- a/client/src/app/videos/+video-edit/video-add.module.ts
+++ b/client/src/app/videos/+video-edit/video-add.module.ts
@@ -5,6 +5,8 @@ import { VideoEditModule } from './shared/video-edit.module'
5import { VideoAddRoutingModule } from './video-add-routing.module' 5import { VideoAddRoutingModule } from './video-add-routing.module'
6import { VideoAddComponent } from './video-add.component' 6import { VideoAddComponent } from './video-add.component'
7import { CanDeactivateGuard } from '../../shared/guards/can-deactivate-guard.service' 7import { CanDeactivateGuard } from '../../shared/guards/can-deactivate-guard.service'
8import { VideoUploadComponent } from '@app/videos/+video-edit/video-upload.component'
9import { VideoImportComponent } from '@app/videos/+video-edit/video-import.component'
8 10
9@NgModule({ 11@NgModule({
10 imports: [ 12 imports: [
@@ -14,7 +16,9 @@ import { CanDeactivateGuard } from '../../shared/guards/can-deactivate-guard.ser
14 ProgressBarModule 16 ProgressBarModule
15 ], 17 ],
16 declarations: [ 18 declarations: [
17 VideoAddComponent 19 VideoAddComponent,
20 VideoUploadComponent,
21 VideoImportComponent
18 ], 22 ],
19 exports: [ 23 exports: [
20 VideoAddComponent 24 VideoAddComponent
diff --git a/client/src/app/videos/+video-edit/video-import.component.html b/client/src/app/videos/+video-edit/video-import.component.html
new file mode 100644
index 000000000..9d71a0717
--- /dev/null
+++ b/client/src/app/videos/+video-edit/video-import.component.html
@@ -0,0 +1,55 @@
1<div *ngIf="!hasImportedVideo" class="upload-video-container">
2 <div class="import-video">
3 <div class="icon icon-upload"></div>
4
5 <div class="form-group">
6 <label i18n for="targetUrl">URL</label>
7 <input type="text" id="targetUrl" [(ngModel)]="targetUrl" />
8 </div>
9
10 <div class="form-group">
11 <label i18n for="first-step-channel">Channel</label>
12 <div class="peertube-select-container">
13 <select id="first-step-channel" [(ngModel)]="firstStepChannelId">
14 <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option>
15 </select>
16 </div>
17 </div>
18
19 <div class="form-group">
20 <label i18n for="first-step-privacy">Privacy</label>
21 <div class="peertube-select-container">
22 <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId">
23 <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option>
24 </select>
25 </div>
26 </div>
27
28 <input
29 type="button" i18n-value value="Import"
30 [disabled]="!isTargetUrlValid() || isImportingVideo" (click)="importVideo()"
31 />
32 </div>
33</div>
34
35<div *ngIf="hasImportedVideo" class="alert alert-info" i18n>
36 Congratulations, the video behind {{ targetUrl }} will be imported! You can already add information about this video.
37</div>
38
39<!-- Hidden because we want to load the component -->
40<form [hidden]="!hasImportedVideo" novalidate [formGroup]="form">
41 <my-video-edit
42 [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [schedulePublicationPossible]="false"
43 [validationMessages]="validationMessages" [videoPrivacies]="videoPrivacies" [userVideoChannels]="userVideoChannels"
44 ></my-video-edit>
45
46 <div class="submit-container">
47 <div class="submit-button"
48 (click)="updateSecondStep()"
49 [ngClass]="{ disabled: !form.valid || isUpdatingVideo === true }"
50 >
51 <span class="icon icon-validate"></span>
52 <input type="button" i18n-value value="Update" />
53 </div>
54 </div>
55</form>
diff --git a/client/src/app/videos/+video-edit/video-import.component.scss b/client/src/app/videos/+video-edit/video-import.component.scss
new file mode 100644
index 000000000..9ada9db19
--- /dev/null
+++ b/client/src/app/videos/+video-edit/video-import.component.scss
@@ -0,0 +1,37 @@
1@import '_variables';
2@import '_mixins';
3
4$width-size: 190px;
5
6.peertube-select-container {
7 @include peertube-select-container($width-size);
8}
9
10.import-video {
11 display: flex;
12 flex-direction: column;
13 align-items: center;
14
15 .icon.icon-upload {
16 @include icon(90px);
17 margin-bottom: 25px;
18 cursor: default;
19
20 background-image: url('../../../assets/images/video/upload.svg');
21 }
22
23 input[type=text] {
24 @include peertube-input-text($width-size);
25 display: block;
26 }
27
28 input[type=button] {
29 @include peertube-button;
30 @include orange-button;
31
32 width: $width-size;
33 margin-top: 30px;
34 }
35}
36
37
diff --git a/client/src/app/videos/+video-edit/video-import.component.ts b/client/src/app/videos/+video-edit/video-import.component.ts
new file mode 100644
index 000000000..bd4482e17
--- /dev/null
+++ b/client/src/app/videos/+video-edit/video-import.component.ts
@@ -0,0 +1,161 @@
1import { Component, EventEmitter, OnInit, Output } from '@angular/core'
2import { Router } from '@angular/router'
3import { CanComponentDeactivate } from '@app/shared/guards/can-deactivate-guard.service'
4import { NotificationsService } from 'angular2-notifications'
5import { VideoConstant, VideoPrivacy, VideoUpdate } from '../../../../../shared/models/videos'
6import { AuthService, ServerService } from '../../core'
7import { FormReactive } from '../../shared'
8import { populateAsyncUserVideoChannels } from '../../shared/misc/utils'
9import { VideoService } from '../../shared/video/video.service'
10import { I18n } from '@ngx-translate/i18n-polyfill'
11import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
12import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model'
13import { VideoImportService } from '@app/shared/video-import'
14import { VideoEdit } from '@app/shared/video/video-edit.model'
15import { switchMap } from 'rxjs/operators'
16import { LoadingBarService } from '@ngx-loading-bar/core'
17import { VideoCaptionService } from '@app/shared/video-caption'
18
19@Component({
20 selector: 'my-video-import',
21 templateUrl: './video-import.component.html',
22 styleUrls: [
23 './shared/video-edit.component.scss',
24 './video-import.component.scss'
25 ]
26})
27export class VideoImportComponent extends FormReactive implements OnInit, CanComponentDeactivate {
28 @Output() firstStepDone = new EventEmitter<string>()
29
30 targetUrl = ''
31 videoFileName: string
32
33 isImportingVideo = false
34 hasImportedVideo = false
35 isUpdatingVideo = false
36
37 userVideoChannels: { id: number, label: string, support: string }[] = []
38 videoPrivacies: VideoConstant<string>[] = []
39 videoCaptions: VideoCaptionEdit[] = []
40
41 firstStepPrivacyId = 0
42 firstStepChannelId = 0
43 video: VideoEdit
44
45 constructor (
46 protected formValidatorService: FormValidatorService,
47 private router: Router,
48 private loadingBar: LoadingBarService,
49 private notificationsService: NotificationsService,
50 private authService: AuthService,
51 private serverService: ServerService,
52 private videoService: VideoService,
53 private videoImportService: VideoImportService,
54 private videoCaptionService: VideoCaptionService,
55 private i18n: I18n
56 ) {
57 super()
58 }
59
60 ngOnInit () {
61 this.buildForm({})
62
63 populateAsyncUserVideoChannels(this.authService, this.userVideoChannels)
64 .then(() => this.firstStepChannelId = this.userVideoChannels[ 0 ].id)
65
66 this.serverService.videoPrivaciesLoaded
67 .subscribe(
68 () => {
69 this.videoPrivacies = this.serverService.getVideoPrivacies()
70
71 // Private by default
72 this.firstStepPrivacyId = VideoPrivacy.PRIVATE
73 })
74 }
75
76 canDeactivate () {
77 return { canDeactivate: true }
78 }
79
80 checkForm () {
81 this.forceCheck()
82
83 return this.form.valid
84 }
85
86 isTargetUrlValid () {
87 return this.targetUrl && this.targetUrl.match(/https?:\/\//)
88 }
89
90 importVideo () {
91 this.isImportingVideo = true
92
93 const videoUpdate: VideoUpdate = {
94 privacy: this.firstStepPrivacyId,
95 waitTranscoding: false,
96 commentsEnabled: true,
97 channelId: this.firstStepChannelId
98 }
99
100 this.videoImportService.importVideo(this.targetUrl, videoUpdate).subscribe(
101 res => {
102 this.firstStepDone.emit(res.video.name)
103 this.isImportingVideo = false
104 this.hasImportedVideo = true
105
106 this.video = new VideoEdit(Object.assign(res.video, {
107 commentsEnabled: videoUpdate.commentsEnabled,
108 support: null,
109 thumbnailUrl: null,
110 previewUrl: null
111 }))
112 this.hydrateFormFromVideo()
113 },
114
115 err => {
116 this.isImportingVideo = false
117 this.notificationsService.error(this.i18n('Error'), err.message)
118 }
119 )
120 }
121
122 updateSecondStep () {
123 if (this.checkForm() === false) {
124 return
125 }
126
127 this.video.patch(this.form.value)
128
129 this.loadingBar.start()
130 this.isUpdatingVideo = true
131
132 // Update the video
133 this.videoService.updateVideo(this.video)
134 .pipe(
135 // Then update captions
136 switchMap(() => this.videoCaptionService.updateCaptions(this.video.id, this.videoCaptions))
137 )
138 .subscribe(
139 () => {
140 this.isUpdatingVideo = false
141 this.loadingBar.complete()
142 this.notificationsService.success(this.i18n('Success'), this.i18n('Video to import updated.'))
143
144 // TODO: route to imports list
145 // this.router.navigate([ '/videos/watch', this.video.uuid ])
146 },
147
148 err => {
149 this.loadingBar.complete()
150 this.isUpdatingVideo = false
151 this.notificationsService.error(this.i18n('Error'), err.message)
152 console.error(err)
153 }
154 )
155
156 }
157
158 private hydrateFormFromVideo () {
159 this.form.patchValue(this.video.toFormPatch())
160 }
161}
diff --git a/client/src/app/videos/+video-edit/video-update.component.ts b/client/src/app/videos/+video-edit/video-update.component.ts
index 798c48f3c..0c60e3439 100644
--- a/client/src/app/videos/+video-edit/video-update.component.ts
+++ b/client/src/app/videos/+video-edit/video-update.component.ts
@@ -126,7 +126,6 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
126 console.error(err) 126 console.error(err)
127 } 127 }
128 ) 128 )
129
130 } 129 }
131 130
132 private hydrateFormFromVideo () { 131 private hydrateFormFromVideo () {
diff --git a/client/src/app/videos/+video-edit/video-upload.component.html b/client/src/app/videos/+video-edit/video-upload.component.html
new file mode 100644
index 000000000..8c0723155
--- /dev/null
+++ b/client/src/app/videos/+video-edit/video-upload.component.html
@@ -0,0 +1,58 @@
1<div *ngIf="!isUploadingVideo" class="upload-video-container">
2 <div class="upload-video">
3 <div class="icon icon-upload"></div>
4
5 <div class="button-file">
6 <span i18n>Select the file to upload</span>
7 <input #videofileInput type="file" name="videofile" id="videofile" [accept]="videoExtensions" (change)="fileChange()" />
8 </div>
9 <span class="button-file-extension">(.mp4, .webm, .ogv)</span>
10
11 <div class="form-group form-group-channel">
12 <label i18n for="first-step-channel">Channel</label>
13 <div class="peertube-select-container">
14 <select id="first-step-channel" [(ngModel)]="firstStepChannelId">
15 <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option>
16 </select>
17 </div>
18 </div>
19
20 <div class="form-group">
21 <label i18n for="first-step-privacy">Privacy</label>
22 <div class="peertube-select-container">
23 <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId">
24 <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option>
25 <option [value]="SPECIAL_SCHEDULED_PRIVACY">Scheduled</option>
26 </select>
27 </div>
28 </div>
29 </div>
30</div>
31
32<div *ngIf="isUploadingVideo" class="upload-progress-cancel">
33 <p-progressBar
34 [value]="videoUploadPercents"
35 [ngClass]="{ processing: videoUploadPercents === 100 && videoUploaded === false }"
36 ></p-progressBar>
37 <input *ngIf="videoUploaded === false" type="button" value="Cancel" (click)="cancelUpload()" />
38</div>
39
40<!-- Hidden because we want to load the component -->
41<form [hidden]="!isUploadingVideo" novalidate [formGroup]="form">
42 <my-video-edit
43 [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions"
44 [validationMessages]="validationMessages" [videoPrivacies]="videoPrivacies" [userVideoChannels]="userVideoChannels"
45 ></my-video-edit>
46
47 <div class="submit-container">
48 <div i18n *ngIf="videoUploaded === false" class="message-submit">Publish will be available when upload is finished</div>
49
50 <div class="submit-button"
51 (click)="updateSecondStep()"
52 [ngClass]="{ disabled: !form.valid || isUpdatingVideo === true || videoUploaded !== true }"
53 >
54 <span class="icon icon-validate"></span>
55 <input type="button" i18n-value value="Publish" />
56 </div>
57 </div>
58</form> \ No newline at end of file
diff --git a/client/src/app/videos/+video-edit/video-upload.component.scss b/client/src/app/videos/+video-edit/video-upload.component.scss
new file mode 100644
index 000000000..015835672
--- /dev/null
+++ b/client/src/app/videos/+video-edit/video-upload.component.scss
@@ -0,0 +1,85 @@
1@import '_variables';
2@import '_mixins';
3
4.peertube-select-container {
5 @include peertube-select-container(190px);
6}
7
8.upload-video {
9 display: flex;
10 flex-direction: column;
11 align-items: center;
12
13 .form-group-channel {
14 margin-bottom: 20px;
15 margin-top: 35px;
16 }
17
18 .icon.icon-upload {
19 @include icon(90px);
20 margin-bottom: 25px;
21 cursor: default;
22
23 background-image: url('../../../assets/images/video/upload.svg');
24 }
25
26 .button-file {
27 @include peertube-button-file(auto);
28
29 min-width: 190px;
30 }
31
32 .button-file-extension {
33 display: block;
34 font-size: 12px;
35 margin-top: 5px;
36 }
37}
38
39.upload-progress-cancel {
40 display: flex;
41 margin-top: 25px;
42 margin-bottom: 40px;
43
44 p-progressBar {
45 flex-grow: 1;
46
47 /deep/ .ui-progressbar {
48 font-size: 15px !important;
49 color: #fff !important;
50 height: 30px !important;
51 line-height: 30px !important;
52 border-radius: 3px !important;
53 background-color: rgba(11, 204, 41, 0.16) !important;
54
55 .ui-progressbar-value {
56 background-color: #0BCC29 !important;
57 }
58
59 .ui-progressbar-label {
60 text-align: left;
61 padding-left: 18px;
62 margin-top: 0 !important;
63 }
64 }
65
66 &.processing {
67 /deep/ .ui-progressbar-label {
68 // Same color as background to hide "100%"
69 color: rgba(11, 204, 41, 0.16) !important;
70
71 &::before {
72 content: 'Processing...';
73 color: #fff;
74 }
75 }
76 }
77 }
78
79 input {
80 @include peertube-button;
81 @include grey-button;
82
83 margin-left: 10px;
84 }
85} \ No newline at end of file
diff --git a/client/src/app/videos/+video-edit/video-upload.component.ts b/client/src/app/videos/+video-edit/video-upload.component.ts
new file mode 100644
index 000000000..e6c391d2f
--- /dev/null
+++ b/client/src/app/videos/+video-edit/video-upload.component.ts
@@ -0,0 +1,251 @@
1import { HttpEventType, HttpResponse } from '@angular/common/http'
2import { Component, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'
3import { Router } from '@angular/router'
4import { UserService } from '@app/shared'
5import { CanComponentDeactivate } from '@app/shared/guards/can-deactivate-guard.service'
6import { LoadingBarService } from '@ngx-loading-bar/core'
7import { NotificationsService } from 'angular2-notifications'
8import { BytesPipe } from 'ngx-pipes'
9import { Subscription } from 'rxjs'
10import { VideoConstant, VideoPrivacy } from '../../../../../shared/models/videos'
11import { AuthService, ServerService } from '../../core'
12import { FormReactive } from '../../shared'
13import { populateAsyncUserVideoChannels } from '../../shared/misc/utils'
14import { VideoEdit } from '../../shared/video/video-edit.model'
15import { VideoService } from '../../shared/video/video.service'
16import { I18n } from '@ngx-translate/i18n-polyfill'
17import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
18import { switchMap } from 'rxjs/operators'
19import { VideoCaptionService } from '@app/shared/video-caption'
20import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model'
21
22@Component({
23 selector: 'my-video-upload',
24 templateUrl: './video-upload.component.html',
25 styleUrls: [
26 './shared/video-edit.component.scss',
27 './video-upload.component.scss'
28 ]
29})
30export class VideoUploadComponent extends FormReactive implements OnInit, OnDestroy, CanComponentDeactivate {
31 @Output() firstStepDone = new EventEmitter<string>()
32 @ViewChild('videofileInput') videofileInput
33
34 // So that it can be accessed in the template
35 readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY
36
37 isUploadingVideo = false
38 isUpdatingVideo = false
39 videoUploaded = false
40 videoUploadObservable: Subscription = null
41 videoUploadPercents = 0
42 videoUploadedIds = {
43 id: 0,
44 uuid: ''
45 }
46
47 userVideoChannels: { id: number, label: string, support: string }[] = []
48 userVideoQuotaUsed = 0
49 videoPrivacies: VideoConstant<string>[] = []
50 firstStepPrivacyId = 0
51 firstStepChannelId = 0
52 videoCaptions: VideoCaptionEdit[] = []
53
54 constructor (
55 protected formValidatorService: FormValidatorService,
56 private router: Router,
57 private notificationsService: NotificationsService,
58 private authService: AuthService,
59 private userService: UserService,
60 private serverService: ServerService,
61 private videoService: VideoService,
62 private loadingBar: LoadingBarService,
63 private i18n: I18n,
64 private videoCaptionService: VideoCaptionService
65 ) {
66 super()
67 }
68
69 get videoExtensions () {
70 return this.serverService.getConfig().video.file.extensions.join(',')
71 }
72
73 ngOnInit () {
74 this.buildForm({})
75
76 populateAsyncUserVideoChannels(this.authService, this.userVideoChannels)
77 .then(() => this.firstStepChannelId = this.userVideoChannels[0].id)
78
79 this.userService.getMyVideoQuotaUsed()
80 .subscribe(data => this.userVideoQuotaUsed = data.videoQuotaUsed)
81
82 this.serverService.videoPrivaciesLoaded
83 .subscribe(
84 () => {
85 this.videoPrivacies = this.serverService.getVideoPrivacies()
86
87 // Public by default
88 this.firstStepPrivacyId = VideoPrivacy.PUBLIC
89 })
90 }
91
92 ngOnDestroy () {
93 if (this.videoUploadObservable) {
94 this.videoUploadObservable.unsubscribe()
95 }
96 }
97
98 canDeactivate () {
99 let text = ''
100
101 if (this.videoUploaded === true) {
102 // FIXME: cannot concatenate strings inside i18n service :/
103 text = this.i18n('Your video was uploaded in your account and is private.') +
104 this.i18n('But associated data (tags, description...) will be lost, are you sure you want to leave this page?')
105 } else {
106 text = this.i18n('Your video is not uploaded yet, are you sure you want to leave this page?')
107 }
108
109 return {
110 canDeactivate: !this.isUploadingVideo,
111 text
112 }
113 }
114
115 fileChange () {
116 this.uploadFirstStep()
117 }
118
119 checkForm () {
120 this.forceCheck()
121
122 return this.form.valid
123 }
124
125 cancelUpload () {
126 if (this.videoUploadObservable !== null) {
127 this.videoUploadObservable.unsubscribe()
128 this.isUploadingVideo = false
129 this.videoUploadPercents = 0
130 this.videoUploadObservable = null
131 this.notificationsService.info(this.i18n('Info'), this.i18n('Upload cancelled'))
132 }
133 }
134
135 uploadFirstStep () {
136 const videofile = this.videofileInput.nativeElement.files[0] as File
137 if (!videofile) return
138
139 // Cannot upload videos > 8GB for now
140 if (videofile.size > 8 * 1024 * 1024 * 1024) {
141 this.notificationsService.error(this.i18n('Error'), this.i18n('We are sorry but PeerTube cannot handle videos > 8GB'))
142 return
143 }
144
145 const videoQuota = this.authService.getUser().videoQuota
146 if (videoQuota !== -1 && (this.userVideoQuotaUsed + videofile.size) > videoQuota) {
147 const bytePipes = new BytesPipe()
148
149 const msg = this.i18n(
150 'Your video quota is exceeded with this video (video size: {{ videoSize }}, used: {{ videoQuotaUsed }}, quota: {{ videoQuota }})',
151 {
152 videoSize: bytePipes.transform(videofile.size, 0),
153 videoQuotaUsed: bytePipes.transform(this.userVideoQuotaUsed, 0),
154 videoQuota: bytePipes.transform(videoQuota, 0)
155 }
156 )
157 this.notificationsService.error(this.i18n('Error'), msg)
158 return
159 }
160
161 const nameWithoutExtension = videofile.name.replace(/\.[^/.]+$/, '')
162 let name: string
163
164 // If the name of the file is very small, keep the extension
165 if (nameWithoutExtension.length < 3) name = videofile.name
166 else name = nameWithoutExtension
167
168 const privacy = this.firstStepPrivacyId.toString()
169 const nsfw = false
170 const waitTranscoding = true
171 const commentsEnabled = true
172 const channelId = this.firstStepChannelId.toString()
173
174 const formData = new FormData()
175 formData.append('name', name)
176 // Put the video "private" -> we are waiting the user validation of the second step
177 formData.append('privacy', VideoPrivacy.PRIVATE.toString())
178 formData.append('nsfw', '' + nsfw)
179 formData.append('commentsEnabled', '' + commentsEnabled)
180 formData.append('waitTranscoding', '' + waitTranscoding)
181 formData.append('channelId', '' + channelId)
182 formData.append('videofile', videofile)
183
184 this.isUploadingVideo = true
185 this.firstStepDone.emit(name)
186
187 this.form.patchValue({
188 name,
189 privacy,
190 nsfw,
191 channelId
192 })
193
194 this.videoUploadObservable = this.videoService.uploadVideo(formData).subscribe(
195 event => {
196 if (event.type === HttpEventType.UploadProgress) {
197 this.videoUploadPercents = Math.round(100 * event.loaded / event.total)
198 } else if (event instanceof HttpResponse) {
199 this.videoUploaded = true
200
201 this.videoUploadedIds = event.body.video
202
203 this.videoUploadObservable = null
204 }
205 },
206
207 err => {
208 // Reset progress
209 this.isUploadingVideo = false
210 this.videoUploadPercents = 0
211 this.videoUploadObservable = null
212 this.notificationsService.error(this.i18n('Error'), err.message)
213 }
214 )
215 }
216
217 updateSecondStep () {
218 if (this.checkForm() === false) {
219 return
220 }
221
222 const video = new VideoEdit()
223 video.patch(this.form.value)
224 video.id = this.videoUploadedIds.id
225 video.uuid = this.videoUploadedIds.uuid
226
227 this.isUpdatingVideo = true
228 this.loadingBar.start()
229 this.videoService.updateVideo(video)
230 .pipe(
231 // Then update captions
232 switchMap(() => this.videoCaptionService.updateCaptions(video.id, this.videoCaptions))
233 )
234 .subscribe(
235 () => {
236 this.isUpdatingVideo = false
237 this.isUploadingVideo = false
238 this.loadingBar.complete()
239
240 this.notificationsService.success(this.i18n('Success'), this.i18n('Video published.'))
241 this.router.navigate([ '/videos/watch', video.uuid ])
242 },
243
244 err => {
245 this.isUpdatingVideo = false
246 this.notificationsService.error(this.i18n('Error'), err.message)
247 console.error(err)
248 }
249 )
250 }
251}
diff --git a/config/default.yaml b/config/default.yaml
index 3b877476d..722b33db3 100644
--- a/config/default.yaml
+++ b/config/default.yaml
@@ -92,6 +92,12 @@ transcoding:
92 720p: false 92 720p: false
93 1080p: false 93 1080p: false
94 94
95import:
96 # Add ability for your users to import remote videos (from YouTube, torrent...)
97 videos:
98 http: # Classic HTTP or all sites supported by youtube-dl https://rg3.github.io/youtube-dl/supportedsites.html
99 enabled: true
100
95instance: 101instance:
96 name: 'PeerTube' 102 name: 'PeerTube'
97 short_description: 'PeerTube, a federated (ActivityPub) video streaming platform using P2P (BitTorrent) directly in the web browser with WebTorrent and Angular.' 103 short_description: 'PeerTube, a federated (ActivityPub) video streaming platform using P2P (BitTorrent) directly in the web browser with WebTorrent and Angular.'
diff --git a/package.json b/package.json
index cd0a3c5b8..8627fe9ba 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
16 "type": "git", 16 "type": "git",
17 "url": "git://github.com/Chocobozzz/PeerTube.git" 17 "url": "git://github.com/Chocobozzz/PeerTube.git"
18 }, 18 },
19 "typings": "*.d.ts",
19 "scripts": { 20 "scripts": {
20 "e2e": "scripty", 21 "e2e": "scripty",
21 "build": "SCRIPTY_PARALLEL=true scripty", 22 "build": "SCRIPTY_PARALLEL=true scripty",
@@ -132,7 +133,8 @@
132 "validator": "^10.2.0", 133 "validator": "^10.2.0",
133 "webfinger.js": "^2.6.6", 134 "webfinger.js": "^2.6.6",
134 "winston": "3.0.0", 135 "winston": "3.0.0",
135 "ws": "^5.0.0" 136 "ws": "^5.0.0",
137 "youtube-dl": "^1.12.2"
136 }, 138 },
137 "devDependencies": { 139 "devDependencies": {
138 "@types/async": "^2.0.40", 140 "@types/async": "^2.0.40",
@@ -184,8 +186,7 @@
184 "tslint-config-standard": "^7.0.0", 186 "tslint-config-standard": "^7.0.0",
185 "typescript": "^2.5.2", 187 "typescript": "^2.5.2",
186 "webtorrent": "^0.100.0", 188 "webtorrent": "^0.100.0",
187 "xliff": "^3.0.1", 189 "xliff": "^3.0.1"
188 "youtube-dl": "^1.12.2"
189 }, 190 },
190 "scripty": { 191 "scripty": {
191 "silent": true 192 "silent": true
diff --git a/server/controllers/api/videos/import.ts b/server/controllers/api/videos/import.ts
new file mode 100644
index 000000000..9761cdbcf
--- /dev/null
+++ b/server/controllers/api/videos/import.ts
@@ -0,0 +1,151 @@
1import * as express from 'express'
2import { auditLoggerFactory } from '../../../helpers/audit-logger'
3import {
4 asyncMiddleware,
5 asyncRetryTransactionMiddleware,
6 authenticate,
7 videoImportAddValidator,
8 videoImportDeleteValidator
9} from '../../../middlewares'
10import { CONFIG, IMAGE_MIMETYPE_EXT, PREVIEWS_SIZE, sequelizeTypescript, THUMBNAILS_SIZE } from '../../../initializers'
11import { getYoutubeDLInfo, YoutubeDLInfo } from '../../../helpers/youtube-dl'
12import { createReqFiles } from '../../../helpers/express-utils'
13import { logger } from '../../../helpers/logger'
14import { VideoImportCreate, VideoImportState, VideoPrivacy, VideoState } from '../../../../shared'
15import { VideoModel } from '../../../models/video/video'
16import { getVideoActivityPubUrl } from '../../../lib/activitypub'
17import { TagModel } from '../../../models/video/tag'
18import { VideoImportModel } from '../../../models/video/video-import'
19import { JobQueue } from '../../../lib/job-queue/job-queue'
20import { processImage } from '../../../helpers/image-utils'
21import { join } from 'path'
22
23const auditLogger = auditLoggerFactory('video-imports')
24const videoImportsRouter = express.Router()
25
26const reqVideoFileImport = createReqFiles(
27 [ 'thumbnailfile', 'previewfile' ],
28 IMAGE_MIMETYPE_EXT,
29 {
30 thumbnailfile: CONFIG.STORAGE.THUMBNAILS_DIR,
31 previewfile: CONFIG.STORAGE.PREVIEWS_DIR
32 }
33)
34
35videoImportsRouter.post('/imports',
36 authenticate,
37 reqVideoFileImport,
38 asyncMiddleware(videoImportAddValidator),
39 asyncRetryTransactionMiddleware(addVideoImport)
40)
41
42videoImportsRouter.delete('/imports/:id',
43 authenticate,
44 videoImportDeleteValidator,
45 asyncRetryTransactionMiddleware(deleteVideoImport)
46)
47
48// ---------------------------------------------------------------------------
49
50export {
51 videoImportsRouter
52}
53
54// ---------------------------------------------------------------------------
55
56async function addVideoImport (req: express.Request, res: express.Response) {
57 const body: VideoImportCreate = req.body
58 const targetUrl = body.targetUrl
59
60 let youtubeDLInfo: YoutubeDLInfo
61 try {
62 youtubeDLInfo = await getYoutubeDLInfo(targetUrl)
63 } catch (err) {
64 logger.info('Cannot fetch information from import for URL %s.', targetUrl, { err })
65
66 return res.status(400).json({
67 error: 'Cannot fetch remote information of this URL.'
68 }).end()
69 }
70
71 // Create video DB object
72 const videoData = {
73 name: body.name || youtubeDLInfo.name,
74 remote: false,
75 category: body.category || youtubeDLInfo.category,
76 licence: body.licence || youtubeDLInfo.licence,
77 language: undefined,
78 commentsEnabled: body.commentsEnabled || true,
79 waitTranscoding: body.waitTranscoding || false,
80 state: VideoState.TO_IMPORT,
81 nsfw: body.nsfw || youtubeDLInfo.nsfw || false,
82 description: body.description || youtubeDLInfo.description,
83 support: body.support || null,
84 privacy: body.privacy || VideoPrivacy.PRIVATE,
85 duration: 0, // duration will be set by the import job
86 channelId: res.locals.videoChannel.id
87 }
88 const video = new VideoModel(videoData)
89 video.url = getVideoActivityPubUrl(video)
90
91 // Process thumbnail file?
92 const thumbnailField = req.files['thumbnailfile']
93 let downloadThumbnail = true
94 if (thumbnailField) {
95 const thumbnailPhysicalFile = thumbnailField[ 0 ]
96 await processImage(thumbnailPhysicalFile, join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName()), THUMBNAILS_SIZE)
97 downloadThumbnail = false
98 }
99
100 // Process preview file?
101 const previewField = req.files['previewfile']
102 let downloadPreview = true
103 if (previewField) {
104 const previewPhysicalFile = previewField[0]
105 await processImage(previewPhysicalFile, join(CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName()), PREVIEWS_SIZE)
106 downloadPreview = false
107 }
108
109 const videoImport: VideoImportModel = await sequelizeTypescript.transaction(async t => {
110 const sequelizeOptions = { transaction: t }
111
112 // Save video object in database
113 const videoCreated = await video.save(sequelizeOptions)
114 videoCreated.VideoChannel = res.locals.videoChannel
115
116 // Set tags to the video
117 if (youtubeDLInfo.tags !== undefined) {
118 const tagInstances = await TagModel.findOrCreateTags(youtubeDLInfo.tags, t)
119
120 await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
121 videoCreated.Tags = tagInstances
122 }
123
124 // Create video import object in database
125 const videoImport = await VideoImportModel.create({
126 targetUrl,
127 state: VideoImportState.PENDING,
128 videoId: videoCreated.id
129 }, sequelizeOptions)
130
131 videoImport.Video = videoCreated
132
133 return videoImport
134 })
135
136 // Create job to import the video
137 const payload = {
138 type: 'youtube-dl' as 'youtube-dl',
139 videoImportId: videoImport.id,
140 thumbnailUrl: youtubeDLInfo.thumbnailUrl,
141 downloadThumbnail,
142 downloadPreview
143 }
144 await JobQueue.Instance.createJob({ type: 'video-import', payload })
145
146 return res.json(videoImport.toFormattedJSON())
147}
148
149async function deleteVideoImport (req: express.Request, res: express.Response) {
150 // TODO: delete video import
151}
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts
index e396ee6be..c9365da08 100644
--- a/server/controllers/api/videos/index.ts
+++ b/server/controllers/api/videos/index.ts
@@ -54,6 +54,7 @@ import { VideoFilter } from '../../../../shared/models/videos/video-query.type'
54import { createReqFiles, buildNSFWFilter } from '../../../helpers/express-utils' 54import { createReqFiles, buildNSFWFilter } from '../../../helpers/express-utils'
55import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' 55import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
56import { videoCaptionsRouter } from './captions' 56import { videoCaptionsRouter } from './captions'
57import { videoImportsRouter } from './import'
57 58
58const auditLogger = auditLoggerFactory('videos') 59const auditLogger = auditLoggerFactory('videos')
59const videosRouter = express.Router() 60const videosRouter = express.Router()
@@ -81,6 +82,7 @@ videosRouter.use('/', blacklistRouter)
81videosRouter.use('/', rateVideoRouter) 82videosRouter.use('/', rateVideoRouter)
82videosRouter.use('/', videoCommentRouter) 83videosRouter.use('/', videoCommentRouter)
83videosRouter.use('/', videoCaptionsRouter) 84videosRouter.use('/', videoCaptionsRouter)
85videosRouter.use('/', videoImportsRouter)
84 86
85videosRouter.get('/categories', listVideoCategories) 87videosRouter.get('/categories', listVideoCategories)
86videosRouter.get('/licences', listVideoLicences) 88videosRouter.get('/licences', listVideoLicences)
@@ -160,7 +162,6 @@ async function addVideo (req: express.Request, res: express.Response) {
160 const videoData = { 162 const videoData = {
161 name: videoInfo.name, 163 name: videoInfo.name,
162 remote: false, 164 remote: false,
163 extname: extname(videoPhysicalFile.filename),
164 category: videoInfo.category, 165 category: videoInfo.category,
165 licence: videoInfo.licence, 166 licence: videoInfo.licence,
166 language: videoInfo.language, 167 language: videoInfo.language,
diff --git a/server/helpers/custom-validators/activitypub/videos.ts b/server/helpers/custom-validators/activitypub/videos.ts
index d97bbd2a9..c6a350236 100644
--- a/server/helpers/custom-validators/activitypub/videos.ts
+++ b/server/helpers/custom-validators/activitypub/videos.ts
@@ -45,7 +45,7 @@ function isActivityPubVideoDurationValid (value: string) {
45} 45}
46 46
47function sanitizeAndCheckVideoTorrentObject (video: any) { 47function sanitizeAndCheckVideoTorrentObject (video: any) {
48 if (video.type !== 'Video') return false 48 if (!video || video.type !== 'Video') return false
49 49
50 if (!setValidRemoteTags(video)) return false 50 if (!setValidRemoteTags(video)) return false
51 if (!setValidRemoteVideoUrls(video)) return false 51 if (!setValidRemoteVideoUrls(video)) return false
diff --git a/server/helpers/custom-validators/video-imports.ts b/server/helpers/custom-validators/video-imports.ts
new file mode 100644
index 000000000..36c0559fd
--- /dev/null
+++ b/server/helpers/custom-validators/video-imports.ts
@@ -0,0 +1,30 @@
1import 'express-validator'
2import 'multer'
3import * as validator from 'validator'
4import { CONSTRAINTS_FIELDS, VIDEO_IMPORT_STATES } from '../../initializers'
5import { exists } from './misc'
6
7function isVideoImportTargetUrlValid (url: string) {
8 const isURLOptions = {
9 require_host: true,
10 require_tld: true,
11 require_protocol: true,
12 require_valid_protocol: true,
13 protocols: [ 'http', 'https' ]
14 }
15
16 return exists(url) &&
17 validator.isURL('' + url, isURLOptions) &&
18 validator.isLength('' + url, CONSTRAINTS_FIELDS.VIDEO_IMPORTS.URL)
19}
20
21function isVideoImportStateValid (value: any) {
22 return exists(value) && VIDEO_IMPORT_STATES[ value ] !== undefined
23}
24
25// ---------------------------------------------------------------------------
26
27export {
28 isVideoImportStateValid,
29 isVideoImportTargetUrlValid
30}
diff --git a/server/helpers/logger.ts b/server/helpers/logger.ts
index 04a19a9c6..480c5b49e 100644
--- a/server/helpers/logger.ts
+++ b/server/helpers/logger.ts
@@ -22,7 +22,7 @@ function loggerReplacer (key: string, value: any) {
22} 22}
23 23
24const consoleLoggerFormat = winston.format.printf(info => { 24const consoleLoggerFormat = winston.format.printf(info => {
25 let additionalInfos = JSON.stringify(info.meta, loggerReplacer, 2) 25 let additionalInfos = JSON.stringify(info.meta || info.err, loggerReplacer, 2)
26 if (additionalInfos === undefined || additionalInfos === '{}') additionalInfos = '' 26 if (additionalInfos === undefined || additionalInfos === '{}') additionalInfos = ''
27 else additionalInfos = ' ' + additionalInfos 27 else additionalInfos = ' ' + additionalInfos
28 28
diff --git a/server/helpers/youtube-dl.ts b/server/helpers/youtube-dl.ts
new file mode 100644
index 000000000..74d3e213b
--- /dev/null
+++ b/server/helpers/youtube-dl.ts
@@ -0,0 +1,142 @@
1import * as youtubeDL from 'youtube-dl'
2import { truncate } from 'lodash'
3import { CONFIG, CONSTRAINTS_FIELDS, VIDEO_CATEGORIES } from '../initializers'
4import { join } from 'path'
5import * as crypto from 'crypto'
6import { logger } from './logger'
7
8export type YoutubeDLInfo = {
9 name: string
10 description: string
11 category: number
12 licence: number
13 nsfw: boolean
14 tags: string[]
15 thumbnailUrl: string
16}
17
18function getYoutubeDLInfo (url: string): Promise<YoutubeDLInfo> {
19 return new Promise<YoutubeDLInfo>((res, rej) => {
20 const options = [ '-j', '--flat-playlist' ]
21
22 youtubeDL.getInfo(url, options, (err, info) => {
23 if (err) return rej(err)
24
25 const obj = normalizeObject(info)
26
27 return res(buildVideoInfo(obj))
28 })
29 })
30}
31
32function downloadYoutubeDLVideo (url: string) {
33 const hash = crypto.createHash('sha256').update(url).digest('base64')
34 const path = join(CONFIG.STORAGE.VIDEOS_DIR, hash + '-import.mp4')
35
36 logger.info('Importing video %s', url)
37
38 const options = [ '-f', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best', '-o', path ]
39
40 return new Promise<string>((res, rej) => {
41 youtubeDL.exec(url, options, async (err, output) => {
42 if (err) return rej(err)
43
44 return res(path)
45 })
46 })
47}
48
49// ---------------------------------------------------------------------------
50
51export {
52 downloadYoutubeDLVideo,
53 getYoutubeDLInfo
54}
55
56// ---------------------------------------------------------------------------
57
58function normalizeObject (obj: any) {
59 const newObj: any = {}
60
61 for (const key of Object.keys(obj)) {
62 // Deprecated key
63 if (key === 'resolution') continue
64
65 const value = obj[key]
66
67 if (typeof value === 'string') {
68 newObj[key] = value.normalize()
69 } else {
70 newObj[key] = value
71 }
72 }
73
74 return newObj
75}
76
77function buildVideoInfo (obj: any) {
78 return {
79 name: titleTruncation(obj.title),
80 description: descriptionTruncation(obj.description),
81 category: getCategory(obj.categories),
82 licence: getLicence(obj.license),
83 nsfw: isNSFW(obj),
84 tags: getTags(obj.tags),
85 thumbnailUrl: obj.thumbnail || undefined
86 }
87}
88
89function titleTruncation (title: string) {
90 return truncate(title, {
91 'length': CONSTRAINTS_FIELDS.VIDEOS.NAME.max,
92 'separator': /,? +/,
93 'omission': ' […]'
94 })
95}
96
97function descriptionTruncation (description: string) {
98 if (!description) return undefined
99
100 return truncate(description, {
101 'length': CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max,
102 'separator': /,? +/,
103 'omission': ' […]'
104 })
105}
106
107function isNSFW (info: any) {
108 return info.age_limit && info.age_limit >= 16
109}
110
111function getTags (tags: any) {
112 if (Array.isArray(tags) === false) return []
113
114 return tags
115 .filter(t => t.length < CONSTRAINTS_FIELDS.VIDEOS.TAG.max && t.length > CONSTRAINTS_FIELDS.VIDEOS.TAG.min)
116 .map(t => t.normalize())
117 .slice(0, 5)
118}
119
120function getLicence (licence: string) {
121 if (!licence) return undefined
122
123 if (licence.indexOf('Creative Commons Attribution licence') !== -1) return 1
124
125 return undefined
126}
127
128function getCategory (categories: string[]) {
129 if (!categories) return undefined
130
131 const categoryString = categories[0]
132 if (!categoryString || typeof categoryString !== 'string') return undefined
133
134 if (categoryString === 'News & Politics') return 11
135
136 for (const key of Object.keys(VIDEO_CATEGORIES)) {
137 const category = VIDEO_CATEGORIES[key]
138 if (categoryString.toLowerCase() === category.toLowerCase()) return parseInt(key, 10)
139 }
140
141 return undefined
142}
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index bec343bb7..fdd772d84 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -8,6 +8,7 @@ import { VideoPrivacy } from '../../shared/models/videos'
8import { buildPath, isTestInstance, root, sanitizeHost, sanitizeUrl } from '../helpers/core-utils' 8import { buildPath, isTestInstance, root, sanitizeHost, sanitizeUrl } from '../helpers/core-utils'
9import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type' 9import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type'
10import { invert } from 'lodash' 10import { invert } from 'lodash'
11import { VideoImportState } from '../../shared/models/videos/video-import-state.enum'
11 12
12// Use a variable to reload the configuration if we need 13// Use a variable to reload the configuration if we need
13let config: IConfig = require('config') 14let config: IConfig = require('config')
@@ -85,6 +86,7 @@ const JOB_ATTEMPTS: { [ id in JobType ]: number } = {
85 'activitypub-follow': 5, 86 'activitypub-follow': 5,
86 'video-file-import': 1, 87 'video-file-import': 1,
87 'video-file': 1, 88 'video-file': 1,
89 'video-import': 1,
88 'email': 5 90 'email': 5
89} 91}
90const JOB_CONCURRENCY: { [ id in JobType ]: number } = { 92const JOB_CONCURRENCY: { [ id in JobType ]: number } = {
@@ -94,6 +96,7 @@ const JOB_CONCURRENCY: { [ id in JobType ]: number } = {
94 'activitypub-follow': 3, 96 'activitypub-follow': 3,
95 'video-file-import': 1, 97 'video-file-import': 1,
96 'video-file': 1, 98 'video-file': 1,
99 'video-import': 1,
97 'email': 5 100 'email': 5
98} 101}
99const BROADCAST_CONCURRENCY = 10 // How many requests in parallel we do in activitypub-http-broadcast job 102const BROADCAST_CONCURRENCY = 10 // How many requests in parallel we do in activitypub-http-broadcast job
@@ -248,6 +251,9 @@ const CONSTRAINTS_FIELDS = {
248 } 251 }
249 } 252 }
250 }, 253 },
254 VIDEO_IMPORTS: {
255 URL: { min: 3, max: 2000 } // Length
256 },
251 VIDEOS: { 257 VIDEOS: {
252 NAME: { min: 3, max: 120 }, // Length 258 NAME: { min: 3, max: 120 }, // Length
253 LANGUAGE: { min: 1, max: 10 }, // Length 259 LANGUAGE: { min: 1, max: 10 }, // Length
@@ -262,7 +268,7 @@ const CONSTRAINTS_FIELDS = {
262 }, 268 },
263 EXTNAME: [ '.mp4', '.ogv', '.webm' ], 269 EXTNAME: [ '.mp4', '.ogv', '.webm' ],
264 INFO_HASH: { min: 40, max: 40 }, // Length, info hash is 20 bytes length but we represent it in hexadecimal so 20 * 2 270 INFO_HASH: { min: 40, max: 40 }, // Length, info hash is 20 bytes length but we represent it in hexadecimal so 20 * 2
265 DURATION: { min: 1 }, // Number 271 DURATION: { min: 0 }, // Number
266 TAGS: { min: 0, max: 5 }, // Number of total tags 272 TAGS: { min: 0, max: 5 }, // Number of total tags
267 TAG: { min: 2, max: 30 }, // Length 273 TAG: { min: 2, max: 30 }, // Length
268 THUMBNAIL: { min: 2, max: 30 }, 274 THUMBNAIL: { min: 2, max: 30 },
@@ -363,7 +369,14 @@ const VIDEO_PRIVACIES = {
363 369
364const VIDEO_STATES = { 370const VIDEO_STATES = {
365 [VideoState.PUBLISHED]: 'Published', 371 [VideoState.PUBLISHED]: 'Published',
366 [VideoState.TO_TRANSCODE]: 'To transcode' 372 [VideoState.TO_TRANSCODE]: 'To transcode',
373 [VideoState.TO_IMPORT]: 'To import'
374}
375
376const VIDEO_IMPORT_STATES = {
377 [VideoImportState.FAILED]: 'Failed',
378 [VideoImportState.PENDING]: 'Pending',
379 [VideoImportState.SUCCESS]: 'Success'
367} 380}
368 381
369const VIDEO_MIMETYPE_EXT = { 382const VIDEO_MIMETYPE_EXT = {
@@ -585,6 +598,7 @@ export {
585 RATES_LIMIT, 598 RATES_LIMIT,
586 VIDEO_EXT_MIMETYPE, 599 VIDEO_EXT_MIMETYPE,
587 JOB_COMPLETED_LIFETIME, 600 JOB_COMPLETED_LIFETIME,
601 VIDEO_IMPORT_STATES,
588 VIDEO_VIEW_LIFETIME, 602 VIDEO_VIEW_LIFETIME,
589 buildLanguages 603 buildLanguages
590} 604}
diff --git a/server/initializers/database.ts b/server/initializers/database.ts
index 21c083084..0be752363 100644
--- a/server/initializers/database.ts
+++ b/server/initializers/database.ts
@@ -24,6 +24,7 @@ import { VideoTagModel } from '../models/video/video-tag'
24import { CONFIG } from './constants' 24import { CONFIG } from './constants'
25import { ScheduleVideoUpdateModel } from '../models/video/schedule-video-update' 25import { ScheduleVideoUpdateModel } from '../models/video/schedule-video-update'
26import { VideoCaptionModel } from '../models/video/video-caption' 26import { VideoCaptionModel } from '../models/video/video-caption'
27import { VideoImportModel } from '../models/video/video-import'
27 28
28require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string 29require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
29 30
@@ -81,7 +82,8 @@ async function initDatabaseModels (silent: boolean) {
81 VideoTagModel, 82 VideoTagModel,
82 VideoModel, 83 VideoModel,
83 VideoCommentModel, 84 VideoCommentModel,
84 ScheduleVideoUpdateModel 85 ScheduleVideoUpdateModel,
86 VideoImportModel
85 ]) 87 ])
86 88
87 // Check extensions exist in the database 89 // Check extensions exist in the database
diff --git a/server/lib/job-queue/handlers/video-import.ts b/server/lib/job-queue/handlers/video-import.ts
new file mode 100644
index 000000000..2f219e986
--- /dev/null
+++ b/server/lib/job-queue/handlers/video-import.ts
@@ -0,0 +1,129 @@
1import * as Bull from 'bull'
2import { logger } from '../../../helpers/logger'
3import { downloadYoutubeDLVideo } from '../../../helpers/youtube-dl'
4import { VideoImportModel } from '../../../models/video/video-import'
5import { VideoImportState } from '../../../../shared/models/videos'
6import { getDurationFromVideoFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffmpeg-utils'
7import { extname, join } from 'path'
8import { VideoFileModel } from '../../../models/video/video-file'
9import { renamePromise, statPromise, unlinkPromise } from '../../../helpers/core-utils'
10import { CONFIG, sequelizeTypescript } from '../../../initializers'
11import { doRequestAndSaveToFile } from '../../../helpers/requests'
12import { VideoState } from '../../../../shared'
13import { JobQueue } from '../index'
14import { federateVideoIfNeeded } from '../../activitypub'
15
16export type VideoImportPayload = {
17 type: 'youtube-dl'
18 videoImportId: number
19 thumbnailUrl: string
20 downloadThumbnail: boolean
21 downloadPreview: boolean
22}
23
24async function processVideoImport (job: Bull.Job) {
25 const payload = job.data as VideoImportPayload
26 logger.info('Processing video import in job %d.', job.id)
27
28 const videoImport = await VideoImportModel.loadAndPopulateVideo(payload.videoImportId)
29 if (!videoImport) throw new Error('Cannot import video %s: the video import entry does not exist anymore.')
30
31 let tempVideoPath: string
32 try {
33 // Download video from youtubeDL
34 tempVideoPath = await downloadYoutubeDLVideo(videoImport.targetUrl)
35
36 // Get information about this video
37 const { videoFileResolution } = await getVideoFileResolution(tempVideoPath)
38 const fps = await getVideoFileFPS(tempVideoPath)
39 const stats = await statPromise(tempVideoPath)
40 const duration = await getDurationFromVideoFile(tempVideoPath)
41
42 // Create video file object in database
43 const videoFileData = {
44 extname: extname(tempVideoPath),
45 resolution: videoFileResolution,
46 size: stats.size,
47 fps,
48 videoId: videoImport.videoId
49 }
50 const videoFile = new VideoFileModel(videoFileData)
51
52 // Move file
53 const destination = join(CONFIG.STORAGE.VIDEOS_DIR, videoImport.Video.getVideoFilename(videoFile))
54 await renamePromise(tempVideoPath, destination)
55
56 // Process thumbnail
57 if (payload.downloadThumbnail) {
58 if (payload.thumbnailUrl) {
59 const destThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, videoImport.Video.getThumbnailName())
60 await doRequestAndSaveToFile({ method: 'GET', uri: payload.thumbnailUrl }, destThumbnailPath)
61 } else {
62 await videoImport.Video.createThumbnail(videoFile)
63 }
64 }
65
66 // Process preview
67 if (payload.downloadPreview) {
68 if (payload.thumbnailUrl) {
69 const destPreviewPath = join(CONFIG.STORAGE.PREVIEWS_DIR, videoImport.Video.getPreviewName())
70 await doRequestAndSaveToFile({ method: 'GET', uri: payload.thumbnailUrl }, destPreviewPath)
71 } else {
72 await videoImport.Video.createPreview(videoFile)
73 }
74 }
75
76 // Create torrent
77 await videoImport.Video.createTorrentAndSetInfoHash(videoFile)
78
79 const videoImportUpdated: VideoImportModel = await sequelizeTypescript.transaction(async t => {
80 await videoFile.save({ transaction: t })
81
82 // Update video DB object
83 videoImport.Video.duration = duration
84 videoImport.Video.state = CONFIG.TRANSCODING.ENABLED ? VideoState.TO_TRANSCODE : VideoState.PUBLISHED
85 const videoUpdated = await videoImport.Video.save({ transaction: t })
86
87 // Now we can federate the video
88 await federateVideoIfNeeded(videoImport.Video, true, t)
89
90 // Update video import object
91 videoImport.state = VideoImportState.SUCCESS
92 const videoImportUpdated = await videoImport.save({ transaction: t })
93
94 logger.info('Video %s imported.', videoImport.targetUrl)
95
96 videoImportUpdated.Video = videoUpdated
97 return videoImportUpdated
98 })
99
100 // Create transcoding jobs?
101 if (videoImportUpdated.Video.state === VideoState.TO_TRANSCODE) {
102 // Put uuid because we don't have id auto incremented for now
103 const dataInput = {
104 videoUUID: videoImportUpdated.Video.uuid,
105 isNewVideo: true
106 }
107
108 await JobQueue.Instance.createJob({ type: 'video-file', payload: dataInput })
109 }
110
111 } catch (err) {
112 try {
113 if (tempVideoPath) await unlinkPromise(tempVideoPath)
114 } catch (errUnlink) {
115 logger.error('Cannot cleanup files after a video import error.', { err: errUnlink })
116 }
117
118 videoImport.state = VideoImportState.FAILED
119 await videoImport.save()
120
121 throw err
122 }
123}
124
125// ---------------------------------------------------------------------------
126
127export {
128 processVideoImport
129}
diff --git a/server/lib/job-queue/job-queue.ts b/server/lib/job-queue/job-queue.ts
index 8ff0c169e..2e14867f2 100644
--- a/server/lib/job-queue/job-queue.ts
+++ b/server/lib/job-queue/job-queue.ts
@@ -9,6 +9,7 @@ import { ActivitypubHttpUnicastPayload, processActivityPubHttpUnicast } from './
9import { EmailPayload, processEmail } from './handlers/email' 9import { EmailPayload, processEmail } from './handlers/email'
10import { processVideoFile, processVideoFileImport, VideoFileImportPayload, VideoFilePayload } from './handlers/video-file' 10import { processVideoFile, processVideoFileImport, VideoFileImportPayload, VideoFilePayload } from './handlers/video-file'
11import { ActivitypubFollowPayload, processActivityPubFollow } from './handlers/activitypub-follow' 11import { ActivitypubFollowPayload, processActivityPubFollow } from './handlers/activitypub-follow'
12import { processVideoImport, VideoImportPayload } from './handlers/video-import'
12 13
13type CreateJobArgument = 14type CreateJobArgument =
14 { type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } | 15 { type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } |
@@ -17,7 +18,8 @@ type CreateJobArgument =
17 { type: 'activitypub-follow', payload: ActivitypubFollowPayload } | 18 { type: 'activitypub-follow', payload: ActivitypubFollowPayload } |
18 { type: 'video-file-import', payload: VideoFileImportPayload } | 19 { type: 'video-file-import', payload: VideoFileImportPayload } |
19 { type: 'video-file', payload: VideoFilePayload } | 20 { type: 'video-file', payload: VideoFilePayload } |
20 { type: 'email', payload: EmailPayload } 21 { type: 'email', payload: EmailPayload } |
22 { type: 'video-import', payload: VideoImportPayload }
21 23
22const handlers: { [ id in JobType ]: (job: Bull.Job) => Promise<any>} = { 24const handlers: { [ id in JobType ]: (job: Bull.Job) => Promise<any>} = {
23 'activitypub-http-broadcast': processActivityPubHttpBroadcast, 25 'activitypub-http-broadcast': processActivityPubHttpBroadcast,
@@ -26,7 +28,8 @@ const handlers: { [ id in JobType ]: (job: Bull.Job) => Promise<any>} = {
26 'activitypub-follow': processActivityPubFollow, 28 'activitypub-follow': processActivityPubFollow,
27 'video-file-import': processVideoFileImport, 29 'video-file-import': processVideoFileImport,
28 'video-file': processVideoFile, 30 'video-file': processVideoFile,
29 'email': processEmail 31 'email': processEmail,
32 'video-import': processVideoImport
30} 33}
31 34
32const jobsWithRequestTimeout: { [ id in JobType ]?: boolean } = { 35const jobsWithRequestTimeout: { [ id in JobType ]?: boolean } = {
@@ -43,7 +46,8 @@ const jobTypes: JobType[] = [
43 'activitypub-http-unicast', 46 'activitypub-http-unicast',
44 'email', 47 'email',
45 'video-file', 48 'video-file',
46 'video-file-import' 49 'video-file-import',
50 'video-import'
47] 51]
48 52
49class JobQueue { 53class JobQueue {
diff --git a/server/middlewares/validators/index.ts b/server/middlewares/validators/index.ts
index e3f0f5963..c5400c8f5 100644
--- a/server/middlewares/validators/index.ts
+++ b/server/middlewares/validators/index.ts
@@ -11,3 +11,4 @@ export * from './video-blacklist'
11export * from './video-channels' 11export * from './video-channels'
12export * from './webfinger' 12export * from './webfinger'
13export * from './search' 13export * from './search'
14export * from './video-imports'
diff --git a/server/middlewares/validators/video-imports.ts b/server/middlewares/validators/video-imports.ts
new file mode 100644
index 000000000..0ba759ff0
--- /dev/null
+++ b/server/middlewares/validators/video-imports.ts
@@ -0,0 +1,51 @@
1import * as express from 'express'
2import { body, param } from 'express-validator/check'
3import { isIdValid } from '../../helpers/custom-validators/misc'
4import { logger } from '../../helpers/logger'
5import { areValidationErrors } from './utils'
6import { getCommonVideoAttributes } from './videos'
7import { isVideoImportTargetUrlValid } from '../../helpers/custom-validators/video-imports'
8import { cleanUpReqFiles } from '../../helpers/utils'
9import { isVideoChannelOfAccountExist, isVideoNameValid } from '../../helpers/custom-validators/videos'
10
11const videoImportAddValidator = getCommonVideoAttributes().concat([
12 body('targetUrl').custom(isVideoImportTargetUrlValid).withMessage('Should have a valid video import target URL'),
13 body('channelId')
14 .toInt()
15 .custom(isIdValid).withMessage('Should have correct video channel id'),
16 body('name')
17 .optional()
18 .custom(isVideoNameValid).withMessage('Should have a valid name'),
19
20 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
21 logger.debug('Checking videoImportAddValidator parameters', { parameters: req.body })
22
23 const user = res.locals.oauth.token.User
24
25 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
26 if (!await isVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
27
28 return next()
29 }
30])
31
32const videoImportDeleteValidator = [
33 param('id').custom(isIdValid).not().isEmpty().withMessage('Should have a valid id'),
34
35 (req: express.Request, res: express.Response, next: express.NextFunction) => {
36 logger.debug('Checking videoImportDeleteValidator parameters', { parameters: req.body })
37
38 if (areValidationErrors(req, res)) return
39
40 return next()
41 }
42]
43
44// ---------------------------------------------------------------------------
45
46export {
47 videoImportAddValidator,
48 videoImportDeleteValidator
49}
50
51// ---------------------------------------------------------------------------
diff --git a/server/middlewares/validators/videos.ts b/server/middlewares/validators/videos.ts
index 9357c1e39..c812d4677 100644
--- a/server/middlewares/validators/videos.ts
+++ b/server/middlewares/validators/videos.ts
@@ -223,36 +223,6 @@ const videosShareValidator = [
223 } 223 }
224] 224]
225 225
226// ---------------------------------------------------------------------------
227
228export {
229 videosAddValidator,
230 videosUpdateValidator,
231 videosGetValidator,
232 videosRemoveValidator,
233 videosShareValidator,
234
235 videoAbuseReportValidator,
236
237 videoRateValidator
238}
239
240// ---------------------------------------------------------------------------
241
242function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) {
243 if (req.body.scheduleUpdate) {
244 if (!req.body.scheduleUpdate.updateAt) {
245 res.status(400)
246 .json({ error: 'Schedule update at is mandatory.' })
247 .end()
248
249 return true
250 }
251 }
252
253 return false
254}
255
256function getCommonVideoAttributes () { 226function getCommonVideoAttributes () {
257 return [ 227 return [
258 body('thumbnailfile') 228 body('thumbnailfile')
@@ -319,3 +289,35 @@ function getCommonVideoAttributes () {
319 .custom(isScheduleVideoUpdatePrivacyValid).withMessage('Should have correct schedule update privacy') 289 .custom(isScheduleVideoUpdatePrivacyValid).withMessage('Should have correct schedule update privacy')
320 ] as (ValidationChain | express.Handler)[] 290 ] as (ValidationChain | express.Handler)[]
321} 291}
292
293// ---------------------------------------------------------------------------
294
295export {
296 videosAddValidator,
297 videosUpdateValidator,
298 videosGetValidator,
299 videosRemoveValidator,
300 videosShareValidator,
301
302 videoAbuseReportValidator,
303
304 videoRateValidator,
305
306 getCommonVideoAttributes
307}
308
309// ---------------------------------------------------------------------------
310
311function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) {
312 if (req.body.scheduleUpdate) {
313 if (!req.body.scheduleUpdate.updateAt) {
314 res.status(400)
315 .json({ error: 'Schedule update at is mandatory.' })
316 .end()
317
318 return true
319 }
320 }
321
322 return false
323}
diff --git a/server/models/account/account.ts b/server/models/account/account.ts
index d674d8d22..66f5dcf2e 100644
--- a/server/models/account/account.ts
+++ b/server/models/account/account.ts
@@ -16,7 +16,6 @@ import {
16} from 'sequelize-typescript' 16} from 'sequelize-typescript'
17import { Account } from '../../../shared/models/actors' 17import { Account } from '../../../shared/models/actors'
18import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts' 18import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts'
19import { logger } from '../../helpers/logger'
20import { sendDeleteActor } from '../../lib/activitypub/send' 19import { sendDeleteActor } from '../../lib/activitypub/send'
21import { ActorModel } from '../activitypub/actor' 20import { ActorModel } from '../activitypub/actor'
22import { ApplicationModel } from '../application/application' 21import { ApplicationModel } from '../application/application'
diff --git a/server/models/video/video-import.ts b/server/models/video/video-import.ts
new file mode 100644
index 000000000..89eeafd6a
--- /dev/null
+++ b/server/models/video/video-import.ts
@@ -0,0 +1,105 @@
1import {
2 AllowNull,
3 BelongsTo,
4 Column,
5 CreatedAt,
6 DataType,
7 Default,
8 DefaultScope,
9 ForeignKey,
10 Is,
11 Model,
12 Table,
13 UpdatedAt
14} from 'sequelize-typescript'
15import { CONSTRAINTS_FIELDS } from '../../initializers'
16import { throwIfNotValid } from '../utils'
17import { VideoModel } from './video'
18import { isVideoImportStateValid, isVideoImportTargetUrlValid } from '../../helpers/custom-validators/video-imports'
19import { VideoImport, VideoImportState } from '../../../shared'
20import { VideoChannelModel } from './video-channel'
21import { AccountModel } from '../account/account'
22
23@DefaultScope({
24 include: [
25 {
26 model: () => VideoModel,
27 required: true,
28 include: [
29 {
30 model: () => VideoChannelModel,
31 required: true,
32 include: [
33 {
34 model: () => AccountModel,
35 required: true
36 }
37 ]
38 }
39 ]
40 }
41 ]
42})
43
44@Table({
45 tableName: 'videoImport',
46 indexes: [
47 {
48 fields: [ 'videoId' ],
49 unique: true
50 }
51 ]
52})
53export class VideoImportModel extends Model<VideoImportModel> {
54 @CreatedAt
55 createdAt: Date
56
57 @UpdatedAt
58 updatedAt: Date
59
60 @AllowNull(false)
61 @Is('VideoImportTargetUrl', value => throwIfNotValid(value, isVideoImportTargetUrlValid, 'targetUrl'))
62 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_IMPORTS.URL.max))
63 targetUrl: string
64
65 @AllowNull(false)
66 @Default(null)
67 @Is('VideoImportState', value => throwIfNotValid(value, isVideoImportStateValid, 'state'))
68 @Column
69 state: VideoImportState
70
71 @AllowNull(true)
72 @Default(null)
73 @Column(DataType.TEXT)
74 error: string
75
76 @ForeignKey(() => VideoModel)
77 @Column
78 videoId: number
79
80 @BelongsTo(() => VideoModel, {
81 foreignKey: {
82 allowNull: false
83 },
84 onDelete: 'CASCADE'
85 })
86 Video: VideoModel
87
88 static loadAndPopulateVideo (id: number) {
89 return VideoImportModel.findById(id)
90 }
91
92 toFormattedJSON (): VideoImport {
93 const videoFormatOptions = {
94 additionalAttributes: { state: true, waitTranscoding: true, scheduledUpdate: true }
95 }
96 const video = Object.assign(this.Video.toFormattedJSON(videoFormatOptions), {
97 tags: this.Video.Tags.map(t => t.name)
98 })
99
100 return {
101 targetUrl: this.targetUrl,
102 video
103 }
104 }
105}
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index a6c4620b2..459fcb31e 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -377,7 +377,7 @@ type AvailableForListOptions = {
377 include: [ 377 include: [
378 { 378 {
379 model: () => VideoFileModel.unscoped(), 379 model: () => VideoFileModel.unscoped(),
380 required: true 380 required: false
381 } 381 }
382 ] 382 ]
383 }, 383 },
diff --git a/shared/models/server/job.model.ts b/shared/models/server/job.model.ts
index a38a8aa3b..2565479f6 100644
--- a/shared/models/server/job.model.ts
+++ b/shared/models/server/job.model.ts
@@ -6,7 +6,8 @@ export type JobType = 'activitypub-http-unicast' |
6 'activitypub-follow' | 6 'activitypub-follow' |
7 'video-file-import' | 7 'video-file-import' |
8 'video-file' | 8 'video-file' |
9 'email' 9 'email' |
10 'video-import'
10 11
11export interface Job { 12export interface Job {
12 id: number 13 id: number
diff --git a/shared/models/videos/index.ts b/shared/models/videos/index.ts
index cb9669772..1b135e26a 100644
--- a/shared/models/videos/index.ts
+++ b/shared/models/videos/index.ts
@@ -15,4 +15,8 @@ export * from './video-update.model'
15export * from './video.model' 15export * from './video.model'
16export * from './video-state.enum' 16export * from './video-state.enum'
17export * from './video-caption-update.model' 17export * from './video-caption-update.model'
18export * from './video-import-create.model'
19export * from './video-import-update.model'
20export * from './video-import-state.enum'
21export * from './video-import.model'
18export { VideoConstant } from './video-constant.model' 22export { VideoConstant } from './video-constant.model'
diff --git a/shared/models/videos/video-import-create.model.ts b/shared/models/videos/video-import-create.model.ts
new file mode 100644
index 000000000..65d142c2b
--- /dev/null
+++ b/shared/models/videos/video-import-create.model.ts
@@ -0,0 +1,6 @@
1import { VideoUpdate } from './video-update.model'
2
3export interface VideoImportCreate extends VideoUpdate {
4 targetUrl: string
5 channelId: number // Required
6}
diff --git a/shared/models/videos/video-import-state.enum.ts b/shared/models/videos/video-import-state.enum.ts
new file mode 100644
index 000000000..b178fbf3a
--- /dev/null
+++ b/shared/models/videos/video-import-state.enum.ts
@@ -0,0 +1,5 @@
1export enum VideoImportState {
2 PENDING = 1,
3 SUCCESS = 2,
4 FAILED = 3
5}
diff --git a/shared/models/videos/video-import-update.model.ts b/shared/models/videos/video-import-update.model.ts
new file mode 100644
index 000000000..5ae244683
--- /dev/null
+++ b/shared/models/videos/video-import-update.model.ts
@@ -0,0 +1,5 @@
1import { VideoUpdate } from './video-update.model'
2
3export interface VideoImportUpdate extends VideoUpdate {
4 targetUrl: string
5}
diff --git a/shared/models/videos/video-import.model.ts b/shared/models/videos/video-import.model.ts
new file mode 100644
index 000000000..858108599
--- /dev/null
+++ b/shared/models/videos/video-import.model.ts
@@ -0,0 +1,7 @@
1import { Video } from './video.model'
2
3export interface VideoImport {
4 targetUrl: string
5
6 video: Video & { tags: string[] }
7}
diff --git a/shared/models/videos/video-state.enum.ts b/shared/models/videos/video-state.enum.ts
index 625aefae1..a50e14e4b 100644
--- a/shared/models/videos/video-state.enum.ts
+++ b/shared/models/videos/video-state.enum.ts
@@ -1,4 +1,5 @@
1export enum VideoState { 1export enum VideoState {
2 PUBLISHED = 1, 2 PUBLISHED = 1,
3 TO_TRANSCODE = 2 3 TO_TRANSCODE = 2,
4 TO_IMPORT = 3
4} 5}
diff --git a/tsconfig.json b/tsconfig.json
index 4254010e7..7633465b2 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -20,6 +20,7 @@
20 ] 20 ]
21 }, 21 },
22 "exclude": [ 22 "exclude": [
23 "client/node_modules",
23 "node_modules", 24 "node_modules",
24 "dist", 25 "dist",
25 "storage", 26 "storage",