Fix broken playlist api
authorChocobozzz <me@florianbigard.com>
Wed, 31 Jul 2019 13:57:32 +0000 (15:57 +0200)
committerChocobozzz <chocobozzz@cpy.re>
Thu, 1 Aug 2019 07:11:04 +0000 (09:11 +0200)
45 files changed:
client/src/app/+accounts/account-video-channels/account-video-channels.component.html
client/src/app/+accounts/account-video-channels/account-video-channels.component.scss
client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html
client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts
client/src/app/shared/video-playlist/video-add-to-playlist.component.ts
client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html
client/src/app/shared/video-playlist/video-playlist-element-miniature.component.scss
client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts
client/src/app/shared/video-playlist/video-playlist-element.model.ts [new file with mode: 0644]
client/src/app/shared/video-playlist/video-playlist.service.ts
client/src/app/shared/video/video.model.ts
client/src/app/shared/video/video.service.ts
client/src/app/videos/+video-watch/video-watch-playlist.component.html
client/src/app/videos/+video-watch/video-watch-playlist.component.scss
client/src/app/videos/+video-watch/video-watch-playlist.component.ts
client/src/app/videos/+video-watch/video-watch.component.ts
client/src/assets/player/peertube-player-manager.ts
client/src/assets/player/videojs-components/settings-menu-item.ts
client/src/standalone/videos/embed.ts
server/controllers/api/users/my-video-playlists.ts
server/controllers/api/video-playlist.ts
server/initializers/constants.ts
server/initializers/migrations/0410-video-playlist-element.ts [new file with mode: 0644]
server/middlewares/validators/videos/video-playlists.ts
server/models/account/account-video-rate.ts
server/models/account/account.ts
server/models/server/server-blocklist.ts
server/models/server/server.ts
server/models/video/video-blacklist.ts
server/models/video/video-channel.ts
server/models/video/video-format-utils.ts
server/models/video/video-playlist-element.ts
server/models/video/video-playlist.ts
server/models/video/video.ts
server/tests/api/check-params/video-playlists.ts
server/tests/api/check-params/videos-filter.ts
server/tests/api/check-params/videos.ts
server/tests/api/videos/video-playlists.ts
shared/extra-utils/server/jobs.ts
shared/extra-utils/videos/video-playlists.ts
shared/models/videos/index.ts
shared/models/videos/playlist/video-exist-in-playlist.model.ts
shared/models/videos/playlist/video-playlist-element.model.ts [new file with mode: 0644]
shared/models/videos/video.model.ts
support/doc/api/openapi.yaml

index cb23bb522ae0f97fc6b3996e60d10f54bf46a881..ea5f61b18a5bfea55b231160d7a82f74518f72e3 100644 (file)
       <div *ngIf="getVideosOf(videoChannel)" class="videos">
         <div class="no-results" i18n *ngIf="getVideosOf(videoChannel).length === 0">This channel does not have videos.</div>
 
-        <my-video-miniature *ngFor="let video of getVideosOf(videoChannel)" [video]="video" [user]="user" [displayVideoActions]="false"></my-video-miniature>
+        <my-video-miniature
+          *ngFor="let video of getVideosOf(videoChannel)"
+          [video]="video" [user]="user" [displayVideoActions]="true"
+        ></my-video-miniature>
       </div>
 
-      <a class="show-more" i18n [routerLink]="getVideoChannelLink(videoChannel)">
+      <a *ngIf="getVideosOf(videoChannel).length !== 0" class="show-more" i18n [routerLink]="getVideoChannelLink(videoChannel)">
         Show this channel
       </a>
     </div>
index 98931f0c2542dbfcc3d28a74ceb0e9de5c75624b..7f765246061b1562581709704ef14668f432f13a 100644 (file)
       height: 50px;
     }
   }
+
+  my-video-miniature ::ng-deep my-video-actions-dropdown > my-action-dropdown {
+    // Fix our overflow
+    position: absolute;
+  }
 }
 
 
index 284694b7fd1339d0049d008b8b3e6b5cad091761..4de4e69da5ee273cc288f10800e666320125a1b6 100644 (file)
       class="videos" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()"
       cdkDropList (cdkDropListDropped)="drop($event)"
     >
-      <div class="video" *ngFor="let video of videos; trackBy: trackByFn" cdkDrag (cdkDragMoved)="onDragMove($event)">
+      <div class="video" *ngFor="let playlistElement of playlistElements; trackBy: trackByFn" cdkDrag>
         <my-video-playlist-element-miniature
-          [video]="video" [playlist]="playlist" [owned]="true" (elementRemoved)="onElementRemoved($event)"
-          [position]="video.playlistElement.position"
+          [playlistElement]="playlistElement" [playlist]="playlist" [owned]="true" (elementRemoved)="onElementRemoved($event)"
+          [position]="playlistElement.position"
         >
         </my-video-playlist-element-miniature>
       </div>
index d5122aeba5adb1279ed1b29190f8fb188b1985f1..6434b9e50fa89e39e14d73bab1e81f5a21b4e960 100644 (file)
@@ -3,15 +3,13 @@ import { Notifier, ServerService } from '@app/core'
 import { AuthService } from '../../core/auth'
 import { ConfirmService } from '../../core/confirm'
 import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
-import { Video } from '@app/shared/video/video.model'
-import { Subject, Subscription } from 'rxjs'
+import { Subscription } from 'rxjs'
 import { ActivatedRoute } from '@angular/router'
-import { VideoService } from '@app/shared/video/video.service'
 import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
 import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { CdkDragDrop, CdkDragMove } from '@angular/cdk/drag-drop'
-import { throttleTime } from 'rxjs/operators'
+import { CdkDragDrop } from '@angular/cdk/drag-drop'
+import { VideoPlaylistElement } from '@app/shared/video-playlist/video-playlist-element.model'
 
 @Component({
   selector: 'my-account-video-playlist-elements',
@@ -19,7 +17,7 @@ import { throttleTime } from 'rxjs/operators'
   styleUrls: [ './my-account-video-playlist-elements.component.scss' ]
 })
 export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestroy {
-  videos: Video[] = []
+  playlistElements: VideoPlaylistElement[] = []
   playlist: VideoPlaylist
 
   pagination: ComponentPagination = {
@@ -30,7 +28,6 @@ export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestro
 
   private videoPlaylistId: string | number
   private paramsSub: Subscription
-  private dragMoveSubject = new Subject<number>()
 
   constructor (
     private authService: AuthService,
@@ -39,7 +36,6 @@ export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestro
     private confirmService: ConfirmService,
     private route: ActivatedRoute,
     private i18n: I18n,
-    private videoService: VideoService,
     private videoPlaylistService: VideoPlaylistService
   ) {}
 
@@ -50,10 +46,6 @@ export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestro
 
       this.loadPlaylistInfo()
     })
-
-    this.dragMoveSubject.asObservable()
-      .pipe(throttleTime(200))
-      .subscribe(y => this.checkScroll(y))
   }
 
   ngOnDestroy () {
@@ -66,8 +58,8 @@ export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestro
 
     if (previousIndex === newIndex) return
 
-    const oldPosition = this.videos[previousIndex].playlistElement.position
-    let insertAfter = this.videos[newIndex].playlistElement.position
+    const oldPosition = this.playlistElements[previousIndex].position
+    let insertAfter = this.playlistElements[newIndex].position
 
     if (oldPosition > insertAfter) insertAfter--
 
@@ -78,42 +70,16 @@ export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestro
         err => this.notifier.error(err.message)
       )
 
-    const video = this.videos[previousIndex]
+    const element = this.playlistElements[previousIndex]
 
-    this.videos.splice(previousIndex, 1)
-    this.videos.splice(newIndex, 0, video)
+    this.playlistElements.splice(previousIndex, 1)
+    this.playlistElements.splice(newIndex, 0, element)
 
     this.reorderClientPositions()
   }
 
-  onDragMove (event: CdkDragMove<any>) {
-    this.dragMoveSubject.next(event.pointerPosition.y)
-  }
-
-  checkScroll (pointerY: number) {
-    // FIXME: Uncomment when https://github.com/angular/material2/issues/14098 is fixed
-    // FIXME: Remove when https://github.com/angular/material2/issues/13588 is implemented
-    // if (pointerY < 150) {
-    //   window.scrollBy({
-    //     left: 0,
-    //     top: -20,
-    //     behavior: 'smooth'
-    //   })
-    //
-    //   return
-    // }
-    //
-    // if (window.innerHeight - pointerY <= 50) {
-    //   window.scrollBy({
-    //     left: 0,
-    //     top: 20,
-    //     behavior: 'smooth'
-    //   })
-    // }
-  }
-
-  onElementRemoved (video: Video) {
-    this.videos = this.videos.filter(v => v.id !== video.id)
+  onElementRemoved (element: VideoPlaylistElement) {
+    this.playlistElements = this.playlistElements.filter(v => v.id !== element.id)
     this.reorderClientPositions()
   }
 
@@ -125,14 +91,14 @@ export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestro
     this.loadElements()
   }
 
-  trackByFn (index: number, elem: Video) {
+  trackByFn (index: number, elem: VideoPlaylistElement) {
     return elem.id
   }
 
   private loadElements () {
-    this.videoService.getPlaylistVideos(this.videoPlaylistId, this.pagination)
+    this.videoPlaylistService.getPlaylistVideos(this.videoPlaylistId, this.pagination)
         .subscribe(({ total, data }) => {
-          this.videos = this.videos.concat(data)
+          this.playlistElements = this.playlistElements.concat(data)
           this.pagination.totalItems = total
         })
   }
@@ -147,8 +113,8 @@ export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestro
   private reorderClientPositions () {
     let i = 1
 
-    for (const video of this.videos) {
-      video.playlistElement.position = i
+    for (const element of this.playlistElements) {
+      element.position = i
       i++
     }
   }
index c6cff03a4b9a82125e31eea7c26f754bff7c5561..08ceb21bc350688bf12dd8d87af8ef2dd7f99f4c 100644 (file)
@@ -37,6 +37,8 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
   }
   displayOptions = false
 
+  private playlistElementId: number
+
   constructor (
     protected formValidatorService: FormValidatorService,
     private authService: AuthService,
@@ -96,6 +98,8 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
               startTimestamp: existingPlaylist ? existingPlaylist.startTimestamp : undefined,
               stopTimestamp: existingPlaylist ? existingPlaylist.stopTimestamp : undefined
             })
+
+            this.playlistElementId = existingPlaylist ? existingPlaylist.playlistElementId : undefined
           }
 
           this.cd.markForCheck()
@@ -177,7 +181,9 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
   }
 
   private removeVideoFromPlaylist (playlist: PlaylistSummary) {
-    this.videoPlaylistService.removeVideoFromPlaylist(playlist.id, this.video.id)
+    if (!this.playlistElementId) return
+
+    this.videoPlaylistService.removeVideoFromPlaylist(playlist.id, this.playlistElementId)
         .subscribe(
           () => {
             this.notifier.success(this.i18n('Video removed from {{name}}', { name: playlist.displayName }))
index ab5a789286c598c4bfd64c882f9c56ae6c84043b..25d4783fb67c73aecb64e8bc4ff8a7765caccb2b 100644 (file)
@@ -6,66 +6,82 @@
     </div>
 
     <my-video-thumbnail
-      [video]="video" [nsfw]="isVideoBlur(video)"
+      *ngIf="playlistElement.video"
+      [video]="playlistElement.video" [nsfw]="isVideoBlur(playlistElement.video)"
       [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()"
     ></my-video-thumbnail>
 
+    <div class="fake-thumbnail" *ngIf="!playlistElement.video"></div>
+
     <div class="video-info">
-      <a tabindex="-1" class="video-info-name"
-         [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()"
-         [attr.title]="video.name"
-      >{{ video.name }}</a>
+      <ng-container *ngIf="playlistElement.video">
+        <a tabindex="-1" class="video-info-name"
+          [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()"
+          [attr.title]="playlistElement.video.name"
+        >{{ playlistElement.video.name }}</a>
+
+        <a *ngIf="accountLink" tabindex="-1" class="video-info-account" [routerLink]="[ '/accounts', playlistElement.video.byAccount ]">
+          {{ playlistElement.video.byAccount }}
+        </a>
+        <span *ngIf="!accountLink" tabindex="-1" class="video-info-account">{{ playlistElement.video.byAccount }}</span>
 
-      <a *ngIf="accountLink" tabindex="-1" class="video-info-account" [routerLink]="[ '/accounts', video.byAccount ]">{{ video.byAccount }}</a>
-      <span *ngIf="!accountLink" tabindex="-1" class="video-info-account">{{ video.byAccount }}</span>
+        <span tabindex="-1" class="video-info-timestamp">{{ formatTimestamp(playlistElement) }}</span>
+      </ng-container>
 
-      <span tabindex="-1" class="video-info-timestamp">{{ formatTimestamp(video) }}</span>
+      <span *ngIf="!playlistElement.video" class="video-info-name">
+        <ng-container i18n *ngIf="isUnavailable(playlistElement)">Unavailable</ng-container>
+        <ng-container i18n *ngIf="isPrivate(playlistElement)">Private</ng-container>
+        <ng-container i18n *ngIf="isDeleted(playlistElement)">Deleted</ng-container>
+      </span>
     </div>
   </a>
 
-  <div *ngIf="owned" class="more" ngbDropdown #moreDropdown="ngbDropdown" placement="bottom-right" (openChange)="onDropdownOpenChange()"
-       autoClose="outside">
+  <div *ngIf="owned" class="more" ngbDropdown #moreDropdown="ngbDropdown" placement="bottom-right"
+       (openChange)="onDropdownOpenChange()" autoClose="outside"
+  >
     <my-global-icon iconName="more-vertical" ngbDropdownToggle role="button" class="icon-more" (click)="$event.preventDefault()"></my-global-icon>
 
     <div ngbDropdownMenu>
-      <div class="dropdown-item" (click)="toggleDisplayTimestampsOptions($event, video)">
-        <my-global-icon iconName="edit"></my-global-icon>
-        <ng-container i18n>Edit starts/stops at</ng-container>
-      </div>
+      <ng-container *ngIf="playlistElement.video">
+        <div class="dropdown-item" (click)="toggleDisplayTimestampsOptions($event, playlistElement)">
+          <my-global-icon iconName="edit"></my-global-icon>
+          <ng-container i18n>Edit starts/stops at</ng-container>
+        </div>
 
-      <div class="timestamp-options" *ngIf="displayTimestampOptions">
-        <div>
-          <my-peertube-checkbox
-            inputName="startAt" [(ngModel)]="timestampOptions.startTimestampEnabled"
-            i18n-labelText labelText="Start at"
-          ></my-peertube-checkbox>
+        <div class="timestamp-options" *ngIf="displayTimestampOptions">
+          <div>
+            <my-peertube-checkbox
+              inputName="startAt" [(ngModel)]="timestampOptions.startTimestampEnabled"
+              i18n-labelText labelText="Start at"
+            ></my-peertube-checkbox>
 
-          <my-timestamp-input
-            [timestamp]="timestampOptions.startTimestamp"
-            [maxTimestamp]="video.duration"
-            [disabled]="!timestampOptions.startTimestampEnabled"
-            [(ngModel)]="timestampOptions.startTimestamp"
-          ></my-timestamp-input>
-        </div>
+            <my-timestamp-input
+              [timestamp]="timestampOptions.startTimestamp"
+              [maxTimestamp]="playlistElement.video.duration"
+              [disabled]="!timestampOptions.startTimestampEnabled"
+              [(ngModel)]="timestampOptions.startTimestamp"
+            ></my-timestamp-input>
+          </div>
 
-        <div>
-          <my-peertube-checkbox
-            inputName="stopAt" [(ngModel)]="timestampOptions.stopTimestampEnabled"
-            i18n-labelText labelText="Stop at"
-          ></my-peertube-checkbox>
+          <div>
+            <my-peertube-checkbox
+              inputName="stopAt" [(ngModel)]="timestampOptions.stopTimestampEnabled"
+              i18n-labelText labelText="Stop at"
+            ></my-peertube-checkbox>
 
-          <my-timestamp-input
-            [timestamp]="timestampOptions.stopTimestamp"
-            [maxTimestamp]="video.duration"
-            [disabled]="!timestampOptions.stopTimestampEnabled"
-            [(ngModel)]="timestampOptions.stopTimestamp"
-          ></my-timestamp-input>
-        </div>
+            <my-timestamp-input
+              [timestamp]="timestampOptions.stopTimestamp"
+              [maxTimestamp]="playlistElement.video.duration"
+              [disabled]="!timestampOptions.stopTimestampEnabled"
+              [(ngModel)]="timestampOptions.stopTimestamp"
+            ></my-timestamp-input>
+          </div>
 
-        <input type="submit" i18n-value value="Save" (click)="updateTimestamps(video)">
-      </div>
+          <input type="submit" i18n-value value="Save" (click)="updateTimestamps(playlistElement)">
+        </div>
+      </ng-container>
 
-      <span class="dropdown-item" (click)="removeFromPlaylist(video)">
+      <span class="dropdown-item" (click)="removeFromPlaylist(playlistElement)">
             <my-global-icon iconName="delete"></my-global-icon> <ng-container i18n>Delete from {{ playlist?.displayName }}</ng-container>
           </span>
     </div>
index cb7072d7f3fe4559ab3d19bef58bdb6023ef4155..9f4061b026b6aa3681bd45fd92e2ba4c4ca06027 100644 (file)
@@ -2,9 +2,21 @@
 @import '_mixins';
 @import '_miniature';
 
+$thumbnail-width: 130px;
+$thumbnail-height: 72px;
+
 my-video-thumbnail {
-  @include thumbnail-size-component(130px, 72px);
+  @include thumbnail-size-component($thumbnail-width, $thumbnail-height);
+}
 
+.fake-thumbnail {
+  width: $thumbnail-width;
+  height: $thumbnail-height;
+  background-color: #ececec;
+}
+
+my-video-thumbnail,
+.fake-thumbnail {
   display: flex; // Avoids an issue with line-height that adds space below the element
   margin-right: 10px;
 }
@@ -31,6 +43,7 @@ my-video-thumbnail {
   a {
     @include disable-default-a-behaviour;
 
+    color: var(--mainForegroundColor);
     display: flex;
     min-width: 0;
     align-items: center;
@@ -58,7 +71,6 @@ my-video-thumbnail {
       min-width: 0;
 
       a {
-        color: var(--mainForegroundColor);
         width: auto;
 
         &:hover {
@@ -66,20 +78,20 @@ my-video-thumbnail {
         }
       }
 
-      .video-info-name {
-        font-size: 18px;
-        font-weight: $font-semibold;
-        display: inline-block;
-
-        @include ellipsis;
-      }
-
       .video-info-account, .video-info-timestamp {
         color: $grey-foreground-color;
       }
     }
   }
 
+  .video-info-name {
+    font-size: 18px;
+    font-weight: $font-semibold;
+    display: inline-block;
+
+    @include ellipsis;
+  }
+
   .more {
     justify-self: flex-end;
     margin-left: auto;
index 62cf6536de9810e8ed7c82c3342a610bf947dc5f..a8e5a4885859e6611540119b0f8eb8299c90bc4c 100644 (file)
@@ -1,6 +1,6 @@
 import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, Output, ViewChild } from '@angular/core'
 import { Video } from '@app/shared/video/video.model'
-import { VideoPlaylistElementUpdate } from '@shared/models'
+import { VideoPlaylistElementType, VideoPlaylistElementUpdate } from '@shared/models'
 import { AuthService, ConfirmService, Notifier, ServerService } from '@app/core'
 import { ActivatedRoute } from '@angular/router'
 import { I18n } from '@ngx-translate/i18n-polyfill'
@@ -9,6 +9,7 @@ import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.
 import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
 import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
 import { secondsToTime } from '../../../assets/player/utils'
+import { VideoPlaylistElement } from '@app/shared/video-playlist/video-playlist-element.model'
 
 @Component({
   selector: 'my-video-playlist-element-miniature',
@@ -20,14 +21,14 @@ export class VideoPlaylistElementMiniatureComponent {
   @ViewChild('moreDropdown', { static: false }) moreDropdown: NgbDropdown
 
   @Input() playlist: VideoPlaylist
-  @Input() video: Video
+  @Input() playlistElement: VideoPlaylistElement
   @Input() owned = false
   @Input() playing = false
   @Input() rowLink = false
   @Input() accountLink = true
-  @Input() position: number
+  @Input() position: number // Keep this property because we're in the OnPush change detection strategy
 
-  @Output() elementRemoved = new EventEmitter<Video>()
+  @Output() elementRemoved = new EventEmitter<VideoPlaylistElement>()
 
   displayTimestampOptions = false
 
@@ -50,6 +51,18 @@ export class VideoPlaylistElementMiniatureComponent {
     private cdr: ChangeDetectorRef
   ) {}
 
+  isUnavailable (e: VideoPlaylistElement) {
+    return e.type === VideoPlaylistElementType.UNAVAILABLE
+  }
+
+  isPrivate (e: VideoPlaylistElement) {
+    return e.type === VideoPlaylistElementType.PRIVATE
+  }
+
+  isDeleted (e: VideoPlaylistElement) {
+    return e.type === VideoPlaylistElementType.DELETED
+  }
+
   buildRouterLink () {
     if (!this.playlist) return null
 
@@ -57,12 +70,12 @@ export class VideoPlaylistElementMiniatureComponent {
   }
 
   buildRouterQuery () {
-    if (!this.video) return {}
+    if (!this.playlistElement || !this.playlistElement.video) return {}
 
     return {
-      videoId: this.video.uuid,
-      start: this.video.playlistElement.startTimestamp,
-      stop: this.video.playlistElement.stopTimestamp
+      videoId: this.playlistElement.video.uuid,
+      start: this.playlistElement.startTimestamp,
+      stop: this.playlistElement.stopTimestamp
     }
   }
 
@@ -70,13 +83,13 @@ export class VideoPlaylistElementMiniatureComponent {
     return video.isVideoNSFWForUser(this.authService.getUser(), this.serverService.getConfig())
   }
 
-  removeFromPlaylist (video: Video) {
-    this.videoPlaylistService.removeVideoFromPlaylist(this.playlist.id, video.id)
+  removeFromPlaylist (playlistElement: VideoPlaylistElement) {
+    this.videoPlaylistService.removeVideoFromPlaylist(this.playlist.id, playlistElement.id)
         .subscribe(
           () => {
             this.notifier.success(this.i18n('Video removed from {{name}}', { name: this.playlist.displayName }))
 
-            this.elementRemoved.emit(this.video)
+            this.elementRemoved.emit(playlistElement)
           },
 
           err => this.notifier.error(err.message)
@@ -85,19 +98,19 @@ export class VideoPlaylistElementMiniatureComponent {
     this.moreDropdown.close()
   }
 
-  updateTimestamps (video: Video) {
+  updateTimestamps (playlistElement: VideoPlaylistElement) {
     const body: VideoPlaylistElementUpdate = {}
 
     body.startTimestamp = this.timestampOptions.startTimestampEnabled ? this.timestampOptions.startTimestamp : null
     body.stopTimestamp = this.timestampOptions.stopTimestampEnabled ? this.timestampOptions.stopTimestamp : null
 
-    this.videoPlaylistService.updateVideoOfPlaylist(this.playlist.id, video.id, body)
+    this.videoPlaylistService.updateVideoOfPlaylist(this.playlist.id, playlistElement.id, body)
         .subscribe(
           () => {
             this.notifier.success(this.i18n('Timestamps updated'))
 
-            video.playlistElement.startTimestamp = body.startTimestamp
-            video.playlistElement.stopTimestamp = body.stopTimestamp
+            playlistElement.startTimestamp = body.startTimestamp
+            playlistElement.stopTimestamp = body.stopTimestamp
 
             this.cdr.detectChanges()
           },
@@ -108,9 +121,9 @@ export class VideoPlaylistElementMiniatureComponent {
     this.moreDropdown.close()
   }
 
-  formatTimestamp (video: Video) {
-    const start = video.playlistElement.startTimestamp
-    const stop = video.playlistElement.stopTimestamp
+  formatTimestamp (playlistElement: VideoPlaylistElement) {
+    const start = playlistElement.startTimestamp
+    const stop = playlistElement.stopTimestamp
 
     const startFormatted = secondsToTime(start, true, ':')
     const stopFormatted = secondsToTime(stop, true, ':')
@@ -127,7 +140,7 @@ export class VideoPlaylistElementMiniatureComponent {
     this.displayTimestampOptions = false
   }
 
-  toggleDisplayTimestampsOptions (event: Event, video: Video) {
+  toggleDisplayTimestampsOptions (event: Event, playlistElement: VideoPlaylistElement) {
     event.preventDefault()
 
     this.displayTimestampOptions = !this.displayTimestampOptions
@@ -137,17 +150,17 @@ export class VideoPlaylistElementMiniatureComponent {
         startTimestampEnabled: false,
         stopTimestampEnabled: false,
         startTimestamp: 0,
-        stopTimestamp: video.duration
+        stopTimestamp: playlistElement.video.duration
       }
 
-      if (video.playlistElement.startTimestamp) {
+      if (playlistElement.startTimestamp) {
         this.timestampOptions.startTimestampEnabled = true
-        this.timestampOptions.startTimestamp = video.playlistElement.startTimestamp
+        this.timestampOptions.startTimestamp = playlistElement.startTimestamp
       }
 
-      if (video.playlistElement.stopTimestamp) {
+      if (playlistElement.stopTimestamp) {
         this.timestampOptions.stopTimestampEnabled = true
-        this.timestampOptions.stopTimestamp = video.playlistElement.stopTimestamp
+        this.timestampOptions.stopTimestamp = playlistElement.stopTimestamp
       }
     }
 
diff --git a/client/src/app/shared/video-playlist/video-playlist-element.model.ts b/client/src/app/shared/video-playlist/video-playlist-element.model.ts
new file mode 100644 (file)
index 0000000..f1c46d1
--- /dev/null
@@ -0,0 +1,24 @@
+import { VideoPlaylistElement as ServerVideoPlaylistElement, VideoPlaylistElementType } from '../../../../../shared/models/videos'
+import { Video } from '@app/shared/video/video.model'
+
+export class VideoPlaylistElement implements ServerVideoPlaylistElement {
+  id: number
+  position: number
+  startTimestamp: number
+  stopTimestamp: number
+
+  type: VideoPlaylistElementType
+
+  video?: Video
+
+  constructor (hash: ServerVideoPlaylistElement, translations: {}) {
+    this.id = hash.id
+    this.position = hash.position
+    this.startTimestamp = hash.startTimestamp
+    this.stopTimestamp = hash.stopTimestamp
+
+    this.type = hash.type
+
+    if (hash.video) this.video = new Video(hash.video, translations)
+  }
+}
index da7437507b5c048dc780c2b63c78ebe5243cf8c1..b93a19356306957988166ac37bfb1b2c5343db2c 100644 (file)
@@ -18,6 +18,9 @@ import { Account } from '@app/shared/account/account.model'
 import { RestService } from '@app/shared/rest'
 import { VideoExistInPlaylist } from '@shared/models/videos/playlist/video-exist-in-playlist.model'
 import { VideoPlaylistReorder } from '@shared/models/videos/playlist/video-playlist-reorder.model'
+import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
+import { VideoPlaylistElement as ServerVideoPlaylistElement } from '@shared/models/videos/playlist/video-playlist-element.model'
+import { VideoPlaylistElement } from '@app/shared/video-playlist/video-playlist-element.model'
 
 @Injectable()
 export class VideoPlaylistService {
@@ -110,16 +113,16 @@ export class VideoPlaylistService {
                )
   }
 
-  updateVideoOfPlaylist (playlistId: number, videoId: number, body: VideoPlaylistElementUpdate) {
-    return this.authHttp.put(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + videoId, body)
+  updateVideoOfPlaylist (playlistId: number, playlistElementId: number, body: VideoPlaylistElementUpdate) {
+    return this.authHttp.put(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + playlistElementId, body)
                .pipe(
                  map(this.restExtractor.extractDataBool),
                  catchError(err => this.restExtractor.handleError(err))
                )
   }
 
-  removeVideoFromPlaylist (playlistId: number, videoId: number) {
-    return this.authHttp.delete(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + videoId)
+  removeVideoFromPlaylist (playlistId: number, playlistElementId: number) {
+    return this.authHttp.delete(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + playlistElementId)
                .pipe(
                  map(this.restExtractor.extractDataBool),
                  catchError(err => this.restExtractor.handleError(err))
@@ -139,6 +142,24 @@ export class VideoPlaylistService {
                )
   }
 
+  getPlaylistVideos (
+    videoPlaylistId: number | string,
+    componentPagination: ComponentPagination
+  ): Observable<ResultList<VideoPlaylistElement>> {
+    const path = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylistId + '/videos'
+    const pagination = this.restService.componentPaginationToRestPagination(componentPagination)
+
+    let params = new HttpParams()
+    params = this.restService.addRestGetParams(params, pagination)
+
+    return this.authHttp
+               .get<ResultList<ServerVideoPlaylistElement>>(path, { params })
+               .pipe(
+                 switchMap(res => this.extractVideoPlaylistElements(res)),
+                 catchError(err => this.restExtractor.handleError(err))
+               )
+  }
+
   doesVideoExistInPlaylist (videoId: number) {
     this.videoExistsInPlaylistSubject.next(videoId)
 
@@ -167,6 +188,23 @@ export class VideoPlaylistService {
                .pipe(map(translations => new VideoPlaylist(playlist, translations)))
   }
 
+  extractVideoPlaylistElements (result: ResultList<ServerVideoPlaylistElement>) {
+    return this.serverService.localeObservable
+               .pipe(
+                 map(translations => {
+                   const elementsJson = result.data
+                   const total = result.total
+                   const elements: VideoPlaylistElement[] = []
+
+                   for (const elementJson of elementsJson) {
+                     elements.push(new VideoPlaylistElement(elementJson, translations))
+                   }
+
+                   return { total, data: elements }
+                 })
+               )
+  }
+
   private doVideosExistInPlaylist (videoIds: number[]): Observable<VideoExistInPlaylist> {
     const url = VideoPlaylistService.MY_VIDEO_PLAYLIST_URL + 'videos-exist'
     let params = new HttpParams()
index 6f9de9241764935cb6edae098ebce42968149c67..fb98d53820929c769eb3986f7647fff3a4bba316 100644 (file)
@@ -1,5 +1,5 @@
 import { User } from '../'
-import { PlaylistElement, UserRight, Video as VideoServerModel, VideoPrivacy, VideoState } from '../../../../../shared'
+import { UserRight, Video as VideoServerModel, VideoPrivacy, VideoState } from '../../../../../shared'
 import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
 import { VideoConstant } from '../../../../../shared/models/videos/video-constant.model'
 import { durationToString, getAbsoluteAPIUrl } from '../misc/utils'
@@ -48,8 +48,6 @@ export class Video implements VideoServerModel {
   blacklisted?: boolean
   blacklistedReason?: string
 
-  playlistElement?: PlaylistElement
-
   account: {
     id: number
     name: string
@@ -126,8 +124,6 @@ export class Video implements VideoServerModel {
     this.blacklistedReason = hash.blacklistedReason
 
     this.userHistory = hash.userHistory
-
-    this.playlistElement = hash.playlistElement
   }
 
   isVideoNSFWForUser (user: User, serverConfig: ServerConfig) {
index 114b014ad198d5d0c716beface38bc1d3dcc7270..45366e3e305039d0ad81377025e715c444f9c92c 100644 (file)
@@ -31,7 +31,6 @@ import { ServerService } from '@app/core'
 import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service'
 import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
 import { I18n } from '@ngx-translate/i18n-polyfill'
-import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
 
 export interface VideosProvider {
   getVideos (parameters: {
@@ -172,23 +171,6 @@ export class VideoService implements VideosProvider {
                )
   }
 
-  getPlaylistVideos (
-    videoPlaylistId: number | string,
-    videoPagination: ComponentPagination
-  ): Observable<ResultList<Video>> {
-    const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
-
-    let params = new HttpParams()
-    params = this.restService.addRestGetParams(params, pagination)
-
-    return this.authHttp
-               .get<ResultList<Video>>(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylistId + '/videos', { params })
-               .pipe(
-                 switchMap(res => this.extractVideos(res)),
-                 catchError(err => this.restExtractor.handleError(err))
-               )
-  }
-
   getUserSubscriptionVideos (parameters: {
     videoPagination: ComponentPagination,
     sort: VideoSortField
index c168a3130ad6c6952d4a64b43cd62b633f02ff48..c89936bd1c46b9e06eeb7a5f3108d64710f0c5f7 100644 (file)
     </div>
   </div>
 
-  <div *ngFor="let playlistVideo of playlistVideos">
+  <div *ngFor="let playlistElement of playlistElements">
     <my-video-playlist-element-miniature
-      [video]="playlistVideo" [playlist]="playlist" [owned]="isPlaylistOwned()" (elementRemoved)="onElementRemoved($event)"
-      [playing]="currentPlaylistPosition === playlistVideo.playlistElement.position" [accountLink]="false" [position]="playlistVideo.playlistElement.position"
+      [playlistElement]="playlistElement" [playlist]="playlist" [owned]="isPlaylistOwned()" (elementRemoved)="onElementRemoved($event)"
+      [playing]="currentPlaylistPosition === playlistElement.position" [accountLink]="false" [position]="playlistElement.position"
     ></my-video-playlist-element-miniature>
   </div>
 </div>
index eeb763bd917d1b31ac96a320cfc5de1ec0d4da09..4c24d6b0599ad2801f5d630cd5d8160136947fcc 100644 (file)
       my-video-thumbnail {
         @include thumbnail-size-component(90px, 50px);
       }
+
+      .fake-thumbnail {
+        width: 90px;
+        height: 50px;
+      }
     }
   }
 }
index 2fb0cb0e565c6824265e0f28d7d00cec0ef9d17d..6e8d58cd80d9bee8db2359443108abc12c82fac3 100644 (file)
@@ -1,11 +1,11 @@
 import { Component, Input } from '@angular/core'
 import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
 import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
-import { Video } from '@app/shared/video/video.model'
 import { VideoDetails, VideoPlaylistPrivacy } from '@shared/models'
-import { VideoService } from '@app/shared/video/video.service'
 import { Router } from '@angular/router'
 import { AuthService } from '@app/core'
+import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
+import { VideoPlaylistElement } from '@app/shared/video-playlist/video-playlist-element.model'
 
 @Component({
   selector: 'my-video-watch-playlist',
@@ -16,7 +16,7 @@ export class VideoWatchPlaylistComponent {
   @Input() video: VideoDetails
   @Input() playlist: VideoPlaylist
 
-  playlistVideos: Video[] = []
+  playlistElements: VideoPlaylistElement[] = []
   playlistPagination: ComponentPagination = {
     currentPage: 1,
     itemsPerPage: 30,
@@ -28,7 +28,7 @@ export class VideoWatchPlaylistComponent {
 
   constructor (
     private auth: AuthService,
-    private videoService: VideoService,
+    private videoPlaylist: VideoPlaylistService,
     private router: Router
   ) {}
 
@@ -40,8 +40,8 @@ export class VideoWatchPlaylistComponent {
     this.loadPlaylistElements(this.playlist,false)
   }
 
-  onElementRemoved (video: Video) {
-    this.playlistVideos = this.playlistVideos.filter(v => v.id !== video.id)
+  onElementRemoved (playlistElement: VideoPlaylistElement) {
+    this.playlistElements = this.playlistElements.filter(e => e.id !== playlistElement.id)
 
     this.playlistPagination.totalItems--
   }
@@ -65,12 +65,13 @@ export class VideoWatchPlaylistComponent {
   }
 
   loadPlaylistElements (playlist: VideoPlaylist, redirectToFirst = false) {
-    this.videoService.getPlaylistVideos(playlist.uuid, this.playlistPagination)
+    this.videoPlaylist.getPlaylistVideos(playlist.uuid, this.playlistPagination)
         .subscribe(({ total, data }) => {
-          this.playlistVideos = this.playlistVideos.concat(data)
+          this.playlistElements = this.playlistElements.concat(data)
           this.playlistPagination.totalItems = total
 
-          if (total === 0) {
+          const firstAvailableVideos = this.playlistElements.find(e => !!e.video)
+          if (!firstAvailableVideos) {
             this.noPlaylistVideos = true
             return
           }
@@ -79,7 +80,7 @@ export class VideoWatchPlaylistComponent {
 
           if (redirectToFirst) {
             const extras = {
-              queryParams: { videoId: this.playlistVideos[ 0 ].uuid },
+              queryParams: { videoId: firstAvailableVideos.video.uuid },
               replaceUrl: true
             }
             this.router.navigate([], extras)
@@ -88,11 +89,11 @@ export class VideoWatchPlaylistComponent {
   }
 
   updatePlaylistIndex (video: VideoDetails) {
-    if (this.playlistVideos.length === 0 || !video) return
+    if (this.playlistElements.length === 0 || !video) return
 
-    for (const playlistVideo of this.playlistVideos) {
-      if (playlistVideo.id === video.id) {
-        this.currentPlaylistPosition = playlistVideo.playlistElement.position
+    for (const playlistElement of this.playlistElements) {
+      if (playlistElement.video && playlistElement.video.id === video.id) {
+        this.currentPlaylistPosition = playlistElement.position
         return
       }
     }
@@ -103,11 +104,17 @@ export class VideoWatchPlaylistComponent {
 
   navigateToNextPlaylistVideo () {
     if (this.currentPlaylistPosition < this.playlistPagination.totalItems) {
-      const next = this.playlistVideos.find(v => v.playlistElement.position === this.currentPlaylistPosition + 1)
+      const next = this.playlistElements.find(e => e.position === this.currentPlaylistPosition + 1)
 
-      const start = next.playlistElement.startTimestamp
-      const stop = next.playlistElement.stopTimestamp
-      this.router.navigate([],{ queryParams: { videoId: next.uuid, start, stop } })
+      if (!next || !next.video) {
+        this.currentPlaylistPosition++
+        this.navigateToNextPlaylistVideo()
+        return
+      }
+
+      const start = next.startTimestamp
+      const stop = next.stopTimestamp
+      this.router.navigate([],{ queryParams: { videoId: next.video.uuid, start, stop } })
     }
   }
 }
index 0d499d47f74930f29e71e0d7f6ec443ccc38f22c..d7c7b74971dfd1ad8f67cc73e79c5d7defa071d6 100644 (file)
@@ -464,7 +464,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
     }
 
     this.zone.runOutsideAngular(async () => {
-      this.player = await PeertubePlayerManager.initialize(mode, options)
+      this.player = await PeertubePlayerManager.initialize(mode, options, player => this.player = player)
 
       this.player.on('customError', ({ err }: { err: any }) => this.handleError(err))
 
index 083c621d21cc376e9003ae996d4b8c47981754c4..6c8b13087c1889b4f56327b9fe34b253702b9698 100644 (file)
@@ -86,6 +86,7 @@ export class PeertubePlayerManager {
 
   private static videojsLocaleCache: { [ path: string ]: any } = {}
   private static playerElementClassName: string
+  private static onPlayerChange: (player: any) => void
 
   static getServerTranslations (serverUrl: string, locale: string) {
     const path = PeertubePlayerManager.getLocalePath(serverUrl, locale)
@@ -100,9 +101,10 @@ export class PeertubePlayerManager {
       })
   }
 
-  static async initialize (mode: PlayerMode, options: PeertubePlayerManagerOptions) {
+  static async initialize (mode: PlayerMode, options: PeertubePlayerManagerOptions, onPlayerChange: (player: any) => void) {
     let p2pMediaLoader: any
 
+    this.onPlayerChange = onPlayerChange
     this.playerElementClassName = options.common.playerElement.className
 
     if (mode === 'webtorrent') await import('./webtorrent/webtorrent-plugin')
@@ -171,6 +173,8 @@ export class PeertubePlayerManager {
       const player = this
 
       self.addContextMenu(mode, player, options.common.embedUrl)
+
+      PeertubePlayerManager.onPlayerChange(player)
     })
   }
 
index 78879a2ec3d57f25937b759221e6665ad1dda543..24b7e0c70ddbdc51f1da207192aaf08e2578138a 100644 (file)
@@ -43,6 +43,9 @@ class SettingsMenuItem extends MenuItem {
     player.ready(() => {
       // Voodoo magic for IOS
       setTimeout(() => {
+        // Player was destroyed
+        if (!this.player_) return
+
         this.build()
 
         // Update on rate change
index cfe8e94b178da84560a1ca29d2e760f286fb8bc1..6ff3efef1afd6ebc551b6da4417404b253e665cd 100644 (file)
@@ -212,7 +212,7 @@ export class PeerTubeEmbed {
       })
     }
 
-    this.player = await PeertubePlayerManager.initialize(this.mode, options)
+    this.player = await PeertubePlayerManager.initialize(this.mode, options, player => this.player = player)
     this.player.on('customError', (event: any, data: any) => this.handleError(data.err, serverTranslations))
 
     window[ 'videojsPlayer' ] = this.player
index 15e92f4f3fe6c3dacea5bd0b03c10665cf2301c9..735a3cbee3b01d6003ff9ba1731f7a6e3f2ad9bb 100644 (file)
@@ -35,6 +35,7 @@ async function doVideosInPlaylistExist (req: express.Request, res: express.Respo
   for (const result of results) {
     for (const element of result.VideoPlaylistElements) {
       existObject[element.videoId].push({
+        playlistElementId: element.id,
         playlistId: result.id,
         startTimestamp: element.startTimestamp,
         stopTimestamp: element.stopTimestamp
index 62490e63b168d60e390ecdd3aa0ce7385a30b6dd..540120cca44e0f8153f103850fd0e8c43ad65f90 100644 (file)
@@ -4,14 +4,13 @@ import {
   asyncMiddleware,
   asyncRetryTransactionMiddleware,
   authenticate,
-  commonVideosFiltersValidator,
   optionalAuthenticate,
   paginationValidator,
   setDefaultPagination,
   setDefaultSort
 } from '../../middlewares'
 import { videoPlaylistsSortValidator } from '../../middlewares/validators'
-import { buildNSFWFilter, createReqFiles, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
+import { buildNSFWFilter, createReqFiles } from '../../helpers/express-utils'
 import { MIMETYPES, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers/constants'
 import { logger } from '../../helpers/logger'
 import { resetSequelizeInstance } from '../../helpers/database-utils'
@@ -32,7 +31,6 @@ import { join } from 'path'
 import { sendCreateVideoPlaylist, sendDeleteVideoPlaylist, sendUpdateVideoPlaylist } from '../../lib/activitypub/send'
 import { getVideoPlaylistActivityPubUrl, getVideoPlaylistElementActivityPubUrl } from '../../lib/activitypub/url'
 import { VideoPlaylistUpdate } from '../../../shared/models/videos/playlist/video-playlist-update.model'
-import { VideoModel } from '../../models/video/video'
 import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element'
 import { VideoPlaylistElementCreate } from '../../../shared/models/videos/playlist/video-playlist-element-create.model'
 import { VideoPlaylistElementUpdate } from '../../../shared/models/videos/playlist/video-playlist-element-update.model'
@@ -88,7 +86,6 @@ videoPlaylistRouter.get('/:playlistId/videos',
   paginationValidator,
   setDefaultPagination,
   optionalAuthenticate,
-  commonVideosFiltersValidator,
   asyncMiddleware(getVideoPlaylistVideos)
 )
 
@@ -104,13 +101,13 @@ videoPlaylistRouter.post('/:playlistId/videos/reorder',
   asyncRetryTransactionMiddleware(reorderVideosPlaylist)
 )
 
-videoPlaylistRouter.put('/:playlistId/videos/:videoId',
+videoPlaylistRouter.put('/:playlistId/videos/:playlistElementId',
   authenticate,
   asyncMiddleware(videoPlaylistsUpdateOrRemoveVideoValidator),
   asyncRetryTransactionMiddleware(updateVideoPlaylistElement)
 )
 
-videoPlaylistRouter.delete('/:playlistId/videos/:videoId',
+videoPlaylistRouter.delete('/:playlistId/videos/:playlistElementId',
   authenticate,
   asyncMiddleware(videoPlaylistsUpdateOrRemoveVideoValidator),
   asyncRetryTransactionMiddleware(removeVideoFromPlaylist)
@@ -426,26 +423,20 @@ async function reorderVideosPlaylist (req: express.Request, res: express.Respons
 
 async function getVideoPlaylistVideos (req: express.Request, res: express.Response) {
   const videoPlaylistInstance = res.locals.videoPlaylist
-  const followerActorId = isUserAbleToSearchRemoteURI(res) ? null : undefined
+  const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
+  const server = await getServerActor()
 
-  const resultList = await VideoModel.listForApi({
-    followerActorId,
+  const resultList = await VideoPlaylistElementModel.listForApi({
     start: req.query.start,
     count: req.query.count,
-    sort: 'VideoPlaylistElements.position',
-    includeLocalVideos: true,
-    categoryOneOf: req.query.categoryOneOf,
-    licenceOneOf: req.query.licenceOneOf,
-    languageOneOf: req.query.languageOneOf,
-    tagsOneOf: req.query.tagsOneOf,
-    tagsAllOf: req.query.tagsAllOf,
-    filter: req.query.filter,
-    nsfw: buildNSFWFilter(res, req.query.nsfw),
-    withFiles: false,
     videoPlaylistId: videoPlaylistInstance.id,
-    user: res.locals.oauth ? res.locals.oauth.token.User : undefined
+    serverAccount: server.Account,
+    user
   })
 
-  const additionalAttributes = { playlistInfo: true }
-  return res.json(getFormattedObjects(resultList.data, resultList.total, { additionalAttributes }))
+  const options = {
+    displayNSFW: buildNSFWFilter(res, req.query.nsfw),
+    accountId: user ? user.Account.id : undefined
+  }
+  return res.json(getFormattedObjects(resultList.data, resultList.total, options))
 }
index 5fe7d416c0b4be8db2a95dea40d23ac419c9ffb2..8ab7c6bbdea06cbec08e8c09eb0569b51c4b4a95 100644 (file)
@@ -14,7 +14,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
 
 // ---------------------------------------------------------------------------
 
-const LAST_MIGRATION_VERSION = 405
+const LAST_MIGRATION_VERSION = 410
 
 // ---------------------------------------------------------------------------
 
diff --git a/server/initializers/migrations/0410-video-playlist-element.ts b/server/initializers/migrations/0410-video-playlist-element.ts
new file mode 100644 (file)
index 0000000..f536632
--- /dev/null
@@ -0,0 +1,39 @@
+import * as Sequelize from 'sequelize'
+
+async function up (utils: {
+  transaction: Sequelize.Transaction,
+  queryInterface: Sequelize.QueryInterface,
+  sequelize: Sequelize.Sequelize,
+  db: any
+}): Promise<void> {
+  {
+    const data = {
+      type: Sequelize.INTEGER,
+      allowNull: true,
+      defaultValue: null
+    }
+
+    await utils.queryInterface.changeColumn('videoPlaylistElement', 'videoId', data)
+  }
+
+  await utils.queryInterface.removeConstraint('videoPlaylistElement', 'videoPlaylistElement_videoId_fkey')
+
+  await utils.queryInterface.addConstraint('videoPlaylistElement', [ 'videoId' ], {
+    type: 'foreign key',
+    references: {
+      table: 'video',
+      field: 'id'
+    },
+    onDelete: 'set null',
+    onUpdate: 'CASCADE'
+  })
+}
+
+function down (options) {
+  throw new Error('Not implemented.')
+}
+
+export {
+  up,
+  down
+}
index 2e9c8aa3341b13c427be5a7dd22b55e95d606e0a..5823795be4a7af41c59b3036711effed4539f285 100644 (file)
@@ -207,8 +207,8 @@ const videoPlaylistsAddVideoValidator = [
 const videoPlaylistsUpdateOrRemoveVideoValidator = [
   param('playlistId')
     .custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'),
-  param('videoId')
-    .custom(isIdOrUUIDValid).withMessage('Should have an video id/uuid'),
+  param('playlistElementId')
+    .custom(isIdValid).withMessage('Should have an element id/uuid'),
   body('startTimestamp')
     .optional()
     .custom(isVideoPlaylistTimestampValid).withMessage('Should have a valid start timestamp'),
@@ -222,12 +222,10 @@ const videoPlaylistsUpdateOrRemoveVideoValidator = [
     if (areValidationErrors(req, res)) return
 
     if (!await doesVideoPlaylistExist(req.params.playlistId, res, 'all')) return
-    if (!await doesVideoExist(req.params.videoId, res, 'id')) return
 
     const videoPlaylist = res.locals.videoPlaylist
-    const video = res.locals.video
 
-    const videoPlaylistElement = await VideoPlaylistElementModel.loadByPlaylistAndVideo(videoPlaylist.id, video.id)
+    const videoPlaylistElement = await VideoPlaylistElementModel.loadById(req.params.playlistElementId)
     if (!videoPlaylistElement) {
       res.status(404)
          .json({ error: 'Video playlist element not found' })
index 59f586b5464db878a1559762260133cb373a95f0..85af9e3782c38535cab1ed01b53e2df1d7606031 100644 (file)
@@ -9,7 +9,7 @@ import { ActorModel } from '../activitypub/actor'
 import { getSort, throwIfNotValid } from '../utils'
 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
 import { AccountVideoRate } from '../../../shared'
-import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from '../video/video-channel'
+import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from '../video/video-channel'
 
 /*
   Account rates per video.
@@ -109,7 +109,7 @@ export class AccountVideoRateModel extends Model<AccountVideoRateModel> {
           required: true,
           include: [
             {
-              model: VideoChannelModel.scope({ method: [VideoChannelScopeNames.SUMMARY, true] }),
+              model: VideoChannelModel.scope({ method: [VideoChannelScopeNames.SUMMARY, { withAccount: true } as SummaryOptions ] }),
               required: true
             }
           ]
index 09cada0967abf5ad39c820fbd20ee888717fa9ee..28014946f34b23df7dcee7694fe28a55feb0a525 100644 (file)
@@ -27,12 +27,19 @@ import { UserModel } from './user'
 import { AvatarModel } from '../avatar/avatar'
 import { VideoPlaylistModel } from '../video/video-playlist'
 import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
-import { Op, Transaction, WhereOptions } from 'sequelize'
+import { FindOptions, IncludeOptions, Op, Transaction, WhereOptions } from 'sequelize'
+import { AccountBlocklistModel } from './account-blocklist'
+import { ServerBlocklistModel } from '../server/server-blocklist'
 
 export enum ScopeNames {
   SUMMARY = 'SUMMARY'
 }
 
+export type SummaryOptions = {
+  whereActor?: WhereOptions
+  withAccountBlockerIds?: number[]
+}
+
 @DefaultScope(() => ({
   include: [
     {
@@ -42,8 +49,16 @@ export enum ScopeNames {
   ]
 }))
 @Scopes(() => ({
-  [ ScopeNames.SUMMARY ]: (whereActor?: WhereOptions) => {
-    return {
+  [ ScopeNames.SUMMARY ]: (options: SummaryOptions = {}) => {
+    const whereActor = options.whereActor || undefined
+
+    const serverInclude: IncludeOptions = {
+      attributes: [ 'host' ],
+      model: ServerModel.unscoped(),
+      required: false
+    }
+
+    const query: FindOptions = {
       attributes: [ 'id', 'name' ],
       include: [
         {
@@ -52,11 +67,8 @@ export enum ScopeNames {
           required: true,
           where: whereActor,
           include: [
-            {
-              attributes: [ 'host' ],
-              model: ServerModel.unscoped(),
-              required: false
-            },
+            serverInclude,
+
             {
               model: AvatarModel.unscoped(),
               required: false
@@ -65,6 +77,35 @@ export enum ScopeNames {
         }
       ]
     }
+
+    if (options.withAccountBlockerIds) {
+      query.include.push({
+        attributes: [ 'id' ],
+        model: AccountBlocklistModel.unscoped(),
+        as: 'BlockedAccounts',
+        required: false,
+        where: {
+          accountId: {
+            [Op.in]: options.withAccountBlockerIds
+          }
+        }
+      })
+
+      serverInclude.include = [
+        {
+          attributes: [ 'id' ],
+          model: ServerBlocklistModel.unscoped(),
+          required: false,
+          where: {
+            accountId: {
+              [Op.in]: options.withAccountBlockerIds
+            }
+          }
+        }
+      ]
+    }
+
+    return query
   }
 }))
 @Table({
@@ -163,6 +204,16 @@ export class AccountModel extends Model<AccountModel> {
   })
   VideoComments: VideoCommentModel[]
 
+  @HasMany(() => AccountBlocklistModel, {
+    foreignKey: {
+      name: 'targetAccountId',
+      allowNull: false
+    },
+    as: 'BlockedAccounts',
+    onDelete: 'CASCADE'
+  })
+  BlockedAccounts: AccountBlocklistModel[]
+
   @BeforeDestroy
   static async sendDeleteIfOwned (instance: AccountModel, options) {
     if (!instance.Actor) {
@@ -343,4 +394,8 @@ export class AccountModel extends Model<AccountModel> {
   getDisplayName () {
     return this.name
   }
+
+  isBlocked () {
+    return this.BlockedAccounts && this.BlockedAccounts.length !== 0
+  }
 }
index 92c01f642037889061865df597332e9ac95050ce..5138b0f76367b8e6e183375515508efab2b2c0ee 100644 (file)
@@ -67,7 +67,6 @@ export class ServerBlocklistModel extends Model<ServerBlocklistModel> {
 
   @BelongsTo(() => ServerModel, {
     foreignKey: {
-      name: 'targetServerId',
       allowNull: false
     },
     onDelete: 'CASCADE'
index 300d7093808c568b76e3520ef8304fafa7fc7522..1d211f1e06b2619bd6a4142c31bfd0852d3b0792 100644 (file)
@@ -2,6 +2,8 @@ import { AllowNull, Column, CreatedAt, Default, HasMany, Is, Model, Table, Updat
 import { isHostValid } from '../../helpers/custom-validators/servers'
 import { ActorModel } from '../activitypub/actor'
 import { throwIfNotValid } from '../utils'
+import { AccountBlocklistModel } from '../account/account-blocklist'
+import { ServerBlocklistModel } from './server-blocklist'
 
 @Table({
   tableName: 'server',
@@ -40,6 +42,14 @@ export class ServerModel extends Model<ServerModel> {
   })
   Actors: ActorModel[]
 
+  @HasMany(() => ServerBlocklistModel, {
+    foreignKey: {
+      allowNull: false
+    },
+    onDelete: 'CASCADE'
+  })
+  BlockedByAccounts: ServerBlocklistModel[]
+
   static loadByHost (host: string) {
     const query = {
       where: {
@@ -50,6 +60,10 @@ export class ServerModel extends Model<ServerModel> {
     return ServerModel.findOne(query)
   }
 
+  isBlocked () {
+    return this.BlockedByAccounts && this.BlockedByAccounts.length !== 0
+  }
+
   toFormattedJSON () {
     return {
       host: this.host
index baef1d6ce80cb859fb99620489b0898888536252..22d949da0f75723b1ad7b826e1cde03b357895e2 100644 (file)
@@ -1,7 +1,7 @@
 import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
 import { getSortOnModel, SortType, throwIfNotValid } from '../utils'
 import { ScopeNames as VideoModelScopeNames, VideoModel } from './video'
-import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
+import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
 import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../helpers/custom-validators/video-blacklist'
 import { VideoBlacklist, VideoBlacklistType } from '../../../shared/models/videos'
 import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
@@ -71,7 +71,7 @@ export class VideoBlacklistModel extends Model<VideoBlacklistModel> {
         required: true,
         include: [
           {
-            model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, true ] }),
+            model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, { withAccount: true } as SummaryOptions ] }),
             required: true
           },
           {
index b0b261c88a93dc965a3955685244a4256883795f..6241a75a30bfffd499cede0102bd4734781cc838 100644 (file)
@@ -24,7 +24,7 @@ import {
   isVideoChannelSupportValid
 } from '../../helpers/custom-validators/video-channels'
 import { sendDeleteActor } from '../../lib/activitypub/send'
-import { AccountModel, ScopeNames as AccountModelScopeNames } from '../account/account'
+import { AccountModel, ScopeNames as AccountModelScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account'
 import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor'
 import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
 import { VideoModel } from './video'
@@ -58,6 +58,11 @@ type AvailableForListOptions = {
   actorId: number
 }
 
+export type SummaryOptions = {
+  withAccount?: boolean // Default: false
+  withAccountBlockerIds?: number[]
+}
+
 @DefaultScope(() => ({
   include: [
     {
@@ -67,7 +72,7 @@ type AvailableForListOptions = {
   ]
 }))
 @Scopes(() => ({
-  [ScopeNames.SUMMARY]: (withAccount = false) => {
+  [ScopeNames.SUMMARY]: (options: SummaryOptions = {}) => {
     const base: FindOptions = {
       attributes: [ 'name', 'description', 'id', 'actorId' ],
       include: [
@@ -90,9 +95,11 @@ type AvailableForListOptions = {
       ]
     }
 
-    if (withAccount === true) {
+    if (options.withAccount === true) {
       base.include.push({
-        model: AccountModel.scope(AccountModelScopeNames.SUMMARY),
+        model: AccountModel.scope({
+          method: [ AccountModelScopeNames.SUMMARY, { withAccountBlockerIds: options.withAccountBlockerIds } as AccountSummaryOptions ]
+        }),
         required: true
       })
     }
index b947eb16f4b6608c16038ccf873227a403deabf0..284539deff6551125d3c15e9df5df6f027ee1f90 100644 (file)
@@ -26,7 +26,6 @@ export type VideoFormattingJSONOptions = {
     waitTranscoding?: boolean,
     scheduledUpdate?: boolean,
     blacklistInfo?: boolean
-    playlistInfo?: boolean
   }
 }
 function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormattingJSONOptions): Video {
@@ -98,17 +97,6 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting
       videoObject.blacklisted = !!video.VideoBlacklist
       videoObject.blacklistedReason = video.VideoBlacklist ? video.VideoBlacklist.reason : null
     }
-
-    if (options.additionalAttributes.playlistInfo === true) {
-      // We filtered on a specific videoId/videoPlaylistId, that is unique
-      const playlistElement = video.VideoPlaylistElements[0]
-
-      videoObject.playlistElement = {
-        position: playlistElement.position,
-        startTimestamp: playlistElement.startTimestamp,
-        stopTimestamp: playlistElement.stopTimestamp
-      }
-    }
   }
 
   return videoObject
index eeb3d6bbd5ec10059be86a45e5cf4b9e881b0cd4..bed6f8eafd6446ebe271fc06d86c73923262e52e 100644 (file)
@@ -13,14 +13,18 @@ import {
   Table,
   UpdatedAt
 } from 'sequelize-typescript'
-import { VideoModel } from './video'
+import { ForAPIOptions, ScopeNames as VideoScopeNames, VideoModel } from './video'
 import { VideoPlaylistModel } from './video-playlist'
 import { getSort, throwIfNotValid } from '../utils'
 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
 import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
 import { PlaylistElementObject } from '../../../shared/models/activitypub/objects/playlist-element-object'
 import * as validator from 'validator'
-import { AggregateOptions, Op, Sequelize, Transaction } from 'sequelize'
+import { AggregateOptions, Op, ScopeOptions, Sequelize, Transaction } from 'sequelize'
+import { UserModel } from '../account/user'
+import { VideoPlaylistElement, VideoPlaylistElementType } from '../../../shared/models/videos/playlist/video-playlist-element.model'
+import { AccountModel } from '../account/account'
+import { VideoPrivacy } from '../../../shared/models/videos'
 
 @Table({
   tableName: 'videoPlaylistElement',
@@ -90,9 +94,9 @@ export class VideoPlaylistElementModel extends Model<VideoPlaylistElementModel>
 
   @BelongsTo(() => VideoModel, {
     foreignKey: {
-      allowNull: false
+      allowNull: true
     },
-    onDelete: 'CASCADE'
+    onDelete: 'set null'
   })
   Video: VideoModel
 
@@ -107,6 +111,57 @@ export class VideoPlaylistElementModel extends Model<VideoPlaylistElementModel>
     return VideoPlaylistElementModel.destroy(query)
   }
 
+  static listForApi (options: {
+    start: number,
+    count: number,
+    videoPlaylistId: number,
+    serverAccount: AccountModel,
+    user?: UserModel
+  }) {
+    const accountIds = [ options.serverAccount.id ]
+    const videoScope: (ScopeOptions | string)[] = [
+      VideoScopeNames.WITH_BLACKLISTED
+    ]
+
+    if (options.user) {
+      accountIds.push(options.user.Account.id)
+      videoScope.push({ method: [ VideoScopeNames.WITH_USER_HISTORY, options.user.id ] })
+    }
+
+    const forApiOptions: ForAPIOptions = { withAccountBlockerIds: accountIds }
+    videoScope.push({
+      method: [
+        VideoScopeNames.FOR_API, forApiOptions
+      ]
+    })
+
+    const findQuery = {
+      offset: options.start,
+      limit: options.count,
+      order: getSort('position'),
+      where: {
+        videoPlaylistId: options.videoPlaylistId
+      },
+      include: [
+        {
+          model: VideoModel.scope(videoScope),
+          required: false
+        }
+      ]
+    }
+
+    const countQuery = {
+      where: {
+        videoPlaylistId: options.videoPlaylistId
+      }
+    }
+
+    return Promise.all([
+      VideoPlaylistElementModel.count(countQuery),
+      VideoPlaylistElementModel.findAll(findQuery)
+    ]).then(([ total, data ]) => ({ total, data }))
+  }
+
   static loadByPlaylistAndVideo (videoPlaylistId: number, videoId: number) {
     const query = {
       where: {
@@ -118,6 +173,10 @@ export class VideoPlaylistElementModel extends Model<VideoPlaylistElementModel>
     return VideoPlaylistElementModel.findOne(query)
   }
 
+  static loadById (playlistElementId: number) {
+    return VideoPlaylistElementModel.findByPk(playlistElementId)
+  }
+
   static loadByPlaylistAndVideoForAP (playlistId: number | string, videoId: number | string) {
     const playlistWhere = validator.isUUID('' + playlistId) ? { uuid: playlistId } : { id: playlistId }
     const videoWhere = validator.isUUID('' + videoId) ? { uuid: videoId } : { id: videoId }
@@ -213,6 +272,42 @@ export class VideoPlaylistElementModel extends Model<VideoPlaylistElementModel>
     return VideoPlaylistElementModel.increment({ position: by }, query)
   }
 
+  getType (displayNSFW?: boolean, accountId?: number) {
+    const video = this.Video
+
+    if (!video) return VideoPlaylistElementType.DELETED
+
+    // Owned video, don't filter it
+    if (accountId && video.VideoChannel.Account.id === accountId) return VideoPlaylistElementType.REGULAR
+
+    if (video.privacy === VideoPrivacy.PRIVATE) return VideoPlaylistElementType.PRIVATE
+
+    if (video.isBlacklisted() || video.isBlocked()) return VideoPlaylistElementType.UNAVAILABLE
+    if (video.nsfw === true && displayNSFW === false) return VideoPlaylistElementType.UNAVAILABLE
+
+    return VideoPlaylistElementType.REGULAR
+  }
+
+  getVideoElement (displayNSFW?: boolean, accountId?: number) {
+    if (!this.Video) return null
+    if (this.getType(displayNSFW, accountId) !== VideoPlaylistElementType.REGULAR) return null
+
+    return this.Video.toFormattedJSON()
+  }
+
+  toFormattedJSON (options: { displayNSFW?: boolean, accountId?: number } = {}): VideoPlaylistElement {
+    return {
+      id: this.id,
+      position: this.position,
+      startTimestamp: this.startTimestamp,
+      stopTimestamp: this.stopTimestamp,
+
+      type: this.getType(options.displayNSFW, options.accountId),
+
+      video: this.getVideoElement(options.displayNSFW, options.accountId)
+    }
+  }
+
   toActivityPubObject (): PlaylistElementObject {
     const base: PlaylistElementObject = {
       id: this.url,
index 63b4a07153654aeb0f136c62311315f235d9f91a..61ff78bd23086081684c17d5ce05974c447d4526 100644 (file)
@@ -33,7 +33,7 @@ import {
   WEBSERVER
 } from '../../initializers/constants'
 import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model'
-import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account'
+import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
 import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
 import { join } from 'path'
 import { VideoPlaylistElementModel } from './video-playlist-element'
@@ -115,7 +115,7 @@ type AvailableForListOptions = {
   [ ScopeNames.AVAILABLE_FOR_LIST ]: (options: AvailableForListOptions) => {
     // Only list local playlists OR playlists that are on an instance followed by actorId
     const inQueryInstanceFollow = buildServerIdsFollowedBy(options.followerActorId)
-    const actorWhere = {
+    const whereActor = {
       [ Op.or ]: [
         {
           serverId: null
@@ -159,7 +159,7 @@ type AvailableForListOptions = {
     }
 
     const accountScope = {
-      method: [ AccountScopeNames.SUMMARY, actorWhere ]
+      method: [ AccountScopeNames.SUMMARY, { whereActor } as SummaryOptions ]
     }
 
     return {
@@ -341,7 +341,7 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
       },
       include: [
         {
-          attributes: [ 'videoId', 'startTimestamp', 'stopTimestamp' ],
+          attributes: [ 'id', 'videoId', 'startTimestamp', 'stopTimestamp' ],
           model: VideoPlaylistElementModel.unscoped(),
           where: {
             videoId: {
index c7f2658edd27e4fedb1cdaa92bed4ec0d86b3cca..05d625fc18a54e8ab7a560ec656fa6f027bfd286 100644 (file)
@@ -91,7 +91,7 @@ import {
 } from '../utils'
 import { TagModel } from './tag'
 import { VideoAbuseModel } from './video-abuse'
-import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
+import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
 import { VideoCommentModel } from './video-comment'
 import { VideoFileModel } from './video-file'
 import { VideoShareModel } from './video-share'
@@ -190,26 +190,29 @@ export enum ScopeNames {
   WITH_FILES = 'WITH_FILES',
   WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE',
   WITH_BLACKLISTED = 'WITH_BLACKLISTED',
+  WITH_BLOCKLIST = 'WITH_BLOCKLIST',
   WITH_USER_HISTORY = 'WITH_USER_HISTORY',
   WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS',
   WITH_USER_ID = 'WITH_USER_ID',
   WITH_THUMBNAILS = 'WITH_THUMBNAILS'
 }
 
-type ForAPIOptions = {
-  ids: number[]
+export type ForAPIOptions = {
+  ids?: number[]
 
   videoPlaylistId?: number
 
   withFiles?: boolean
+
+  withAccountBlockerIds?: number[]
 }
 
-type AvailableForListIDsOptions = {
+export type AvailableForListIDsOptions = {
   serverAccountId: number
   followerActorId: number
   includeLocalVideos: boolean
 
-  withoutId?: boolean
+  attributesType?: 'none' | 'id' | 'all'
 
   filter?: VideoFilter
   categoryOneOf?: number[]
@@ -236,14 +239,16 @@ type AvailableForListIDsOptions = {
 @Scopes(() => ({
   [ ScopeNames.FOR_API ]: (options: ForAPIOptions) => {
     const query: FindOptions = {
-      where: {
-        id: {
-          [ Op.in ]: options.ids // FIXME: sequelize ANY seems broken
-        }
-      },
       include: [
         {
-          model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, true ] }),
+          model: VideoChannelModel.scope({
+            method: [
+              VideoChannelScopeNames.SUMMARY, {
+                withAccount: true,
+                withAccountBlockerIds: options.withAccountBlockerIds
+              } as SummaryOptions
+            ]
+          }),
           required: true
         },
         {
@@ -254,6 +259,14 @@ type AvailableForListIDsOptions = {
       ]
     }
 
+    if (options.ids) {
+      query.where = {
+        id: {
+          [ Op.in ]: options.ids // FIXME: sequelize ANY seems broken
+        }
+      }
+    }
+
     if (options.withFiles === true) {
       query.include.push({
         model: VideoFileModel.unscoped(),
@@ -278,10 +291,14 @@ type AvailableForListIDsOptions = {
 
     const query: FindOptions = {
       raw: true,
-      attributes: options.withoutId === true ? [] : [ 'id' ],
       include: []
     }
 
+    const attributesType = options.attributesType || 'id'
+
+    if (attributesType === 'id') query.attributes = [ 'id' ]
+    else if (attributesType === 'none') query.attributes = [ ]
+
     whereAnd.push({
       id: {
         [ Op.notIn ]: Sequelize.literal(
@@ -290,17 +307,19 @@ type AvailableForListIDsOptions = {
       }
     })
 
-    whereAnd.push({
-      channelId: {
-        [ Op.notIn ]: Sequelize.literal(
-          '(' +
-            'SELECT id FROM "videoChannel" WHERE "accountId" IN (' +
-              buildBlockedAccountSQL(options.serverAccountId, options.user ? options.user.Account.id : undefined) +
-            ')' +
-          ')'
-        )
-      }
-    })
+    if (options.serverAccountId) {
+      whereAnd.push({
+        channelId: {
+          [ Op.notIn ]: Sequelize.literal(
+            '(' +
+              'SELECT id FROM "videoChannel" WHERE "accountId" IN (' +
+                buildBlockedAccountSQL(options.serverAccountId, options.user ? options.user.Account.id : undefined) +
+              ')' +
+            ')'
+          )
+        }
+      })
+    }
 
     // Only list public/published videos
     if (!options.filter || options.filter !== 'all-local') {
@@ -527,6 +546,9 @@ type AvailableForListIDsOptions = {
     }
 
     return query
+  },
+  [ScopeNames.WITH_BLOCKLIST]: {
+
   },
   [ ScopeNames.WITH_THUMBNAILS ]: {
     include: [
@@ -845,9 +867,9 @@ export class VideoModel extends Model<VideoModel> {
   @HasMany(() => VideoPlaylistElementModel, {
     foreignKey: {
       name: 'videoId',
-      allowNull: false
+      allowNull: true
     },
-    onDelete: 'cascade'
+    onDelete: 'set null'
   })
   VideoPlaylistElements: VideoPlaylistElementModel[]
 
@@ -1586,7 +1608,7 @@ export class VideoModel extends Model<VideoModel> {
       serverAccountId: serverActor.Account.id,
       followerActorId,
       includeLocalVideos: true,
-      withoutId: true // Don't break aggregation
+      attributesType: 'none' // Don't break aggregation
     }
 
     const query: FindOptions = {
@@ -1719,6 +1741,11 @@ export class VideoModel extends Model<VideoModel> {
     return !!this.VideoBlacklist
   }
 
+  isBlocked () {
+    return (this.VideoChannel.Account.Actor.Server && this.VideoChannel.Account.Actor.Server.isBlocked()) ||
+      this.VideoChannel.Account.isBlocked()
+  }
+
   getOriginalFile () {
     if (Array.isArray(this.VideoFiles) === false) return undefined
 
index 8c5e44bdd91e540d00aa4b6f496dc00de6406747..ae5aa287fb9ac978fa496aa7a55add6e69b16340 100644 (file)
@@ -37,6 +37,7 @@ describe('Test video playlists API validator', function () {
   let watchLaterPlaylistId: number
   let videoId: number
   let videoId2: number
+  let playlistElementId: number
 
   // ---------------------------------------------------------------
 
@@ -132,18 +133,18 @@ describe('Test video playlists API validator', function () {
   })
 
   describe('When listing videos of a playlist', function () {
-    const path = '/api/v1/video-playlists'
+    const path = '/api/v1/video-playlists/'
 
     it('Should fail with a bad start pagination', async function () {
-      await checkBadStartPagination(server.url, path, server.accessToken)
+      await checkBadStartPagination(server.url, path + playlistUUID + '/videos', server.accessToken)
     })
 
     it('Should fail with a bad count pagination', async function () {
-      await checkBadCountPagination(server.url, path, server.accessToken)
+      await checkBadCountPagination(server.url, path + playlistUUID + '/videos', server.accessToken)
     })
 
-    it('Should fail with a bad filter', async function () {
-      await checkBadSortPagination(server.url, path, server.accessToken)
+    it('Should success with the correct parameters', async function () {
+      await makeGetRequest({ url: server.url, path: path + playlistUUID + '/videos', statusCodeExpected: 200 })
     })
   })
 
@@ -296,7 +297,7 @@ describe('Test video playlists API validator', function () {
         token: server.accessToken,
         playlistId: playlistUUID,
         elementAttrs: Object.assign({
-          videoId: videoId,
+          videoId,
           startTimestamp: 2,
           stopTimestamp: 3
         }, elementAttrs)
@@ -344,7 +345,8 @@ describe('Test video playlists API validator', function () {
 
     it('Succeed with the correct params', async function () {
       const params = getBase({}, { expectedStatus: 200 })
-      await addVideoInPlaylist(params)
+      const res = await addVideoInPlaylist(params)
+      playlistElementId = res.body.videoPlaylistElement.id
     })
 
     it('Should fail if the video was already added in the playlist', async function () {
@@ -362,7 +364,7 @@ describe('Test video playlists API validator', function () {
           startTimestamp: 1,
           stopTimestamp: 2
         }, elementAttrs),
-        videoId: videoId,
+        playlistElementId,
         playlistId: playlistUUID,
         expectedStatus: 400
       }, wrapper)
@@ -390,14 +392,14 @@ describe('Test video playlists API validator', function () {
       }
     })
 
-    it('Should fail with an unknown or incorrect video id', async function () {
+    it('Should fail with an unknown or incorrect playlistElement id', async function () {
       {
-        const params = getBase({}, { videoId: 'toto' })
+        const params = getBase({}, { playlistElementId: 'toto' })
         await updateVideoPlaylistElement(params)
       }
 
       {
-        const params = getBase({}, { videoId: 42, expectedStatus: 404 })
+        const params = getBase({}, { playlistElementId: 42, expectedStatus: 404 })
         await updateVideoPlaylistElement(params)
       }
     })
@@ -415,7 +417,7 @@ describe('Test video playlists API validator', function () {
     })
 
     it('Should fail with an unknown element', async function () {
-      const params = getBase({}, { videoId: videoId2, expectedStatus: 404 })
+      const params = getBase({}, { playlistElementId: 888, expectedStatus: 404 })
       await updateVideoPlaylistElement(params)
     })
 
@@ -587,7 +589,7 @@ describe('Test video playlists API validator', function () {
       return Object.assign({
         url: server.url,
         token: server.accessToken,
-        videoId: videoId,
+        playlistElementId,
         playlistId: playlistUUID,
         expectedStatus: 400
       }, wrapper)
@@ -617,18 +619,18 @@ describe('Test video playlists API validator', function () {
 
     it('Should fail with an unknown or incorrect video id', async function () {
       {
-        const params = getBase({ videoId: 'toto' })
+        const params = getBase({ playlistElementId: 'toto' })
         await removeVideoFromPlaylist(params)
       }
 
       {
-        const params = getBase({ videoId: 42, expectedStatus: 404 })
+        const params = getBase({ playlistElementId: 42, expectedStatus: 404 })
         await removeVideoFromPlaylist(params)
       }
     })
 
     it('Should fail with an unknown element', async function () {
-      const params = getBase({ videoId: videoId2, expectedStatus: 404 })
+      const params = getBase({ playlistElementId: 888, expectedStatus: 404 })
       await removeVideoFromPlaylist(params)
     })
 
index babef82234f79cb8856012fe3bdb08d0d14bf25d..5a5668665f6553a39df2078c6cbcbb385889e60a 100644 (file)
@@ -15,13 +15,12 @@ import {
 import { UserRole } from '../../../../shared/models/users'
 import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model'
 
-async function testEndpoints (server: ServerInfo, token: string, filter: string, playlistUUID: string, statusCodeExpected: number) {
+async function testEndpoints (server: ServerInfo, token: string, filter: string, statusCodeExpected: number) {
   const paths = [
     '/api/v1/video-channels/root_channel/videos',
     '/api/v1/accounts/root/videos',
     '/api/v1/videos',
-    '/api/v1/search/videos',
-    '/api/v1/video-playlists/' + playlistUUID + '/videos'
+    '/api/v1/search/videos'
   ]
 
   for (const path of paths) {
@@ -70,39 +69,28 @@ describe('Test videos filters', function () {
       }
     )
     moderatorAccessToken = await userLogin(server, moderator)
-
-    const res = await createVideoPlaylist({
-      url: server.url,
-      token: server.accessToken,
-      playlistAttrs: {
-        displayName: 'super playlist',
-        privacy: VideoPlaylistPrivacy.PUBLIC,
-        videoChannelId: server.videoChannel.id
-      }
-    })
-    playlistUUID = res.body.videoPlaylist.uuid
   })
 
   describe('When setting a video filter', function () {
 
     it('Should fail with a bad filter', async function () {
-      await testEndpoints(server, server.accessToken, 'bad-filter', playlistUUID, 400)
+      await testEndpoints(server, server.accessToken, 'bad-filter', 400)
     })
 
     it('Should succeed with a good filter', async function () {
-      await testEndpoints(server, server.accessToken,'local', playlistUUID, 200)
+      await testEndpoints(server, server.accessToken,'local', 200)
     })
 
     it('Should fail to list all-local with a simple user', async function () {
-      await testEndpoints(server, userAccessToken, 'all-local', playlistUUID, 401)
+      await testEndpoints(server, userAccessToken, 'all-local', 401)
     })
 
     it('Should succeed to list all-local with a moderator', async function () {
-      await testEndpoints(server, moderatorAccessToken, 'all-local', playlistUUID, 200)
+      await testEndpoints(server, moderatorAccessToken, 'all-local', 200)
     })
 
     it('Should succeed to list all-local with an admin', async function () {
-      await testEndpoints(server, server.accessToken, 'all-local', playlistUUID, 200)
+      await testEndpoints(server, server.accessToken, 'all-local', 200)
     })
 
     // Because we cannot authenticate the user on the RSS endpoint
index 51e592a15ca29967c2df0578cce69149a230ae1f..fa6d6f6226d9a773a10d7df761024a975b00f2c7 100644 (file)
@@ -62,7 +62,7 @@ describe('Test videos API validator', function () {
     }
   })
 
-  describe('When listing a video', function () {
+  describe('When listing videos', function () {
     it('Should fail with a bad start pagination', async function () {
       await checkBadStartPagination(server.url, path)
     })
index f82c8cbce0937f4b19390c9292f702acd4251c6d..7d5e3914bf9808d9202742f91337c63eee7a3f96 100644 (file)
@@ -5,6 +5,7 @@ import 'mocha'
 import {
   addVideoChannel,
   addVideoInPlaylist,
+  addVideoToBlacklist,
   checkPlaylistFilesWereRemoved,
   cleanupTests,
   createUser,
@@ -14,6 +15,8 @@ import {
   doubleFollow,
   doVideosExistInMyPlaylist,
   flushAndRunMultipleServers,
+  generateUserAccessToken,
+  getAccessToken,
   getAccountPlaylistsList,
   getAccountPlaylistsListWithToken,
   getMyUserInformation,
@@ -24,6 +27,7 @@ import {
   getVideoPlaylistsList,
   getVideoPlaylistWithToken,
   removeUser,
+  removeVideoFromBlacklist,
   removeVideoFromPlaylist,
   reorderVideosPlaylist,
   ServerInfo,
@@ -31,23 +35,58 @@ import {
   setDefaultVideoChannel,
   testImage,
   unfollow,
+  updateVideo,
   updateVideoPlaylist,
   updateVideoPlaylistElement,
   uploadVideo,
   uploadVideoAndGetId,
   userLogin,
-  waitJobs,
-  generateUserAccessToken
+  waitJobs
 } from '../../../../shared/extra-utils'
 import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model'
 import { VideoPlaylist } from '../../../../shared/models/videos/playlist/video-playlist.model'
-import { Video } from '../../../../shared/models/videos'
+import { VideoPrivacy } from '../../../../shared/models/videos'
 import { VideoPlaylistType } from '../../../../shared/models/videos/playlist/video-playlist-type.model'
 import { VideoExistInPlaylist } from '../../../../shared/models/videos/playlist/video-exist-in-playlist.model'
 import { User } from '../../../../shared/models/users'
+import { VideoPlaylistElement, VideoPlaylistElementType } from '../../../../shared/models/videos/playlist/video-playlist-element.model'
+import {
+  addAccountToAccountBlocklist,
+  addAccountToServerBlocklist,
+  addServerToAccountBlocklist,
+  addServerToServerBlocklist,
+  removeAccountFromAccountBlocklist,
+  removeAccountFromServerBlocklist,
+  removeServerFromAccountBlocklist,
+  removeServerFromServerBlocklist
+} from '../../../../shared/extra-utils/users/blocklist'
 
 const expect = chai.expect
 
+async function checkPlaylistElementType (
+  servers: ServerInfo[],
+  playlistId: string,
+  type: VideoPlaylistElementType,
+  position: number,
+  name: string,
+  total: number
+) {
+  for (const server of servers) {
+    const res = await getPlaylistVideos(server.url, server.accessToken, playlistId, 0, 10)
+    expect(res.body.total).to.equal(total)
+
+    const videoElement: VideoPlaylistElement = res.body.data.find((e: VideoPlaylistElement) => e.position === position)
+    expect(videoElement.type).to.equal(type, 'On server ' + server.url)
+
+    if (type === VideoPlaylistElementType.REGULAR) {
+      expect(videoElement.video).to.not.be.null
+      expect(videoElement.video.name).to.equal(name)
+    } else {
+      expect(videoElement.video).to.be.null
+    }
+  }
+}
+
 describe('Test video playlists', function () {
   let servers: ServerInfo[] = []
 
@@ -57,9 +96,16 @@ describe('Test video playlists', function () {
 
   let playlistServer1Id: number
   let playlistServer1UUID: string
+  let playlistServer1UUID2: string
+
+  let playlistElementServer1Video4: number
+  let playlistElementServer1Video5: number
+  let playlistElementNSFW: number
 
   let nsfwVideoServer1: number
 
+  let userAccessTokenServer1: string
+
   before(async function () {
     this.timeout(120000)
 
@@ -97,814 +143,1039 @@ describe('Test video playlists', function () {
 
     nsfwVideoServer1 = (await uploadVideoAndGetId({ server: servers[ 0 ], videoName: 'NSFW video', nsfw: true })).id
 
+    {
+      await createUser({
+        url: servers[ 0 ].url,
+        accessToken: servers[ 0 ].accessToken,
+        username: 'user1',
+        password: 'password'
+      })
+      userAccessTokenServer1 = await getAccessToken(servers[0].url, 'user1', 'password')
+    }
+
     await waitJobs(servers)
   })
 
-  it('Should list video playlist privacies', async function () {
-    const res = await getVideoPlaylistPrivacies(servers[0].url)
+  describe('Get default playlists', function () {
+    it('Should list video playlist privacies', async function () {
+      const res = await getVideoPlaylistPrivacies(servers[ 0 ].url)
 
-    const privacies = res.body
-    expect(Object.keys(privacies)).to.have.length.at.least(3)
+      const privacies = res.body
+      expect(Object.keys(privacies)).to.have.length.at.least(3)
 
-    expect(privacies[3]).to.equal('Private')
-  })
+      expect(privacies[ 3 ]).to.equal('Private')
+    })
 
-  it('Should list watch later playlist', async function () {
-    const url = servers[ 0 ].url
-    const accessToken = servers[ 0 ].accessToken
+    it('Should list watch later playlist', async function () {
+      const url = servers[ 0 ].url
+      const accessToken = servers[ 0 ].accessToken
 
-    {
-      const res = await getAccountPlaylistsListWithToken(url, accessToken, 'root', 0, 5, VideoPlaylistType.WATCH_LATER)
+      {
+        const res = await getAccountPlaylistsListWithToken(url, accessToken, 'root', 0, 5, VideoPlaylistType.WATCH_LATER)
+
+        expect(res.body.total).to.equal(1)
+        expect(res.body.data).to.have.lengthOf(1)
+
+        const playlist: VideoPlaylist = res.body.data[ 0 ]
+        expect(playlist.displayName).to.equal('Watch later')
+        expect(playlist.type.id).to.equal(VideoPlaylistType.WATCH_LATER)
+        expect(playlist.type.label).to.equal('Watch later')
+      }
+
+      {
+        const res = await getAccountPlaylistsListWithToken(url, accessToken, 'root', 0, 5, VideoPlaylistType.REGULAR)
+
+        expect(res.body.total).to.equal(0)
+        expect(res.body.data).to.have.lengthOf(0)
+      }
+
+      {
+        const res = await getAccountPlaylistsList(url, 'root', 0, 5)
+        expect(res.body.total).to.equal(0)
+        expect(res.body.data).to.have.lengthOf(0)
+      }
+    })
+
+    it('Should get private playlist for a classic user', async function () {
+      const token = await generateUserAccessToken(servers[ 0 ], 'toto')
+
+      const res = await getAccountPlaylistsListWithToken(servers[ 0 ].url, token, 'toto', 0, 5)
 
       expect(res.body.total).to.equal(1)
       expect(res.body.data).to.have.lengthOf(1)
 
-      const playlist: VideoPlaylist = res.body.data[ 0 ]
-      expect(playlist.displayName).to.equal('Watch later')
-      expect(playlist.type.id).to.equal(VideoPlaylistType.WATCH_LATER)
-      expect(playlist.type.label).to.equal('Watch later')
-    }
+      const playlistId = res.body.data[ 0 ].id
+      await getPlaylistVideos(servers[ 0 ].url, token, playlistId, 0, 5)
+    })
+  })
 
-    {
-      const res = await getAccountPlaylistsListWithToken(url, accessToken, 'root', 0, 5, VideoPlaylistType.REGULAR)
+  describe('Create and federate playlists', function () {
 
-      expect(res.body.total).to.equal(0)
-      expect(res.body.data).to.have.lengthOf(0)
-    }
+    it('Should create a playlist on server 1 and have the playlist on server 2 and 3', async function () {
+      this.timeout(30000)
 
-    {
-      const res = await getAccountPlaylistsList(url, 'root', 0, 5)
-      expect(res.body.total).to.equal(0)
-      expect(res.body.data).to.have.lengthOf(0)
-    }
-  })
+      await createVideoPlaylist({
+        url: servers[ 0 ].url,
+        token: servers[ 0 ].accessToken,
+        playlistAttrs: {
+          displayName: 'my super playlist',
+          privacy: VideoPlaylistPrivacy.PUBLIC,
+          description: 'my super description',
+          thumbnailfile: 'thumbnail.jpg',
+          videoChannelId: servers[ 0 ].videoChannel.id
+        }
+      })
+
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const res = await getVideoPlaylistsList(server.url, 0, 5)
+        expect(res.body.total).to.equal(1)
+        expect(res.body.data).to.have.lengthOf(1)
+
+        const playlistFromList = res.body.data[ 0 ] as VideoPlaylist
+
+        const res2 = await getVideoPlaylist(server.url, playlistFromList.uuid)
+        const playlistFromGet = res2.body
+
+        for (const playlist of [ playlistFromGet, playlistFromList ]) {
+          expect(playlist.id).to.be.a('number')
+          expect(playlist.uuid).to.be.a('string')
+
+          expect(playlist.isLocal).to.equal(server.serverNumber === 1)
+
+          expect(playlist.displayName).to.equal('my super playlist')
+          expect(playlist.description).to.equal('my super description')
+          expect(playlist.privacy.id).to.equal(VideoPlaylistPrivacy.PUBLIC)
+          expect(playlist.privacy.label).to.equal('Public')
+          expect(playlist.type.id).to.equal(VideoPlaylistType.REGULAR)
+          expect(playlist.type.label).to.equal('Regular')
+
+          expect(playlist.videosLength).to.equal(0)
 
-  it('Should get private playlist for a classic user', async function () {
-    const token = await generateUserAccessToken(servers[0], 'toto')
+          expect(playlist.ownerAccount.name).to.equal('root')
+          expect(playlist.ownerAccount.displayName).to.equal('root')
+          expect(playlist.videoChannel.name).to.equal('root_channel')
+          expect(playlist.videoChannel.displayName).to.equal('Main root channel')
+        }
+      }
+    })
 
-    const res = await getAccountPlaylistsListWithToken(servers[0].url, token, 'toto', 0, 5)
+    it('Should create a playlist on server 2 and have the playlist on server 1 but not on server 3', async function () {
+      this.timeout(30000)
+
+      {
+        const res = await createVideoPlaylist({
+          url: servers[ 1 ].url,
+          token: servers[ 1 ].accessToken,
+          playlistAttrs: {
+            displayName: 'playlist 2',
+            privacy: VideoPlaylistPrivacy.PUBLIC,
+            videoChannelId: servers[ 1 ].videoChannel.id
+          }
+        })
+        playlistServer2Id1 = res.body.videoPlaylist.id
+      }
 
-    expect(res.body.total).to.equal(1)
-    expect(res.body.data).to.have.lengthOf(1)
+      {
+        const res = await createVideoPlaylist({
+          url: servers[ 1 ].url,
+          token: servers[ 1 ].accessToken,
+          playlistAttrs: {
+            displayName: 'playlist 3',
+            privacy: VideoPlaylistPrivacy.PUBLIC,
+            thumbnailfile: 'thumbnail.jpg',
+            videoChannelId: servers[ 1 ].videoChannel.id
+          }
+        })
+
+        playlistServer2Id2 = res.body.videoPlaylist.id
+        playlistServer2UUID2 = res.body.videoPlaylist.uuid
+      }
+
+      for (let id of [ playlistServer2Id1, playlistServer2Id2 ]) {
+        await addVideoInPlaylist({
+          url: servers[ 1 ].url,
+          token: servers[ 1 ].accessToken,
+          playlistId: id,
+          elementAttrs: { videoId: servers[ 1 ].videos[ 0 ].id, startTimestamp: 1, stopTimestamp: 2 }
+        })
+        await addVideoInPlaylist({
+          url: servers[ 1 ].url,
+          token: servers[ 1 ].accessToken,
+          playlistId: id,
+          elementAttrs: { videoId: servers[ 1 ].videos[ 1 ].id }
+        })
+      }
+
+      await waitJobs(servers)
+
+      for (const server of [ servers[ 0 ], servers[ 1 ] ]) {
+        const res = await getVideoPlaylistsList(server.url, 0, 5)
+
+        const playlist2 = res.body.data.find(p => p.displayName === 'playlist 2')
+        expect(playlist2).to.not.be.undefined
+        await testImage(server.url, 'thumbnail-playlist', playlist2.thumbnailPath)
+
+        const playlist3 = res.body.data.find(p => p.displayName === 'playlist 3')
+        expect(playlist3).to.not.be.undefined
+        await testImage(server.url, 'thumbnail', playlist3.thumbnailPath)
+      }
 
-    const playlistId = res.body.data[0].id
-    await getPlaylistVideos(servers[0].url, token, playlistId, 0, 5)
+      const res = await getVideoPlaylistsList(servers[ 2 ].url, 0, 5)
+      expect(res.body.data.find(p => p.displayName === 'playlist 2')).to.be.undefined
+      expect(res.body.data.find(p => p.displayName === 'playlist 3')).to.be.undefined
+    })
+
+    it('Should have the playlist on server 3 after a new follow', async function () {
+      this.timeout(30000)
+
+      // Server 2 and server 3 follow each other
+      await doubleFollow(servers[ 1 ], servers[ 2 ])
+
+      const res = await getVideoPlaylistsList(servers[ 2 ].url, 0, 5)
+
+      const playlist2 = res.body.data.find(p => p.displayName === 'playlist 2')
+      expect(playlist2).to.not.be.undefined
+      await testImage(servers[ 2 ].url, 'thumbnail-playlist', playlist2.thumbnailPath)
+
+      expect(res.body.data.find(p => p.displayName === 'playlist 3')).to.not.be.undefined
+    })
   })
 
-  it('Should create a playlist on server 1 and have the playlist on server 2 and 3', async function () {
-    this.timeout(30000)
+  describe('List playlists', function () {
+    it('Should correctly list the playlists', async function () {
+      this.timeout(30000)
+
+      {
+        const res = await getVideoPlaylistsList(servers[ 2 ].url, 1, 2, 'createdAt')
 
-    await createVideoPlaylist({
-      url: servers[0].url,
-      token: servers[0].accessToken,
-      playlistAttrs: {
-        displayName: 'my super playlist',
-        privacy: VideoPlaylistPrivacy.PUBLIC,
-        description: 'my super description',
-        thumbnailfile: 'thumbnail.jpg',
-        videoChannelId: servers[0].videoChannel.id
+        expect(res.body.total).to.equal(3)
+
+        const data: VideoPlaylist[] = res.body.data
+        expect(data).to.have.lengthOf(2)
+        expect(data[ 0 ].displayName).to.equal('playlist 2')
+        expect(data[ 1 ].displayName).to.equal('playlist 3')
+      }
+
+      {
+        const res = await getVideoPlaylistsList(servers[ 2 ].url, 1, 2, '-createdAt')
+
+        expect(res.body.total).to.equal(3)
+
+        const data: VideoPlaylist[] = res.body.data
+        expect(data).to.have.lengthOf(2)
+        expect(data[ 0 ].displayName).to.equal('playlist 2')
+        expect(data[ 1 ].displayName).to.equal('my super playlist')
       }
     })
 
-    await waitJobs(servers)
+    it('Should list video channel playlists', async function () {
+      this.timeout(30000)
 
-    for (const server of servers) {
-      const res = await getVideoPlaylistsList(server.url, 0, 5)
-      expect(res.body.total).to.equal(1)
-      expect(res.body.data).to.have.lengthOf(1)
+      {
+        const res = await getVideoChannelPlaylistsList(servers[ 0 ].url, 'root_channel', 0, 2, '-createdAt')
 
-      const playlistFromList = res.body.data[0] as VideoPlaylist
+        expect(res.body.total).to.equal(1)
 
-      const res2 = await getVideoPlaylist(server.url, playlistFromList.uuid)
-      const playlistFromGet = res2.body
+        const data: VideoPlaylist[] = res.body.data
+        expect(data).to.have.lengthOf(1)
+        expect(data[ 0 ].displayName).to.equal('my super playlist')
+      }
+    })
 
-      for (const playlist of [ playlistFromGet, playlistFromList ]) {
-        expect(playlist.id).to.be.a('number')
-        expect(playlist.uuid).to.be.a('string')
+    it('Should list account playlists', async function () {
+      this.timeout(30000)
 
-        expect(playlist.isLocal).to.equal(server.serverNumber === 1)
+      {
+        const res = await getAccountPlaylistsList(servers[ 1 ].url, 'root', 1, 2, '-createdAt')
 
-        expect(playlist.displayName).to.equal('my super playlist')
-        expect(playlist.description).to.equal('my super description')
-        expect(playlist.privacy.id).to.equal(VideoPlaylistPrivacy.PUBLIC)
-        expect(playlist.privacy.label).to.equal('Public')
-        expect(playlist.type.id).to.equal(VideoPlaylistType.REGULAR)
-        expect(playlist.type.label).to.equal('Regular')
+        expect(res.body.total).to.equal(2)
+
+        const data: VideoPlaylist[] = res.body.data
+        expect(data).to.have.lengthOf(1)
+        expect(data[ 0 ].displayName).to.equal('playlist 2')
+      }
 
-        expect(playlist.videosLength).to.equal(0)
+      {
+        const res = await getAccountPlaylistsList(servers[ 1 ].url, 'root', 1, 2, 'createdAt')
 
-        expect(playlist.ownerAccount.name).to.equal('root')
-        expect(playlist.ownerAccount.displayName).to.equal('root')
-        expect(playlist.videoChannel.name).to.equal('root_channel')
-        expect(playlist.videoChannel.displayName).to.equal('Main root channel')
+        expect(res.body.total).to.equal(2)
+
+        const data: VideoPlaylist[] = res.body.data
+        expect(data).to.have.lengthOf(1)
+        expect(data[ 0 ].displayName).to.equal('playlist 3')
       }
-    }
-  })
+    })
 
-  it('Should create a playlist on server 2 and have the playlist on server 1 but not on server 3', async function () {
-    this.timeout(30000)
+    it('Should not list unlisted or private playlists', async function () {
+      this.timeout(30000)
 
-    {
-      const res = await createVideoPlaylist({
-        url: servers[1].url,
-        token: servers[1].accessToken,
+      await createVideoPlaylist({
+        url: servers[ 1 ].url,
+        token: servers[ 1 ].accessToken,
         playlistAttrs: {
-          displayName: 'playlist 2',
-          privacy: VideoPlaylistPrivacy.PUBLIC,
-          videoChannelId: servers[1].videoChannel.id
+          displayName: 'playlist unlisted',
+          privacy: VideoPlaylistPrivacy.UNLISTED
         }
       })
-      playlistServer2Id1 = res.body.videoPlaylist.id
-    }
 
-    {
-      const res = await createVideoPlaylist({
+      await createVideoPlaylist({
         url: servers[ 1 ].url,
         token: servers[ 1 ].accessToken,
         playlistAttrs: {
-          displayName: 'playlist 3',
-          privacy: VideoPlaylistPrivacy.PUBLIC,
-          thumbnailfile: 'thumbnail.jpg',
-          videoChannelId: servers[1].videoChannel.id
+          displayName: 'playlist private',
+          privacy: VideoPlaylistPrivacy.PRIVATE
         }
       })
 
-      playlistServer2Id2 = res.body.videoPlaylist.id
-      playlistServer2UUID2 = res.body.videoPlaylist.uuid
-    }
+      await waitJobs(servers)
 
-    for (let id of [ playlistServer2Id1, playlistServer2Id2 ]) {
-      await addVideoInPlaylist({
-        url: servers[ 1 ].url,
-        token: servers[ 1 ].accessToken,
-        playlistId: id,
-        elementAttrs: { videoId: servers[ 1 ].videos[ 0 ].id, startTimestamp: 1, stopTimestamp: 2 }
-      })
-      await addVideoInPlaylist({
-        url: servers[ 1 ].url,
-        token: servers[ 1 ].accessToken,
-        playlistId: id,
-        elementAttrs: { videoId: servers[ 1 ].videos[ 1 ].id }
-      })
-    }
+      for (const server of servers) {
+        const results = [
+          await getAccountPlaylistsList(server.url, 'root@localhost:' + servers[ 1 ].port, 0, 5, '-createdAt'),
+          await getVideoPlaylistsList(server.url, 0, 2, '-createdAt')
+        ]
+
+        expect(results[ 0 ].body.total).to.equal(2)
+        expect(results[ 1 ].body.total).to.equal(3)
+
+        for (const res of results) {
+          const data: VideoPlaylist[] = res.body.data
+          expect(data).to.have.lengthOf(2)
+          expect(data[ 0 ].displayName).to.equal('playlist 3')
+          expect(data[ 1 ].displayName).to.equal('playlist 2')
+        }
+      }
+    })
+  })
 
-    await waitJobs(servers)
+  describe('Update playlists', function () {
 
-    for (const server of [ servers[0], servers[1] ]) {
-      const res = await getVideoPlaylistsList(server.url, 0, 5)
+    it('Should update a playlist', async function () {
+      this.timeout(30000)
 
-      const playlist2 = res.body.data.find(p => p.displayName === 'playlist 2')
-      expect(playlist2).to.not.be.undefined
-      await testImage(server.url, 'thumbnail-playlist', playlist2.thumbnailPath)
+      await updateVideoPlaylist({
+        url: servers[1].url,
+        token: servers[1].accessToken,
+        playlistAttrs: {
+          displayName: 'playlist 3 updated',
+          description: 'description updated',
+          privacy: VideoPlaylistPrivacy.UNLISTED,
+          thumbnailfile: 'thumbnail.jpg',
+          videoChannelId: servers[1].videoChannel.id
+        },
+        playlistId: playlistServer2Id2
+      })
 
-      const playlist3 = res.body.data.find(p => p.displayName === 'playlist 3')
-      expect(playlist3).to.not.be.undefined
-      await testImage(server.url, 'thumbnail', playlist3.thumbnailPath)
-    }
+      await waitJobs(servers)
 
-    const res = await getVideoPlaylistsList(servers[2].url, 0, 5)
-    expect(res.body.data.find(p => p.displayName === 'playlist 2')).to.be.undefined
-    expect(res.body.data.find(p => p.displayName === 'playlist 3')).to.be.undefined
-  })
+      for (const server of servers) {
+        const res = await getVideoPlaylist(server.url, playlistServer2UUID2)
+        const playlist: VideoPlaylist = res.body
 
-  it('Should have the playlist on server 3 after a new follow', async function () {
-    this.timeout(30000)
+        expect(playlist.displayName).to.equal('playlist 3 updated')
+        expect(playlist.description).to.equal('description updated')
 
-    // Server 2 and server 3 follow each other
-    await doubleFollow(servers[1], servers[2])
+        expect(playlist.privacy.id).to.equal(VideoPlaylistPrivacy.UNLISTED)
+        expect(playlist.privacy.label).to.equal('Unlisted')
 
-    const res = await getVideoPlaylistsList(servers[2].url, 0, 5)
+        expect(playlist.type.id).to.equal(VideoPlaylistType.REGULAR)
+        expect(playlist.type.label).to.equal('Regular')
 
-    const playlist2 = res.body.data.find(p => p.displayName === 'playlist 2')
-    expect(playlist2).to.not.be.undefined
-    await testImage(servers[2].url, 'thumbnail-playlist', playlist2.thumbnailPath)
+        expect(playlist.videosLength).to.equal(2)
 
-    expect(res.body.data.find(p => p.displayName === 'playlist 3')).to.not.be.undefined
+        expect(playlist.ownerAccount.name).to.equal('root')
+        expect(playlist.ownerAccount.displayName).to.equal('root')
+        expect(playlist.videoChannel.name).to.equal('root_channel')
+        expect(playlist.videoChannel.displayName).to.equal('Main root channel')
+      }
+    })
   })
 
-  it('Should correctly list the playlists', async function () {
-    this.timeout(30000)
+  describe('Element timestamps', function () {
 
-    {
-      const res = await getVideoPlaylistsList(servers[ 2 ].url, 1, 2, 'createdAt')
+    it('Should create a playlist containing different startTimestamp/endTimestamp videos', async function () {
+      this.timeout(30000)
 
-      expect(res.body.total).to.equal(3)
+      const addVideo = (elementAttrs: any) => {
+        return addVideoInPlaylist({ url: servers[ 0 ].url, token: servers[ 0 ].accessToken, playlistId: playlistServer1Id, elementAttrs })
+      }
 
-      const data: VideoPlaylist[] = res.body.data
-      expect(data).to.have.lengthOf(2)
-      expect(data[ 0 ].displayName).to.equal('playlist 2')
-      expect(data[ 1 ].displayName).to.equal('playlist 3')
-    }
+      const res = await createVideoPlaylist({
+        url: servers[ 0 ].url,
+        token: servers[ 0 ].accessToken,
+        playlistAttrs: {
+          displayName: 'playlist 4',
+          privacy: VideoPlaylistPrivacy.PUBLIC,
+          videoChannelId: servers[ 0 ].videoChannel.id
+        }
+      })
 
-    {
-      const res = await getVideoPlaylistsList(servers[ 2 ].url, 1, 2, '-createdAt')
+      playlistServer1Id = res.body.videoPlaylist.id
+      playlistServer1UUID = res.body.videoPlaylist.uuid
 
-      expect(res.body.total).to.equal(3)
+      await addVideo({ videoId: servers[ 0 ].videos[ 0 ].uuid, startTimestamp: 15, stopTimestamp: 28 })
+      await addVideo({ videoId: servers[ 2 ].videos[ 1 ].uuid, startTimestamp: 35 })
+      await addVideo({ videoId: servers[ 2 ].videos[ 2 ].uuid })
+      {
+        const res = await addVideo({ videoId: servers[ 0 ].videos[ 3 ].uuid, stopTimestamp: 35 })
+        playlistElementServer1Video4 = res.body.videoPlaylistElement.id
+      }
 
-      const data: VideoPlaylist[] = res.body.data
-      expect(data).to.have.lengthOf(2)
-      expect(data[ 0 ].displayName).to.equal('playlist 2')
-      expect(data[ 1 ].displayName).to.equal('my super playlist')
-    }
-  })
+      {
+        const res = await addVideo({ videoId: servers[ 0 ].videos[ 4 ].uuid, startTimestamp: 45, stopTimestamp: 60 })
+        playlistElementServer1Video5 = res.body.videoPlaylistElement.id
+      }
 
-  it('Should list video channel playlists', async function () {
-    this.timeout(30000)
+      {
+        const res = await addVideo({ videoId: nsfwVideoServer1, startTimestamp: 5 })
+        playlistElementNSFW = res.body.videoPlaylistElement.id
+      }
 
-    {
-      const res = await getVideoChannelPlaylistsList(servers[ 0 ].url, 'root_channel', 0, 2, '-createdAt')
+      await waitJobs(servers)
+    })
 
-      expect(res.body.total).to.equal(1)
+    it('Should correctly list playlist videos', async function () {
+      this.timeout(30000)
 
-      const data: VideoPlaylist[] = res.body.data
-      expect(data).to.have.lengthOf(1)
-      expect(data[ 0 ].displayName).to.equal('my super playlist')
-    }
-  })
+      for (const server of servers) {
+        const res = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10)
 
-  it('Should list account playlists', async function () {
-    this.timeout(30000)
+        expect(res.body.total).to.equal(6)
 
-    {
-      const res = await getAccountPlaylistsList(servers[ 1 ].url, 'root', 1, 2, '-createdAt')
+        const videoElements: VideoPlaylistElement[] = res.body.data
+        expect(videoElements).to.have.lengthOf(6)
 
-      expect(res.body.total).to.equal(2)
+        expect(videoElements[ 0 ].video.name).to.equal('video 0 server 1')
+        expect(videoElements[ 0 ].position).to.equal(1)
+        expect(videoElements[ 0 ].startTimestamp).to.equal(15)
+        expect(videoElements[ 0 ].stopTimestamp).to.equal(28)
 
-      const data: VideoPlaylist[] = res.body.data
-      expect(data).to.have.lengthOf(1)
-      expect(data[ 0 ].displayName).to.equal('playlist 2')
-    }
+        expect(videoElements[ 1 ].video.name).to.equal('video 1 server 3')
+        expect(videoElements[ 1 ].position).to.equal(2)
+        expect(videoElements[ 1 ].startTimestamp).to.equal(35)
+        expect(videoElements[ 1 ].stopTimestamp).to.be.null
 
-    {
-      const res = await getAccountPlaylistsList(servers[ 1 ].url, 'root', 1, 2, 'createdAt')
+        expect(videoElements[ 2 ].video.name).to.equal('video 2 server 3')
+        expect(videoElements[ 2 ].position).to.equal(3)
+        expect(videoElements[ 2 ].startTimestamp).to.be.null
+        expect(videoElements[ 2 ].stopTimestamp).to.be.null
 
-      expect(res.body.total).to.equal(2)
+        expect(videoElements[ 3 ].video.name).to.equal('video 3 server 1')
+        expect(videoElements[ 3 ].position).to.equal(4)
+        expect(videoElements[ 3 ].startTimestamp).to.be.null
+        expect(videoElements[ 3 ].stopTimestamp).to.equal(35)
 
-      const data: VideoPlaylist[] = res.body.data
-      expect(data).to.have.lengthOf(1)
-      expect(data[ 0 ].displayName).to.equal('playlist 3')
-    }
-  })
+        expect(videoElements[ 4 ].video.name).to.equal('video 4 server 1')
+        expect(videoElements[ 4 ].position).to.equal(5)
+        expect(videoElements[ 4 ].startTimestamp).to.equal(45)
+        expect(videoElements[ 4 ].stopTimestamp).to.equal(60)
 
-  it('Should not list unlisted or private playlists', async function () {
-    this.timeout(30000)
+        expect(videoElements[ 5 ].video.name).to.equal('NSFW video')
+        expect(videoElements[ 5 ].position).to.equal(6)
+        expect(videoElements[ 5 ].startTimestamp).to.equal(5)
+        expect(videoElements[ 5 ].stopTimestamp).to.be.null
 
-    await createVideoPlaylist({
-      url: servers[ 1 ].url,
-      token: servers[ 1 ].accessToken,
-      playlistAttrs: {
-        displayName: 'playlist unlisted',
-        privacy: VideoPlaylistPrivacy.UNLISTED
+        const res3 = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 2)
+        expect(res3.body.data).to.have.lengthOf(2)
       }
     })
+  })
 
-    await createVideoPlaylist({
-      url: servers[ 1 ].url,
-      token: servers[ 1 ].accessToken,
-      playlistAttrs: {
-        displayName: 'playlist private',
-        privacy: VideoPlaylistPrivacy.PRIVATE
-      }
-    })
+  describe('Element type', function () {
+    let groupUser1: ServerInfo[]
+    let groupWithoutToken1: ServerInfo[]
+    let group1: ServerInfo[]
+    let group2: ServerInfo[]
 
-    await waitJobs(servers)
+    let video1: string
+    let video2: string
+    let video3: string
 
-    for (const server of servers) {
-      const results = [
-        await getAccountPlaylistsList(server.url, 'root@localhost:' + servers[1].port, 0, 5, '-createdAt'),
-        await getVideoPlaylistsList(server.url, 0, 2, '-createdAt')
-      ]
+    before(async function () {
+      this.timeout(30000)
 
-      expect(results[0].body.total).to.equal(2)
-      expect(results[1].body.total).to.equal(3)
+      groupUser1 = [ Object.assign({}, servers[ 0 ], { accessToken: userAccessTokenServer1 }) ]
+      groupWithoutToken1 = [ Object.assign({}, servers[ 0 ], { accessToken: undefined }) ]
+      group1 = [ servers[ 0 ] ]
+      group2 = [ servers[ 1 ], servers[ 2 ] ]
 
-      for (const res of results) {
-        const data: VideoPlaylist[] = res.body.data
-        expect(data).to.have.lengthOf(2)
-        expect(data[ 0 ].displayName).to.equal('playlist 3')
-        expect(data[ 1 ].displayName).to.equal('playlist 2')
-      }
-    }
-  })
+      const res = await createVideoPlaylist({
+        url: servers[ 0 ].url,
+        token: userAccessTokenServer1,
+        playlistAttrs: {
+          displayName: 'playlist 56',
+          privacy: VideoPlaylistPrivacy.PUBLIC,
+          videoChannelId: servers[ 0 ].videoChannel.id
+        }
+      })
 
-  it('Should update a playlist', async function () {
-    this.timeout(30000)
-
-    await updateVideoPlaylist({
-      url: servers[1].url,
-      token: servers[1].accessToken,
-      playlistAttrs: {
-        displayName: 'playlist 3 updated',
-        description: 'description updated',
-        privacy: VideoPlaylistPrivacy.UNLISTED,
-        thumbnailfile: 'thumbnail.jpg',
-        videoChannelId: servers[1].videoChannel.id
-      },
-      playlistId: playlistServer2Id2
-    })
+      const playlistServer1Id2 = res.body.videoPlaylist.id
+      playlistServer1UUID2 = res.body.videoPlaylist.uuid
 
-    await waitJobs(servers)
+      const addVideo = (elementAttrs: any) => {
+        return addVideoInPlaylist({ url: servers[ 0 ].url, token: userAccessTokenServer1, playlistId: playlistServer1Id2, elementAttrs })
+      }
 
-    for (const server of servers) {
-      const res = await getVideoPlaylist(server.url, playlistServer2UUID2)
-      const playlist: VideoPlaylist = res.body
+      video1 = (await uploadVideoAndGetId({ server: servers[0], videoName: 'video 89', token: userAccessTokenServer1 })).uuid
+      video2 = (await uploadVideoAndGetId({ server: servers[1], videoName: 'video 90' })).uuid
+      video3 = (await uploadVideoAndGetId({ server: servers[0], videoName: 'video 91', nsfw: true })).uuid
 
-      expect(playlist.displayName).to.equal('playlist 3 updated')
-      expect(playlist.description).to.equal('description updated')
+      await addVideo({ videoId: video1, startTimestamp: 15, stopTimestamp: 28 })
+      await addVideo({ videoId: video2, startTimestamp: 35 })
+      await addVideo({ videoId: video3 })
 
-      expect(playlist.privacy.id).to.equal(VideoPlaylistPrivacy.UNLISTED)
-      expect(playlist.privacy.label).to.equal('Unlisted')
+      await waitJobs(servers)
+    })
 
-      expect(playlist.type.id).to.equal(VideoPlaylistType.REGULAR)
-      expect(playlist.type.label).to.equal('Regular')
+    it('Should update the element type if the video is private', async function () {
+      this.timeout(20000)
 
-      expect(playlist.videosLength).to.equal(2)
+      const name = 'video 89'
+      const position = 1
 
-      expect(playlist.ownerAccount.name).to.equal('root')
-      expect(playlist.ownerAccount.displayName).to.equal('root')
-      expect(playlist.videoChannel.name).to.equal('root_channel')
-      expect(playlist.videoChannel.displayName).to.equal('Main root channel')
-    }
-  })
+      {
+        await updateVideo(servers[ 0 ].url, servers[ 0 ].accessToken, video1, { privacy: VideoPrivacy.PRIVATE })
+        await waitJobs(servers)
 
-  it('Should create a playlist containing different startTimestamp/endTimestamp videos', async function () {
-    this.timeout(30000)
+        await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
+        await checkPlaylistElementType(groupWithoutToken1, playlistServer1UUID2, VideoPlaylistElementType.PRIVATE, position, name, 3)
+        await checkPlaylistElementType(group1, playlistServer1UUID2, VideoPlaylistElementType.PRIVATE, position, name, 3)
+        await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.DELETED, position, name, 3)
+      }
 
-    const addVideo = (elementAttrs: any) => {
-      return addVideoInPlaylist({ url: servers[0].url, token: servers[0].accessToken, playlistId: playlistServer1Id, elementAttrs })
-    }
+      {
+        await updateVideo(servers[ 0 ].url, servers[ 0 ].accessToken, video1, { privacy: VideoPrivacy.PUBLIC })
+        await waitJobs(servers)
 
-    const res = await createVideoPlaylist({
-      url: servers[ 0 ].url,
-      token: servers[ 0 ].accessToken,
-      playlistAttrs: {
-        displayName: 'playlist 4',
-        privacy: VideoPlaylistPrivacy.PUBLIC,
-        videoChannelId: servers[0].videoChannel.id
+        await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
+        await checkPlaylistElementType(groupWithoutToken1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
+        await checkPlaylistElementType(group1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
+        // We deleted the video, so even if we recreated it, the old entry is still deleted
+        await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.DELETED, position, name, 3)
       }
     })
 
-    playlistServer1Id = res.body.videoPlaylist.id
-    playlistServer1UUID = res.body.videoPlaylist.uuid
+    it('Should update the element type if the video is blacklisted', async function () {
+      this.timeout(20000)
 
-    await addVideo({ videoId: servers[0].videos[0].uuid, startTimestamp: 15, stopTimestamp: 28 })
-    await addVideo({ videoId: servers[2].videos[1].uuid, startTimestamp: 35 })
-    await addVideo({ videoId: servers[2].videos[2].uuid })
-    await addVideo({ videoId: servers[0].videos[3].uuid, stopTimestamp: 35 })
-    await addVideo({ videoId: servers[0].videos[4].uuid, startTimestamp: 45, stopTimestamp: 60 })
-    await addVideo({ videoId: nsfwVideoServer1, startTimestamp: 5 })
+      const name = 'video 89'
+      const position = 1
 
-    await waitJobs(servers)
-  })
+      {
+        await addVideoToBlacklist(servers[ 0 ].url, servers[ 0 ].accessToken, video1, 'reason', true)
+        await waitJobs(servers)
 
-  it('Should correctly list playlist videos', async function () {
-    this.timeout(30000)
+        await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
+        await checkPlaylistElementType(groupWithoutToken1, playlistServer1UUID2, VideoPlaylistElementType.UNAVAILABLE, position, name, 3)
+        await checkPlaylistElementType(group1, playlistServer1UUID2, VideoPlaylistElementType.UNAVAILABLE, position, name, 3)
+        await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.DELETED, position, name, 3)
+      }
 
-    for (const server of servers) {
-      const res = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10)
+      {
+        await removeVideoFromBlacklist(servers[ 0 ].url, servers[ 0 ].accessToken, video1)
+        await waitJobs(servers)
 
-      expect(res.body.total).to.equal(6)
+        await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
+        await checkPlaylistElementType(groupWithoutToken1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
+        await checkPlaylistElementType(group1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
+        // We deleted the video (because unfederated), so even if we recreated it, the old entry is still deleted
+        await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.DELETED, position, name, 3)
+      }
+    })
 
-      const videos: Video[] = res.body.data
-      expect(videos).to.have.lengthOf(6)
+    it('Should update the element type if the account or server of the video is blocked', async function () {
+      this.timeout(90000)
 
-      expect(videos[0].name).to.equal('video 0 server 1')
-      expect(videos[0].playlistElement.position).to.equal(1)
-      expect(videos[0].playlistElement.startTimestamp).to.equal(15)
-      expect(videos[0].playlistElement.stopTimestamp).to.equal(28)
+      const name = 'video 90'
+      const position = 2
 
-      expect(videos[1].name).to.equal('video 1 server 3')
-      expect(videos[1].playlistElement.position).to.equal(2)
-      expect(videos[1].playlistElement.startTimestamp).to.equal(35)
-      expect(videos[1].playlistElement.stopTimestamp).to.be.null
+      {
+        await addAccountToAccountBlocklist(servers[ 0 ].url, userAccessTokenServer1, 'root@localhost:' + servers[1].port)
+        await waitJobs(servers)
 
-      expect(videos[2].name).to.equal('video 2 server 3')
-      expect(videos[2].playlistElement.position).to.equal(3)
-      expect(videos[2].playlistElement.startTimestamp).to.be.null
-      expect(videos[2].playlistElement.stopTimestamp).to.be.null
+        await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.UNAVAILABLE, position, name, 3)
+        await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
 
-      expect(videos[3].name).to.equal('video 3 server 1')
-      expect(videos[3].playlistElement.position).to.equal(4)
-      expect(videos[3].playlistElement.startTimestamp).to.be.null
-      expect(videos[3].playlistElement.stopTimestamp).to.equal(35)
+        await removeAccountFromAccountBlocklist(servers[ 0 ].url, userAccessTokenServer1, 'root@localhost:' + servers[1].port)
+        await waitJobs(servers)
 
-      expect(videos[4].name).to.equal('video 4 server 1')
-      expect(videos[4].playlistElement.position).to.equal(5)
-      expect(videos[4].playlistElement.startTimestamp).to.equal(45)
-      expect(videos[4].playlistElement.stopTimestamp).to.equal(60)
+        await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
+      }
 
-      expect(videos[5].name).to.equal('NSFW video')
-      expect(videos[5].playlistElement.position).to.equal(6)
-      expect(videos[5].playlistElement.startTimestamp).to.equal(5)
-      expect(videos[5].playlistElement.stopTimestamp).to.be.null
+      {
+        await addServerToAccountBlocklist(servers[ 0 ].url, userAccessTokenServer1, 'localhost:' + servers[1].port)
+        await waitJobs(servers)
 
-      const res2 = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10, { nsfw: false })
-      expect(res2.body.total).to.equal(5)
-      expect(res2.body.data.find(v => v.name === 'NSFW video')).to.be.undefined
+        await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.UNAVAILABLE, position, name, 3)
+        await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
 
-      const res3 = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 2)
-      expect(res3.body.data).to.have.lengthOf(2)
-    }
-  })
+        await removeServerFromAccountBlocklist(servers[ 0 ].url, userAccessTokenServer1, 'localhost:' + servers[1].port)
+        await waitJobs(servers)
 
-  it('Should reorder the playlist', async function () {
-    this.timeout(30000)
+        await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
+      }
 
-    {
-      await reorderVideosPlaylist({
-        url: servers[ 0 ].url,
-        token: servers[ 0 ].accessToken,
-        playlistId: playlistServer1Id,
-        elementAttrs: {
-          startPosition: 2,
-          insertAfterPosition: 3
-        }
-      })
+      {
+        await addAccountToServerBlocklist(servers[ 0 ].url, servers[ 0 ].accessToken, 'root@localhost:' + servers[1].port)
+        await waitJobs(servers)
 
-      await waitJobs(servers)
+        await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.UNAVAILABLE, position, name, 3)
+        await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
 
-      for (const server of servers) {
-        const res = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10)
-        const names = res.body.data.map(v => v.name)
+        await removeAccountFromServerBlocklist(servers[ 0 ].url, servers[ 0 ].accessToken, 'root@localhost:' + servers[1].port)
+        await waitJobs(servers)
 
-        expect(names).to.deep.equal([
-          'video 0 server 1',
-          'video 2 server 3',
-          'video 1 server 3',
-          'video 3 server 1',
-          'video 4 server 1',
-          'NSFW video'
-        ])
+        await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
       }
-    }
 
-    {
-      await reorderVideosPlaylist({
-        url: servers[0].url,
-        token: servers[0].accessToken,
-        playlistId: playlistServer1Id,
-        elementAttrs: {
-          startPosition: 1,
-          reorderLength: 3,
-          insertAfterPosition: 4
-        }
-      })
+      {
+        await addServerToServerBlocklist(servers[ 0 ].url, servers[ 0 ].accessToken, 'localhost:' + servers[1].port)
+        await waitJobs(servers)
 
-      await waitJobs(servers)
+        await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.UNAVAILABLE, position, name, 3)
+        await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
 
-      for (const server of servers) {
-        const res = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10)
-        const names = res.body.data.map(v => v.name)
+        await removeServerFromServerBlocklist(servers[ 0 ].url, servers[ 0 ].accessToken, 'localhost:' + servers[1].port)
+        await waitJobs(servers)
 
-        expect(names).to.deep.equal([
-          'video 3 server 1',
-          'video 0 server 1',
-          'video 2 server 3',
-          'video 1 server 3',
-          'video 4 server 1',
-          'NSFW video'
-        ])
+        await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
       }
-    }
-
-    {
-      await reorderVideosPlaylist({
-        url: servers[0].url,
-        token: servers[0].accessToken,
-        playlistId: playlistServer1Id,
-        elementAttrs: {
-          startPosition: 6,
-          insertAfterPosition: 3
-        }
-      })
+    })
 
-      await waitJobs(servers)
+    it('Should hide the video if it is NSFW', async function () {
+      const res = await getPlaylistVideos(servers[0].url, userAccessTokenServer1, playlistServer1UUID2, 0, 10, { nsfw: false })
+      expect(res.body.total).to.equal(3)
 
-      for (const server of servers) {
-        const res = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10)
-        const videos: Video[] = res.body.data
+      const elements: VideoPlaylistElement[] = res.body.data
+      const element = elements.find(e => e.position === 3)
 
-        const names = videos.map(v => v.name)
+      expect(element).to.exist
+      expect(element.video).to.be.null
+      expect(element.type).to.equal(VideoPlaylistElementType.UNAVAILABLE)
+    })
 
-        expect(names).to.deep.equal([
-          'video 3 server 1',
-          'video 0 server 1',
-          'video 2 server 3',
-          'NSFW video',
-          'video 1 server 3',
-          'video 4 server 1'
-        ])
+  })
 
-        for (let i = 1; i <= videos.length; i++) {
-          expect(videos[i - 1].playlistElement.position).to.equal(i)
+  describe('Managing playlist elements', function () {
+
+    it('Should reorder the playlist', async function () {
+      this.timeout(30000)
+
+      {
+        await reorderVideosPlaylist({
+          url: servers[ 0 ].url,
+          token: servers[ 0 ].accessToken,
+          playlistId: playlistServer1Id,
+          elementAttrs: {
+            startPosition: 2,
+            insertAfterPosition: 3
+          }
+        })
+
+        await waitJobs(servers)
+
+        for (const server of servers) {
+          const res = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10)
+          const names = (res.body.data as VideoPlaylistElement[]).map(v => v.video.name)
+
+          expect(names).to.deep.equal([
+            'video 0 server 1',
+            'video 2 server 3',
+            'video 1 server 3',
+            'video 3 server 1',
+            'video 4 server 1',
+            'NSFW video'
+          ])
         }
       }
-    }
-  })
-
-  it('Should update startTimestamp/endTimestamp of some elements', async function () {
-    this.timeout(30000)
 
-    await updateVideoPlaylistElement({
-      url: servers[0].url,
-      token: servers[0].accessToken,
-      playlistId: playlistServer1Id,
-      videoId: servers[0].videos[3].uuid,
-      elementAttrs: {
-        startTimestamp: 1
+      {
+        await reorderVideosPlaylist({
+          url: servers[ 0 ].url,
+          token: servers[ 0 ].accessToken,
+          playlistId: playlistServer1Id,
+          elementAttrs: {
+            startPosition: 1,
+            reorderLength: 3,
+            insertAfterPosition: 4
+          }
+        })
+
+        await waitJobs(servers)
+
+        for (const server of servers) {
+          const res = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10)
+          const names = (res.body.data as VideoPlaylistElement[]).map(v => v.video.name)
+
+          expect(names).to.deep.equal([
+            'video 3 server 1',
+            'video 0 server 1',
+            'video 2 server 3',
+            'video 1 server 3',
+            'video 4 server 1',
+            'NSFW video'
+          ])
+        }
       }
-    })
 
-    await updateVideoPlaylistElement({
-      url: servers[0].url,
-      token: servers[0].accessToken,
-      playlistId: playlistServer1Id,
-      videoId: servers[0].videos[4].uuid,
-      elementAttrs: {
-        stopTimestamp: null
+      {
+        await reorderVideosPlaylist({
+          url: servers[ 0 ].url,
+          token: servers[ 0 ].accessToken,
+          playlistId: playlistServer1Id,
+          elementAttrs: {
+            startPosition: 6,
+            insertAfterPosition: 3
+          }
+        })
+
+        await waitJobs(servers)
+
+        for (const server of servers) {
+          const res = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10)
+          const elements: VideoPlaylistElement[] = res.body.data
+          const names = elements.map(v => v.video.name)
+
+          expect(names).to.deep.equal([
+            'video 3 server 1',
+            'video 0 server 1',
+            'video 2 server 3',
+            'NSFW video',
+            'video 1 server 3',
+            'video 4 server 1'
+          ])
+
+          for (let i = 1; i <= elements.length; i++) {
+            expect(elements[ i - 1 ].position).to.equal(i)
+          }
+        }
       }
     })
 
-    await waitJobs(servers)
+    it('Should update startTimestamp/endTimestamp of some elements', async function () {
+      this.timeout(30000)
+
+      await updateVideoPlaylistElement({
+        url: servers[ 0 ].url,
+        token: servers[ 0 ].accessToken,
+        playlistId: playlistServer1Id,
+        playlistElementId: playlistElementServer1Video4,
+        elementAttrs: {
+          startTimestamp: 1
+        }
+      })
 
-    for (const server of servers) {
-      const res = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10)
-      const videos: Video[] = res.body.data
+      await updateVideoPlaylistElement({
+        url: servers[ 0 ].url,
+        token: servers[ 0 ].accessToken,
+        playlistId: playlistServer1Id,
+        playlistElementId: playlistElementServer1Video5,
+        elementAttrs: {
+          stopTimestamp: null
+        }
+      })
 
-      expect(videos[0].name).to.equal('video 3 server 1')
-      expect(videos[0].playlistElement.position).to.equal(1)
-      expect(videos[0].playlistElement.startTimestamp).to.equal(1)
-      expect(videos[0].playlistElement.stopTimestamp).to.equal(35)
+      await waitJobs(servers)
 
-      expect(videos[5].name).to.equal('video 4 server 1')
-      expect(videos[5].playlistElement.position).to.equal(6)
-      expect(videos[5].playlistElement.startTimestamp).to.equal(45)
-      expect(videos[5].playlistElement.stopTimestamp).to.be.null
-    }
-  })
+      for (const server of servers) {
+        const res = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10)
+        const elements: VideoPlaylistElement[] = res.body.data
 
-  it('Should check videos existence in my playlist', async function () {
-    const videoIds = [
-      servers[0].videos[0].id,
-      42000,
-      servers[0].videos[3].id,
-      43000,
-      servers[0].videos[4].id
-    ]
-    const res = await doVideosExistInMyPlaylist(servers[ 0 ].url, servers[ 0 ].accessToken, videoIds)
-    const obj = res.body as VideoExistInPlaylist
+        expect(elements[ 0 ].video.name).to.equal('video 3 server 1')
+        expect(elements[ 0 ].position).to.equal(1)
+        expect(elements[ 0 ].startTimestamp).to.equal(1)
+        expect(elements[ 0 ].stopTimestamp).to.equal(35)
 
-    {
-      const elem = obj[servers[0].videos[0].id]
-      expect(elem).to.have.lengthOf(1)
-      expect(elem[ 0 ].playlistId).to.equal(playlistServer1Id)
-      expect(elem[ 0 ].startTimestamp).to.equal(15)
-      expect(elem[ 0 ].stopTimestamp).to.equal(28)
-    }
+        expect(elements[ 5 ].video.name).to.equal('video 4 server 1')
+        expect(elements[ 5 ].position).to.equal(6)
+        expect(elements[ 5 ].startTimestamp).to.equal(45)
+        expect(elements[ 5 ].stopTimestamp).to.be.null
+      }
+    })
 
-    {
-      const elem = obj[servers[0].videos[3].id]
-      expect(elem).to.have.lengthOf(1)
-      expect(elem[ 0 ].playlistId).to.equal(playlistServer1Id)
-      expect(elem[ 0 ].startTimestamp).to.equal(1)
-      expect(elem[ 0 ].stopTimestamp).to.equal(35)
-    }
+    it('Should check videos existence in my playlist', async function () {
+      const videoIds = [
+        servers[ 0 ].videos[ 0 ].id,
+        42000,
+        servers[ 0 ].videos[ 3 ].id,
+        43000,
+        servers[ 0 ].videos[ 4 ].id
+      ]
+      const res = await doVideosExistInMyPlaylist(servers[ 0 ].url, servers[ 0 ].accessToken, videoIds)
+      const obj = res.body as VideoExistInPlaylist
+
+      {
+        const elem = obj[ servers[ 0 ].videos[ 0 ].id ]
+        expect(elem).to.have.lengthOf(1)
+        expect(elem[ 0 ].playlistElementId).to.exist
+        expect(elem[ 0 ].playlistId).to.equal(playlistServer1Id)
+        expect(elem[ 0 ].startTimestamp).to.equal(15)
+        expect(elem[ 0 ].stopTimestamp).to.equal(28)
+      }
 
-    {
-      const elem = obj[servers[0].videos[4].id]
-      expect(elem).to.have.lengthOf(1)
-      expect(elem[ 0 ].playlistId).to.equal(playlistServer1Id)
-      expect(elem[ 0 ].startTimestamp).to.equal(45)
-      expect(elem[ 0 ].stopTimestamp).to.equal(null)
-    }
+      {
+        const elem = obj[ servers[ 0 ].videos[ 3 ].id ]
+        expect(elem).to.have.lengthOf(1)
+        expect(elem[ 0 ].playlistElementId).to.equal(playlistElementServer1Video4)
+        expect(elem[ 0 ].playlistId).to.equal(playlistServer1Id)
+        expect(elem[ 0 ].startTimestamp).to.equal(1)
+        expect(elem[ 0 ].stopTimestamp).to.equal(35)
+      }
 
-    expect(obj[42000]).to.have.lengthOf(0)
-    expect(obj[43000]).to.have.lengthOf(0)
-  })
+      {
+        const elem = obj[ servers[ 0 ].videos[ 4 ].id ]
+        expect(elem).to.have.lengthOf(1)
+        expect(elem[ 0 ].playlistId).to.equal(playlistServer1Id)
+        expect(elem[ 0 ].startTimestamp).to.equal(45)
+        expect(elem[ 0 ].stopTimestamp).to.equal(null)
+      }
 
-  it('Should automatically update updatedAt field of playlists', async function () {
-    const server = servers[1]
-    const videoId = servers[1].videos[5].id
+      expect(obj[ 42000 ]).to.have.lengthOf(0)
+      expect(obj[ 43000 ]).to.have.lengthOf(0)
+    })
 
-    async function getPlaylistNames () {
-      const res = await getAccountPlaylistsListWithToken(server.url, server.accessToken, 'root', 0, 5, undefined, '-updatedAt')
+    it('Should automatically update updatedAt field of playlists', async function () {
+      const server = servers[ 1 ]
+      const videoId = servers[ 1 ].videos[ 5 ].id
 
-      return (res.body.data as VideoPlaylist[]).map(p => p.displayName)
-    }
+      async function getPlaylistNames () {
+        const res = await getAccountPlaylistsListWithToken(server.url, server.accessToken, 'root', 0, 5, undefined, '-updatedAt')
 
-    const elementAttrs = { videoId }
-    await addVideoInPlaylist({ url: server.url, token: server.accessToken, playlistId: playlistServer2Id1, elementAttrs })
-    await addVideoInPlaylist({ url: server.url, token: server.accessToken, playlistId: playlistServer2Id2, elementAttrs })
+        return (res.body.data as VideoPlaylist[]).map(p => p.displayName)
+      }
 
-    const names1 = await getPlaylistNames()
-    expect(names1[0]).to.equal('playlist 3 updated')
-    expect(names1[1]).to.equal('playlist 2')
+      const elementAttrs = { videoId }
+      const res1 = await addVideoInPlaylist({ url: server.url, token: server.accessToken, playlistId: playlistServer2Id1, elementAttrs })
+      const res2 = await addVideoInPlaylist({ url: server.url, token: server.accessToken, playlistId: playlistServer2Id2, elementAttrs })
 
-    await removeVideoFromPlaylist({ url: server.url, token: server.accessToken, playlistId: playlistServer2Id1, videoId })
+      const element1 = res1.body.videoPlaylistElement.id
+      const element2 = res2.body.videoPlaylistElement.id
 
-    const names2 = await getPlaylistNames()
-    expect(names2[0]).to.equal('playlist 2')
-    expect(names2[1]).to.equal('playlist 3 updated')
+      const names1 = await getPlaylistNames()
+      expect(names1[ 0 ]).to.equal('playlist 3 updated')
+      expect(names1[ 1 ]).to.equal('playlist 2')
 
-    await removeVideoFromPlaylist({ url: server.url, token: server.accessToken, playlistId: playlistServer2Id2, videoId })
+      await removeVideoFromPlaylist({
+        url: server.url,
+        token: server.accessToken,
+        playlistId: playlistServer2Id1,
+        playlistElementId: element1
+      })
 
-    const names3 = await getPlaylistNames()
-    expect(names3[0]).to.equal('playlist 3 updated')
-    expect(names3[1]).to.equal('playlist 2')
-  })
+      const names2 = await getPlaylistNames()
+      expect(names2[ 0 ]).to.equal('playlist 2')
+      expect(names2[ 1 ]).to.equal('playlist 3 updated')
 
-  it('Should delete some elements', async function () {
-    this.timeout(30000)
+      await removeVideoFromPlaylist({
+        url: server.url,
+        token: server.accessToken,
+        playlistId: playlistServer2Id2,
+        playlistElementId: element2
+      })
 
-    await removeVideoFromPlaylist({
-      url: servers[0].url,
-      token: servers[0].accessToken,
-      playlistId: playlistServer1Id,
-      videoId: servers[0].videos[3].uuid
+      const names3 = await getPlaylistNames()
+      expect(names3[ 0 ]).to.equal('playlist 3 updated')
+      expect(names3[ 1 ]).to.equal('playlist 2')
     })
 
-    await removeVideoFromPlaylist({
-      url: servers[0].url,
-      token: servers[0].accessToken,
-      playlistId: playlistServer1Id,
-      videoId: nsfwVideoServer1
-    })
+    it('Should delete some elements', async function () {
+      this.timeout(30000)
 
-    await waitJobs(servers)
+      await removeVideoFromPlaylist({
+        url: servers[ 0 ].url,
+        token: servers[ 0 ].accessToken,
+        playlistId: playlistServer1Id,
+        playlistElementId: playlistElementServer1Video4
+      })
 
-    for (const server of servers) {
-      const res = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10)
+      await removeVideoFromPlaylist({
+        url: servers[ 0 ].url,
+        token: servers[ 0 ].accessToken,
+        playlistId: playlistServer1Id,
+        playlistElementId: playlistElementNSFW
+      })
 
-      expect(res.body.total).to.equal(4)
+      await waitJobs(servers)
 
-      const videos: Video[] = res.body.data
-      expect(videos).to.have.lengthOf(4)
+      for (const server of servers) {
+        const res = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10)
 
-      expect(videos[ 0 ].name).to.equal('video 0 server 1')
-      expect(videos[ 0 ].playlistElement.position).to.equal(1)
+        expect(res.body.total).to.equal(4)
 
-      expect(videos[ 1 ].name).to.equal('video 2 server 3')
-      expect(videos[ 1 ].playlistElement.position).to.equal(2)
+        const elements: VideoPlaylistElement[] = res.body.data
+        expect(elements).to.have.lengthOf(4)
 
-      expect(videos[ 2 ].name).to.equal('video 1 server 3')
-      expect(videos[ 2 ].playlistElement.position).to.equal(3)
+        expect(elements[ 0 ].video.name).to.equal('video 0 server 1')
+        expect(elements[ 0 ].position).to.equal(1)
 
-      expect(videos[ 3 ].name).to.equal('video 4 server 1')
-      expect(videos[ 3 ].playlistElement.position).to.equal(4)
-    }
-  })
+        expect(elements[ 1 ].video.name).to.equal('video 2 server 3')
+        expect(elements[ 1 ].position).to.equal(2)
 
-  it('Should be able to create a public playlist, and set it to private', async function () {
-    this.timeout(30000)
+        expect(elements[ 2 ].video.name).to.equal('video 1 server 3')
+        expect(elements[ 2 ].position).to.equal(3)
 
-    const res = await createVideoPlaylist({
-      url: servers[0].url,
-      token: servers[0].accessToken,
-      playlistAttrs: {
-        displayName: 'my super public playlist',
-        privacy: VideoPlaylistPrivacy.PUBLIC,
-        videoChannelId: servers[0].videoChannel.id
+        expect(elements[ 3 ].video.name).to.equal('video 4 server 1')
+        expect(elements[ 3 ].position).to.equal(4)
       }
     })
-    const videoPlaylistIds = res.body.videoPlaylist
 
-    await waitJobs(servers)
+    it('Should be able to create a public playlist, and set it to private', async function () {
+      this.timeout(30000)
 
-    for (const server of servers) {
-      await getVideoPlaylist(server.url, videoPlaylistIds.uuid, 200)
-    }
+      const res = await createVideoPlaylist({
+        url: servers[ 0 ].url,
+        token: servers[ 0 ].accessToken,
+        playlistAttrs: {
+          displayName: 'my super public playlist',
+          privacy: VideoPlaylistPrivacy.PUBLIC,
+          videoChannelId: servers[ 0 ].videoChannel.id
+        }
+      })
+      const videoPlaylistIds = res.body.videoPlaylist
 
-    const playlistAttrs = { privacy: VideoPlaylistPrivacy.PRIVATE }
-    await updateVideoPlaylist({ url: servers[0].url, token: servers[0].accessToken, playlistId: videoPlaylistIds.id, playlistAttrs })
+      await waitJobs(servers)
 
-    await waitJobs(servers)
+      for (const server of servers) {
+        await getVideoPlaylist(server.url, videoPlaylistIds.uuid, 200)
+      }
 
-    for (const server of [ servers[1], servers[2] ]) {
-      await getVideoPlaylist(server.url, videoPlaylistIds.uuid, 404)
-    }
-    await getVideoPlaylist(servers[0].url, videoPlaylistIds.uuid, 401)
+      const playlistAttrs = { privacy: VideoPlaylistPrivacy.PRIVATE }
+      await updateVideoPlaylist({ url: servers[ 0 ].url, token: servers[ 0 ].accessToken, playlistId: videoPlaylistIds.id, playlistAttrs })
 
-    await getVideoPlaylistWithToken(servers[0].url, servers[0].accessToken, videoPlaylistIds.uuid, 200)
-  })
+      await waitJobs(servers)
 
-  it('Should delete the playlist on server 1 and delete on server 2 and 3', async function () {
-    this.timeout(30000)
+      for (const server of [ servers[ 1 ], servers[ 2 ] ]) {
+        await getVideoPlaylist(server.url, videoPlaylistIds.uuid, 404)
+      }
+      await getVideoPlaylist(servers[ 0 ].url, videoPlaylistIds.uuid, 401)
 
-    await deleteVideoPlaylist(servers[0].url, servers[0].accessToken, playlistServer1Id)
+      await getVideoPlaylistWithToken(servers[ 0 ].url, servers[ 0 ].accessToken, videoPlaylistIds.uuid, 200)
+    })
+  })
 
-    await waitJobs(servers)
+  describe('Playlist deletion', function () {
 
-    for (const server of servers) {
-      await getVideoPlaylist(server.url, playlistServer1UUID, 404)
-    }
-  })
+    it('Should delete the playlist on server 1 and delete on server 2 and 3', async function () {
+      this.timeout(30000)
 
-  it('Should have deleted the thumbnail on server 1, 2 and 3', async function () {
-    this.timeout(30000)
+      await deleteVideoPlaylist(servers[ 0 ].url, servers[ 0 ].accessToken, playlistServer1Id)
 
-    for (const server of servers) {
-      await checkPlaylistFilesWereRemoved(playlistServer1UUID, server.internalServerNumber)
-    }
-  })
+      await waitJobs(servers)
 
-  it('Should unfollow servers 1 and 2 and hide their playlists', async function () {
-    this.timeout(30000)
+      for (const server of servers) {
+        await getVideoPlaylist(server.url, playlistServer1UUID, 404)
+      }
+    })
 
-    const finder = data => data.find(p => p.displayName === 'my super playlist')
+    it('Should have deleted the thumbnail on server 1, 2 and 3', async function () {
+      this.timeout(30000)
 
-    {
-      const res = await getVideoPlaylistsList(servers[ 2 ].url, 0, 5)
-      expect(res.body.total).to.equal(2)
-      expect(finder(res.body.data)).to.not.be.undefined
-    }
+      for (const server of servers) {
+        await checkPlaylistFilesWereRemoved(playlistServer1UUID, server.internalServerNumber)
+      }
+    })
 
-    await unfollow(servers[2].url, servers[2].accessToken, servers[0])
+    it('Should unfollow servers 1 and 2 and hide their playlists', async function () {
+      this.timeout(30000)
 
-    {
-      const res = await getVideoPlaylistsList(servers[ 2 ].url, 0, 5)
-      expect(res.body.total).to.equal(1)
+      const finder = data => data.find(p => p.displayName === 'my super playlist')
 
-      expect(finder(res.body.data)).to.be.undefined
-    }
-  })
+      {
+        const res = await getVideoPlaylistsList(servers[ 2 ].url, 0, 5)
+        expect(res.body.total).to.equal(3)
+        expect(finder(res.body.data)).to.not.be.undefined
+      }
 
-  it('Should delete a channel and put the associated playlist in private mode', async function () {
-    this.timeout(30000)
+      await unfollow(servers[ 2 ].url, servers[ 2 ].accessToken, servers[ 0 ])
 
-    const res = await addVideoChannel(servers[0].url, servers[0].accessToken, { name: 'super_channel', displayName: 'super channel' })
-    const videoChannelId = res.body.videoChannel.id
+      {
+        const res = await getVideoPlaylistsList(servers[ 2 ].url, 0, 5)
+        expect(res.body.total).to.equal(1)
 
-    const res2 = await createVideoPlaylist({
-      url: servers[0].url,
-      token: servers[0].accessToken,
-      playlistAttrs: {
-        displayName: 'channel playlist',
-        privacy: VideoPlaylistPrivacy.PUBLIC,
-        videoChannelId
+        expect(finder(res.body.data)).to.be.undefined
       }
     })
-    const videoPlaylistUUID = res2.body.videoPlaylist.uuid
 
-    await waitJobs(servers)
+    it('Should delete a channel and put the associated playlist in private mode', async function () {
+      this.timeout(30000)
 
-    await deleteVideoChannel(servers[0].url, servers[0].accessToken, 'super_channel')
+      const res = await addVideoChannel(servers[ 0 ].url, servers[ 0 ].accessToken, { name: 'super_channel', displayName: 'super channel' })
+      const videoChannelId = res.body.videoChannel.id
 
-    await waitJobs(servers)
+      const res2 = await createVideoPlaylist({
+        url: servers[ 0 ].url,
+        token: servers[ 0 ].accessToken,
+        playlistAttrs: {
+          displayName: 'channel playlist',
+          privacy: VideoPlaylistPrivacy.PUBLIC,
+          videoChannelId
+        }
+      })
+      const videoPlaylistUUID = res2.body.videoPlaylist.uuid
+
+      await waitJobs(servers)
 
-    const res3 = await getVideoPlaylistWithToken(servers[0].url, servers[0].accessToken, videoPlaylistUUID)
-    expect(res3.body.displayName).to.equal('channel playlist')
-    expect(res3.body.privacy.id).to.equal(VideoPlaylistPrivacy.PRIVATE)
+      await deleteVideoChannel(servers[ 0 ].url, servers[ 0 ].accessToken, 'super_channel')
 
-    await getVideoPlaylist(servers[1].url, videoPlaylistUUID, 404)
-  })
+      await waitJobs(servers)
 
-  it('Should delete an account and delete its playlists', async function () {
-    this.timeout(30000)
+      const res3 = await getVideoPlaylistWithToken(servers[ 0 ].url, servers[ 0 ].accessToken, videoPlaylistUUID)
+      expect(res3.body.displayName).to.equal('channel playlist')
+      expect(res3.body.privacy.id).to.equal(VideoPlaylistPrivacy.PRIVATE)
 
-    const user = { username: 'user_1', password: 'password' }
-    const res = await createUser({
-      url: servers[ 0 ].url,
-      accessToken: servers[ 0 ].accessToken,
-      username: user.username,
-      password: user.password
+      await getVideoPlaylist(servers[ 1 ].url, videoPlaylistUUID, 404)
     })
 
-    const userId = res.body.user.id
-    const userAccessToken = await userLogin(servers[0], user)
+    it('Should delete an account and delete its playlists', async function () {
+      this.timeout(30000)
 
-    const resChannel = await getMyUserInformation(servers[0].url, userAccessToken)
-    const userChannel = (resChannel.body as User).videoChannels[0]
+      const user = { username: 'user_1', password: 'password' }
+      const res = await createUser({
+        url: servers[ 0 ].url,
+        accessToken: servers[ 0 ].accessToken,
+        username: user.username,
+        password: user.password
+      })
 
-    await createVideoPlaylist({
-      url: servers[0].url,
-      token: userAccessToken,
-      playlistAttrs: {
-        displayName: 'playlist to be deleted',
-        privacy: VideoPlaylistPrivacy.PUBLIC,
-        videoChannelId: userChannel.id
-      }
-    })
+      const userId = res.body.user.id
+      const userAccessToken = await userLogin(servers[ 0 ], user)
 
-    await waitJobs(servers)
+      const resChannel = await getMyUserInformation(servers[ 0 ].url, userAccessToken)
+      const userChannel = (resChannel.body as User).videoChannels[ 0 ]
 
-    const finder = data => data.find(p => p.displayName === 'playlist to be deleted')
+      await createVideoPlaylist({
+        url: servers[ 0 ].url,
+        token: userAccessToken,
+        playlistAttrs: {
+          displayName: 'playlist to be deleted',
+          privacy: VideoPlaylistPrivacy.PUBLIC,
+          videoChannelId: userChannel.id
+        }
+      })
 
-    {
-      for (const server of [ servers[0], servers[1] ]) {
-        const res = await getVideoPlaylistsList(server.url, 0, 15)
-        expect(finder(res.body.data)).to.not.be.undefined
+      await waitJobs(servers)
+
+      const finder = data => data.find(p => p.displayName === 'playlist to be deleted')
+
+      {
+        for (const server of [ servers[ 0 ], servers[ 1 ] ]) {
+          const res = await getVideoPlaylistsList(server.url, 0, 15)
+          expect(finder(res.body.data)).to.not.be.undefined
+        }
       }
-    }
 
-    await removeUser(servers[0].url, userId, servers[0].accessToken)
-    await waitJobs(servers)
+      await removeUser(servers[ 0 ].url, userId, servers[ 0 ].accessToken)
+      await waitJobs(servers)
 
-    {
-      for (const server of [ servers[0], servers[1] ]) {
-        const res = await getVideoPlaylistsList(server.url, 0, 15)
-        expect(finder(res.body.data)).to.be.undefined
+      {
+        for (const server of [ servers[ 0 ], servers[ 1 ] ]) {
+          const res = await getVideoPlaylistsList(server.url, 0, 15)
+          expect(finder(res.body.data)).to.be.undefined
+        }
       }
-    }
+    })
   })
 
   after(async function () {
index 11b570f609764d301ad997d5c287789c19bd285a..b3db885e85d3db8fe5d9771abe4baa87f09f13be 100644 (file)
@@ -2,7 +2,6 @@ import * as request from 'supertest'
 import { Job, JobState } from '../../models'
 import { wait } from '../miscs/miscs'
 import { ServerInfo } from './servers'
-import { inspect } from 'util'
 
 function getJobsList (url: string, accessToken: string, state: JobState) {
   const path = '/api/v1/jobs/' + state
@@ -37,11 +36,10 @@ async function waitJobs (serversArg: ServerInfo[] | ServerInfo) {
   else servers = serversArg as ServerInfo[]
 
   const states: JobState[] = [ 'waiting', 'active', 'delayed' ]
-  let pendingRequests = false
+  let pendingRequests: boolean
 
   function tasksBuilder () {
     const tasks: Promise<any>[] = []
-    pendingRequests = false
 
     // Check if each server has pending request
     for (const server of servers) {
@@ -62,6 +60,7 @@ async function waitJobs (serversArg: ServerInfo[] | ServerInfo) {
   }
 
   do {
+    pendingRequests = false
     await Promise.all(tasksBuilder())
 
     // Retry, in case of new jobs were created
index fd62bef1999e93747e2a598cbb1b4cdc06294776..cbb073fbc5c927e87d9f540f793f52d7fc1f77c9 100644 (file)
@@ -196,11 +196,11 @@ function updateVideoPlaylistElement (options: {
   url: string,
   token: string,
   playlistId: number | string,
-  videoId: number | string,
+  playlistElementId: number | string,
   elementAttrs: VideoPlaylistElementUpdate,
   expectedStatus?: number
 }) {
-  const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.videoId
+  const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.playlistElementId
 
   return makePutBodyRequest({
     url: options.url,
@@ -215,10 +215,10 @@ function removeVideoFromPlaylist (options: {
   url: string,
   token: string,
   playlistId: number | string,
-  videoId: number | string,
+  playlistElementId: number,
   expectedStatus?: number
 }) {
-  const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.videoId
+  const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.playlistElementId
 
   return makeDeleteRequest({
     url: options.url,
index e3d78220e1621f1ca319dcd7e4e243f1cca43ee8..194ae1b962f14680a7c4e00d5d181cf7e6eae596 100644 (file)
@@ -19,6 +19,7 @@ export * from './playlist/video-playlist-privacy.model'
 export * from './playlist/video-playlist-type.model'
 export * from './playlist/video-playlist-update.model'
 export * from './playlist/video-playlist.model'
+export * from './playlist/video-playlist-element.model'
 export * from './video-change-ownership.model'
 export * from './video-change-ownership-create.model'
 export * from './video-create.model'
index 71240f51d4d4a391ac26433ecd6bdbf277df26ee..1b57257e2a411078d4b6055b249a219fdd6e2dd2 100644 (file)
@@ -1,5 +1,6 @@
 export type VideoExistInPlaylist = {
   [videoId: number ]: {
+    playlistElementId: number
     playlistId: number
     startTimestamp?: number
     stopTimestamp?: number
diff --git a/shared/models/videos/playlist/video-playlist-element.model.ts b/shared/models/videos/playlist/video-playlist-element.model.ts
new file mode 100644 (file)
index 0000000..9a12038
--- /dev/null
@@ -0,0 +1,19 @@
+import { Video } from '../video.model'
+
+export enum VideoPlaylistElementType {
+  REGULAR = 0,
+  DELETED = 1,
+  PRIVATE = 2,
+  UNAVAILABLE = 3 // Blacklisted, blocked by the user/instance, NSFW...
+}
+
+export interface VideoPlaylistElement {
+  id: number
+  position: number
+  startTimestamp: number
+  stopTimestamp: number
+
+  type: VideoPlaylistElementType
+
+  video?: Video
+}
index 0489147e41908bf3b6302fcab18523375191cde1..e057b3e0699177aa49e50ff46ac1db5eb361e9b5 100644 (file)
@@ -17,12 +17,6 @@ export interface VideoFile {
   fps: number
 }
 
-export interface PlaylistElement {
-  position: number
-  startTimestamp: number
-  stopTimestamp: number
-}
-
 export interface Video {
   id: number
   uuid: string
@@ -59,8 +53,6 @@ export interface Video {
   userHistory?: {
     currentTime: number
   }
-
-  playlistElement?: PlaylistElement
 }
 
 export interface VideoDetails extends Video {
index a6f61b3b21d21e9694749f07baeb3ef9baaf10e1..39fa3cef51fa20af5044696aeb61ac7f0aa094c1 100644 (file)
@@ -1922,6 +1922,9 @@ components:
           type: number
         stopTimestamp:
           type: number
+        video:
+          nullable: true
+          $ref: '#/components/schemas/Video'
     VideoFile:
       properties:
         magnetUri:
@@ -2029,9 +2032,6 @@ components:
           properties:
             currentTime:
               type: number
-        playlistElement:
-          nullable: true
-          $ref: '#/components/schemas/PlaylistElement'
     VideoDetails:
       allOf:
         - $ref: '#/components/schemas/Video'