Add ability to import video with youtube-dl
authorChocobozzz <me@florianbigard.com>
Thu, 2 Aug 2018 13:34:09 +0000 (15:34 +0200)
committerChocobozzz <me@florianbigard.com>
Mon, 6 Aug 2018 09:19:16 +0000 (11:19 +0200)
42 files changed:
client/package.json
client/src/app/shared/shared.module.ts
client/src/app/shared/video-import/index.ts [new file with mode: 0644]
client/src/app/shared/video-import/video-import.service.ts [new file with mode: 0644]
client/src/app/shared/video/video-edit.model.ts
client/src/app/videos/+video-edit/video-add.component.html
client/src/app/videos/+video-edit/video-add.component.scss
client/src/app/videos/+video-edit/video-add.component.ts
client/src/app/videos/+video-edit/video-add.module.ts
client/src/app/videos/+video-edit/video-import.component.html [new file with mode: 0644]
client/src/app/videos/+video-edit/video-import.component.scss [new file with mode: 0644]
client/src/app/videos/+video-edit/video-import.component.ts [new file with mode: 0644]
client/src/app/videos/+video-edit/video-update.component.ts
client/src/app/videos/+video-edit/video-upload.component.html [new file with mode: 0644]
client/src/app/videos/+video-edit/video-upload.component.scss [new file with mode: 0644]
client/src/app/videos/+video-edit/video-upload.component.ts [new file with mode: 0644]
config/default.yaml
package.json
server/controllers/api/videos/import.ts [new file with mode: 0644]
server/controllers/api/videos/index.ts
server/helpers/custom-validators/activitypub/videos.ts
server/helpers/custom-validators/video-imports.ts [new file with mode: 0644]
server/helpers/logger.ts
server/helpers/youtube-dl.ts [new file with mode: 0644]
server/initializers/constants.ts
server/initializers/database.ts
server/lib/job-queue/handlers/video-import.ts [new file with mode: 0644]
server/lib/job-queue/job-queue.ts
server/middlewares/validators/index.ts
server/middlewares/validators/video-imports.ts [new file with mode: 0644]
server/middlewares/validators/videos.ts
server/models/account/account.ts
server/models/video/video-import.ts [new file with mode: 0644]
server/models/video/video.ts
shared/models/server/job.model.ts
shared/models/videos/index.ts
shared/models/videos/video-import-create.model.ts [new file with mode: 0644]
shared/models/videos/video-import-state.enum.ts [new file with mode: 0644]
shared/models/videos/video-import-update.model.ts [new file with mode: 0644]
shared/models/videos/video-import.model.ts [new file with mode: 0644]
shared/models/videos/video-state.enum.ts
tsconfig.json

index aae7643c73ff2609a012c2e41aa6b5bb7d994cdb..34305ee446ca33dc93053741b21586f54467993c 100644 (file)
@@ -23,6 +23,7 @@
     "ngx-extractor": "ngx-extractor"
   },
   "license": "GPLv3",
+  "typings": "*.d.ts",
   "resolutions": {
     "video.js": "^7",
     "webtorrent/create-torrent/junk": "^1",
index 99df61cdb4dbadf3c96897cae998781c44feb73f..62ce97102746d98b84ecb9b2b9e963cdadd77e68 100644 (file)
@@ -51,6 +51,7 @@ import { ScreenService } from '@app/shared/misc/screen.service'
 import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service'
 import { VideoCaptionService } from '@app/shared/video-caption'
 import { PeertubeCheckboxComponent } from '@app/shared/forms/peertube-checkbox.component'
+import { VideoImportService } from '@app/shared/video-import/video-import.service'
 
 @NgModule({
   imports: [
@@ -143,6 +144,7 @@ import { PeertubeCheckboxComponent } from '@app/shared/forms/peertube-checkbox.c
     VideoCommentValidatorsService,
     VideoValidatorsService,
     VideoCaptionsValidatorsService,
+    VideoImportService,
 
     I18nPrimengCalendarService,
     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 (file)
index 0000000..9bb73ec
--- /dev/null
@@ -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 (file)
index 0000000..b470986
--- /dev/null
@@ -0,0 +1,56 @@
+import { catchError } from 'rxjs/operators'
+import { HttpClient } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { Observable } from 'rxjs'
+import { VideoImport } from '../../../../../shared'
+import { environment } from '../../../environments/environment'
+import { RestExtractor, RestService } from '../rest'
+import { VideoImportCreate } from '../../../../../shared/models/videos/video-import-create.model'
+import { objectToFormData } from '@app/shared/misc/utils'
+import { VideoUpdate } from '../../../../../shared/models/videos'
+
+@Injectable()
+export class VideoImportService {
+  private static BASE_VIDEO_IMPORT_URL = environment.apiUrl + '/api/v1/videos/imports/'
+
+  constructor (
+    private authHttp: HttpClient,
+    private restService: RestService,
+    private restExtractor: RestExtractor
+  ) {}
+
+  importVideo (targetUrl: string, video: VideoUpdate): Observable<VideoImport> {
+    const url = VideoImportService.BASE_VIDEO_IMPORT_URL
+    const language = video.language || null
+    const licence = video.licence || null
+    const category = video.category || null
+    const description = video.description || null
+    const support = video.support || null
+    const scheduleUpdate = video.scheduleUpdate || null
+
+    const body: VideoImportCreate = {
+      targetUrl,
+
+      name: video.name,
+      category,
+      licence,
+      language,
+      support,
+      description,
+      channelId: video.channelId,
+      privacy: video.privacy,
+      tags: video.tags,
+      nsfw: video.nsfw,
+      waitTranscoding: video.waitTranscoding,
+      commentsEnabled: video.commentsEnabled,
+      thumbnailfile: video.thumbnailfile,
+      previewfile: video.previewfile,
+      scheduleUpdate
+    }
+
+    const data = objectToFormData(body)
+    return this.authHttp.post<VideoImport>(url, data)
+               .pipe(catchError(res => this.restExtractor.handleError(res)))
+  }
+
+}
index 8562f8d2540d662d8ea030a6adf08a55899c6cb8..0046be96455c08abf1478e1a899643f7750534ed 100644 (file)
@@ -1,7 +1,7 @@
-import { VideoDetails } from './video-details.model'
 import { VideoPrivacy } from '../../../../../shared/models/videos/video-privacy.enum'
 import { VideoUpdate } from '../../../../../shared/models/videos'
 import { VideoScheduleUpdate } from '../../../../../shared/models/videos/video-schedule-update.model'
+import { Video } from '../../../../../shared/models/videos/video.model'
 
 export class VideoEdit implements VideoUpdate {
   static readonly SPECIAL_SCHEDULED_PRIVACY = -1
@@ -26,26 +26,26 @@ export class VideoEdit implements VideoUpdate {
   id?: number
   scheduleUpdate?: VideoScheduleUpdate
 
-  constructor (videoDetails?: VideoDetails) {
-    if (videoDetails) {
-      this.id = videoDetails.id
-      this.uuid = videoDetails.uuid
-      this.category = videoDetails.category.id
-      this.licence = videoDetails.licence.id
-      this.language = videoDetails.language.id
-      this.description = videoDetails.description
-      this.name = videoDetails.name
-      this.tags = videoDetails.tags
-      this.nsfw = videoDetails.nsfw
-      this.commentsEnabled = videoDetails.commentsEnabled
-      this.waitTranscoding = videoDetails.waitTranscoding
-      this.channelId = videoDetails.channel.id
-      this.privacy = videoDetails.privacy.id
-      this.support = videoDetails.support
-      this.thumbnailUrl = videoDetails.thumbnailUrl
-      this.previewUrl = videoDetails.previewUrl
+  constructor (video?: Video & { tags: string[], commentsEnabled: boolean, support: string, thumbnailUrl: string, previewUrl: string }) {
+    if (video) {
+      this.id = video.id
+      this.uuid = video.uuid
+      this.category = video.category.id
+      this.licence = video.licence.id
+      this.language = video.language.id
+      this.description = video.description
+      this.name = video.name
+      this.tags = video.tags
+      this.nsfw = video.nsfw
+      this.commentsEnabled = video.commentsEnabled
+      this.waitTranscoding = video.waitTranscoding
+      this.channelId = video.channel.id
+      this.privacy = video.privacy.id
+      this.support = video.support
+      this.thumbnailUrl = video.thumbnailUrl
+      this.previewUrl = video.previewUrl
 
-      this.scheduleUpdate = videoDetails.scheduledUpdate
+      this.scheduleUpdate = video.scheduledUpdate
     }
   }
 
index 9c2c01c65c38b23af1712eb7ede614d3d45140cf..ed8d91c1117f46c5e5ac901d124955505be9a97d 100644 (file)
@@ -1,65 +1,17 @@
 <div class="margin-content">
   <div class="title-page title-page-single">
-    <ng-container *ngIf="!videoFileName" i18n>Upload your video</ng-container>
-    <ng-container *ngIf="videoFileName" i18n>Upload {{ videoFileName }}</ng-container>
+    <ng-container *ngIf="secondStepType === 'import'" i18n>Import {{ videoName }}</ng-container>
+    <ng-container *ngIf="secondStepType === 'upload'" i18n>Upload {{ videoName }}</ng-container>
   </div>
 
-  <div *ngIf="!isUploadingVideo" class="upload-video-container">
-    <div class="upload-video">
-      <div class="icon icon-upload"></div>
+  <tabset class="video-add-tabset root-tabset bootstrap" [ngClass]="{ 'hide-nav': secondStepType !== undefined }">
 
-      <div class="button-file">
-        <span i18n>Select the file to upload</span>
-        <input #videofileInput type="file" name="videofile" id="videofile" [accept]="videoExtensions" (change)="fileChange()" />
-      </div>
-      <span class="button-file-extension">(.mp4, .webm, .ogv)</span>
+    <tab i18n-heading heading="Upload your video">
+      <my-video-upload #videoUpload (firstStepDone)="onFirstStepDone('upload', $event)"></my-video-upload>
+    </tab>
 
-      <div class="form-group form-group-channel">
-        <label i18n for="first-step-channel">Channel</label>
-        <div class="peertube-select-container">
-          <select id="first-step-channel" [(ngModel)]="firstStepChannelId">
-            <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option>
-          </select>
-        </div>
-      </div>
-
-      <div class="form-group">
-        <label i18n for="first-step-privacy">Privacy</label>
-        <div class="peertube-select-container">
-          <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId">
-            <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option>
-            <option [value]="SPECIAL_SCHEDULED_PRIVACY">Scheduled</option>
-          </select>
-        </div>
-      </div>
-    </div>
-  </div>
-
-  <div *ngIf="isUploadingVideo" class="upload-progress-cancel">
-    <p-progressBar
-      [value]="videoUploadPercents"
-      [ngClass]="{ processing: videoUploadPercents === 100 && videoUploaded === false }"
-    ></p-progressBar>
-    <input *ngIf="videoUploaded === false" type="button" value="Cancel" (click)="cancelUpload()" />
-  </div>
-
-  <!-- Hidden because we want to load the component -->
-  <form [hidden]="!isUploadingVideo" novalidate [formGroup]="form">
-    <my-video-edit
-      [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions"
-      [validationMessages]="validationMessages" [videoPrivacies]="videoPrivacies" [userVideoChannels]="userVideoChannels"
-    ></my-video-edit>
-
-    <div class="submit-container">
-      <div i18n *ngIf="videoUploaded === false" class="message-submit">Publish will be available when upload is finished</div>
-
-      <div class="submit-button"
-         (click)="updateSecondStep()"
-         [ngClass]="{ disabled: !form.valid || isUpdatingVideo === true || videoUploaded !== true }"
-      >
-        <span class="icon icon-validate"></span>
-        <input type="button" i18n-value value="Publish" />
-      </div>
-    </div>
-  </form>
+    <tab i18n-heading heading="Import your video">
+      <my-video-import #videoImport (firstStepDone)="onFirstStepDone('import', $event)"></my-video-import>
+    </tab>
+  </tabset>
 </div>
index c0b5f3d070fe6a746c487413f9035770b68c966b..a811b9cf0c91dcffa08d6c60c59f2dd440a21630 100644 (file)
 @import '_variables';
 @import '_mixins';
 
-.upload-video-container {
-  border-radius: 3px;
-  background-color: #F7F7F7;
-  border: 3px solid #EAEAEA;
-  width: 100%;
-  height: 440px;
-  margin-top: 40px;
-  display: flex;
-  justify-content: center;
-  align-items: center;
+$border-width: 3px;
+$border-type: solid;
+$border-color: #EAEAEA;
 
-  .peertube-select-container {
-    @include peertube-select-container(190px);
-  }
-
-  .upload-video {
-    display: flex;
-    flex-direction: column;
-    align-items: center;
-
-    .form-group-channel {
-      margin-bottom: 20px;
-    }
-
-    .icon.icon-upload {
-      @include icon(90px);
-      margin-bottom: 25px;
-      cursor: default;
-
-      background-image: url('../../../assets/images/video/upload.svg');
-    }
-
-    .button-file {
-      @include peertube-button-file(auto);
-
-      min-width: 190px;
-    }
+$background-color:  #F7F7F7;
 
-    .button-file-extension {
-      display: block;
-      font-size: 12px;
-      margin-top: 5px;
-    }
-  }
-
-  .form-group-channel {
-    margin-top: 35px;
+/deep/ tabset.root-tabset.video-add-tabset {
+  &.hide-nav .nav {
+    display: none !important;
   }
-}
 
-.upload-progress-cancel {
-  display: flex;
-  margin-top: 25px;
-  margin-bottom: 40px;
+  & > .nav {
 
-  p-progressBar {
-    flex-grow: 1;
-
-    /deep/ .ui-progressbar {
-      font-size: 15px !important;
-      color: #fff !important;
-      height: 30px !important;
-      line-height: 30px !important;
-      border-radius: 3px !important;
-      background-color: rgba(11, 204, 41, 0.16) !important;
-
-      .ui-progressbar-value {
-        background-color: #0BCC29 !important;
-      }
+    border-bottom: $border-width $border-type $border-color;
+    margin: 0 !important;
 
-      .ui-progressbar-label {
-        text-align: left;
-        padding-left: 18px;
-        margin-top: 0 !important;
-      }
+    & > li {
+      margin-bottom: -$border-width;
     }
 
-    &.processing {
-      /deep/ .ui-progressbar-label {
-        // Same color as background to hide "100%"
-        color: rgba(11, 204, 41, 0.16) !important;
+    .nav-link {
+      height: 40px !important;
+      padding: 0 30px !important;
+      font-size: 15px;
+
+      &.active {
+        border: $border-width $border-type $border-color;
+        border-bottom: none;
+        background-color: $background-color !important;
 
-        &::before {
-          content: 'Processing...';
-          color: #fff;
+        span {
+          border-bottom: 2px solid #F1680D;
+          font-weight: $font-bold;
         }
       }
     }
   }
 
-  input {
-    @include peertube-button;
-    @include grey-button;
+  .upload-video-container {
+    border: $border-width $border-type $border-color;
+    border-top: none;
 
-    margin-left: 10px;
+    background-color: $background-color;
+    border-radius: 3px;
+    width: 100%;
+    height: 440px;
+    display: flex;
+    justify-content: center;
+    align-items: center;
   }
-}
-
+}
\ No newline at end of file
index 651ee8dd29f46322ffc54102b1ca44f30f7e2351..64071b40c37753dc4f0dc9f3c4bf5dba418d5c3f 100644 (file)
-import { HttpEventType, HttpResponse } from '@angular/common/http'
-import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'
-import { Router } from '@angular/router'
-import { UserService } from '@app/shared'
+import { Component, ViewChild } from '@angular/core'
 import { CanComponentDeactivate } from '@app/shared/guards/can-deactivate-guard.service'
-import { LoadingBarService } from '@ngx-loading-bar/core'
-import { NotificationsService } from 'angular2-notifications'
-import { BytesPipe } from 'ngx-pipes'
-import { Subscription } from 'rxjs'
-import { VideoConstant, VideoPrivacy } from '../../../../../shared/models/videos'
-import { AuthService, ServerService } from '../../core'
-import { FormReactive } from '../../shared'
-import { populateAsyncUserVideoChannels } from '../../shared/misc/utils'
-import { VideoEdit } from '../../shared/video/video-edit.model'
-import { VideoService } from '../../shared/video/video.service'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { switchMap } from 'rxjs/operators'
-import { VideoCaptionService } from '@app/shared/video-caption'
-import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model'
+import { VideoImportComponent } from '@app/videos/+video-edit/video-import.component'
+import { VideoUploadComponent } from '@app/videos/+video-edit/video-upload.component'
 
 @Component({
   selector: 'my-videos-add',
   templateUrl: './video-add.component.html',
-  styleUrls: [
-    './shared/video-edit.component.scss',
-    './video-add.component.scss'
-  ]
+  styleUrls: [ './video-add.component.scss' ]
 })
-export class VideoAddComponent extends FormReactive implements OnInit, OnDestroy, CanComponentDeactivate {
-  @ViewChild('videofileInput') videofileInput
+export class VideoAddComponent implements CanComponentDeactivate {
+  @ViewChild('videoUpload') videoUpload: VideoUploadComponent
+  @ViewChild('videoImport') videoImport: VideoImportComponent
 
-  // So that it can be accessed in the template
-  readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY
+  secondStepType: 'upload' | 'import'
+  videoName: string
 
-  isUploadingVideo = false
-  isUpdatingVideo = false
-  videoUploaded = false
-  videoUploadObservable: Subscription = null
-  videoUploadPercents = 0
-  videoUploadedIds = {
-    id: 0,
-    uuid: ''
-  }
-  videoFileName: string
-
-  userVideoChannels: { id: number, label: string, support: string }[] = []
-  userVideoQuotaUsed = 0
-  videoPrivacies: VideoConstant<string>[] = []
-  firstStepPrivacyId = 0
-  firstStepChannelId = 0
-  videoCaptions: VideoCaptionEdit[] = []
-
-  constructor (
-    protected formValidatorService: FormValidatorService,
-    private router: Router,
-    private notificationsService: NotificationsService,
-    private authService: AuthService,
-    private userService: UserService,
-    private serverService: ServerService,
-    private videoService: VideoService,
-    private loadingBar: LoadingBarService,
-    private i18n: I18n,
-    private videoCaptionService: VideoCaptionService
-  ) {
-    super()
-  }
-
-  get videoExtensions () {
-    return this.serverService.getConfig().video.file.extensions.join(',')
-  }
-
-  ngOnInit () {
-    this.buildForm({})
-
-    populateAsyncUserVideoChannels(this.authService, this.userVideoChannels)
-      .then(() => this.firstStepChannelId = this.userVideoChannels[0].id)
-
-    this.userService.getMyVideoQuotaUsed()
-      .subscribe(data => this.userVideoQuotaUsed = data.videoQuotaUsed)
-
-    this.serverService.videoPrivaciesLoaded
-      .subscribe(
-        () => {
-          this.videoPrivacies = this.serverService.getVideoPrivacies()
-
-          // Public by default
-          this.firstStepPrivacyId = VideoPrivacy.PUBLIC
-        })
-  }
-
-  ngOnDestroy () {
-    if (this.videoUploadObservable) {
-      this.videoUploadObservable.unsubscribe()
-    }
+  onFirstStepDone (type: 'upload' | 'import', videoName: string) {
+    this.secondStepType = type
+    this.videoName = videoName
   }
 
   canDeactivate () {
-    let text = ''
-
-    if (this.videoUploaded === true) {
-      // FIXME: cannot concatenate strings inside i18n service :/
-      text = this.i18n('Your video was uploaded in your account and is private.') +
-        this.i18n('But associated data (tags, description...) will be lost, are you sure you want to leave this page?')
-    } else {
-      text = this.i18n('Your video is not uploaded yet, are you sure you want to leave this page?')
-    }
-
-    return {
-      canDeactivate: !this.isUploadingVideo,
-      text
-    }
-  }
-
-  fileChange () {
-    this.uploadFirstStep()
-  }
-
-  checkForm () {
-    this.forceCheck()
-
-    return this.form.valid
-  }
-
-  cancelUpload () {
-    if (this.videoUploadObservable !== null) {
-      this.videoUploadObservable.unsubscribe()
-      this.isUploadingVideo = false
-      this.videoUploadPercents = 0
-      this.videoUploadObservable = null
-      this.notificationsService.info(this.i18n('Info'), this.i18n('Upload cancelled'))
-    }
-  }
-
-  uploadFirstStep () {
-    const videofile = this.videofileInput.nativeElement.files[0] as File
-    if (!videofile) return
-
-    // Cannot upload videos > 8GB for now
-    if (videofile.size > 8 * 1024 * 1024 * 1024) {
-      this.notificationsService.error(this.i18n('Error'), this.i18n('We are sorry but PeerTube cannot handle videos > 8GB'))
-      return
-    }
-
-    const videoQuota = this.authService.getUser().videoQuota
-    if (videoQuota !== -1 && (this.userVideoQuotaUsed + videofile.size) > videoQuota) {
-      const bytePipes = new BytesPipe()
-
-      const msg = this.i18n(
-        'Your video quota is exceeded with this video (video size: {{ videoSize }}, used: {{ videoQuotaUsed }}, quota: {{ videoQuota }})',
-        {
-          videoSize: bytePipes.transform(videofile.size, 0),
-          videoQuotaUsed: bytePipes.transform(this.userVideoQuotaUsed, 0),
-          videoQuota: bytePipes.transform(videoQuota, 0)
-        }
-      )
-      this.notificationsService.error(this.i18n('Error'), msg)
-      return
-    }
-
-    this.videoFileName = videofile.name
-
-    const nameWithoutExtension = videofile.name.replace(/\.[^/.]+$/, '')
-    let name: string
-
-    // If the name of the file is very small, keep the extension
-    if (nameWithoutExtension.length < 3) name = videofile.name
-    else name = nameWithoutExtension
-
-    const privacy = this.firstStepPrivacyId.toString()
-    const nsfw = false
-    const waitTranscoding = true
-    const commentsEnabled = true
-    const channelId = this.firstStepChannelId.toString()
-
-    const formData = new FormData()
-    formData.append('name', name)
-    // Put the video "private" -> we are waiting the user validation of the second step
-    formData.append('privacy', VideoPrivacy.PRIVATE.toString())
-    formData.append('nsfw', '' + nsfw)
-    formData.append('commentsEnabled', '' + commentsEnabled)
-    formData.append('waitTranscoding', '' + waitTranscoding)
-    formData.append('channelId', '' + channelId)
-    formData.append('videofile', videofile)
-
-    this.isUploadingVideo = true
-    this.form.patchValue({
-      name,
-      privacy,
-      nsfw,
-      channelId
-    })
-
-    this.videoUploadObservable = this.videoService.uploadVideo(formData).subscribe(
-      event => {
-        if (event.type === HttpEventType.UploadProgress) {
-          this.videoUploadPercents = Math.round(100 * event.loaded / event.total)
-        } else if (event instanceof HttpResponse) {
-          this.videoUploaded = true
-
-          this.videoUploadedIds = event.body.video
-
-          this.videoUploadObservable = null
-        }
-      },
-
-      err => {
-        // Reset progress
-        this.isUploadingVideo = false
-        this.videoUploadPercents = 0
-        this.videoUploadObservable = null
-        this.notificationsService.error(this.i18n('Error'), err.message)
-      }
-    )
-  }
-
-  updateSecondStep () {
-    if (this.checkForm() === false) {
-      return
-    }
-
-    const video = new VideoEdit()
-    video.patch(this.form.value)
-    video.id = this.videoUploadedIds.id
-    video.uuid = this.videoUploadedIds.uuid
-
-    this.isUpdatingVideo = true
-    this.loadingBar.start()
-    this.videoService.updateVideo(video)
-        .pipe(
-          // Then update captions
-          switchMap(() => this.videoCaptionService.updateCaptions(video.id, this.videoCaptions))
-        )
-        .subscribe(
-          () => {
-            this.isUpdatingVideo = false
-            this.isUploadingVideo = false
-            this.loadingBar.complete()
-
-            this.notificationsService.success(this.i18n('Success'), this.i18n('Video published.'))
-            this.router.navigate([ '/videos/watch', video.uuid ])
-          },
+    if (this.secondStepType === 'upload') return this.videoUpload.canDeactivate()
+    if (this.secondStepType === 'import') return this.videoImport.canDeactivate()
 
-          err => {
-            this.isUpdatingVideo = false
-            this.notificationsService.error(this.i18n('Error'), err.message)
-            console.error(err)
-          }
-        )
+    return { canDeactivate: true }
   }
 }
index 1bfedf25176e60a40ba4cf5689ae6a435c27f7f1..91f54497102437ca28f7bddd29eb6d21dcf20fba 100644 (file)
@@ -5,6 +5,8 @@ import { VideoEditModule } from './shared/video-edit.module'
 import { VideoAddRoutingModule } from './video-add-routing.module'
 import { VideoAddComponent } from './video-add.component'
 import { CanDeactivateGuard } from '../../shared/guards/can-deactivate-guard.service'
+import { VideoUploadComponent } from '@app/videos/+video-edit/video-upload.component'
+import { VideoImportComponent } from '@app/videos/+video-edit/video-import.component'
 
 @NgModule({
   imports: [
@@ -14,7 +16,9 @@ import { CanDeactivateGuard } from '../../shared/guards/can-deactivate-guard.ser
     ProgressBarModule
   ],
   declarations: [
-    VideoAddComponent
+    VideoAddComponent,
+    VideoUploadComponent,
+    VideoImportComponent
   ],
   exports: [
     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 (file)
index 0000000..9d71a07
--- /dev/null
@@ -0,0 +1,55 @@
+<div *ngIf="!hasImportedVideo" class="upload-video-container">
+  <div class="import-video">
+    <div class="icon icon-upload"></div>
+
+    <div class="form-group">
+      <label i18n for="targetUrl">URL</label>
+      <input type="text" id="targetUrl" [(ngModel)]="targetUrl" />
+    </div>
+
+    <div class="form-group">
+      <label i18n for="first-step-channel">Channel</label>
+      <div class="peertube-select-container">
+        <select id="first-step-channel" [(ngModel)]="firstStepChannelId">
+          <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option>
+        </select>
+      </div>
+    </div>
+
+    <div class="form-group">
+      <label i18n for="first-step-privacy">Privacy</label>
+      <div class="peertube-select-container">
+        <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId">
+          <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option>
+        </select>
+      </div>
+    </div>
+
+    <input
+      type="button" i18n-value value="Import"
+      [disabled]="!isTargetUrlValid() || isImportingVideo" (click)="importVideo()"
+    />
+  </div>
+</div>
+
+<div *ngIf="hasImportedVideo" class="alert alert-info" i18n>
+  Congratulations, the video behind {{ targetUrl }} will be imported! You can already add information about this video.
+</div>
+
+<!-- Hidden because we want to load the component -->
+<form [hidden]="!hasImportedVideo" novalidate [formGroup]="form">
+  <my-video-edit
+    [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [schedulePublicationPossible]="false"
+    [validationMessages]="validationMessages" [videoPrivacies]="videoPrivacies" [userVideoChannels]="userVideoChannels"
+  ></my-video-edit>
+
+  <div class="submit-container">
+    <div class="submit-button"
+       (click)="updateSecondStep()"
+       [ngClass]="{ disabled: !form.valid || isUpdatingVideo === true }"
+    >
+      <span class="icon icon-validate"></span>
+      <input type="button" i18n-value value="Update" />
+    </div>
+  </div>
+</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 (file)
index 0000000..9ada9db
--- /dev/null
@@ -0,0 +1,37 @@
+@import '_variables';
+@import '_mixins';
+
+$width-size: 190px;
+
+.peertube-select-container {
+  @include peertube-select-container($width-size);
+}
+
+.import-video {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+
+  .icon.icon-upload {
+    @include icon(90px);
+    margin-bottom: 25px;
+    cursor: default;
+
+    background-image: url('../../../assets/images/video/upload.svg');
+  }
+
+  input[type=text] {
+    @include peertube-input-text($width-size);
+    display: block;
+  }
+
+  input[type=button] {
+    @include peertube-button;
+    @include orange-button;
+
+    width: $width-size;
+    margin-top: 30px;
+  }
+}
+
+
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 (file)
index 0000000..bd4482e
--- /dev/null
@@ -0,0 +1,161 @@
+import { Component, EventEmitter, OnInit, Output } from '@angular/core'
+import { Router } from '@angular/router'
+import { CanComponentDeactivate } from '@app/shared/guards/can-deactivate-guard.service'
+import { NotificationsService } from 'angular2-notifications'
+import { VideoConstant, VideoPrivacy, VideoUpdate } from '../../../../../shared/models/videos'
+import { AuthService, ServerService } from '../../core'
+import { FormReactive } from '../../shared'
+import { populateAsyncUserVideoChannels } from '../../shared/misc/utils'
+import { VideoService } from '../../shared/video/video.service'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
+import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model'
+import { VideoImportService } from '@app/shared/video-import'
+import { VideoEdit } from '@app/shared/video/video-edit.model'
+import { switchMap } from 'rxjs/operators'
+import { LoadingBarService } from '@ngx-loading-bar/core'
+import { VideoCaptionService } from '@app/shared/video-caption'
+
+@Component({
+  selector: 'my-video-import',
+  templateUrl: './video-import.component.html',
+  styleUrls: [
+    './shared/video-edit.component.scss',
+    './video-import.component.scss'
+  ]
+})
+export class VideoImportComponent extends FormReactive implements OnInit, CanComponentDeactivate {
+  @Output() firstStepDone = new EventEmitter<string>()
+
+  targetUrl = ''
+  videoFileName: string
+
+  isImportingVideo = false
+  hasImportedVideo = false
+  isUpdatingVideo = false
+
+  userVideoChannels: { id: number, label: string, support: string }[] = []
+  videoPrivacies: VideoConstant<string>[] = []
+  videoCaptions: VideoCaptionEdit[] = []
+
+  firstStepPrivacyId = 0
+  firstStepChannelId = 0
+  video: VideoEdit
+
+  constructor (
+    protected formValidatorService: FormValidatorService,
+    private router: Router,
+    private loadingBar: LoadingBarService,
+    private notificationsService: NotificationsService,
+    private authService: AuthService,
+    private serverService: ServerService,
+    private videoService: VideoService,
+    private videoImportService: VideoImportService,
+    private videoCaptionService: VideoCaptionService,
+    private i18n: I18n
+  ) {
+    super()
+  }
+
+  ngOnInit () {
+    this.buildForm({})
+
+    populateAsyncUserVideoChannels(this.authService, this.userVideoChannels)
+      .then(() => this.firstStepChannelId = this.userVideoChannels[ 0 ].id)
+
+    this.serverService.videoPrivaciesLoaded
+        .subscribe(
+          () => {
+            this.videoPrivacies = this.serverService.getVideoPrivacies()
+
+            // Private by default
+            this.firstStepPrivacyId = VideoPrivacy.PRIVATE
+          })
+  }
+
+  canDeactivate () {
+    return { canDeactivate: true }
+  }
+
+  checkForm () {
+    this.forceCheck()
+
+    return this.form.valid
+  }
+
+  isTargetUrlValid () {
+    return this.targetUrl && this.targetUrl.match(/https?:\/\//)
+  }
+
+  importVideo () {
+    this.isImportingVideo = true
+
+    const videoUpdate: VideoUpdate = {
+      privacy: this.firstStepPrivacyId,
+      waitTranscoding: false,
+      commentsEnabled: true,
+      channelId: this.firstStepChannelId
+    }
+
+    this.videoImportService.importVideo(this.targetUrl, videoUpdate).subscribe(
+      res => {
+        this.firstStepDone.emit(res.video.name)
+        this.isImportingVideo = false
+        this.hasImportedVideo = true
+
+        this.video = new VideoEdit(Object.assign(res.video, {
+          commentsEnabled: videoUpdate.commentsEnabled,
+          support: null,
+          thumbnailUrl: null,
+          previewUrl: null
+        }))
+        this.hydrateFormFromVideo()
+      },
+
+      err => {
+        this.isImportingVideo = false
+        this.notificationsService.error(this.i18n('Error'), err.message)
+      }
+    )
+  }
+
+  updateSecondStep () {
+    if (this.checkForm() === false) {
+      return
+    }
+
+    this.video.patch(this.form.value)
+
+    this.loadingBar.start()
+    this.isUpdatingVideo = true
+
+    // Update the video
+    this.videoService.updateVideo(this.video)
+        .pipe(
+          // Then update captions
+          switchMap(() => this.videoCaptionService.updateCaptions(this.video.id, this.videoCaptions))
+        )
+        .subscribe(
+          () => {
+            this.isUpdatingVideo = false
+            this.loadingBar.complete()
+            this.notificationsService.success(this.i18n('Success'), this.i18n('Video to import updated.'))
+
+            // TODO: route to imports list
+            // this.router.navigate([ '/videos/watch', this.video.uuid ])
+          },
+
+          err => {
+            this.loadingBar.complete()
+            this.isUpdatingVideo = false
+            this.notificationsService.error(this.i18n('Error'), err.message)
+            console.error(err)
+          }
+        )
+
+  }
+
+  private hydrateFormFromVideo () {
+    this.form.patchValue(this.video.toFormPatch())
+  }
+}
index 798c48f3cc9c88fa2df39464d0b00bfa4c819369..0c60e3439c29433ef445e5162358b49deb6334e5 100644 (file)
@@ -126,7 +126,6 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
             console.error(err)
           }
         )
-
   }
 
   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 (file)
index 0000000..8c07231
--- /dev/null
@@ -0,0 +1,58 @@
+<div *ngIf="!isUploadingVideo" class="upload-video-container">
+  <div class="upload-video">
+    <div class="icon icon-upload"></div>
+
+    <div class="button-file">
+      <span i18n>Select the file to upload</span>
+      <input #videofileInput type="file" name="videofile" id="videofile" [accept]="videoExtensions" (change)="fileChange()" />
+    </div>
+    <span class="button-file-extension">(.mp4, .webm, .ogv)</span>
+
+    <div class="form-group form-group-channel">
+      <label i18n for="first-step-channel">Channel</label>
+      <div class="peertube-select-container">
+        <select id="first-step-channel" [(ngModel)]="firstStepChannelId">
+          <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option>
+        </select>
+      </div>
+    </div>
+
+    <div class="form-group">
+      <label i18n for="first-step-privacy">Privacy</label>
+      <div class="peertube-select-container">
+        <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId">
+          <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option>
+          <option [value]="SPECIAL_SCHEDULED_PRIVACY">Scheduled</option>
+        </select>
+      </div>
+    </div>
+  </div>
+</div>
+
+<div *ngIf="isUploadingVideo" class="upload-progress-cancel">
+  <p-progressBar
+    [value]="videoUploadPercents"
+    [ngClass]="{ processing: videoUploadPercents === 100 && videoUploaded === false }"
+  ></p-progressBar>
+  <input *ngIf="videoUploaded === false" type="button" value="Cancel" (click)="cancelUpload()" />
+</div>
+
+<!-- Hidden because we want to load the component -->
+<form [hidden]="!isUploadingVideo" novalidate [formGroup]="form">
+  <my-video-edit
+    [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions"
+    [validationMessages]="validationMessages" [videoPrivacies]="videoPrivacies" [userVideoChannels]="userVideoChannels"
+  ></my-video-edit>
+
+  <div class="submit-container">
+    <div i18n *ngIf="videoUploaded === false" class="message-submit">Publish will be available when upload is finished</div>
+
+    <div class="submit-button"
+       (click)="updateSecondStep()"
+       [ngClass]="{ disabled: !form.valid || isUpdatingVideo === true || videoUploaded !== true }"
+    >
+      <span class="icon icon-validate"></span>
+      <input type="button" i18n-value value="Publish" />
+    </div>
+  </div>
+</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 (file)
index 0000000..0158356
--- /dev/null
@@ -0,0 +1,85 @@
+@import '_variables';
+@import '_mixins';
+
+.peertube-select-container {
+  @include peertube-select-container(190px);
+}
+
+.upload-video {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+
+  .form-group-channel {
+    margin-bottom: 20px;
+    margin-top: 35px;
+  }
+
+  .icon.icon-upload {
+    @include icon(90px);
+    margin-bottom: 25px;
+    cursor: default;
+
+    background-image: url('../../../assets/images/video/upload.svg');
+  }
+
+  .button-file {
+    @include peertube-button-file(auto);
+
+    min-width: 190px;
+  }
+
+  .button-file-extension {
+    display: block;
+    font-size: 12px;
+    margin-top: 5px;
+  }
+}
+
+.upload-progress-cancel {
+  display: flex;
+  margin-top: 25px;
+  margin-bottom: 40px;
+
+  p-progressBar {
+    flex-grow: 1;
+
+    /deep/ .ui-progressbar {
+      font-size: 15px !important;
+      color: #fff !important;
+      height: 30px !important;
+      line-height: 30px !important;
+      border-radius: 3px !important;
+      background-color: rgba(11, 204, 41, 0.16) !important;
+
+      .ui-progressbar-value {
+        background-color: #0BCC29 !important;
+      }
+
+      .ui-progressbar-label {
+        text-align: left;
+        padding-left: 18px;
+        margin-top: 0 !important;
+      }
+    }
+
+    &.processing {
+      /deep/ .ui-progressbar-label {
+        // Same color as background to hide "100%"
+        color: rgba(11, 204, 41, 0.16) !important;
+
+        &::before {
+          content: 'Processing...';
+          color: #fff;
+        }
+      }
+    }
+  }
+
+  input {
+    @include peertube-button;
+    @include grey-button;
+
+    margin-left: 10px;
+  }
+}
\ 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 (file)
index 0000000..e6c391d
--- /dev/null
@@ -0,0 +1,251 @@
+import { HttpEventType, HttpResponse } from '@angular/common/http'
+import { Component, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'
+import { Router } from '@angular/router'
+import { UserService } from '@app/shared'
+import { CanComponentDeactivate } from '@app/shared/guards/can-deactivate-guard.service'
+import { LoadingBarService } from '@ngx-loading-bar/core'
+import { NotificationsService } from 'angular2-notifications'
+import { BytesPipe } from 'ngx-pipes'
+import { Subscription } from 'rxjs'
+import { VideoConstant, VideoPrivacy } from '../../../../../shared/models/videos'
+import { AuthService, ServerService } from '../../core'
+import { FormReactive } from '../../shared'
+import { populateAsyncUserVideoChannels } from '../../shared/misc/utils'
+import { VideoEdit } from '../../shared/video/video-edit.model'
+import { VideoService } from '../../shared/video/video.service'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
+import { switchMap } from 'rxjs/operators'
+import { VideoCaptionService } from '@app/shared/video-caption'
+import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model'
+
+@Component({
+  selector: 'my-video-upload',
+  templateUrl: './video-upload.component.html',
+  styleUrls: [
+    './shared/video-edit.component.scss',
+    './video-upload.component.scss'
+  ]
+})
+export class VideoUploadComponent extends FormReactive implements OnInit, OnDestroy, CanComponentDeactivate {
+  @Output() firstStepDone = new EventEmitter<string>()
+  @ViewChild('videofileInput') videofileInput
+
+  // So that it can be accessed in the template
+  readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY
+
+  isUploadingVideo = false
+  isUpdatingVideo = false
+  videoUploaded = false
+  videoUploadObservable: Subscription = null
+  videoUploadPercents = 0
+  videoUploadedIds = {
+    id: 0,
+    uuid: ''
+  }
+
+  userVideoChannels: { id: number, label: string, support: string }[] = []
+  userVideoQuotaUsed = 0
+  videoPrivacies: VideoConstant<string>[] = []
+  firstStepPrivacyId = 0
+  firstStepChannelId = 0
+  videoCaptions: VideoCaptionEdit[] = []
+
+  constructor (
+    protected formValidatorService: FormValidatorService,
+    private router: Router,
+    private notificationsService: NotificationsService,
+    private authService: AuthService,
+    private userService: UserService,
+    private serverService: ServerService,
+    private videoService: VideoService,
+    private loadingBar: LoadingBarService,
+    private i18n: I18n,
+    private videoCaptionService: VideoCaptionService
+  ) {
+    super()
+  }
+
+  get videoExtensions () {
+    return this.serverService.getConfig().video.file.extensions.join(',')
+  }
+
+  ngOnInit () {
+    this.buildForm({})
+
+    populateAsyncUserVideoChannels(this.authService, this.userVideoChannels)
+      .then(() => this.firstStepChannelId = this.userVideoChannels[0].id)
+
+    this.userService.getMyVideoQuotaUsed()
+      .subscribe(data => this.userVideoQuotaUsed = data.videoQuotaUsed)
+
+    this.serverService.videoPrivaciesLoaded
+      .subscribe(
+        () => {
+          this.videoPrivacies = this.serverService.getVideoPrivacies()
+
+          // Public by default
+          this.firstStepPrivacyId = VideoPrivacy.PUBLIC
+        })
+  }
+
+  ngOnDestroy () {
+    if (this.videoUploadObservable) {
+      this.videoUploadObservable.unsubscribe()
+    }
+  }
+
+  canDeactivate () {
+    let text = ''
+
+    if (this.videoUploaded === true) {
+      // FIXME: cannot concatenate strings inside i18n service :/
+      text = this.i18n('Your video was uploaded in your account and is private.') +
+        this.i18n('But associated data (tags, description...) will be lost, are you sure you want to leave this page?')
+    } else {
+      text = this.i18n('Your video is not uploaded yet, are you sure you want to leave this page?')
+    }
+
+    return {
+      canDeactivate: !this.isUploadingVideo,
+      text
+    }
+  }
+
+  fileChange () {
+    this.uploadFirstStep()
+  }
+
+  checkForm () {
+    this.forceCheck()
+
+    return this.form.valid
+  }
+
+  cancelUpload () {
+    if (this.videoUploadObservable !== null) {
+      this.videoUploadObservable.unsubscribe()
+      this.isUploadingVideo = false
+      this.videoUploadPercents = 0
+      this.videoUploadObservable = null
+      this.notificationsService.info(this.i18n('Info'), this.i18n('Upload cancelled'))
+    }
+  }
+
+  uploadFirstStep () {
+    const videofile = this.videofileInput.nativeElement.files[0] as File
+    if (!videofile) return
+
+    // Cannot upload videos > 8GB for now
+    if (videofile.size > 8 * 1024 * 1024 * 1024) {
+      this.notificationsService.error(this.i18n('Error'), this.i18n('We are sorry but PeerTube cannot handle videos > 8GB'))
+      return
+    }
+
+    const videoQuota = this.authService.getUser().videoQuota
+    if (videoQuota !== -1 && (this.userVideoQuotaUsed + videofile.size) > videoQuota) {
+      const bytePipes = new BytesPipe()
+
+      const msg = this.i18n(
+        'Your video quota is exceeded with this video (video size: {{ videoSize }}, used: {{ videoQuotaUsed }}, quota: {{ videoQuota }})',
+        {
+          videoSize: bytePipes.transform(videofile.size, 0),
+          videoQuotaUsed: bytePipes.transform(this.userVideoQuotaUsed, 0),
+          videoQuota: bytePipes.transform(videoQuota, 0)
+        }
+      )
+      this.notificationsService.error(this.i18n('Error'), msg)
+      return
+    }
+
+    const nameWithoutExtension = videofile.name.replace(/\.[^/.]+$/, '')
+    let name: string
+
+    // If the name of the file is very small, keep the extension
+    if (nameWithoutExtension.length < 3) name = videofile.name
+    else name = nameWithoutExtension
+
+    const privacy = this.firstStepPrivacyId.toString()
+    const nsfw = false
+    const waitTranscoding = true
+    const commentsEnabled = true
+    const channelId = this.firstStepChannelId.toString()
+
+    const formData = new FormData()
+    formData.append('name', name)
+    // Put the video "private" -> we are waiting the user validation of the second step
+    formData.append('privacy', VideoPrivacy.PRIVATE.toString())
+    formData.append('nsfw', '' + nsfw)
+    formData.append('commentsEnabled', '' + commentsEnabled)
+    formData.append('waitTranscoding', '' + waitTranscoding)
+    formData.append('channelId', '' + channelId)
+    formData.append('videofile', videofile)
+
+    this.isUploadingVideo = true
+    this.firstStepDone.emit(name)
+
+    this.form.patchValue({
+      name,
+      privacy,
+      nsfw,
+      channelId
+    })
+
+    this.videoUploadObservable = this.videoService.uploadVideo(formData).subscribe(
+      event => {
+        if (event.type === HttpEventType.UploadProgress) {
+          this.videoUploadPercents = Math.round(100 * event.loaded / event.total)
+        } else if (event instanceof HttpResponse) {
+          this.videoUploaded = true
+
+          this.videoUploadedIds = event.body.video
+
+          this.videoUploadObservable = null
+        }
+      },
+
+      err => {
+        // Reset progress
+        this.isUploadingVideo = false
+        this.videoUploadPercents = 0
+        this.videoUploadObservable = null
+        this.notificationsService.error(this.i18n('Error'), err.message)
+      }
+    )
+  }
+
+  updateSecondStep () {
+    if (this.checkForm() === false) {
+      return
+    }
+
+    const video = new VideoEdit()
+    video.patch(this.form.value)
+    video.id = this.videoUploadedIds.id
+    video.uuid = this.videoUploadedIds.uuid
+
+    this.isUpdatingVideo = true
+    this.loadingBar.start()
+    this.videoService.updateVideo(video)
+        .pipe(
+          // Then update captions
+          switchMap(() => this.videoCaptionService.updateCaptions(video.id, this.videoCaptions))
+        )
+        .subscribe(
+          () => {
+            this.isUpdatingVideo = false
+            this.isUploadingVideo = false
+            this.loadingBar.complete()
+
+            this.notificationsService.success(this.i18n('Success'), this.i18n('Video published.'))
+            this.router.navigate([ '/videos/watch', video.uuid ])
+          },
+
+          err => {
+            this.isUpdatingVideo = false
+            this.notificationsService.error(this.i18n('Error'), err.message)
+            console.error(err)
+          }
+        )
+  }
+}
index 3b877476db72f46aaa4ae7387d09c8c266cc69ae..722b33db3943a57d9b737d27a526f83a7dee99b9 100644 (file)
@@ -92,6 +92,12 @@ transcoding:
     720p: false
     1080p: false
 
+import:
+  # Add ability for your users to import remote videos (from YouTube, torrent...)
+  videos:
+    http: # Classic HTTP or all sites supported by youtube-dl https://rg3.github.io/youtube-dl/supportedsites.html
+      enabled: true
+
 instance:
   name: 'PeerTube'
   short_description: 'PeerTube, a federated (ActivityPub) video streaming platform using P2P (BitTorrent) directly in the web browser with WebTorrent and Angular.'
index cd0a3c5b81c8b6d210b11d8676536d3cac41b8d3..8627fe9bafd22937c4f3f2bb17ead2fa4ad0874a 100644 (file)
@@ -16,6 +16,7 @@
     "type": "git",
     "url": "git://github.com/Chocobozzz/PeerTube.git"
   },
+  "typings": "*.d.ts",
   "scripts": {
     "e2e": "scripty",
     "build": "SCRIPTY_PARALLEL=true scripty",
     "validator": "^10.2.0",
     "webfinger.js": "^2.6.6",
     "winston": "3.0.0",
-    "ws": "^5.0.0"
+    "ws": "^5.0.0",
+    "youtube-dl": "^1.12.2"
   },
   "devDependencies": {
     "@types/async": "^2.0.40",
     "tslint-config-standard": "^7.0.0",
     "typescript": "^2.5.2",
     "webtorrent": "^0.100.0",
-    "xliff": "^3.0.1",
-    "youtube-dl": "^1.12.2"
+    "xliff": "^3.0.1"
   },
   "scripty": {
     "silent": true
diff --git a/server/controllers/api/videos/import.ts b/server/controllers/api/videos/import.ts
new file mode 100644 (file)
index 0000000..9761cdb
--- /dev/null
@@ -0,0 +1,151 @@
+import * as express from 'express'
+import { auditLoggerFactory } from '../../../helpers/audit-logger'
+import {
+  asyncMiddleware,
+  asyncRetryTransactionMiddleware,
+  authenticate,
+  videoImportAddValidator,
+  videoImportDeleteValidator
+} from '../../../middlewares'
+import { CONFIG, IMAGE_MIMETYPE_EXT, PREVIEWS_SIZE, sequelizeTypescript, THUMBNAILS_SIZE } from '../../../initializers'
+import { getYoutubeDLInfo, YoutubeDLInfo } from '../../../helpers/youtube-dl'
+import { createReqFiles } from '../../../helpers/express-utils'
+import { logger } from '../../../helpers/logger'
+import { VideoImportCreate, VideoImportState, VideoPrivacy, VideoState } from '../../../../shared'
+import { VideoModel } from '../../../models/video/video'
+import { getVideoActivityPubUrl } from '../../../lib/activitypub'
+import { TagModel } from '../../../models/video/tag'
+import { VideoImportModel } from '../../../models/video/video-import'
+import { JobQueue } from '../../../lib/job-queue/job-queue'
+import { processImage } from '../../../helpers/image-utils'
+import { join } from 'path'
+
+const auditLogger = auditLoggerFactory('video-imports')
+const videoImportsRouter = express.Router()
+
+const reqVideoFileImport = createReqFiles(
+  [ 'thumbnailfile', 'previewfile' ],
+  IMAGE_MIMETYPE_EXT,
+  {
+    thumbnailfile: CONFIG.STORAGE.THUMBNAILS_DIR,
+    previewfile: CONFIG.STORAGE.PREVIEWS_DIR
+  }
+)
+
+videoImportsRouter.post('/imports',
+  authenticate,
+  reqVideoFileImport,
+  asyncMiddleware(videoImportAddValidator),
+  asyncRetryTransactionMiddleware(addVideoImport)
+)
+
+videoImportsRouter.delete('/imports/:id',
+  authenticate,
+  videoImportDeleteValidator,
+  asyncRetryTransactionMiddleware(deleteVideoImport)
+)
+
+// ---------------------------------------------------------------------------
+
+export {
+  videoImportsRouter
+}
+
+// ---------------------------------------------------------------------------
+
+async function addVideoImport (req: express.Request, res: express.Response) {
+  const body: VideoImportCreate = req.body
+  const targetUrl = body.targetUrl
+
+  let youtubeDLInfo: YoutubeDLInfo
+  try {
+    youtubeDLInfo = await getYoutubeDLInfo(targetUrl)
+  } catch (err) {
+    logger.info('Cannot fetch information from import for URL %s.', targetUrl, { err })
+
+    return res.status(400).json({
+      error: 'Cannot fetch remote information of this URL.'
+    }).end()
+  }
+
+  // Create video DB object
+  const videoData = {
+    name: body.name || youtubeDLInfo.name,
+    remote: false,
+    category: body.category || youtubeDLInfo.category,
+    licence: body.licence || youtubeDLInfo.licence,
+    language: undefined,
+    commentsEnabled: body.commentsEnabled || true,
+    waitTranscoding: body.waitTranscoding || false,
+    state: VideoState.TO_IMPORT,
+    nsfw: body.nsfw || youtubeDLInfo.nsfw || false,
+    description: body.description || youtubeDLInfo.description,
+    support: body.support || null,
+    privacy: body.privacy || VideoPrivacy.PRIVATE,
+    duration: 0, // duration will be set by the import job
+    channelId: res.locals.videoChannel.id
+  }
+  const video = new VideoModel(videoData)
+  video.url = getVideoActivityPubUrl(video)
+
+  // Process thumbnail file?
+  const thumbnailField = req.files['thumbnailfile']
+  let downloadThumbnail = true
+  if (thumbnailField) {
+    const thumbnailPhysicalFile = thumbnailField[ 0 ]
+    await processImage(thumbnailPhysicalFile, join(CONFIG.STORAGE.THUMBNAILS_DIR, video.getThumbnailName()), THUMBNAILS_SIZE)
+    downloadThumbnail = false
+  }
+
+  // Process preview file?
+  const previewField = req.files['previewfile']
+  let downloadPreview = true
+  if (previewField) {
+    const previewPhysicalFile = previewField[0]
+    await processImage(previewPhysicalFile, join(CONFIG.STORAGE.PREVIEWS_DIR, video.getPreviewName()), PREVIEWS_SIZE)
+    downloadPreview = false
+  }
+
+  const videoImport: VideoImportModel = await sequelizeTypescript.transaction(async t => {
+    const sequelizeOptions = { transaction: t }
+
+    // Save video object in database
+    const videoCreated = await video.save(sequelizeOptions)
+    videoCreated.VideoChannel = res.locals.videoChannel
+
+    // Set tags to the video
+    if (youtubeDLInfo.tags !== undefined) {
+      const tagInstances = await TagModel.findOrCreateTags(youtubeDLInfo.tags, t)
+
+      await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
+      videoCreated.Tags = tagInstances
+    }
+
+    // Create video import object in database
+    const videoImport = await VideoImportModel.create({
+      targetUrl,
+      state: VideoImportState.PENDING,
+      videoId: videoCreated.id
+    }, sequelizeOptions)
+
+    videoImport.Video = videoCreated
+
+    return videoImport
+  })
+
+  // Create job to import the video
+  const payload = {
+    type: 'youtube-dl' as 'youtube-dl',
+    videoImportId: videoImport.id,
+    thumbnailUrl: youtubeDLInfo.thumbnailUrl,
+    downloadThumbnail,
+    downloadPreview
+  }
+  await JobQueue.Instance.createJob({ type: 'video-import', payload })
+
+  return res.json(videoImport.toFormattedJSON())
+}
+
+async function deleteVideoImport (req: express.Request, res: express.Response) {
+  // TODO: delete video import
+}
index e396ee6be7b83a4462b90ce0af63bf24ac4aca90..c9365da08f33065658b22e4582cbd3212f643536 100644 (file)
@@ -54,6 +54,7 @@ import { VideoFilter } from '../../../../shared/models/videos/video-query.type'
 import { createReqFiles, buildNSFWFilter } from '../../../helpers/express-utils'
 import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
 import { videoCaptionsRouter } from './captions'
+import { videoImportsRouter } from './import'
 
 const auditLogger = auditLoggerFactory('videos')
 const videosRouter = express.Router()
@@ -81,6 +82,7 @@ videosRouter.use('/', blacklistRouter)
 videosRouter.use('/', rateVideoRouter)
 videosRouter.use('/', videoCommentRouter)
 videosRouter.use('/', videoCaptionsRouter)
+videosRouter.use('/', videoImportsRouter)
 
 videosRouter.get('/categories', listVideoCategories)
 videosRouter.get('/licences', listVideoLicences)
@@ -160,7 +162,6 @@ async function addVideo (req: express.Request, res: express.Response) {
   const videoData = {
     name: videoInfo.name,
     remote: false,
-    extname: extname(videoPhysicalFile.filename),
     category: videoInfo.category,
     licence: videoInfo.licence,
     language: videoInfo.language,
index d97bbd2a94636e6560593dfa2ec83a1c979712b8..c6a3502367fc526a2d8ceae5ca81e8a56968df52 100644 (file)
@@ -45,7 +45,7 @@ function isActivityPubVideoDurationValid (value: string) {
 }
 
 function sanitizeAndCheckVideoTorrentObject (video: any) {
-  if (video.type !== 'Video') return false
+  if (!video || video.type !== 'Video') return false
 
   if (!setValidRemoteTags(video)) return false
   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 (file)
index 0000000..36c0559
--- /dev/null
@@ -0,0 +1,30 @@
+import 'express-validator'
+import 'multer'
+import * as validator from 'validator'
+import { CONSTRAINTS_FIELDS, VIDEO_IMPORT_STATES } from '../../initializers'
+import { exists } from './misc'
+
+function isVideoImportTargetUrlValid (url: string) {
+  const isURLOptions = {
+    require_host: true,
+    require_tld: true,
+    require_protocol: true,
+    require_valid_protocol: true,
+    protocols: [ 'http', 'https' ]
+  }
+
+  return exists(url) &&
+    validator.isURL('' + url, isURLOptions) &&
+    validator.isLength('' + url, CONSTRAINTS_FIELDS.VIDEO_IMPORTS.URL)
+}
+
+function isVideoImportStateValid (value: any) {
+  return exists(value) && VIDEO_IMPORT_STATES[ value ] !== undefined
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  isVideoImportStateValid,
+  isVideoImportTargetUrlValid
+}
index 04a19a9c61adb537cb42249176f67d6f99adf028..480c5b49eac3df0ee1f2cc4766bab769f6b0ea15 100644 (file)
@@ -22,7 +22,7 @@ function loggerReplacer (key: string, value: any) {
 }
 
 const consoleLoggerFormat = winston.format.printf(info => {
-  let additionalInfos = JSON.stringify(info.meta, loggerReplacer, 2)
+  let additionalInfos = JSON.stringify(info.meta || info.err, loggerReplacer, 2)
   if (additionalInfos === undefined || additionalInfos === '{}') additionalInfos = ''
   else additionalInfos = ' ' + additionalInfos
 
diff --git a/server/helpers/youtube-dl.ts b/server/helpers/youtube-dl.ts
new file mode 100644 (file)
index 0000000..74d3e21
--- /dev/null
@@ -0,0 +1,142 @@
+import * as youtubeDL from 'youtube-dl'
+import { truncate } from 'lodash'
+import { CONFIG, CONSTRAINTS_FIELDS, VIDEO_CATEGORIES } from '../initializers'
+import { join } from 'path'
+import * as crypto from 'crypto'
+import { logger } from './logger'
+
+export type YoutubeDLInfo = {
+  name: string
+  description: string
+  category: number
+  licence: number
+  nsfw: boolean
+  tags: string[]
+  thumbnailUrl: string
+}
+
+function getYoutubeDLInfo (url: string): Promise<YoutubeDLInfo> {
+  return new Promise<YoutubeDLInfo>((res, rej) => {
+    const options = [ '-j', '--flat-playlist' ]
+
+    youtubeDL.getInfo(url, options, (err, info) => {
+      if (err) return rej(err)
+
+      const obj = normalizeObject(info)
+
+      return res(buildVideoInfo(obj))
+    })
+  })
+}
+
+function downloadYoutubeDLVideo (url: string) {
+  const hash = crypto.createHash('sha256').update(url).digest('base64')
+  const path = join(CONFIG.STORAGE.VIDEOS_DIR, hash + '-import.mp4')
+
+  logger.info('Importing video %s', url)
+
+  const options = [ '-f', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best', '-o', path ]
+
+  return new Promise<string>((res, rej) => {
+    youtubeDL.exec(url, options, async (err, output) => {
+      if (err) return rej(err)
+
+      return res(path)
+    })
+  })
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  downloadYoutubeDLVideo,
+  getYoutubeDLInfo
+}
+
+// ---------------------------------------------------------------------------
+
+function normalizeObject (obj: any) {
+  const newObj: any = {}
+
+  for (const key of Object.keys(obj)) {
+    // Deprecated key
+    if (key === 'resolution') continue
+
+    const value = obj[key]
+
+    if (typeof value === 'string') {
+      newObj[key] = value.normalize()
+    } else {
+      newObj[key] = value
+    }
+  }
+
+  return newObj
+}
+
+function buildVideoInfo (obj: any) {
+  return {
+    name: titleTruncation(obj.title),
+    description: descriptionTruncation(obj.description),
+    category: getCategory(obj.categories),
+    licence: getLicence(obj.license),
+    nsfw: isNSFW(obj),
+    tags: getTags(obj.tags),
+    thumbnailUrl: obj.thumbnail || undefined
+  }
+}
+
+function titleTruncation (title: string) {
+  return truncate(title, {
+    'length': CONSTRAINTS_FIELDS.VIDEOS.NAME.max,
+    'separator': /,? +/,
+    'omission': ' […]'
+  })
+}
+
+function descriptionTruncation (description: string) {
+  if (!description) return undefined
+
+  return truncate(description, {
+    'length': CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max,
+    'separator': /,? +/,
+    'omission': ' […]'
+  })
+}
+
+function isNSFW (info: any) {
+  return info.age_limit && info.age_limit >= 16
+}
+
+function getTags (tags: any) {
+  if (Array.isArray(tags) === false) return []
+
+  return tags
+    .filter(t => t.length < CONSTRAINTS_FIELDS.VIDEOS.TAG.max && t.length > CONSTRAINTS_FIELDS.VIDEOS.TAG.min)
+    .map(t => t.normalize())
+    .slice(0, 5)
+}
+
+function getLicence (licence: string) {
+  if (!licence) return undefined
+
+  if (licence.indexOf('Creative Commons Attribution licence') !== -1) return 1
+
+  return undefined
+}
+
+function getCategory (categories: string[]) {
+  if (!categories) return undefined
+
+  const categoryString = categories[0]
+  if (!categoryString || typeof categoryString !== 'string') return undefined
+
+  if (categoryString === 'News & Politics') return 11
+
+  for (const key of Object.keys(VIDEO_CATEGORIES)) {
+    const category = VIDEO_CATEGORIES[key]
+    if (categoryString.toLowerCase() === category.toLowerCase()) return parseInt(key, 10)
+  }
+
+  return undefined
+}
index bec343bb7c4a33ef366384e0ab34d6fed16ae9e3..fdd772d849acce2f6a8b7da32f3fd01fd8df4431 100644 (file)
@@ -8,6 +8,7 @@ import { VideoPrivacy } from '../../shared/models/videos'
 import { buildPath, isTestInstance, root, sanitizeHost, sanitizeUrl } from '../helpers/core-utils'
 import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type'
 import { invert } from 'lodash'
+import { VideoImportState } from '../../shared/models/videos/video-import-state.enum'
 
 // Use a variable to reload the configuration if we need
 let config: IConfig = require('config')
@@ -85,6 +86,7 @@ const JOB_ATTEMPTS: { [ id in JobType ]: number } = {
   'activitypub-follow': 5,
   'video-file-import': 1,
   'video-file': 1,
+  'video-import': 1,
   'email': 5
 }
 const JOB_CONCURRENCY: { [ id in JobType ]: number } = {
@@ -94,6 +96,7 @@ const JOB_CONCURRENCY: { [ id in JobType ]: number } = {
   'activitypub-follow': 3,
   'video-file-import': 1,
   'video-file': 1,
+  'video-import': 1,
   'email': 5
 }
 const BROADCAST_CONCURRENCY = 10 // How many requests in parallel we do in activitypub-http-broadcast job
@@ -248,6 +251,9 @@ const CONSTRAINTS_FIELDS = {
       }
     }
   },
+  VIDEO_IMPORTS: {
+    URL: { min: 3, max: 2000 } // Length
+  },
   VIDEOS: {
     NAME: { min: 3, max: 120 }, // Length
     LANGUAGE: { min: 1, max: 10 }, // Length
@@ -262,7 +268,7 @@ const CONSTRAINTS_FIELDS = {
     },
     EXTNAME: [ '.mp4', '.ogv', '.webm' ],
     INFO_HASH: { min: 40, max: 40 }, // Length, info hash is 20 bytes length but we represent it in hexadecimal so 20 * 2
-    DURATION: { min: 1 }, // Number
+    DURATION: { min: 0 }, // Number
     TAGS: { min: 0, max: 5 }, // Number of total tags
     TAG: { min: 2, max: 30 }, // Length
     THUMBNAIL: { min: 2, max: 30 },
@@ -363,7 +369,14 @@ const VIDEO_PRIVACIES = {
 
 const VIDEO_STATES = {
   [VideoState.PUBLISHED]: 'Published',
-  [VideoState.TO_TRANSCODE]: 'To transcode'
+  [VideoState.TO_TRANSCODE]: 'To transcode',
+  [VideoState.TO_IMPORT]: 'To import'
+}
+
+const VIDEO_IMPORT_STATES = {
+  [VideoImportState.FAILED]: 'Failed',
+  [VideoImportState.PENDING]: 'Pending',
+  [VideoImportState.SUCCESS]: 'Success'
 }
 
 const VIDEO_MIMETYPE_EXT = {
@@ -585,6 +598,7 @@ export {
   RATES_LIMIT,
   VIDEO_EXT_MIMETYPE,
   JOB_COMPLETED_LIFETIME,
+  VIDEO_IMPORT_STATES,
   VIDEO_VIEW_LIFETIME,
   buildLanguages
 }
index 21c08308457bf3e996f74c2b0fbcaaa672c4e55c..0be7523637d86a0ce77c41fa9f17d6c8ef649686 100644 (file)
@@ -24,6 +24,7 @@ import { VideoTagModel } from '../models/video/video-tag'
 import { CONFIG } from './constants'
 import { ScheduleVideoUpdateModel } from '../models/video/schedule-video-update'
 import { VideoCaptionModel } from '../models/video/video-caption'
+import { VideoImportModel } from '../models/video/video-import'
 
 require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
 
@@ -81,7 +82,8 @@ async function initDatabaseModels (silent: boolean) {
     VideoTagModel,
     VideoModel,
     VideoCommentModel,
-    ScheduleVideoUpdateModel
+    ScheduleVideoUpdateModel,
+    VideoImportModel
   ])
 
   // 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 (file)
index 0000000..2f219e9
--- /dev/null
@@ -0,0 +1,129 @@
+import * as Bull from 'bull'
+import { logger } from '../../../helpers/logger'
+import { downloadYoutubeDLVideo } from '../../../helpers/youtube-dl'
+import { VideoImportModel } from '../../../models/video/video-import'
+import { VideoImportState } from '../../../../shared/models/videos'
+import { getDurationFromVideoFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffmpeg-utils'
+import { extname, join } from 'path'
+import { VideoFileModel } from '../../../models/video/video-file'
+import { renamePromise, statPromise, unlinkPromise } from '../../../helpers/core-utils'
+import { CONFIG, sequelizeTypescript } from '../../../initializers'
+import { doRequestAndSaveToFile } from '../../../helpers/requests'
+import { VideoState } from '../../../../shared'
+import { JobQueue } from '../index'
+import { federateVideoIfNeeded } from '../../activitypub'
+
+export type VideoImportPayload = {
+  type: 'youtube-dl'
+  videoImportId: number
+  thumbnailUrl: string
+  downloadThumbnail: boolean
+  downloadPreview: boolean
+}
+
+async function processVideoImport (job: Bull.Job) {
+  const payload = job.data as VideoImportPayload
+  logger.info('Processing video import in job %d.', job.id)
+
+  const videoImport = await VideoImportModel.loadAndPopulateVideo(payload.videoImportId)
+  if (!videoImport) throw new Error('Cannot import video %s: the video import entry does not exist anymore.')
+
+  let tempVideoPath: string
+  try {
+    // Download video from youtubeDL
+    tempVideoPath = await downloadYoutubeDLVideo(videoImport.targetUrl)
+
+    // Get information about this video
+    const { videoFileResolution } = await getVideoFileResolution(tempVideoPath)
+    const fps = await getVideoFileFPS(tempVideoPath)
+    const stats = await statPromise(tempVideoPath)
+    const duration = await getDurationFromVideoFile(tempVideoPath)
+
+    // Create video file object in database
+    const videoFileData = {
+      extname: extname(tempVideoPath),
+      resolution: videoFileResolution,
+      size: stats.size,
+      fps,
+      videoId: videoImport.videoId
+    }
+    const videoFile = new VideoFileModel(videoFileData)
+
+    // Move file
+    const destination = join(CONFIG.STORAGE.VIDEOS_DIR, videoImport.Video.getVideoFilename(videoFile))
+    await renamePromise(tempVideoPath, destination)
+
+    // Process thumbnail
+    if (payload.downloadThumbnail) {
+      if (payload.thumbnailUrl) {
+        const destThumbnailPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, videoImport.Video.getThumbnailName())
+        await doRequestAndSaveToFile({ method: 'GET', uri: payload.thumbnailUrl }, destThumbnailPath)
+      } else {
+        await videoImport.Video.createThumbnail(videoFile)
+      }
+    }
+
+    // Process preview
+    if (payload.downloadPreview) {
+      if (payload.thumbnailUrl) {
+        const destPreviewPath = join(CONFIG.STORAGE.PREVIEWS_DIR, videoImport.Video.getPreviewName())
+        await doRequestAndSaveToFile({ method: 'GET', uri: payload.thumbnailUrl }, destPreviewPath)
+      } else {
+        await videoImport.Video.createPreview(videoFile)
+      }
+    }
+
+    // Create torrent
+    await videoImport.Video.createTorrentAndSetInfoHash(videoFile)
+
+    const videoImportUpdated: VideoImportModel = await sequelizeTypescript.transaction(async t => {
+      await videoFile.save({ transaction: t })
+
+      // Update video DB object
+      videoImport.Video.duration = duration
+      videoImport.Video.state = CONFIG.TRANSCODING.ENABLED ? VideoState.TO_TRANSCODE : VideoState.PUBLISHED
+      const videoUpdated = await videoImport.Video.save({ transaction: t })
+
+      // Now we can federate the video
+      await federateVideoIfNeeded(videoImport.Video, true, t)
+
+      // Update video import object
+      videoImport.state = VideoImportState.SUCCESS
+      const videoImportUpdated = await videoImport.save({ transaction: t })
+
+      logger.info('Video %s imported.', videoImport.targetUrl)
+
+      videoImportUpdated.Video = videoUpdated
+      return videoImportUpdated
+    })
+
+    // Create transcoding jobs?
+    if (videoImportUpdated.Video.state === VideoState.TO_TRANSCODE) {
+      // Put uuid because we don't have id auto incremented for now
+      const dataInput = {
+        videoUUID: videoImportUpdated.Video.uuid,
+        isNewVideo: true
+      }
+
+      await JobQueue.Instance.createJob({ type: 'video-file', payload: dataInput })
+    }
+
+  } catch (err) {
+    try {
+      if (tempVideoPath) await unlinkPromise(tempVideoPath)
+    } catch (errUnlink) {
+      logger.error('Cannot cleanup files after a video import error.', { err: errUnlink })
+    }
+
+    videoImport.state = VideoImportState.FAILED
+    await videoImport.save()
+
+    throw err
+  }
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  processVideoImport
+}
index 8ff0c169e092276543b2eaa5dfbaf6aa12906787..2e14867f247134ce6782b31e0e1268abaac53aa6 100644 (file)
@@ -9,6 +9,7 @@ import { ActivitypubHttpUnicastPayload, processActivityPubHttpUnicast } from './
 import { EmailPayload, processEmail } from './handlers/email'
 import { processVideoFile, processVideoFileImport, VideoFileImportPayload, VideoFilePayload } from './handlers/video-file'
 import { ActivitypubFollowPayload, processActivityPubFollow } from './handlers/activitypub-follow'
+import { processVideoImport, VideoImportPayload } from './handlers/video-import'
 
 type CreateJobArgument =
   { type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } |
@@ -17,7 +18,8 @@ type CreateJobArgument =
   { type: 'activitypub-follow', payload: ActivitypubFollowPayload } |
   { type: 'video-file-import', payload: VideoFileImportPayload } |
   { type: 'video-file', payload: VideoFilePayload } |
-  { type: 'email', payload: EmailPayload }
+  { type: 'email', payload: EmailPayload } |
+  { type: 'video-import', payload: VideoImportPayload }
 
 const handlers: { [ id in JobType ]: (job: Bull.Job) => Promise<any>} = {
   'activitypub-http-broadcast': processActivityPubHttpBroadcast,
@@ -26,7 +28,8 @@ const handlers: { [ id in JobType ]: (job: Bull.Job) => Promise<any>} = {
   'activitypub-follow': processActivityPubFollow,
   'video-file-import': processVideoFileImport,
   'video-file': processVideoFile,
-  'email': processEmail
+  'email': processEmail,
+  'video-import': processVideoImport
 }
 
 const jobsWithRequestTimeout: { [ id in JobType ]?: boolean } = {
@@ -43,7 +46,8 @@ const jobTypes: JobType[] = [
   'activitypub-http-unicast',
   'email',
   'video-file',
-  'video-file-import'
+  'video-file-import',
+  'video-import'
 ]
 
 class JobQueue {
index e3f0f5963c9d803bf6e6dfa9bbaaff8f1f34b0dc..c5400c8f5b5abb2aaf75d7b2944e890f94f8a86a 100644 (file)
@@ -11,3 +11,4 @@ export * from './video-blacklist'
 export * from './video-channels'
 export * from './webfinger'
 export * from './search'
+export * from './video-imports'
diff --git a/server/middlewares/validators/video-imports.ts b/server/middlewares/validators/video-imports.ts
new file mode 100644 (file)
index 0000000..0ba759f
--- /dev/null
@@ -0,0 +1,51 @@
+import * as express from 'express'
+import { body, param } from 'express-validator/check'
+import { isIdValid } from '../../helpers/custom-validators/misc'
+import { logger } from '../../helpers/logger'
+import { areValidationErrors } from './utils'
+import { getCommonVideoAttributes } from './videos'
+import { isVideoImportTargetUrlValid } from '../../helpers/custom-validators/video-imports'
+import { cleanUpReqFiles } from '../../helpers/utils'
+import { isVideoChannelOfAccountExist, isVideoNameValid } from '../../helpers/custom-validators/videos'
+
+const videoImportAddValidator = getCommonVideoAttributes().concat([
+  body('targetUrl').custom(isVideoImportTargetUrlValid).withMessage('Should have a valid video import target URL'),
+  body('channelId')
+    .toInt()
+    .custom(isIdValid).withMessage('Should have correct video channel id'),
+  body('name')
+    .optional()
+    .custom(isVideoNameValid).withMessage('Should have a valid name'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking videoImportAddValidator parameters', { parameters: req.body })
+
+    const user = res.locals.oauth.token.User
+
+    if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
+    if (!await isVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
+
+    return next()
+  }
+])
+
+const videoImportDeleteValidator = [
+  param('id').custom(isIdValid).not().isEmpty().withMessage('Should have a valid id'),
+
+  (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking videoImportDeleteValidator parameters', { parameters: req.body })
+
+    if (areValidationErrors(req, res)) return
+
+    return next()
+  }
+]
+
+// ---------------------------------------------------------------------------
+
+export {
+  videoImportAddValidator,
+  videoImportDeleteValidator
+}
+
+// ---------------------------------------------------------------------------
index 9357c1e39709ea3c1e5869c73181fd8bffad804c..c812d4677e28da3762284d4cef087526d5e8f95b 100644 (file)
@@ -223,36 +223,6 @@ const videosShareValidator = [
   }
 ]
 
-// ---------------------------------------------------------------------------
-
-export {
-  videosAddValidator,
-  videosUpdateValidator,
-  videosGetValidator,
-  videosRemoveValidator,
-  videosShareValidator,
-
-  videoAbuseReportValidator,
-
-  videoRateValidator
-}
-
-// ---------------------------------------------------------------------------
-
-function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) {
-  if (req.body.scheduleUpdate) {
-    if (!req.body.scheduleUpdate.updateAt) {
-      res.status(400)
-         .json({ error: 'Schedule update at is mandatory.' })
-         .end()
-
-      return true
-    }
-  }
-
-  return false
-}
-
 function getCommonVideoAttributes () {
   return [
     body('thumbnailfile')
@@ -319,3 +289,35 @@ function getCommonVideoAttributes () {
       .custom(isScheduleVideoUpdatePrivacyValid).withMessage('Should have correct schedule update privacy')
   ] as (ValidationChain | express.Handler)[]
 }
+
+// ---------------------------------------------------------------------------
+
+export {
+  videosAddValidator,
+  videosUpdateValidator,
+  videosGetValidator,
+  videosRemoveValidator,
+  videosShareValidator,
+
+  videoAbuseReportValidator,
+
+  videoRateValidator,
+
+  getCommonVideoAttributes
+}
+
+// ---------------------------------------------------------------------------
+
+function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) {
+  if (req.body.scheduleUpdate) {
+    if (!req.body.scheduleUpdate.updateAt) {
+      res.status(400)
+         .json({ error: 'Schedule update at is mandatory.' })
+         .end()
+
+      return true
+    }
+  }
+
+  return false
+}
index d674d8d22de3402f5a230a0289324bbd3c8739c9..66f5dcf2ef3d22d269769bc008cde0a782a1a4e5 100644 (file)
@@ -16,7 +16,6 @@ import {
 } from 'sequelize-typescript'
 import { Account } from '../../../shared/models/actors'
 import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts'
-import { logger } from '../../helpers/logger'
 import { sendDeleteActor } from '../../lib/activitypub/send'
 import { ActorModel } from '../activitypub/actor'
 import { ApplicationModel } from '../application/application'
diff --git a/server/models/video/video-import.ts b/server/models/video/video-import.ts
new file mode 100644 (file)
index 0000000..89eeafd
--- /dev/null
@@ -0,0 +1,105 @@
+import {
+  AllowNull,
+  BelongsTo,
+  Column,
+  CreatedAt,
+  DataType,
+  Default,
+  DefaultScope,
+  ForeignKey,
+  Is,
+  Model,
+  Table,
+  UpdatedAt
+} from 'sequelize-typescript'
+import { CONSTRAINTS_FIELDS } from '../../initializers'
+import { throwIfNotValid } from '../utils'
+import { VideoModel } from './video'
+import { isVideoImportStateValid, isVideoImportTargetUrlValid } from '../../helpers/custom-validators/video-imports'
+import { VideoImport, VideoImportState } from '../../../shared'
+import { VideoChannelModel } from './video-channel'
+import { AccountModel } from '../account/account'
+
+@DefaultScope({
+  include: [
+    {
+      model: () => VideoModel,
+      required: true,
+      include: [
+        {
+          model: () => VideoChannelModel,
+          required: true,
+          include: [
+            {
+              model: () => AccountModel,
+              required: true
+            }
+          ]
+        }
+      ]
+    }
+  ]
+})
+
+@Table({
+  tableName: 'videoImport',
+  indexes: [
+    {
+      fields: [ 'videoId' ],
+      unique: true
+    }
+  ]
+})
+export class VideoImportModel extends Model<VideoImportModel> {
+  @CreatedAt
+  createdAt: Date
+
+  @UpdatedAt
+  updatedAt: Date
+
+  @AllowNull(false)
+  @Is('VideoImportTargetUrl', value => throwIfNotValid(value, isVideoImportTargetUrlValid, 'targetUrl'))
+  @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_IMPORTS.URL.max))
+  targetUrl: string
+
+  @AllowNull(false)
+  @Default(null)
+  @Is('VideoImportState', value => throwIfNotValid(value, isVideoImportStateValid, 'state'))
+  @Column
+  state: VideoImportState
+
+  @AllowNull(true)
+  @Default(null)
+  @Column(DataType.TEXT)
+  error: string
+
+  @ForeignKey(() => VideoModel)
+  @Column
+  videoId: number
+
+  @BelongsTo(() => VideoModel, {
+    foreignKey: {
+      allowNull: false
+    },
+    onDelete: 'CASCADE'
+  })
+  Video: VideoModel
+
+  static loadAndPopulateVideo (id: number) {
+    return VideoImportModel.findById(id)
+  }
+
+  toFormattedJSON (): VideoImport {
+    const videoFormatOptions = {
+      additionalAttributes: { state: true, waitTranscoding: true, scheduledUpdate: true }
+    }
+    const video = Object.assign(this.Video.toFormattedJSON(videoFormatOptions), {
+      tags: this.Video.Tags.map(t => t.name)
+    })
+
+    return {
+      targetUrl: this.targetUrl,
+      video
+    }
+  }
+}
index a6c4620b2367a8a90979827071b3a40bf37df05c..459fcb31e5ddb351cd749a9cd625c1eeaee667b7 100644 (file)
@@ -377,7 +377,7 @@ type AvailableForListOptions = {
     include: [
       {
         model: () => VideoFileModel.unscoped(),
-        required: true
+        required: false
       }
     ]
   },
index a38a8aa3b9b236715b505e686970322de0c0c95b..2565479f6a802e89d346aeb740be1999fd777678 100644 (file)
@@ -6,7 +6,8 @@ export type JobType = 'activitypub-http-unicast' |
   'activitypub-follow' |
   'video-file-import' |
   'video-file' |
-  'email'
+  'email' |
+  'video-import'
 
 export interface Job {
   id: number
index cb9669772a92e16374b50f3bf55260f48d58199c..1b135e26ac82e4c168d8316fa291401bf6b47423 100644 (file)
@@ -15,4 +15,8 @@ export * from './video-update.model'
 export * from './video.model'
 export * from './video-state.enum'
 export * from './video-caption-update.model'
+export * from './video-import-create.model'
+export * from './video-import-update.model'
+export * from './video-import-state.enum'
+export * from './video-import.model'
 export { 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 (file)
index 0000000..65d142c
--- /dev/null
@@ -0,0 +1,6 @@
+import { VideoUpdate } from './video-update.model'
+
+export interface VideoImportCreate extends VideoUpdate {
+  targetUrl: string
+  channelId: number // Required
+}
diff --git a/shared/models/videos/video-import-state.enum.ts b/shared/models/videos/video-import-state.enum.ts
new file mode 100644 (file)
index 0000000..b178fbf
--- /dev/null
@@ -0,0 +1,5 @@
+export enum VideoImportState {
+  PENDING = 1,
+  SUCCESS = 2,
+  FAILED = 3
+}
diff --git a/shared/models/videos/video-import-update.model.ts b/shared/models/videos/video-import-update.model.ts
new file mode 100644 (file)
index 0000000..5ae2446
--- /dev/null
@@ -0,0 +1,5 @@
+import { VideoUpdate } from './video-update.model'
+
+export interface VideoImportUpdate extends VideoUpdate {
+  targetUrl: string
+}
diff --git a/shared/models/videos/video-import.model.ts b/shared/models/videos/video-import.model.ts
new file mode 100644 (file)
index 0000000..8581085
--- /dev/null
@@ -0,0 +1,7 @@
+import { Video } from './video.model'
+
+export interface VideoImport {
+  targetUrl: string
+
+  video: Video & { tags: string[] }
+}
index 625aefae12d08c3879f98064e31578dcdb8f9829..a50e14e4b71fc9f2e28fc6381d100049115202c8 100644 (file)
@@ -1,4 +1,5 @@
 export enum VideoState {
   PUBLISHED = 1,
-  TO_TRANSCODE = 2
+  TO_TRANSCODE = 2,
+  TO_IMPORT = 3
 }
index 4254010e7fb6daf4a8e971c3339408986159ac4b..7633465b2094db4b31274edfae04e633a6676009 100644 (file)
@@ -20,6 +20,7 @@
     ]
   },
   "exclude": [
+    "client/node_modules",
     "node_modules",
     "dist",
     "storage",