aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2019-07-31 15:57:32 +0200
committerChocobozzz <chocobozzz@cpy.re>2019-08-01 09:11:04 +0200
commitbfbd912886eba17b4aa9a40dcef2fddc685d85bf (patch)
tree85e0f22980210a8ccd0888eb5e1790b152074677
parent85394ba22a07bde1dfccebf3f591a5d6dbe9df56 (diff)
downloadPeerTube-bfbd912886eba17b4aa9a40dcef2fddc685d85bf.tar.gz
PeerTube-bfbd912886eba17b4aa9a40dcef2fddc685d85bf.tar.zst
PeerTube-bfbd912886eba17b4aa9a40dcef2fddc685d85bf.zip
Fix broken playlist api
-rw-r--r--client/src/app/+accounts/account-video-channels/account-video-channels.component.html7
-rw-r--r--client/src/app/+accounts/account-video-channels/account-video-channels.component.scss5
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html6
-rw-r--r--client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts66
-rw-r--r--client/src/app/shared/video-playlist/video-add-to-playlist.component.ts8
-rw-r--r--client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html100
-rw-r--r--client/src/app/shared/video-playlist/video-playlist-element-miniature.component.scss32
-rw-r--r--client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts61
-rw-r--r--client/src/app/shared/video-playlist/video-playlist-element.model.ts24
-rw-r--r--client/src/app/shared/video-playlist/video-playlist.service.ts46
-rw-r--r--client/src/app/shared/video/video.model.ts6
-rw-r--r--client/src/app/shared/video/video.service.ts18
-rw-r--r--client/src/app/videos/+video-watch/video-watch-playlist.component.html6
-rw-r--r--client/src/app/videos/+video-watch/video-watch-playlist.component.scss5
-rw-r--r--client/src/app/videos/+video-watch/video-watch-playlist.component.ts43
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.ts2
-rw-r--r--client/src/assets/player/peertube-player-manager.ts6
-rw-r--r--client/src/assets/player/videojs-components/settings-menu-item.ts3
-rw-r--r--client/src/standalone/videos/embed.ts2
-rw-r--r--server/controllers/api/users/my-video-playlists.ts1
-rw-r--r--server/controllers/api/video-playlist.ts35
-rw-r--r--server/initializers/constants.ts2
-rw-r--r--server/initializers/migrations/0410-video-playlist-element.ts39
-rw-r--r--server/middlewares/validators/videos/video-playlists.ts8
-rw-r--r--server/models/account/account-video-rate.ts4
-rw-r--r--server/models/account/account.ts71
-rw-r--r--server/models/server/server-blocklist.ts1
-rw-r--r--server/models/server/server.ts14
-rw-r--r--server/models/video/video-blacklist.ts4
-rw-r--r--server/models/video/video-channel.ts15
-rw-r--r--server/models/video/video-format-utils.ts12
-rw-r--r--server/models/video/video-playlist-element.ts103
-rw-r--r--server/models/video/video-playlist.ts8
-rw-r--r--server/models/video/video.ts79
-rw-r--r--server/tests/api/check-params/video-playlists.ts34
-rw-r--r--server/tests/api/check-params/videos-filter.ts26
-rw-r--r--server/tests/api/check-params/videos.ts2
-rw-r--r--server/tests/api/videos/video-playlists.ts1473
-rw-r--r--shared/extra-utils/server/jobs.ts5
-rw-r--r--shared/extra-utils/videos/video-playlists.ts8
-rw-r--r--shared/models/videos/index.ts1
-rw-r--r--shared/models/videos/playlist/video-exist-in-playlist.model.ts1
-rw-r--r--shared/models/videos/playlist/video-playlist-element.model.ts19
-rw-r--r--shared/models/videos/video.model.ts8
-rw-r--r--support/doc/api/openapi.yaml6
45 files changed, 1496 insertions, 929 deletions
diff --git a/client/src/app/+accounts/account-video-channels/account-video-channels.component.html b/client/src/app/+accounts/account-video-channels/account-video-channels.component.html
index cb23bb522..ea5f61b18 100644
--- a/client/src/app/+accounts/account-video-channels/account-video-channels.component.html
+++ b/client/src/app/+accounts/account-video-channels/account-video-channels.component.html
@@ -18,10 +18,13 @@
18 <div *ngIf="getVideosOf(videoChannel)" class="videos"> 18 <div *ngIf="getVideosOf(videoChannel)" class="videos">
19 <div class="no-results" i18n *ngIf="getVideosOf(videoChannel).length === 0">This channel does not have videos.</div> 19 <div class="no-results" i18n *ngIf="getVideosOf(videoChannel).length === 0">This channel does not have videos.</div>
20 20
21 <my-video-miniature *ngFor="let video of getVideosOf(videoChannel)" [video]="video" [user]="user" [displayVideoActions]="false"></my-video-miniature> 21 <my-video-miniature
22 *ngFor="let video of getVideosOf(videoChannel)"
23 [video]="video" [user]="user" [displayVideoActions]="true"
24 ></my-video-miniature>
22 </div> 25 </div>
23 26
24 <a class="show-more" i18n [routerLink]="getVideoChannelLink(videoChannel)"> 27 <a *ngIf="getVideosOf(videoChannel).length !== 0" class="show-more" i18n [routerLink]="getVideoChannelLink(videoChannel)">
25 Show this channel 28 Show this channel
26 </a> 29 </a>
27 </div> 30 </div>
diff --git a/client/src/app/+accounts/account-video-channels/account-video-channels.component.scss b/client/src/app/+accounts/account-video-channels/account-video-channels.component.scss
index 98931f0c2..7f7652460 100644
--- a/client/src/app/+accounts/account-video-channels/account-video-channels.component.scss
+++ b/client/src/app/+accounts/account-video-channels/account-video-channels.component.scss
@@ -23,6 +23,11 @@
23 height: 50px; 23 height: 50px;
24 } 24 }
25 } 25 }
26
27 my-video-miniature ::ng-deep my-video-actions-dropdown > my-action-dropdown {
28 // Fix our overflow
29 position: absolute;
30 }
26} 31}
27 32
28 33
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html
index 284694b7f..4de4e69da 100644
--- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html
@@ -14,10 +14,10 @@
14 class="videos" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" 14 class="videos" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()"
15 cdkDropList (cdkDropListDropped)="drop($event)" 15 cdkDropList (cdkDropListDropped)="drop($event)"
16 > 16 >
17 <div class="video" *ngFor="let video of videos; trackBy: trackByFn" cdkDrag (cdkDragMoved)="onDragMove($event)"> 17 <div class="video" *ngFor="let playlistElement of playlistElements; trackBy: trackByFn" cdkDrag>
18 <my-video-playlist-element-miniature 18 <my-video-playlist-element-miniature
19 [video]="video" [playlist]="playlist" [owned]="true" (elementRemoved)="onElementRemoved($event)" 19 [playlistElement]="playlistElement" [playlist]="playlist" [owned]="true" (elementRemoved)="onElementRemoved($event)"
20 [position]="video.playlistElement.position" 20 [position]="playlistElement.position"
21 > 21 >
22 </my-video-playlist-element-miniature> 22 </my-video-playlist-element-miniature>
23 </div> 23 </div>
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts
index d5122aeba..6434b9e50 100644
--- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts
+++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts
@@ -3,15 +3,13 @@ import { Notifier, ServerService } from '@app/core'
3import { AuthService } from '../../core/auth' 3import { AuthService } from '../../core/auth'
4import { ConfirmService } from '../../core/confirm' 4import { ConfirmService } from '../../core/confirm'
5import { ComponentPagination } from '@app/shared/rest/component-pagination.model' 5import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
6import { Video } from '@app/shared/video/video.model' 6import { Subscription } from 'rxjs'
7import { Subject, Subscription } from 'rxjs'
8import { ActivatedRoute } from '@angular/router' 7import { ActivatedRoute } from '@angular/router'
9import { VideoService } from '@app/shared/video/video.service'
10import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' 8import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
11import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' 9import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
12import { I18n } from '@ngx-translate/i18n-polyfill' 10import { I18n } from '@ngx-translate/i18n-polyfill'
13import { CdkDragDrop, CdkDragMove } from '@angular/cdk/drag-drop' 11import { CdkDragDrop } from '@angular/cdk/drag-drop'
14import { throttleTime } from 'rxjs/operators' 12import { VideoPlaylistElement } from '@app/shared/video-playlist/video-playlist-element.model'
15 13
16@Component({ 14@Component({
17 selector: 'my-account-video-playlist-elements', 15 selector: 'my-account-video-playlist-elements',
@@ -19,7 +17,7 @@ import { throttleTime } from 'rxjs/operators'
19 styleUrls: [ './my-account-video-playlist-elements.component.scss' ] 17 styleUrls: [ './my-account-video-playlist-elements.component.scss' ]
20}) 18})
21export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestroy { 19export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestroy {
22 videos: Video[] = [] 20 playlistElements: VideoPlaylistElement[] = []
23 playlist: VideoPlaylist 21 playlist: VideoPlaylist
24 22
25 pagination: ComponentPagination = { 23 pagination: ComponentPagination = {
@@ -30,7 +28,6 @@ export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestro
30 28
31 private videoPlaylistId: string | number 29 private videoPlaylistId: string | number
32 private paramsSub: Subscription 30 private paramsSub: Subscription
33 private dragMoveSubject = new Subject<number>()
34 31
35 constructor ( 32 constructor (
36 private authService: AuthService, 33 private authService: AuthService,
@@ -39,7 +36,6 @@ export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestro
39 private confirmService: ConfirmService, 36 private confirmService: ConfirmService,
40 private route: ActivatedRoute, 37 private route: ActivatedRoute,
41 private i18n: I18n, 38 private i18n: I18n,
42 private videoService: VideoService,
43 private videoPlaylistService: VideoPlaylistService 39 private videoPlaylistService: VideoPlaylistService
44 ) {} 40 ) {}
45 41
@@ -50,10 +46,6 @@ export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestro
50 46
51 this.loadPlaylistInfo() 47 this.loadPlaylistInfo()
52 }) 48 })
53
54 this.dragMoveSubject.asObservable()
55 .pipe(throttleTime(200))
56 .subscribe(y => this.checkScroll(y))
57 } 49 }
58 50
59 ngOnDestroy () { 51 ngOnDestroy () {
@@ -66,8 +58,8 @@ export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestro
66 58
67 if (previousIndex === newIndex) return 59 if (previousIndex === newIndex) return
68 60
69 const oldPosition = this.videos[previousIndex].playlistElement.position 61 const oldPosition = this.playlistElements[previousIndex].position
70 let insertAfter = this.videos[newIndex].playlistElement.position 62 let insertAfter = this.playlistElements[newIndex].position
71 63
72 if (oldPosition > insertAfter) insertAfter-- 64 if (oldPosition > insertAfter) insertAfter--
73 65
@@ -78,42 +70,16 @@ export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestro
78 err => this.notifier.error(err.message) 70 err => this.notifier.error(err.message)
79 ) 71 )
80 72
81 const video = this.videos[previousIndex] 73 const element = this.playlistElements[previousIndex]
82 74
83 this.videos.splice(previousIndex, 1) 75 this.playlistElements.splice(previousIndex, 1)
84 this.videos.splice(newIndex, 0, video) 76 this.playlistElements.splice(newIndex, 0, element)
85 77
86 this.reorderClientPositions() 78 this.reorderClientPositions()
87 } 79 }
88 80
89 onDragMove (event: CdkDragMove<any>) { 81 onElementRemoved (element: VideoPlaylistElement) {
90 this.dragMoveSubject.next(event.pointerPosition.y) 82 this.playlistElements = this.playlistElements.filter(v => v.id !== element.id)
91 }
92
93 checkScroll (pointerY: number) {
94 // FIXME: Uncomment when https://github.com/angular/material2/issues/14098 is fixed
95 // FIXME: Remove when https://github.com/angular/material2/issues/13588 is implemented
96 // if (pointerY < 150) {
97 // window.scrollBy({
98 // left: 0,
99 // top: -20,
100 // behavior: 'smooth'
101 // })
102 //
103 // return
104 // }
105 //
106 // if (window.innerHeight - pointerY <= 50) {
107 // window.scrollBy({
108 // left: 0,
109 // top: 20,
110 // behavior: 'smooth'
111 // })
112 // }
113 }
114
115 onElementRemoved (video: Video) {
116 this.videos = this.videos.filter(v => v.id !== video.id)
117 this.reorderClientPositions() 83 this.reorderClientPositions()
118 } 84 }
119 85
@@ -125,14 +91,14 @@ export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestro
125 this.loadElements() 91 this.loadElements()
126 } 92 }
127 93
128 trackByFn (index: number, elem: Video) { 94 trackByFn (index: number, elem: VideoPlaylistElement) {
129 return elem.id 95 return elem.id
130 } 96 }
131 97
132 private loadElements () { 98 private loadElements () {
133 this.videoService.getPlaylistVideos(this.videoPlaylistId, this.pagination) 99 this.videoPlaylistService.getPlaylistVideos(this.videoPlaylistId, this.pagination)
134 .subscribe(({ total, data }) => { 100 .subscribe(({ total, data }) => {
135 this.videos = this.videos.concat(data) 101 this.playlistElements = this.playlistElements.concat(data)
136 this.pagination.totalItems = total 102 this.pagination.totalItems = total
137 }) 103 })
138 } 104 }
@@ -147,8 +113,8 @@ export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestro
147 private reorderClientPositions () { 113 private reorderClientPositions () {
148 let i = 1 114 let i = 1
149 115
150 for (const video of this.videos) { 116 for (const element of this.playlistElements) {
151 video.playlistElement.position = i 117 element.position = i
152 i++ 118 i++
153 } 119 }
154 } 120 }
diff --git a/client/src/app/shared/video-playlist/video-add-to-playlist.component.ts b/client/src/app/shared/video-playlist/video-add-to-playlist.component.ts
index c6cff03a4..08ceb21bc 100644
--- a/client/src/app/shared/video-playlist/video-add-to-playlist.component.ts
+++ b/client/src/app/shared/video-playlist/video-add-to-playlist.component.ts
@@ -37,6 +37,8 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
37 } 37 }
38 displayOptions = false 38 displayOptions = false
39 39
40 private playlistElementId: number
41
40 constructor ( 42 constructor (
41 protected formValidatorService: FormValidatorService, 43 protected formValidatorService: FormValidatorService,
42 private authService: AuthService, 44 private authService: AuthService,
@@ -96,6 +98,8 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
96 startTimestamp: existingPlaylist ? existingPlaylist.startTimestamp : undefined, 98 startTimestamp: existingPlaylist ? existingPlaylist.startTimestamp : undefined,
97 stopTimestamp: existingPlaylist ? existingPlaylist.stopTimestamp : undefined 99 stopTimestamp: existingPlaylist ? existingPlaylist.stopTimestamp : undefined
98 }) 100 })
101
102 this.playlistElementId = existingPlaylist ? existingPlaylist.playlistElementId : undefined
99 } 103 }
100 104
101 this.cd.markForCheck() 105 this.cd.markForCheck()
@@ -177,7 +181,9 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
177 } 181 }
178 182
179 private removeVideoFromPlaylist (playlist: PlaylistSummary) { 183 private removeVideoFromPlaylist (playlist: PlaylistSummary) {
180 this.videoPlaylistService.removeVideoFromPlaylist(playlist.id, this.video.id) 184 if (!this.playlistElementId) return
185
186 this.videoPlaylistService.removeVideoFromPlaylist(playlist.id, this.playlistElementId)
181 .subscribe( 187 .subscribe(
182 () => { 188 () => {
183 this.notifier.success(this.i18n('Video removed from {{name}}', { name: playlist.displayName })) 189 this.notifier.success(this.i18n('Video removed from {{name}}', { name: playlist.displayName }))
diff --git a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html
index ab5a78928..25d4783fb 100644
--- a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html
+++ b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html
@@ -6,66 +6,82 @@
6 </div> 6 </div>
7 7
8 <my-video-thumbnail 8 <my-video-thumbnail
9 [video]="video" [nsfw]="isVideoBlur(video)" 9 *ngIf="playlistElement.video"
10 [video]="playlistElement.video" [nsfw]="isVideoBlur(playlistElement.video)"
10 [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()" 11 [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()"
11 ></my-video-thumbnail> 12 ></my-video-thumbnail>
12 13
14 <div class="fake-thumbnail" *ngIf="!playlistElement.video"></div>
15
13 <div class="video-info"> 16 <div class="video-info">
14 <a tabindex="-1" class="video-info-name" 17 <ng-container *ngIf="playlistElement.video">
15 [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()" 18 <a tabindex="-1" class="video-info-name"
16 [attr.title]="video.name" 19 [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()"
17 >{{ video.name }}</a> 20 [attr.title]="playlistElement.video.name"
21 >{{ playlistElement.video.name }}</a>
22
23 <a *ngIf="accountLink" tabindex="-1" class="video-info-account" [routerLink]="[ '/accounts', playlistElement.video.byAccount ]">
24 {{ playlistElement.video.byAccount }}
25 </a>
26 <span *ngIf="!accountLink" tabindex="-1" class="video-info-account">{{ playlistElement.video.byAccount }}</span>
18 27
19 <a *ngIf="accountLink" tabindex="-1" class="video-info-account" [routerLink]="[ '/accounts', video.byAccount ]">{{ video.byAccount }}</a> 28 <span tabindex="-1" class="video-info-timestamp">{{ formatTimestamp(playlistElement) }}</span>
20 <span *ngIf="!accountLink" tabindex="-1" class="video-info-account">{{ video.byAccount }}</span> 29 </ng-container>
21 30
22 <span tabindex="-1" class="video-info-timestamp">{{ formatTimestamp(video) }}</span> 31 <span *ngIf="!playlistElement.video" class="video-info-name">
32 <ng-container i18n *ngIf="isUnavailable(playlistElement)">Unavailable</ng-container>
33 <ng-container i18n *ngIf="isPrivate(playlistElement)">Private</ng-container>
34 <ng-container i18n *ngIf="isDeleted(playlistElement)">Deleted</ng-container>
35 </span>
23 </div> 36 </div>
24 </a> 37 </a>
25 38
26 <div *ngIf="owned" class="more" ngbDropdown #moreDropdown="ngbDropdown" placement="bottom-right" (openChange)="onDropdownOpenChange()" 39 <div *ngIf="owned" class="more" ngbDropdown #moreDropdown="ngbDropdown" placement="bottom-right"
27 autoClose="outside"> 40 (openChange)="onDropdownOpenChange()" autoClose="outside"
41 >
28 <my-global-icon iconName="more-vertical" ngbDropdownToggle role="button" class="icon-more" (click)="$event.preventDefault()"></my-global-icon> 42 <my-global-icon iconName="more-vertical" ngbDropdownToggle role="button" class="icon-more" (click)="$event.preventDefault()"></my-global-icon>
29 43
30 <div ngbDropdownMenu> 44 <div ngbDropdownMenu>
31 <div class="dropdown-item" (click)="toggleDisplayTimestampsOptions($event, video)"> 45 <ng-container *ngIf="playlistElement.video">
32 <my-global-icon iconName="edit"></my-global-icon> 46 <div class="dropdown-item" (click)="toggleDisplayTimestampsOptions($event, playlistElement)">
33 <ng-container i18n>Edit starts/stops at</ng-container> 47 <my-global-icon iconName="edit"></my-global-icon>
34 </div> 48 <ng-container i18n>Edit starts/stops at</ng-container>
49 </div>
35 50
36 <div class="timestamp-options" *ngIf="displayTimestampOptions"> 51 <div class="timestamp-options" *ngIf="displayTimestampOptions">
37 <div> 52 <div>
38 <my-peertube-checkbox 53 <my-peertube-checkbox
39 inputName="startAt" [(ngModel)]="timestampOptions.startTimestampEnabled" 54 inputName="startAt" [(ngModel)]="timestampOptions.startTimestampEnabled"
40 i18n-labelText labelText="Start at" 55 i18n-labelText labelText="Start at"
41 ></my-peertube-checkbox> 56 ></my-peertube-checkbox>
42 57
43 <my-timestamp-input 58 <my-timestamp-input
44 [timestamp]="timestampOptions.startTimestamp" 59 [timestamp]="timestampOptions.startTimestamp"
45 [maxTimestamp]="video.duration" 60 [maxTimestamp]="playlistElement.video.duration"
46 [disabled]="!timestampOptions.startTimestampEnabled" 61 [disabled]="!timestampOptions.startTimestampEnabled"
47 [(ngModel)]="timestampOptions.startTimestamp" 62 [(ngModel)]="timestampOptions.startTimestamp"
48 ></my-timestamp-input> 63 ></my-timestamp-input>
49 </div> 64 </div>
50 65
51 <div> 66 <div>
52 <my-peertube-checkbox 67 <my-peertube-checkbox
53 inputName="stopAt" [(ngModel)]="timestampOptions.stopTimestampEnabled" 68 inputName="stopAt" [(ngModel)]="timestampOptions.stopTimestampEnabled"
54 i18n-labelText labelText="Stop at" 69 i18n-labelText labelText="Stop at"
55 ></my-peertube-checkbox> 70 ></my-peertube-checkbox>
56 71
57 <my-timestamp-input 72 <my-timestamp-input
58 [timestamp]="timestampOptions.stopTimestamp" 73 [timestamp]="timestampOptions.stopTimestamp"
59 [maxTimestamp]="video.duration" 74 [maxTimestamp]="playlistElement.video.duration"
60 [disabled]="!timestampOptions.stopTimestampEnabled" 75 [disabled]="!timestampOptions.stopTimestampEnabled"
61 [(ngModel)]="timestampOptions.stopTimestamp" 76 [(ngModel)]="timestampOptions.stopTimestamp"
62 ></my-timestamp-input> 77 ></my-timestamp-input>
63 </div> 78 </div>
64 79
65 <input type="submit" i18n-value value="Save" (click)="updateTimestamps(video)"> 80 <input type="submit" i18n-value value="Save" (click)="updateTimestamps(playlistElement)">
66 </div> 81 </div>
82 </ng-container>
67 83
68 <span class="dropdown-item" (click)="removeFromPlaylist(video)"> 84 <span class="dropdown-item" (click)="removeFromPlaylist(playlistElement)">
69 <my-global-icon iconName="delete"></my-global-icon> <ng-container i18n>Delete from {{ playlist?.displayName }}</ng-container> 85 <my-global-icon iconName="delete"></my-global-icon> <ng-container i18n>Delete from {{ playlist?.displayName }}</ng-container>
70 </span> 86 </span>
71 </div> 87 </div>
diff --git a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.scss b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.scss
index cb7072d7f..9f4061b02 100644
--- a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.scss
+++ b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.scss
@@ -2,9 +2,21 @@
2@import '_mixins'; 2@import '_mixins';
3@import '_miniature'; 3@import '_miniature';
4 4
5$thumbnail-width: 130px;
6$thumbnail-height: 72px;
7
5my-video-thumbnail { 8my-video-thumbnail {
6 @include thumbnail-size-component(130px, 72px); 9 @include thumbnail-size-component($thumbnail-width, $thumbnail-height);
10}
7 11
12.fake-thumbnail {
13 width: $thumbnail-width;
14 height: $thumbnail-height;
15 background-color: #ececec;
16}
17
18my-video-thumbnail,
19.fake-thumbnail {
8 display: flex; // Avoids an issue with line-height that adds space below the element 20 display: flex; // Avoids an issue with line-height that adds space below the element
9 margin-right: 10px; 21 margin-right: 10px;
10} 22}
@@ -31,6 +43,7 @@ my-video-thumbnail {
31 a { 43 a {
32 @include disable-default-a-behaviour; 44 @include disable-default-a-behaviour;
33 45
46 color: var(--mainForegroundColor);
34 display: flex; 47 display: flex;
35 min-width: 0; 48 min-width: 0;
36 align-items: center; 49 align-items: center;
@@ -58,7 +71,6 @@ my-video-thumbnail {
58 min-width: 0; 71 min-width: 0;
59 72
60 a { 73 a {
61 color: var(--mainForegroundColor);
62 width: auto; 74 width: auto;
63 75
64 &:hover { 76 &:hover {
@@ -66,20 +78,20 @@ my-video-thumbnail {
66 } 78 }
67 } 79 }
68 80
69 .video-info-name {
70 font-size: 18px;
71 font-weight: $font-semibold;
72 display: inline-block;
73
74 @include ellipsis;
75 }
76
77 .video-info-account, .video-info-timestamp { 81 .video-info-account, .video-info-timestamp {
78 color: $grey-foreground-color; 82 color: $grey-foreground-color;
79 } 83 }
80 } 84 }
81 } 85 }
82 86
87 .video-info-name {
88 font-size: 18px;
89 font-weight: $font-semibold;
90 display: inline-block;
91
92 @include ellipsis;
93 }
94
83 .more { 95 .more {
84 justify-self: flex-end; 96 justify-self: flex-end;
85 margin-left: auto; 97 margin-left: auto;
diff --git a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts
index 62cf6536d..a8e5a4885 100644
--- a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts
+++ b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts
@@ -1,6 +1,6 @@
1import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, Output, ViewChild } from '@angular/core' 1import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, Output, ViewChild } from '@angular/core'
2import { Video } from '@app/shared/video/video.model' 2import { Video } from '@app/shared/video/video.model'
3import { VideoPlaylistElementUpdate } from '@shared/models' 3import { VideoPlaylistElementType, VideoPlaylistElementUpdate } from '@shared/models'
4import { AuthService, ConfirmService, Notifier, ServerService } from '@app/core' 4import { AuthService, ConfirmService, Notifier, ServerService } from '@app/core'
5import { ActivatedRoute } from '@angular/router' 5import { ActivatedRoute } from '@angular/router'
6import { I18n } from '@ngx-translate/i18n-polyfill' 6import { I18n } from '@ngx-translate/i18n-polyfill'
@@ -9,6 +9,7 @@ import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.
9import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' 9import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
10import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' 10import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
11import { secondsToTime } from '../../../assets/player/utils' 11import { secondsToTime } from '../../../assets/player/utils'
12import { VideoPlaylistElement } from '@app/shared/video-playlist/video-playlist-element.model'
12 13
13@Component({ 14@Component({
14 selector: 'my-video-playlist-element-miniature', 15 selector: 'my-video-playlist-element-miniature',
@@ -20,14 +21,14 @@ export class VideoPlaylistElementMiniatureComponent {
20 @ViewChild('moreDropdown', { static: false }) moreDropdown: NgbDropdown 21 @ViewChild('moreDropdown', { static: false }) moreDropdown: NgbDropdown
21 22
22 @Input() playlist: VideoPlaylist 23 @Input() playlist: VideoPlaylist
23 @Input() video: Video 24 @Input() playlistElement: VideoPlaylistElement
24 @Input() owned = false 25 @Input() owned = false
25 @Input() playing = false 26 @Input() playing = false
26 @Input() rowLink = false 27 @Input() rowLink = false
27 @Input() accountLink = true 28 @Input() accountLink = true
28 @Input() position: number 29 @Input() position: number // Keep this property because we're in the OnPush change detection strategy
29 30
30 @Output() elementRemoved = new EventEmitter<Video>() 31 @Output() elementRemoved = new EventEmitter<VideoPlaylistElement>()
31 32
32 displayTimestampOptions = false 33 displayTimestampOptions = false
33 34
@@ -50,6 +51,18 @@ export class VideoPlaylistElementMiniatureComponent {
50 private cdr: ChangeDetectorRef 51 private cdr: ChangeDetectorRef
51 ) {} 52 ) {}
52 53
54 isUnavailable (e: VideoPlaylistElement) {
55 return e.type === VideoPlaylistElementType.UNAVAILABLE
56 }
57
58 isPrivate (e: VideoPlaylistElement) {
59 return e.type === VideoPlaylistElementType.PRIVATE
60 }
61
62 isDeleted (e: VideoPlaylistElement) {
63 return e.type === VideoPlaylistElementType.DELETED
64 }
65
53 buildRouterLink () { 66 buildRouterLink () {
54 if (!this.playlist) return null 67 if (!this.playlist) return null
55 68
@@ -57,12 +70,12 @@ export class VideoPlaylistElementMiniatureComponent {
57 } 70 }
58 71
59 buildRouterQuery () { 72 buildRouterQuery () {
60 if (!this.video) return {} 73 if (!this.playlistElement || !this.playlistElement.video) return {}
61 74
62 return { 75 return {
63 videoId: this.video.uuid, 76 videoId: this.playlistElement.video.uuid,
64 start: this.video.playlistElement.startTimestamp, 77 start: this.playlistElement.startTimestamp,
65 stop: this.video.playlistElement.stopTimestamp 78 stop: this.playlistElement.stopTimestamp
66 } 79 }
67 } 80 }
68 81
@@ -70,13 +83,13 @@ export class VideoPlaylistElementMiniatureComponent {
70 return video.isVideoNSFWForUser(this.authService.getUser(), this.serverService.getConfig()) 83 return video.isVideoNSFWForUser(this.authService.getUser(), this.serverService.getConfig())
71 } 84 }
72 85
73 removeFromPlaylist (video: Video) { 86 removeFromPlaylist (playlistElement: VideoPlaylistElement) {
74 this.videoPlaylistService.removeVideoFromPlaylist(this.playlist.id, video.id) 87 this.videoPlaylistService.removeVideoFromPlaylist(this.playlist.id, playlistElement.id)
75 .subscribe( 88 .subscribe(
76 () => { 89 () => {
77 this.notifier.success(this.i18n('Video removed from {{name}}', { name: this.playlist.displayName })) 90 this.notifier.success(this.i18n('Video removed from {{name}}', { name: this.playlist.displayName }))
78 91
79 this.elementRemoved.emit(this.video) 92 this.elementRemoved.emit(playlistElement)
80 }, 93 },
81 94
82 err => this.notifier.error(err.message) 95 err => this.notifier.error(err.message)
@@ -85,19 +98,19 @@ export class VideoPlaylistElementMiniatureComponent {
85 this.moreDropdown.close() 98 this.moreDropdown.close()
86 } 99 }
87 100
88 updateTimestamps (video: Video) { 101 updateTimestamps (playlistElement: VideoPlaylistElement) {
89 const body: VideoPlaylistElementUpdate = {} 102 const body: VideoPlaylistElementUpdate = {}
90 103
91 body.startTimestamp = this.timestampOptions.startTimestampEnabled ? this.timestampOptions.startTimestamp : null 104 body.startTimestamp = this.timestampOptions.startTimestampEnabled ? this.timestampOptions.startTimestamp : null
92 body.stopTimestamp = this.timestampOptions.stopTimestampEnabled ? this.timestampOptions.stopTimestamp : null 105 body.stopTimestamp = this.timestampOptions.stopTimestampEnabled ? this.timestampOptions.stopTimestamp : null
93 106
94 this.videoPlaylistService.updateVideoOfPlaylist(this.playlist.id, video.id, body) 107 this.videoPlaylistService.updateVideoOfPlaylist(this.playlist.id, playlistElement.id, body)
95 .subscribe( 108 .subscribe(
96 () => { 109 () => {
97 this.notifier.success(this.i18n('Timestamps updated')) 110 this.notifier.success(this.i18n('Timestamps updated'))
98 111
99 video.playlistElement.startTimestamp = body.startTimestamp 112 playlistElement.startTimestamp = body.startTimestamp
100 video.playlistElement.stopTimestamp = body.stopTimestamp 113 playlistElement.stopTimestamp = body.stopTimestamp
101 114
102 this.cdr.detectChanges() 115 this.cdr.detectChanges()
103 }, 116 },
@@ -108,9 +121,9 @@ export class VideoPlaylistElementMiniatureComponent {
108 this.moreDropdown.close() 121 this.moreDropdown.close()
109 } 122 }
110 123
111 formatTimestamp (video: Video) { 124 formatTimestamp (playlistElement: VideoPlaylistElement) {
112 const start = video.playlistElement.startTimestamp 125 const start = playlistElement.startTimestamp
113 const stop = video.playlistElement.stopTimestamp 126 const stop = playlistElement.stopTimestamp
114 127
115 const startFormatted = secondsToTime(start, true, ':') 128 const startFormatted = secondsToTime(start, true, ':')
116 const stopFormatted = secondsToTime(stop, true, ':') 129 const stopFormatted = secondsToTime(stop, true, ':')
@@ -127,7 +140,7 @@ export class VideoPlaylistElementMiniatureComponent {
127 this.displayTimestampOptions = false 140 this.displayTimestampOptions = false
128 } 141 }
129 142
130 toggleDisplayTimestampsOptions (event: Event, video: Video) { 143 toggleDisplayTimestampsOptions (event: Event, playlistElement: VideoPlaylistElement) {
131 event.preventDefault() 144 event.preventDefault()
132 145
133 this.displayTimestampOptions = !this.displayTimestampOptions 146 this.displayTimestampOptions = !this.displayTimestampOptions
@@ -137,17 +150,17 @@ export class VideoPlaylistElementMiniatureComponent {
137 startTimestampEnabled: false, 150 startTimestampEnabled: false,
138 stopTimestampEnabled: false, 151 stopTimestampEnabled: false,
139 startTimestamp: 0, 152 startTimestamp: 0,
140 stopTimestamp: video.duration 153 stopTimestamp: playlistElement.video.duration
141 } 154 }
142 155
143 if (video.playlistElement.startTimestamp) { 156 if (playlistElement.startTimestamp) {
144 this.timestampOptions.startTimestampEnabled = true 157 this.timestampOptions.startTimestampEnabled = true
145 this.timestampOptions.startTimestamp = video.playlistElement.startTimestamp 158 this.timestampOptions.startTimestamp = playlistElement.startTimestamp
146 } 159 }
147 160
148 if (video.playlistElement.stopTimestamp) { 161 if (playlistElement.stopTimestamp) {
149 this.timestampOptions.stopTimestampEnabled = true 162 this.timestampOptions.stopTimestampEnabled = true
150 this.timestampOptions.stopTimestamp = video.playlistElement.stopTimestamp 163 this.timestampOptions.stopTimestamp = playlistElement.stopTimestamp
151 } 164 }
152 } 165 }
153 166
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
index 000000000..f1c46d1eb
--- /dev/null
+++ b/client/src/app/shared/video-playlist/video-playlist-element.model.ts
@@ -0,0 +1,24 @@
1import { VideoPlaylistElement as ServerVideoPlaylistElement, VideoPlaylistElementType } from '../../../../../shared/models/videos'
2import { Video } from '@app/shared/video/video.model'
3
4export class VideoPlaylistElement implements ServerVideoPlaylistElement {
5 id: number
6 position: number
7 startTimestamp: number
8 stopTimestamp: number
9
10 type: VideoPlaylistElementType
11
12 video?: Video
13
14 constructor (hash: ServerVideoPlaylistElement, translations: {}) {
15 this.id = hash.id
16 this.position = hash.position
17 this.startTimestamp = hash.startTimestamp
18 this.stopTimestamp = hash.stopTimestamp
19
20 this.type = hash.type
21
22 if (hash.video) this.video = new Video(hash.video, translations)
23 }
24}
diff --git a/client/src/app/shared/video-playlist/video-playlist.service.ts b/client/src/app/shared/video-playlist/video-playlist.service.ts
index da7437507..b93a19356 100644
--- a/client/src/app/shared/video-playlist/video-playlist.service.ts
+++ b/client/src/app/shared/video-playlist/video-playlist.service.ts
@@ -18,6 +18,9 @@ import { Account } from '@app/shared/account/account.model'
18import { RestService } from '@app/shared/rest' 18import { RestService } from '@app/shared/rest'
19import { VideoExistInPlaylist } from '@shared/models/videos/playlist/video-exist-in-playlist.model' 19import { VideoExistInPlaylist } from '@shared/models/videos/playlist/video-exist-in-playlist.model'
20import { VideoPlaylistReorder } from '@shared/models/videos/playlist/video-playlist-reorder.model' 20import { VideoPlaylistReorder } from '@shared/models/videos/playlist/video-playlist-reorder.model'
21import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
22import { VideoPlaylistElement as ServerVideoPlaylistElement } from '@shared/models/videos/playlist/video-playlist-element.model'
23import { VideoPlaylistElement } from '@app/shared/video-playlist/video-playlist-element.model'
21 24
22@Injectable() 25@Injectable()
23export class VideoPlaylistService { 26export class VideoPlaylistService {
@@ -110,16 +113,16 @@ export class VideoPlaylistService {
110 ) 113 )
111 } 114 }
112 115
113 updateVideoOfPlaylist (playlistId: number, videoId: number, body: VideoPlaylistElementUpdate) { 116 updateVideoOfPlaylist (playlistId: number, playlistElementId: number, body: VideoPlaylistElementUpdate) {
114 return this.authHttp.put(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + videoId, body) 117 return this.authHttp.put(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + playlistElementId, body)
115 .pipe( 118 .pipe(
116 map(this.restExtractor.extractDataBool), 119 map(this.restExtractor.extractDataBool),
117 catchError(err => this.restExtractor.handleError(err)) 120 catchError(err => this.restExtractor.handleError(err))
118 ) 121 )
119 } 122 }
120 123
121 removeVideoFromPlaylist (playlistId: number, videoId: number) { 124 removeVideoFromPlaylist (playlistId: number, playlistElementId: number) {
122 return this.authHttp.delete(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + videoId) 125 return this.authHttp.delete(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + playlistElementId)
123 .pipe( 126 .pipe(
124 map(this.restExtractor.extractDataBool), 127 map(this.restExtractor.extractDataBool),
125 catchError(err => this.restExtractor.handleError(err)) 128 catchError(err => this.restExtractor.handleError(err))
@@ -139,6 +142,24 @@ export class VideoPlaylistService {
139 ) 142 )
140 } 143 }
141 144
145 getPlaylistVideos (
146 videoPlaylistId: number | string,
147 componentPagination: ComponentPagination
148 ): Observable<ResultList<VideoPlaylistElement>> {
149 const path = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylistId + '/videos'
150 const pagination = this.restService.componentPaginationToRestPagination(componentPagination)
151
152 let params = new HttpParams()
153 params = this.restService.addRestGetParams(params, pagination)
154
155 return this.authHttp
156 .get<ResultList<ServerVideoPlaylistElement>>(path, { params })
157 .pipe(
158 switchMap(res => this.extractVideoPlaylistElements(res)),
159 catchError(err => this.restExtractor.handleError(err))
160 )
161 }
162
142 doesVideoExistInPlaylist (videoId: number) { 163 doesVideoExistInPlaylist (videoId: number) {
143 this.videoExistsInPlaylistSubject.next(videoId) 164 this.videoExistsInPlaylistSubject.next(videoId)
144 165
@@ -167,6 +188,23 @@ export class VideoPlaylistService {
167 .pipe(map(translations => new VideoPlaylist(playlist, translations))) 188 .pipe(map(translations => new VideoPlaylist(playlist, translations)))
168 } 189 }
169 190
191 extractVideoPlaylistElements (result: ResultList<ServerVideoPlaylistElement>) {
192 return this.serverService.localeObservable
193 .pipe(
194 map(translations => {
195 const elementsJson = result.data
196 const total = result.total
197 const elements: VideoPlaylistElement[] = []
198
199 for (const elementJson of elementsJson) {
200 elements.push(new VideoPlaylistElement(elementJson, translations))
201 }
202
203 return { total, data: elements }
204 })
205 )
206 }
207
170 private doVideosExistInPlaylist (videoIds: number[]): Observable<VideoExistInPlaylist> { 208 private doVideosExistInPlaylist (videoIds: number[]): Observable<VideoExistInPlaylist> {
171 const url = VideoPlaylistService.MY_VIDEO_PLAYLIST_URL + 'videos-exist' 209 const url = VideoPlaylistService.MY_VIDEO_PLAYLIST_URL + 'videos-exist'
172 let params = new HttpParams() 210 let params = new HttpParams()
diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts
index 6f9de9241..fb98d5382 100644
--- a/client/src/app/shared/video/video.model.ts
+++ b/client/src/app/shared/video/video.model.ts
@@ -1,5 +1,5 @@
1import { User } from '../' 1import { User } from '../'
2import { PlaylistElement, UserRight, Video as VideoServerModel, VideoPrivacy, VideoState } from '../../../../../shared' 2import { UserRight, Video as VideoServerModel, VideoPrivacy, VideoState } from '../../../../../shared'
3import { Avatar } from '../../../../../shared/models/avatars/avatar.model' 3import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
4import { VideoConstant } from '../../../../../shared/models/videos/video-constant.model' 4import { VideoConstant } from '../../../../../shared/models/videos/video-constant.model'
5import { durationToString, getAbsoluteAPIUrl } from '../misc/utils' 5import { durationToString, getAbsoluteAPIUrl } from '../misc/utils'
@@ -48,8 +48,6 @@ export class Video implements VideoServerModel {
48 blacklisted?: boolean 48 blacklisted?: boolean
49 blacklistedReason?: string 49 blacklistedReason?: string
50 50
51 playlistElement?: PlaylistElement
52
53 account: { 51 account: {
54 id: number 52 id: number
55 name: string 53 name: string
@@ -126,8 +124,6 @@ export class Video implements VideoServerModel {
126 this.blacklistedReason = hash.blacklistedReason 124 this.blacklistedReason = hash.blacklistedReason
127 125
128 this.userHistory = hash.userHistory 126 this.userHistory = hash.userHistory
129
130 this.playlistElement = hash.playlistElement
131 } 127 }
132 128
133 isVideoNSFWForUser (user: User, serverConfig: ServerConfig) { 129 isVideoNSFWForUser (user: User, serverConfig: ServerConfig) {
diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts
index 114b014ad..45366e3e3 100644
--- a/client/src/app/shared/video/video.service.ts
+++ b/client/src/app/shared/video/video.service.ts
@@ -31,7 +31,6 @@ import { ServerService } from '@app/core'
31import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service' 31import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service'
32import { VideoChannel } from '@app/shared/video-channel/video-channel.model' 32import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
33import { I18n } from '@ngx-translate/i18n-polyfill' 33import { I18n } from '@ngx-translate/i18n-polyfill'
34import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
35 34
36export interface VideosProvider { 35export interface VideosProvider {
37 getVideos (parameters: { 36 getVideos (parameters: {
@@ -172,23 +171,6 @@ export class VideoService implements VideosProvider {
172 ) 171 )
173 } 172 }
174 173
175 getPlaylistVideos (
176 videoPlaylistId: number | string,
177 videoPagination: ComponentPagination
178 ): Observable<ResultList<Video>> {
179 const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
180
181 let params = new HttpParams()
182 params = this.restService.addRestGetParams(params, pagination)
183
184 return this.authHttp
185 .get<ResultList<Video>>(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylistId + '/videos', { params })
186 .pipe(
187 switchMap(res => this.extractVideos(res)),
188 catchError(err => this.restExtractor.handleError(err))
189 )
190 }
191
192 getUserSubscriptionVideos (parameters: { 174 getUserSubscriptionVideos (parameters: {
193 videoPagination: ComponentPagination, 175 videoPagination: ComponentPagination,
194 sort: VideoSortField 176 sort: VideoSortField
diff --git a/client/src/app/videos/+video-watch/video-watch-playlist.component.html b/client/src/app/videos/+video-watch/video-watch-playlist.component.html
index c168a3130..c89936bd1 100644
--- a/client/src/app/videos/+video-watch/video-watch-playlist.component.html
+++ b/client/src/app/videos/+video-watch/video-watch-playlist.component.html
@@ -16,10 +16,10 @@
16 </div> 16 </div>
17 </div> 17 </div>
18 18
19 <div *ngFor="let playlistVideo of playlistVideos"> 19 <div *ngFor="let playlistElement of playlistElements">
20 <my-video-playlist-element-miniature 20 <my-video-playlist-element-miniature
21 [video]="playlistVideo" [playlist]="playlist" [owned]="isPlaylistOwned()" (elementRemoved)="onElementRemoved($event)" 21 [playlistElement]="playlistElement" [playlist]="playlist" [owned]="isPlaylistOwned()" (elementRemoved)="onElementRemoved($event)"
22 [playing]="currentPlaylistPosition === playlistVideo.playlistElement.position" [accountLink]="false" [position]="playlistVideo.playlistElement.position" 22 [playing]="currentPlaylistPosition === playlistElement.position" [accountLink]="false" [position]="playlistElement.position"
23 ></my-video-playlist-element-miniature> 23 ></my-video-playlist-element-miniature>
24 </div> 24 </div>
25</div> 25</div>
diff --git a/client/src/app/videos/+video-watch/video-watch-playlist.component.scss b/client/src/app/videos/+video-watch/video-watch-playlist.component.scss
index eeb763bd9..4c24d6b05 100644
--- a/client/src/app/videos/+video-watch/video-watch-playlist.component.scss
+++ b/client/src/app/videos/+video-watch/video-watch-playlist.component.scss
@@ -53,6 +53,11 @@
53 my-video-thumbnail { 53 my-video-thumbnail {
54 @include thumbnail-size-component(90px, 50px); 54 @include thumbnail-size-component(90px, 50px);
55 } 55 }
56
57 .fake-thumbnail {
58 width: 90px;
59 height: 50px;
60 }
56 } 61 }
57 } 62 }
58} 63}
diff --git a/client/src/app/videos/+video-watch/video-watch-playlist.component.ts b/client/src/app/videos/+video-watch/video-watch-playlist.component.ts
index 2fb0cb0e5..6e8d58cd8 100644
--- a/client/src/app/videos/+video-watch/video-watch-playlist.component.ts
+++ b/client/src/app/videos/+video-watch/video-watch-playlist.component.ts
@@ -1,11 +1,11 @@
1import { Component, Input } from '@angular/core' 1import { Component, Input } from '@angular/core'
2import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' 2import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
3import { ComponentPagination } from '@app/shared/rest/component-pagination.model' 3import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
4import { Video } from '@app/shared/video/video.model'
5import { VideoDetails, VideoPlaylistPrivacy } from '@shared/models' 4import { VideoDetails, VideoPlaylistPrivacy } from '@shared/models'
6import { VideoService } from '@app/shared/video/video.service'
7import { Router } from '@angular/router' 5import { Router } from '@angular/router'
8import { AuthService } from '@app/core' 6import { AuthService } from '@app/core'
7import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
8import { VideoPlaylistElement } from '@app/shared/video-playlist/video-playlist-element.model'
9 9
10@Component({ 10@Component({
11 selector: 'my-video-watch-playlist', 11 selector: 'my-video-watch-playlist',
@@ -16,7 +16,7 @@ export class VideoWatchPlaylistComponent {
16 @Input() video: VideoDetails 16 @Input() video: VideoDetails
17 @Input() playlist: VideoPlaylist 17 @Input() playlist: VideoPlaylist
18 18
19 playlistVideos: Video[] = [] 19 playlistElements: VideoPlaylistElement[] = []
20 playlistPagination: ComponentPagination = { 20 playlistPagination: ComponentPagination = {
21 currentPage: 1, 21 currentPage: 1,
22 itemsPerPage: 30, 22 itemsPerPage: 30,
@@ -28,7 +28,7 @@ export class VideoWatchPlaylistComponent {
28 28
29 constructor ( 29 constructor (
30 private auth: AuthService, 30 private auth: AuthService,
31 private videoService: VideoService, 31 private videoPlaylist: VideoPlaylistService,
32 private router: Router 32 private router: Router
33 ) {} 33 ) {}
34 34
@@ -40,8 +40,8 @@ export class VideoWatchPlaylistComponent {
40 this.loadPlaylistElements(this.playlist,false) 40 this.loadPlaylistElements(this.playlist,false)
41 } 41 }
42 42
43 onElementRemoved (video: Video) { 43 onElementRemoved (playlistElement: VideoPlaylistElement) {
44 this.playlistVideos = this.playlistVideos.filter(v => v.id !== video.id) 44 this.playlistElements = this.playlistElements.filter(e => e.id !== playlistElement.id)
45 45
46 this.playlistPagination.totalItems-- 46 this.playlistPagination.totalItems--
47 } 47 }
@@ -65,12 +65,13 @@ export class VideoWatchPlaylistComponent {
65 } 65 }
66 66
67 loadPlaylistElements (playlist: VideoPlaylist, redirectToFirst = false) { 67 loadPlaylistElements (playlist: VideoPlaylist, redirectToFirst = false) {
68 this.videoService.getPlaylistVideos(playlist.uuid, this.playlistPagination) 68 this.videoPlaylist.getPlaylistVideos(playlist.uuid, this.playlistPagination)
69 .subscribe(({ total, data }) => { 69 .subscribe(({ total, data }) => {
70 this.playlistVideos = this.playlistVideos.concat(data) 70 this.playlistElements = this.playlistElements.concat(data)
71 this.playlistPagination.totalItems = total 71 this.playlistPagination.totalItems = total
72 72
73 if (total === 0) { 73 const firstAvailableVideos = this.playlistElements.find(e => !!e.video)
74 if (!firstAvailableVideos) {
74 this.noPlaylistVideos = true 75 this.noPlaylistVideos = true
75 return 76 return
76 } 77 }
@@ -79,7 +80,7 @@ export class VideoWatchPlaylistComponent {
79 80
80 if (redirectToFirst) { 81 if (redirectToFirst) {
81 const extras = { 82 const extras = {
82 queryParams: { videoId: this.playlistVideos[ 0 ].uuid }, 83 queryParams: { videoId: firstAvailableVideos.video.uuid },
83 replaceUrl: true 84 replaceUrl: true
84 } 85 }
85 this.router.navigate([], extras) 86 this.router.navigate([], extras)
@@ -88,11 +89,11 @@ export class VideoWatchPlaylistComponent {
88 } 89 }
89 90
90 updatePlaylistIndex (video: VideoDetails) { 91 updatePlaylistIndex (video: VideoDetails) {
91 if (this.playlistVideos.length === 0 || !video) return 92 if (this.playlistElements.length === 0 || !video) return
92 93
93 for (const playlistVideo of this.playlistVideos) { 94 for (const playlistElement of this.playlistElements) {
94 if (playlistVideo.id === video.id) { 95 if (playlistElement.video && playlistElement.video.id === video.id) {
95 this.currentPlaylistPosition = playlistVideo.playlistElement.position 96 this.currentPlaylistPosition = playlistElement.position
96 return 97 return
97 } 98 }
98 } 99 }
@@ -103,11 +104,17 @@ export class VideoWatchPlaylistComponent {
103 104
104 navigateToNextPlaylistVideo () { 105 navigateToNextPlaylistVideo () {
105 if (this.currentPlaylistPosition < this.playlistPagination.totalItems) { 106 if (this.currentPlaylistPosition < this.playlistPagination.totalItems) {
106 const next = this.playlistVideos.find(v => v.playlistElement.position === this.currentPlaylistPosition + 1) 107 const next = this.playlistElements.find(e => e.position === this.currentPlaylistPosition + 1)
107 108
108 const start = next.playlistElement.startTimestamp 109 if (!next || !next.video) {
109 const stop = next.playlistElement.stopTimestamp 110 this.currentPlaylistPosition++
110 this.router.navigate([],{ queryParams: { videoId: next.uuid, start, stop } }) 111 this.navigateToNextPlaylistVideo()
112 return
113 }
114
115 const start = next.startTimestamp
116 const stop = next.stopTimestamp
117 this.router.navigate([],{ queryParams: { videoId: next.video.uuid, start, stop } })
111 } 118 }
112 } 119 }
113} 120}
diff --git a/client/src/app/videos/+video-watch/video-watch.component.ts b/client/src/app/videos/+video-watch/video-watch.component.ts
index 0d499d47f..d7c7b7497 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/videos/+video-watch/video-watch.component.ts
@@ -464,7 +464,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
464 } 464 }
465 465
466 this.zone.runOutsideAngular(async () => { 466 this.zone.runOutsideAngular(async () => {
467 this.player = await PeertubePlayerManager.initialize(mode, options) 467 this.player = await PeertubePlayerManager.initialize(mode, options, player => this.player = player)
468 468
469 this.player.on('customError', ({ err }: { err: any }) => this.handleError(err)) 469 this.player.on('customError', ({ err }: { err: any }) => this.handleError(err))
470 470
diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts
index 083c621d2..6c8b13087 100644
--- a/client/src/assets/player/peertube-player-manager.ts
+++ b/client/src/assets/player/peertube-player-manager.ts
@@ -86,6 +86,7 @@ export class PeertubePlayerManager {
86 86
87 private static videojsLocaleCache: { [ path: string ]: any } = {} 87 private static videojsLocaleCache: { [ path: string ]: any } = {}
88 private static playerElementClassName: string 88 private static playerElementClassName: string
89 private static onPlayerChange: (player: any) => void
89 90
90 static getServerTranslations (serverUrl: string, locale: string) { 91 static getServerTranslations (serverUrl: string, locale: string) {
91 const path = PeertubePlayerManager.getLocalePath(serverUrl, locale) 92 const path = PeertubePlayerManager.getLocalePath(serverUrl, locale)
@@ -100,9 +101,10 @@ export class PeertubePlayerManager {
100 }) 101 })
101 } 102 }
102 103
103 static async initialize (mode: PlayerMode, options: PeertubePlayerManagerOptions) { 104 static async initialize (mode: PlayerMode, options: PeertubePlayerManagerOptions, onPlayerChange: (player: any) => void) {
104 let p2pMediaLoader: any 105 let p2pMediaLoader: any
105 106
107 this.onPlayerChange = onPlayerChange
106 this.playerElementClassName = options.common.playerElement.className 108 this.playerElementClassName = options.common.playerElement.className
107 109
108 if (mode === 'webtorrent') await import('./webtorrent/webtorrent-plugin') 110 if (mode === 'webtorrent') await import('./webtorrent/webtorrent-plugin')
@@ -171,6 +173,8 @@ export class PeertubePlayerManager {
171 const player = this 173 const player = this
172 174
173 self.addContextMenu(mode, player, options.common.embedUrl) 175 self.addContextMenu(mode, player, options.common.embedUrl)
176
177 PeertubePlayerManager.onPlayerChange(player)
174 }) 178 })
175 } 179 }
176 180
diff --git a/client/src/assets/player/videojs-components/settings-menu-item.ts b/client/src/assets/player/videojs-components/settings-menu-item.ts
index 78879a2ec..24b7e0c70 100644
--- a/client/src/assets/player/videojs-components/settings-menu-item.ts
+++ b/client/src/assets/player/videojs-components/settings-menu-item.ts
@@ -43,6 +43,9 @@ class SettingsMenuItem extends MenuItem {
43 player.ready(() => { 43 player.ready(() => {
44 // Voodoo magic for IOS 44 // Voodoo magic for IOS
45 setTimeout(() => { 45 setTimeout(() => {
46 // Player was destroyed
47 if (!this.player_) return
48
46 this.build() 49 this.build()
47 50
48 // Update on rate change 51 // Update on rate change
diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts
index cfe8e94b1..6ff3efef1 100644
--- a/client/src/standalone/videos/embed.ts
+++ b/client/src/standalone/videos/embed.ts
@@ -212,7 +212,7 @@ export class PeerTubeEmbed {
212 }) 212 })
213 } 213 }
214 214
215 this.player = await PeertubePlayerManager.initialize(this.mode, options) 215 this.player = await PeertubePlayerManager.initialize(this.mode, options, player => this.player = player)
216 this.player.on('customError', (event: any, data: any) => this.handleError(data.err, serverTranslations)) 216 this.player.on('customError', (event: any, data: any) => this.handleError(data.err, serverTranslations))
217 217
218 window[ 'videojsPlayer' ] = this.player 218 window[ 'videojsPlayer' ] = this.player
diff --git a/server/controllers/api/users/my-video-playlists.ts b/server/controllers/api/users/my-video-playlists.ts
index 15e92f4f3..735a3cbee 100644
--- a/server/controllers/api/users/my-video-playlists.ts
+++ b/server/controllers/api/users/my-video-playlists.ts
@@ -35,6 +35,7 @@ async function doVideosInPlaylistExist (req: express.Request, res: express.Respo
35 for (const result of results) { 35 for (const result of results) {
36 for (const element of result.VideoPlaylistElements) { 36 for (const element of result.VideoPlaylistElements) {
37 existObject[element.videoId].push({ 37 existObject[element.videoId].push({
38 playlistElementId: element.id,
38 playlistId: result.id, 39 playlistId: result.id,
39 startTimestamp: element.startTimestamp, 40 startTimestamp: element.startTimestamp,
40 stopTimestamp: element.stopTimestamp 41 stopTimestamp: element.stopTimestamp
diff --git a/server/controllers/api/video-playlist.ts b/server/controllers/api/video-playlist.ts
index 62490e63b..540120cca 100644
--- a/server/controllers/api/video-playlist.ts
+++ b/server/controllers/api/video-playlist.ts
@@ -4,14 +4,13 @@ import {
4 asyncMiddleware, 4 asyncMiddleware,
5 asyncRetryTransactionMiddleware, 5 asyncRetryTransactionMiddleware,
6 authenticate, 6 authenticate,
7 commonVideosFiltersValidator,
8 optionalAuthenticate, 7 optionalAuthenticate,
9 paginationValidator, 8 paginationValidator,
10 setDefaultPagination, 9 setDefaultPagination,
11 setDefaultSort 10 setDefaultSort
12} from '../../middlewares' 11} from '../../middlewares'
13import { videoPlaylistsSortValidator } from '../../middlewares/validators' 12import { videoPlaylistsSortValidator } from '../../middlewares/validators'
14import { buildNSFWFilter, createReqFiles, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils' 13import { buildNSFWFilter, createReqFiles } from '../../helpers/express-utils'
15import { MIMETYPES, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers/constants' 14import { MIMETYPES, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers/constants'
16import { logger } from '../../helpers/logger' 15import { logger } from '../../helpers/logger'
17import { resetSequelizeInstance } from '../../helpers/database-utils' 16import { resetSequelizeInstance } from '../../helpers/database-utils'
@@ -32,7 +31,6 @@ import { join } from 'path'
32import { sendCreateVideoPlaylist, sendDeleteVideoPlaylist, sendUpdateVideoPlaylist } from '../../lib/activitypub/send' 31import { sendCreateVideoPlaylist, sendDeleteVideoPlaylist, sendUpdateVideoPlaylist } from '../../lib/activitypub/send'
33import { getVideoPlaylistActivityPubUrl, getVideoPlaylistElementActivityPubUrl } from '../../lib/activitypub/url' 32import { getVideoPlaylistActivityPubUrl, getVideoPlaylistElementActivityPubUrl } from '../../lib/activitypub/url'
34import { VideoPlaylistUpdate } from '../../../shared/models/videos/playlist/video-playlist-update.model' 33import { VideoPlaylistUpdate } from '../../../shared/models/videos/playlist/video-playlist-update.model'
35import { VideoModel } from '../../models/video/video'
36import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element' 34import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element'
37import { VideoPlaylistElementCreate } from '../../../shared/models/videos/playlist/video-playlist-element-create.model' 35import { VideoPlaylistElementCreate } from '../../../shared/models/videos/playlist/video-playlist-element-create.model'
38import { VideoPlaylistElementUpdate } from '../../../shared/models/videos/playlist/video-playlist-element-update.model' 36import { VideoPlaylistElementUpdate } from '../../../shared/models/videos/playlist/video-playlist-element-update.model'
@@ -88,7 +86,6 @@ videoPlaylistRouter.get('/:playlistId/videos',
88 paginationValidator, 86 paginationValidator,
89 setDefaultPagination, 87 setDefaultPagination,
90 optionalAuthenticate, 88 optionalAuthenticate,
91 commonVideosFiltersValidator,
92 asyncMiddleware(getVideoPlaylistVideos) 89 asyncMiddleware(getVideoPlaylistVideos)
93) 90)
94 91
@@ -104,13 +101,13 @@ videoPlaylistRouter.post('/:playlistId/videos/reorder',
104 asyncRetryTransactionMiddleware(reorderVideosPlaylist) 101 asyncRetryTransactionMiddleware(reorderVideosPlaylist)
105) 102)
106 103
107videoPlaylistRouter.put('/:playlistId/videos/:videoId', 104videoPlaylistRouter.put('/:playlistId/videos/:playlistElementId',
108 authenticate, 105 authenticate,
109 asyncMiddleware(videoPlaylistsUpdateOrRemoveVideoValidator), 106 asyncMiddleware(videoPlaylistsUpdateOrRemoveVideoValidator),
110 asyncRetryTransactionMiddleware(updateVideoPlaylistElement) 107 asyncRetryTransactionMiddleware(updateVideoPlaylistElement)
111) 108)
112 109
113videoPlaylistRouter.delete('/:playlistId/videos/:videoId', 110videoPlaylistRouter.delete('/:playlistId/videos/:playlistElementId',
114 authenticate, 111 authenticate,
115 asyncMiddleware(videoPlaylistsUpdateOrRemoveVideoValidator), 112 asyncMiddleware(videoPlaylistsUpdateOrRemoveVideoValidator),
116 asyncRetryTransactionMiddleware(removeVideoFromPlaylist) 113 asyncRetryTransactionMiddleware(removeVideoFromPlaylist)
@@ -426,26 +423,20 @@ async function reorderVideosPlaylist (req: express.Request, res: express.Respons
426 423
427async function getVideoPlaylistVideos (req: express.Request, res: express.Response) { 424async function getVideoPlaylistVideos (req: express.Request, res: express.Response) {
428 const videoPlaylistInstance = res.locals.videoPlaylist 425 const videoPlaylistInstance = res.locals.videoPlaylist
429 const followerActorId = isUserAbleToSearchRemoteURI(res) ? null : undefined 426 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
427 const server = await getServerActor()
430 428
431 const resultList = await VideoModel.listForApi({ 429 const resultList = await VideoPlaylistElementModel.listForApi({
432 followerActorId,
433 start: req.query.start, 430 start: req.query.start,
434 count: req.query.count, 431 count: req.query.count,
435 sort: 'VideoPlaylistElements.position',
436 includeLocalVideos: true,
437 categoryOneOf: req.query.categoryOneOf,
438 licenceOneOf: req.query.licenceOneOf,
439 languageOneOf: req.query.languageOneOf,
440 tagsOneOf: req.query.tagsOneOf,
441 tagsAllOf: req.query.tagsAllOf,
442 filter: req.query.filter,
443 nsfw: buildNSFWFilter(res, req.query.nsfw),
444 withFiles: false,
445 videoPlaylistId: videoPlaylistInstance.id, 432 videoPlaylistId: videoPlaylistInstance.id,
446 user: res.locals.oauth ? res.locals.oauth.token.User : undefined 433 serverAccount: server.Account,
434 user
447 }) 435 })
448 436
449 const additionalAttributes = { playlistInfo: true } 437 const options = {
450 return res.json(getFormattedObjects(resultList.data, resultList.total, { additionalAttributes })) 438 displayNSFW: buildNSFWFilter(res, req.query.nsfw),
439 accountId: user ? user.Account.id : undefined
440 }
441 return res.json(getFormattedObjects(resultList.data, resultList.total, options))
451} 442}
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 5fe7d416c..8ab7c6bbd 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -14,7 +14,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
14 14
15// --------------------------------------------------------------------------- 15// ---------------------------------------------------------------------------
16 16
17const LAST_MIGRATION_VERSION = 405 17const LAST_MIGRATION_VERSION = 410
18 18
19// --------------------------------------------------------------------------- 19// ---------------------------------------------------------------------------
20 20
diff --git a/server/initializers/migrations/0410-video-playlist-element.ts b/server/initializers/migrations/0410-video-playlist-element.ts
new file mode 100644
index 000000000..f536632a2
--- /dev/null
+++ b/server/initializers/migrations/0410-video-playlist-element.ts
@@ -0,0 +1,39 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction,
5 queryInterface: Sequelize.QueryInterface,
6 sequelize: Sequelize.Sequelize,
7 db: any
8}): Promise<void> {
9 {
10 const data = {
11 type: Sequelize.INTEGER,
12 allowNull: true,
13 defaultValue: null
14 }
15
16 await utils.queryInterface.changeColumn('videoPlaylistElement', 'videoId', data)
17 }
18
19 await utils.queryInterface.removeConstraint('videoPlaylistElement', 'videoPlaylistElement_videoId_fkey')
20
21 await utils.queryInterface.addConstraint('videoPlaylistElement', [ 'videoId' ], {
22 type: 'foreign key',
23 references: {
24 table: 'video',
25 field: 'id'
26 },
27 onDelete: 'set null',
28 onUpdate: 'CASCADE'
29 })
30}
31
32function down (options) {
33 throw new Error('Not implemented.')
34}
35
36export {
37 up,
38 down
39}
diff --git a/server/middlewares/validators/videos/video-playlists.ts b/server/middlewares/validators/videos/video-playlists.ts
index 2e9c8aa33..5823795be 100644
--- a/server/middlewares/validators/videos/video-playlists.ts
+++ b/server/middlewares/validators/videos/video-playlists.ts
@@ -207,8 +207,8 @@ const videoPlaylistsAddVideoValidator = [
207const videoPlaylistsUpdateOrRemoveVideoValidator = [ 207const videoPlaylistsUpdateOrRemoveVideoValidator = [
208 param('playlistId') 208 param('playlistId')
209 .custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'), 209 .custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'),
210 param('videoId') 210 param('playlistElementId')
211 .custom(isIdOrUUIDValid).withMessage('Should have an video id/uuid'), 211 .custom(isIdValid).withMessage('Should have an element id/uuid'),
212 body('startTimestamp') 212 body('startTimestamp')
213 .optional() 213 .optional()
214 .custom(isVideoPlaylistTimestampValid).withMessage('Should have a valid start timestamp'), 214 .custom(isVideoPlaylistTimestampValid).withMessage('Should have a valid start timestamp'),
@@ -222,12 +222,10 @@ const videoPlaylistsUpdateOrRemoveVideoValidator = [
222 if (areValidationErrors(req, res)) return 222 if (areValidationErrors(req, res)) return
223 223
224 if (!await doesVideoPlaylistExist(req.params.playlistId, res, 'all')) return 224 if (!await doesVideoPlaylistExist(req.params.playlistId, res, 'all')) return
225 if (!await doesVideoExist(req.params.videoId, res, 'id')) return
226 225
227 const videoPlaylist = res.locals.videoPlaylist 226 const videoPlaylist = res.locals.videoPlaylist
228 const video = res.locals.video
229 227
230 const videoPlaylistElement = await VideoPlaylistElementModel.loadByPlaylistAndVideo(videoPlaylist.id, video.id) 228 const videoPlaylistElement = await VideoPlaylistElementModel.loadById(req.params.playlistElementId)
231 if (!videoPlaylistElement) { 229 if (!videoPlaylistElement) {
232 res.status(404) 230 res.status(404)
233 .json({ error: 'Video playlist element not found' }) 231 .json({ error: 'Video playlist element not found' })
diff --git a/server/models/account/account-video-rate.ts b/server/models/account/account-video-rate.ts
index 59f586b54..85af9e378 100644
--- a/server/models/account/account-video-rate.ts
+++ b/server/models/account/account-video-rate.ts
@@ -9,7 +9,7 @@ import { ActorModel } from '../activitypub/actor'
9import { getSort, throwIfNotValid } from '../utils' 9import { getSort, throwIfNotValid } from '../utils'
10import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 10import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
11import { AccountVideoRate } from '../../../shared' 11import { AccountVideoRate } from '../../../shared'
12import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from '../video/video-channel' 12import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from '../video/video-channel'
13 13
14/* 14/*
15 Account rates per video. 15 Account rates per video.
@@ -109,7 +109,7 @@ export class AccountVideoRateModel extends Model<AccountVideoRateModel> {
109 required: true, 109 required: true,
110 include: [ 110 include: [
111 { 111 {
112 model: VideoChannelModel.scope({ method: [VideoChannelScopeNames.SUMMARY, true] }), 112 model: VideoChannelModel.scope({ method: [VideoChannelScopeNames.SUMMARY, { withAccount: true } as SummaryOptions ] }),
113 required: true 113 required: true
114 } 114 }
115 ] 115 ]
diff --git a/server/models/account/account.ts b/server/models/account/account.ts
index 09cada096..28014946f 100644
--- a/server/models/account/account.ts
+++ b/server/models/account/account.ts
@@ -27,12 +27,19 @@ import { UserModel } from './user'
27import { AvatarModel } from '../avatar/avatar' 27import { AvatarModel } from '../avatar/avatar'
28import { VideoPlaylistModel } from '../video/video-playlist' 28import { VideoPlaylistModel } from '../video/video-playlist'
29import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants' 29import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
30import { Op, Transaction, WhereOptions } from 'sequelize' 30import { FindOptions, IncludeOptions, Op, Transaction, WhereOptions } from 'sequelize'
31import { AccountBlocklistModel } from './account-blocklist'
32import { ServerBlocklistModel } from '../server/server-blocklist'
31 33
32export enum ScopeNames { 34export enum ScopeNames {
33 SUMMARY = 'SUMMARY' 35 SUMMARY = 'SUMMARY'
34} 36}
35 37
38export type SummaryOptions = {
39 whereActor?: WhereOptions
40 withAccountBlockerIds?: number[]
41}
42
36@DefaultScope(() => ({ 43@DefaultScope(() => ({
37 include: [ 44 include: [
38 { 45 {
@@ -42,8 +49,16 @@ export enum ScopeNames {
42 ] 49 ]
43})) 50}))
44@Scopes(() => ({ 51@Scopes(() => ({
45 [ ScopeNames.SUMMARY ]: (whereActor?: WhereOptions) => { 52 [ ScopeNames.SUMMARY ]: (options: SummaryOptions = {}) => {
46 return { 53 const whereActor = options.whereActor || undefined
54
55 const serverInclude: IncludeOptions = {
56 attributes: [ 'host' ],
57 model: ServerModel.unscoped(),
58 required: false
59 }
60
61 const query: FindOptions = {
47 attributes: [ 'id', 'name' ], 62 attributes: [ 'id', 'name' ],
48 include: [ 63 include: [
49 { 64 {
@@ -52,11 +67,8 @@ export enum ScopeNames {
52 required: true, 67 required: true,
53 where: whereActor, 68 where: whereActor,
54 include: [ 69 include: [
55 { 70 serverInclude,
56 attributes: [ 'host' ], 71
57 model: ServerModel.unscoped(),
58 required: false
59 },
60 { 72 {
61 model: AvatarModel.unscoped(), 73 model: AvatarModel.unscoped(),
62 required: false 74 required: false
@@ -65,6 +77,35 @@ export enum ScopeNames {
65 } 77 }
66 ] 78 ]
67 } 79 }
80
81 if (options.withAccountBlockerIds) {
82 query.include.push({
83 attributes: [ 'id' ],
84 model: AccountBlocklistModel.unscoped(),
85 as: 'BlockedAccounts',
86 required: false,
87 where: {
88 accountId: {
89 [Op.in]: options.withAccountBlockerIds
90 }
91 }
92 })
93
94 serverInclude.include = [
95 {
96 attributes: [ 'id' ],
97 model: ServerBlocklistModel.unscoped(),
98 required: false,
99 where: {
100 accountId: {
101 [Op.in]: options.withAccountBlockerIds
102 }
103 }
104 }
105 ]
106 }
107
108 return query
68 } 109 }
69})) 110}))
70@Table({ 111@Table({
@@ -163,6 +204,16 @@ export class AccountModel extends Model<AccountModel> {
163 }) 204 })
164 VideoComments: VideoCommentModel[] 205 VideoComments: VideoCommentModel[]
165 206
207 @HasMany(() => AccountBlocklistModel, {
208 foreignKey: {
209 name: 'targetAccountId',
210 allowNull: false
211 },
212 as: 'BlockedAccounts',
213 onDelete: 'CASCADE'
214 })
215 BlockedAccounts: AccountBlocklistModel[]
216
166 @BeforeDestroy 217 @BeforeDestroy
167 static async sendDeleteIfOwned (instance: AccountModel, options) { 218 static async sendDeleteIfOwned (instance: AccountModel, options) {
168 if (!instance.Actor) { 219 if (!instance.Actor) {
@@ -343,4 +394,8 @@ export class AccountModel extends Model<AccountModel> {
343 getDisplayName () { 394 getDisplayName () {
344 return this.name 395 return this.name
345 } 396 }
397
398 isBlocked () {
399 return this.BlockedAccounts && this.BlockedAccounts.length !== 0
400 }
346} 401}
diff --git a/server/models/server/server-blocklist.ts b/server/models/server/server-blocklist.ts
index 92c01f642..5138b0f76 100644
--- a/server/models/server/server-blocklist.ts
+++ b/server/models/server/server-blocklist.ts
@@ -67,7 +67,6 @@ export class ServerBlocklistModel extends Model<ServerBlocklistModel> {
67 67
68 @BelongsTo(() => ServerModel, { 68 @BelongsTo(() => ServerModel, {
69 foreignKey: { 69 foreignKey: {
70 name: 'targetServerId',
71 allowNull: false 70 allowNull: false
72 }, 71 },
73 onDelete: 'CASCADE' 72 onDelete: 'CASCADE'
diff --git a/server/models/server/server.ts b/server/models/server/server.ts
index 300d70938..1d211f1e0 100644
--- a/server/models/server/server.ts
+++ b/server/models/server/server.ts
@@ -2,6 +2,8 @@ import { AllowNull, Column, CreatedAt, Default, HasMany, Is, Model, Table, Updat
2import { isHostValid } from '../../helpers/custom-validators/servers' 2import { isHostValid } from '../../helpers/custom-validators/servers'
3import { ActorModel } from '../activitypub/actor' 3import { ActorModel } from '../activitypub/actor'
4import { throwIfNotValid } from '../utils' 4import { throwIfNotValid } from '../utils'
5import { AccountBlocklistModel } from '../account/account-blocklist'
6import { ServerBlocklistModel } from './server-blocklist'
5 7
6@Table({ 8@Table({
7 tableName: 'server', 9 tableName: 'server',
@@ -40,6 +42,14 @@ export class ServerModel extends Model<ServerModel> {
40 }) 42 })
41 Actors: ActorModel[] 43 Actors: ActorModel[]
42 44
45 @HasMany(() => ServerBlocklistModel, {
46 foreignKey: {
47 allowNull: false
48 },
49 onDelete: 'CASCADE'
50 })
51 BlockedByAccounts: ServerBlocklistModel[]
52
43 static loadByHost (host: string) { 53 static loadByHost (host: string) {
44 const query = { 54 const query = {
45 where: { 55 where: {
@@ -50,6 +60,10 @@ export class ServerModel extends Model<ServerModel> {
50 return ServerModel.findOne(query) 60 return ServerModel.findOne(query)
51 } 61 }
52 62
63 isBlocked () {
64 return this.BlockedByAccounts && this.BlockedByAccounts.length !== 0
65 }
66
53 toFormattedJSON () { 67 toFormattedJSON () {
54 return { 68 return {
55 host: this.host 69 host: this.host
diff --git a/server/models/video/video-blacklist.ts b/server/models/video/video-blacklist.ts
index baef1d6ce..22d949da0 100644
--- a/server/models/video/video-blacklist.ts
+++ b/server/models/video/video-blacklist.ts
@@ -1,7 +1,7 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' 1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { getSortOnModel, SortType, throwIfNotValid } from '../utils' 2import { getSortOnModel, SortType, throwIfNotValid } from '../utils'
3import { ScopeNames as VideoModelScopeNames, VideoModel } from './video' 3import { ScopeNames as VideoModelScopeNames, VideoModel } from './video'
4import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel' 4import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
5import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../helpers/custom-validators/video-blacklist' 5import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../helpers/custom-validators/video-blacklist'
6import { VideoBlacklist, VideoBlacklistType } from '../../../shared/models/videos' 6import { VideoBlacklist, VideoBlacklistType } from '../../../shared/models/videos'
7import { CONSTRAINTS_FIELDS } from '../../initializers/constants' 7import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
@@ -71,7 +71,7 @@ export class VideoBlacklistModel extends Model<VideoBlacklistModel> {
71 required: true, 71 required: true,
72 include: [ 72 include: [
73 { 73 {
74 model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, true ] }), 74 model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, { withAccount: true } as SummaryOptions ] }),
75 required: true 75 required: true
76 }, 76 },
77 { 77 {
diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts
index b0b261c88..6241a75a3 100644
--- a/server/models/video/video-channel.ts
+++ b/server/models/video/video-channel.ts
@@ -24,7 +24,7 @@ import {
24 isVideoChannelSupportValid 24 isVideoChannelSupportValid
25} from '../../helpers/custom-validators/video-channels' 25} from '../../helpers/custom-validators/video-channels'
26import { sendDeleteActor } from '../../lib/activitypub/send' 26import { sendDeleteActor } from '../../lib/activitypub/send'
27import { AccountModel, ScopeNames as AccountModelScopeNames } from '../account/account' 27import { AccountModel, ScopeNames as AccountModelScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account'
28import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor' 28import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor'
29import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils' 29import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
30import { VideoModel } from './video' 30import { VideoModel } from './video'
@@ -58,6 +58,11 @@ type AvailableForListOptions = {
58 actorId: number 58 actorId: number
59} 59}
60 60
61export type SummaryOptions = {
62 withAccount?: boolean // Default: false
63 withAccountBlockerIds?: number[]
64}
65
61@DefaultScope(() => ({ 66@DefaultScope(() => ({
62 include: [ 67 include: [
63 { 68 {
@@ -67,7 +72,7 @@ type AvailableForListOptions = {
67 ] 72 ]
68})) 73}))
69@Scopes(() => ({ 74@Scopes(() => ({
70 [ScopeNames.SUMMARY]: (withAccount = false) => { 75 [ScopeNames.SUMMARY]: (options: SummaryOptions = {}) => {
71 const base: FindOptions = { 76 const base: FindOptions = {
72 attributes: [ 'name', 'description', 'id', 'actorId' ], 77 attributes: [ 'name', 'description', 'id', 'actorId' ],
73 include: [ 78 include: [
@@ -90,9 +95,11 @@ type AvailableForListOptions = {
90 ] 95 ]
91 } 96 }
92 97
93 if (withAccount === true) { 98 if (options.withAccount === true) {
94 base.include.push({ 99 base.include.push({
95 model: AccountModel.scope(AccountModelScopeNames.SUMMARY), 100 model: AccountModel.scope({
101 method: [ AccountModelScopeNames.SUMMARY, { withAccountBlockerIds: options.withAccountBlockerIds } as AccountSummaryOptions ]
102 }),
96 required: true 103 required: true
97 }) 104 })
98 } 105 }
diff --git a/server/models/video/video-format-utils.ts b/server/models/video/video-format-utils.ts
index b947eb16f..284539def 100644
--- a/server/models/video/video-format-utils.ts
+++ b/server/models/video/video-format-utils.ts
@@ -26,7 +26,6 @@ export type VideoFormattingJSONOptions = {
26 waitTranscoding?: boolean, 26 waitTranscoding?: boolean,
27 scheduledUpdate?: boolean, 27 scheduledUpdate?: boolean,
28 blacklistInfo?: boolean 28 blacklistInfo?: boolean
29 playlistInfo?: boolean
30 } 29 }
31} 30}
32function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormattingJSONOptions): Video { 31function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormattingJSONOptions): Video {
@@ -98,17 +97,6 @@ function videoModelToFormattedJSON (video: VideoModel, options?: VideoFormatting
98 videoObject.blacklisted = !!video.VideoBlacklist 97 videoObject.blacklisted = !!video.VideoBlacklist
99 videoObject.blacklistedReason = video.VideoBlacklist ? video.VideoBlacklist.reason : null 98 videoObject.blacklistedReason = video.VideoBlacklist ? video.VideoBlacklist.reason : null
100 } 99 }
101
102 if (options.additionalAttributes.playlistInfo === true) {
103 // We filtered on a specific videoId/videoPlaylistId, that is unique
104 const playlistElement = video.VideoPlaylistElements[0]
105
106 videoObject.playlistElement = {
107 position: playlistElement.position,
108 startTimestamp: playlistElement.startTimestamp,
109 stopTimestamp: playlistElement.stopTimestamp
110 }
111 }
112 } 100 }
113 101
114 return videoObject 102 return videoObject
diff --git a/server/models/video/video-playlist-element.ts b/server/models/video/video-playlist-element.ts
index eeb3d6bbd..bed6f8eaf 100644
--- a/server/models/video/video-playlist-element.ts
+++ b/server/models/video/video-playlist-element.ts
@@ -13,14 +13,18 @@ import {
13 Table, 13 Table,
14 UpdatedAt 14 UpdatedAt
15} from 'sequelize-typescript' 15} from 'sequelize-typescript'
16import { VideoModel } from './video' 16import { ForAPIOptions, ScopeNames as VideoScopeNames, VideoModel } from './video'
17import { VideoPlaylistModel } from './video-playlist' 17import { VideoPlaylistModel } from './video-playlist'
18import { getSort, throwIfNotValid } from '../utils' 18import { getSort, throwIfNotValid } from '../utils'
19import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 19import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
20import { CONSTRAINTS_FIELDS } from '../../initializers/constants' 20import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
21import { PlaylistElementObject } from '../../../shared/models/activitypub/objects/playlist-element-object' 21import { PlaylistElementObject } from '../../../shared/models/activitypub/objects/playlist-element-object'
22import * as validator from 'validator' 22import * as validator from 'validator'
23import { AggregateOptions, Op, Sequelize, Transaction } from 'sequelize' 23import { AggregateOptions, Op, ScopeOptions, Sequelize, Transaction } from 'sequelize'
24import { UserModel } from '../account/user'
25import { VideoPlaylistElement, VideoPlaylistElementType } from '../../../shared/models/videos/playlist/video-playlist-element.model'
26import { AccountModel } from '../account/account'
27import { VideoPrivacy } from '../../../shared/models/videos'
24 28
25@Table({ 29@Table({
26 tableName: 'videoPlaylistElement', 30 tableName: 'videoPlaylistElement',
@@ -90,9 +94,9 @@ export class VideoPlaylistElementModel extends Model<VideoPlaylistElementModel>
90 94
91 @BelongsTo(() => VideoModel, { 95 @BelongsTo(() => VideoModel, {
92 foreignKey: { 96 foreignKey: {
93 allowNull: false 97 allowNull: true
94 }, 98 },
95 onDelete: 'CASCADE' 99 onDelete: 'set null'
96 }) 100 })
97 Video: VideoModel 101 Video: VideoModel
98 102
@@ -107,6 +111,57 @@ export class VideoPlaylistElementModel extends Model<VideoPlaylistElementModel>
107 return VideoPlaylistElementModel.destroy(query) 111 return VideoPlaylistElementModel.destroy(query)
108 } 112 }
109 113
114 static listForApi (options: {
115 start: number,
116 count: number,
117 videoPlaylistId: number,
118 serverAccount: AccountModel,
119 user?: UserModel
120 }) {
121 const accountIds = [ options.serverAccount.id ]
122 const videoScope: (ScopeOptions | string)[] = [
123 VideoScopeNames.WITH_BLACKLISTED
124 ]
125
126 if (options.user) {
127 accountIds.push(options.user.Account.id)
128 videoScope.push({ method: [ VideoScopeNames.WITH_USER_HISTORY, options.user.id ] })
129 }
130
131 const forApiOptions: ForAPIOptions = { withAccountBlockerIds: accountIds }
132 videoScope.push({
133 method: [
134 VideoScopeNames.FOR_API, forApiOptions
135 ]
136 })
137
138 const findQuery = {
139 offset: options.start,
140 limit: options.count,
141 order: getSort('position'),
142 where: {
143 videoPlaylistId: options.videoPlaylistId
144 },
145 include: [
146 {
147 model: VideoModel.scope(videoScope),
148 required: false
149 }
150 ]
151 }
152
153 const countQuery = {
154 where: {
155 videoPlaylistId: options.videoPlaylistId
156 }
157 }
158
159 return Promise.all([
160 VideoPlaylistElementModel.count(countQuery),
161 VideoPlaylistElementModel.findAll(findQuery)
162 ]).then(([ total, data ]) => ({ total, data }))
163 }
164
110 static loadByPlaylistAndVideo (videoPlaylistId: number, videoId: number) { 165 static loadByPlaylistAndVideo (videoPlaylistId: number, videoId: number) {
111 const query = { 166 const query = {
112 where: { 167 where: {
@@ -118,6 +173,10 @@ export class VideoPlaylistElementModel extends Model<VideoPlaylistElementModel>
118 return VideoPlaylistElementModel.findOne(query) 173 return VideoPlaylistElementModel.findOne(query)
119 } 174 }
120 175
176 static loadById (playlistElementId: number) {
177 return VideoPlaylistElementModel.findByPk(playlistElementId)
178 }
179
121 static loadByPlaylistAndVideoForAP (playlistId: number | string, videoId: number | string) { 180 static loadByPlaylistAndVideoForAP (playlistId: number | string, videoId: number | string) {
122 const playlistWhere = validator.isUUID('' + playlistId) ? { uuid: playlistId } : { id: playlistId } 181 const playlistWhere = validator.isUUID('' + playlistId) ? { uuid: playlistId } : { id: playlistId }
123 const videoWhere = validator.isUUID('' + videoId) ? { uuid: videoId } : { id: videoId } 182 const videoWhere = validator.isUUID('' + videoId) ? { uuid: videoId } : { id: videoId }
@@ -213,6 +272,42 @@ export class VideoPlaylistElementModel extends Model<VideoPlaylistElementModel>
213 return VideoPlaylistElementModel.increment({ position: by }, query) 272 return VideoPlaylistElementModel.increment({ position: by }, query)
214 } 273 }
215 274
275 getType (displayNSFW?: boolean, accountId?: number) {
276 const video = this.Video
277
278 if (!video) return VideoPlaylistElementType.DELETED
279
280 // Owned video, don't filter it
281 if (accountId && video.VideoChannel.Account.id === accountId) return VideoPlaylistElementType.REGULAR
282
283 if (video.privacy === VideoPrivacy.PRIVATE) return VideoPlaylistElementType.PRIVATE
284
285 if (video.isBlacklisted() || video.isBlocked()) return VideoPlaylistElementType.UNAVAILABLE
286 if (video.nsfw === true && displayNSFW === false) return VideoPlaylistElementType.UNAVAILABLE
287
288 return VideoPlaylistElementType.REGULAR
289 }
290
291 getVideoElement (displayNSFW?: boolean, accountId?: number) {
292 if (!this.Video) return null
293 if (this.getType(displayNSFW, accountId) !== VideoPlaylistElementType.REGULAR) return null
294
295 return this.Video.toFormattedJSON()
296 }
297
298 toFormattedJSON (options: { displayNSFW?: boolean, accountId?: number } = {}): VideoPlaylistElement {
299 return {
300 id: this.id,
301 position: this.position,
302 startTimestamp: this.startTimestamp,
303 stopTimestamp: this.stopTimestamp,
304
305 type: this.getType(options.displayNSFW, options.accountId),
306
307 video: this.getVideoElement(options.displayNSFW, options.accountId)
308 }
309 }
310
216 toActivityPubObject (): PlaylistElementObject { 311 toActivityPubObject (): PlaylistElementObject {
217 const base: PlaylistElementObject = { 312 const base: PlaylistElementObject = {
218 id: this.url, 313 id: this.url,
diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts
index 63b4a0715..61ff78bd2 100644
--- a/server/models/video/video-playlist.ts
+++ b/server/models/video/video-playlist.ts
@@ -33,7 +33,7 @@ import {
33 WEBSERVER 33 WEBSERVER
34} from '../../initializers/constants' 34} from '../../initializers/constants'
35import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model' 35import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model'
36import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account' 36import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
37import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel' 37import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
38import { join } from 'path' 38import { join } from 'path'
39import { VideoPlaylistElementModel } from './video-playlist-element' 39import { VideoPlaylistElementModel } from './video-playlist-element'
@@ -115,7 +115,7 @@ type AvailableForListOptions = {
115 [ ScopeNames.AVAILABLE_FOR_LIST ]: (options: AvailableForListOptions) => { 115 [ ScopeNames.AVAILABLE_FOR_LIST ]: (options: AvailableForListOptions) => {
116 // Only list local playlists OR playlists that are on an instance followed by actorId 116 // Only list local playlists OR playlists that are on an instance followed by actorId
117 const inQueryInstanceFollow = buildServerIdsFollowedBy(options.followerActorId) 117 const inQueryInstanceFollow = buildServerIdsFollowedBy(options.followerActorId)
118 const actorWhere = { 118 const whereActor = {
119 [ Op.or ]: [ 119 [ Op.or ]: [
120 { 120 {
121 serverId: null 121 serverId: null
@@ -159,7 +159,7 @@ type AvailableForListOptions = {
159 } 159 }
160 160
161 const accountScope = { 161 const accountScope = {
162 method: [ AccountScopeNames.SUMMARY, actorWhere ] 162 method: [ AccountScopeNames.SUMMARY, { whereActor } as SummaryOptions ]
163 } 163 }
164 164
165 return { 165 return {
@@ -341,7 +341,7 @@ export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
341 }, 341 },
342 include: [ 342 include: [
343 { 343 {
344 attributes: [ 'videoId', 'startTimestamp', 'stopTimestamp' ], 344 attributes: [ 'id', 'videoId', 'startTimestamp', 'stopTimestamp' ],
345 model: VideoPlaylistElementModel.unscoped(), 345 model: VideoPlaylistElementModel.unscoped(),
346 where: { 346 where: {
347 videoId: { 347 videoId: {
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index c7f2658ed..05d625fc1 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -91,7 +91,7 @@ import {
91} from '../utils' 91} from '../utils'
92import { TagModel } from './tag' 92import { TagModel } from './tag'
93import { VideoAbuseModel } from './video-abuse' 93import { VideoAbuseModel } from './video-abuse'
94import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel' 94import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
95import { VideoCommentModel } from './video-comment' 95import { VideoCommentModel } from './video-comment'
96import { VideoFileModel } from './video-file' 96import { VideoFileModel } from './video-file'
97import { VideoShareModel } from './video-share' 97import { VideoShareModel } from './video-share'
@@ -190,26 +190,29 @@ export enum ScopeNames {
190 WITH_FILES = 'WITH_FILES', 190 WITH_FILES = 'WITH_FILES',
191 WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE', 191 WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE',
192 WITH_BLACKLISTED = 'WITH_BLACKLISTED', 192 WITH_BLACKLISTED = 'WITH_BLACKLISTED',
193 WITH_BLOCKLIST = 'WITH_BLOCKLIST',
193 WITH_USER_HISTORY = 'WITH_USER_HISTORY', 194 WITH_USER_HISTORY = 'WITH_USER_HISTORY',
194 WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS', 195 WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS',
195 WITH_USER_ID = 'WITH_USER_ID', 196 WITH_USER_ID = 'WITH_USER_ID',
196 WITH_THUMBNAILS = 'WITH_THUMBNAILS' 197 WITH_THUMBNAILS = 'WITH_THUMBNAILS'
197} 198}
198 199
199type ForAPIOptions = { 200export type ForAPIOptions = {
200 ids: number[] 201 ids?: number[]
201 202
202 videoPlaylistId?: number 203 videoPlaylistId?: number
203 204
204 withFiles?: boolean 205 withFiles?: boolean
206
207 withAccountBlockerIds?: number[]
205} 208}
206 209
207type AvailableForListIDsOptions = { 210export type AvailableForListIDsOptions = {
208 serverAccountId: number 211 serverAccountId: number
209 followerActorId: number 212 followerActorId: number
210 includeLocalVideos: boolean 213 includeLocalVideos: boolean
211 214
212 withoutId?: boolean 215 attributesType?: 'none' | 'id' | 'all'
213 216
214 filter?: VideoFilter 217 filter?: VideoFilter
215 categoryOneOf?: number[] 218 categoryOneOf?: number[]
@@ -236,14 +239,16 @@ type AvailableForListIDsOptions = {
236@Scopes(() => ({ 239@Scopes(() => ({
237 [ ScopeNames.FOR_API ]: (options: ForAPIOptions) => { 240 [ ScopeNames.FOR_API ]: (options: ForAPIOptions) => {
238 const query: FindOptions = { 241 const query: FindOptions = {
239 where: {
240 id: {
241 [ Op.in ]: options.ids // FIXME: sequelize ANY seems broken
242 }
243 },
244 include: [ 242 include: [
245 { 243 {
246 model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, true ] }), 244 model: VideoChannelModel.scope({
245 method: [
246 VideoChannelScopeNames.SUMMARY, {
247 withAccount: true,
248 withAccountBlockerIds: options.withAccountBlockerIds
249 } as SummaryOptions
250 ]
251 }),
247 required: true 252 required: true
248 }, 253 },
249 { 254 {
@@ -254,6 +259,14 @@ type AvailableForListIDsOptions = {
254 ] 259 ]
255 } 260 }
256 261
262 if (options.ids) {
263 query.where = {
264 id: {
265 [ Op.in ]: options.ids // FIXME: sequelize ANY seems broken
266 }
267 }
268 }
269
257 if (options.withFiles === true) { 270 if (options.withFiles === true) {
258 query.include.push({ 271 query.include.push({
259 model: VideoFileModel.unscoped(), 272 model: VideoFileModel.unscoped(),
@@ -278,10 +291,14 @@ type AvailableForListIDsOptions = {
278 291
279 const query: FindOptions = { 292 const query: FindOptions = {
280 raw: true, 293 raw: true,
281 attributes: options.withoutId === true ? [] : [ 'id' ],
282 include: [] 294 include: []
283 } 295 }
284 296
297 const attributesType = options.attributesType || 'id'
298
299 if (attributesType === 'id') query.attributes = [ 'id' ]
300 else if (attributesType === 'none') query.attributes = [ ]
301
285 whereAnd.push({ 302 whereAnd.push({
286 id: { 303 id: {
287 [ Op.notIn ]: Sequelize.literal( 304 [ Op.notIn ]: Sequelize.literal(
@@ -290,17 +307,19 @@ type AvailableForListIDsOptions = {
290 } 307 }
291 }) 308 })
292 309
293 whereAnd.push({ 310 if (options.serverAccountId) {
294 channelId: { 311 whereAnd.push({
295 [ Op.notIn ]: Sequelize.literal( 312 channelId: {
296 '(' + 313 [ Op.notIn ]: Sequelize.literal(
297 'SELECT id FROM "videoChannel" WHERE "accountId" IN (' + 314 '(' +
298 buildBlockedAccountSQL(options.serverAccountId, options.user ? options.user.Account.id : undefined) + 315 'SELECT id FROM "videoChannel" WHERE "accountId" IN (' +
299 ')' + 316 buildBlockedAccountSQL(options.serverAccountId, options.user ? options.user.Account.id : undefined) +
300 ')' 317 ')' +
301 ) 318 ')'
302 } 319 )
303 }) 320 }
321 })
322 }
304 323
305 // Only list public/published videos 324 // Only list public/published videos
306 if (!options.filter || options.filter !== 'all-local') { 325 if (!options.filter || options.filter !== 'all-local') {
@@ -528,6 +547,9 @@ type AvailableForListIDsOptions = {
528 547
529 return query 548 return query
530 }, 549 },
550 [ScopeNames.WITH_BLOCKLIST]: {
551
552 },
531 [ ScopeNames.WITH_THUMBNAILS ]: { 553 [ ScopeNames.WITH_THUMBNAILS ]: {
532 include: [ 554 include: [
533 { 555 {
@@ -845,9 +867,9 @@ export class VideoModel extends Model<VideoModel> {
845 @HasMany(() => VideoPlaylistElementModel, { 867 @HasMany(() => VideoPlaylistElementModel, {
846 foreignKey: { 868 foreignKey: {
847 name: 'videoId', 869 name: 'videoId',
848 allowNull: false 870 allowNull: true
849 }, 871 },
850 onDelete: 'cascade' 872 onDelete: 'set null'
851 }) 873 })
852 VideoPlaylistElements: VideoPlaylistElementModel[] 874 VideoPlaylistElements: VideoPlaylistElementModel[]
853 875
@@ -1586,7 +1608,7 @@ export class VideoModel extends Model<VideoModel> {
1586 serverAccountId: serverActor.Account.id, 1608 serverAccountId: serverActor.Account.id,
1587 followerActorId, 1609 followerActorId,
1588 includeLocalVideos: true, 1610 includeLocalVideos: true,
1589 withoutId: true // Don't break aggregation 1611 attributesType: 'none' // Don't break aggregation
1590 } 1612 }
1591 1613
1592 const query: FindOptions = { 1614 const query: FindOptions = {
@@ -1719,6 +1741,11 @@ export class VideoModel extends Model<VideoModel> {
1719 return !!this.VideoBlacklist 1741 return !!this.VideoBlacklist
1720 } 1742 }
1721 1743
1744 isBlocked () {
1745 return (this.VideoChannel.Account.Actor.Server && this.VideoChannel.Account.Actor.Server.isBlocked()) ||
1746 this.VideoChannel.Account.isBlocked()
1747 }
1748
1722 getOriginalFile () { 1749 getOriginalFile () {
1723 if (Array.isArray(this.VideoFiles) === false) return undefined 1750 if (Array.isArray(this.VideoFiles) === false) return undefined
1724 1751
diff --git a/server/tests/api/check-params/video-playlists.ts b/server/tests/api/check-params/video-playlists.ts
index 8c5e44bdd..ae5aa287f 100644
--- a/server/tests/api/check-params/video-playlists.ts
+++ b/server/tests/api/check-params/video-playlists.ts
@@ -37,6 +37,7 @@ describe('Test video playlists API validator', function () {
37 let watchLaterPlaylistId: number 37 let watchLaterPlaylistId: number
38 let videoId: number 38 let videoId: number
39 let videoId2: number 39 let videoId2: number
40 let playlistElementId: number
40 41
41 // --------------------------------------------------------------- 42 // ---------------------------------------------------------------
42 43
@@ -132,18 +133,18 @@ describe('Test video playlists API validator', function () {
132 }) 133 })
133 134
134 describe('When listing videos of a playlist', function () { 135 describe('When listing videos of a playlist', function () {
135 const path = '/api/v1/video-playlists' 136 const path = '/api/v1/video-playlists/'
136 137
137 it('Should fail with a bad start pagination', async function () { 138 it('Should fail with a bad start pagination', async function () {
138 await checkBadStartPagination(server.url, path, server.accessToken) 139 await checkBadStartPagination(server.url, path + playlistUUID + '/videos', server.accessToken)
139 }) 140 })
140 141
141 it('Should fail with a bad count pagination', async function () { 142 it('Should fail with a bad count pagination', async function () {
142 await checkBadCountPagination(server.url, path, server.accessToken) 143 await checkBadCountPagination(server.url, path + playlistUUID + '/videos', server.accessToken)
143 }) 144 })
144 145
145 it('Should fail with a bad filter', async function () { 146 it('Should success with the correct parameters', async function () {
146 await checkBadSortPagination(server.url, path, server.accessToken) 147 await makeGetRequest({ url: server.url, path: path + playlistUUID + '/videos', statusCodeExpected: 200 })
147 }) 148 })
148 }) 149 })
149 150
@@ -296,7 +297,7 @@ describe('Test video playlists API validator', function () {
296 token: server.accessToken, 297 token: server.accessToken,
297 playlistId: playlistUUID, 298 playlistId: playlistUUID,
298 elementAttrs: Object.assign({ 299 elementAttrs: Object.assign({
299 videoId: videoId, 300 videoId,
300 startTimestamp: 2, 301 startTimestamp: 2,
301 stopTimestamp: 3 302 stopTimestamp: 3
302 }, elementAttrs) 303 }, elementAttrs)
@@ -344,7 +345,8 @@ describe('Test video playlists API validator', function () {
344 345
345 it('Succeed with the correct params', async function () { 346 it('Succeed with the correct params', async function () {
346 const params = getBase({}, { expectedStatus: 200 }) 347 const params = getBase({}, { expectedStatus: 200 })
347 await addVideoInPlaylist(params) 348 const res = await addVideoInPlaylist(params)
349 playlistElementId = res.body.videoPlaylistElement.id
348 }) 350 })
349 351
350 it('Should fail if the video was already added in the playlist', async function () { 352 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 () {
362 startTimestamp: 1, 364 startTimestamp: 1,
363 stopTimestamp: 2 365 stopTimestamp: 2
364 }, elementAttrs), 366 }, elementAttrs),
365 videoId: videoId, 367 playlistElementId,
366 playlistId: playlistUUID, 368 playlistId: playlistUUID,
367 expectedStatus: 400 369 expectedStatus: 400
368 }, wrapper) 370 }, wrapper)
@@ -390,14 +392,14 @@ describe('Test video playlists API validator', function () {
390 } 392 }
391 }) 393 })
392 394
393 it('Should fail with an unknown or incorrect video id', async function () { 395 it('Should fail with an unknown or incorrect playlistElement id', async function () {
394 { 396 {
395 const params = getBase({}, { videoId: 'toto' }) 397 const params = getBase({}, { playlistElementId: 'toto' })
396 await updateVideoPlaylistElement(params) 398 await updateVideoPlaylistElement(params)
397 } 399 }
398 400
399 { 401 {
400 const params = getBase({}, { videoId: 42, expectedStatus: 404 }) 402 const params = getBase({}, { playlistElementId: 42, expectedStatus: 404 })
401 await updateVideoPlaylistElement(params) 403 await updateVideoPlaylistElement(params)
402 } 404 }
403 }) 405 })
@@ -415,7 +417,7 @@ describe('Test video playlists API validator', function () {
415 }) 417 })
416 418
417 it('Should fail with an unknown element', async function () { 419 it('Should fail with an unknown element', async function () {
418 const params = getBase({}, { videoId: videoId2, expectedStatus: 404 }) 420 const params = getBase({}, { playlistElementId: 888, expectedStatus: 404 })
419 await updateVideoPlaylistElement(params) 421 await updateVideoPlaylistElement(params)
420 }) 422 })
421 423
@@ -587,7 +589,7 @@ describe('Test video playlists API validator', function () {
587 return Object.assign({ 589 return Object.assign({
588 url: server.url, 590 url: server.url,
589 token: server.accessToken, 591 token: server.accessToken,
590 videoId: videoId, 592 playlistElementId,
591 playlistId: playlistUUID, 593 playlistId: playlistUUID,
592 expectedStatus: 400 594 expectedStatus: 400
593 }, wrapper) 595 }, wrapper)
@@ -617,18 +619,18 @@ describe('Test video playlists API validator', function () {
617 619
618 it('Should fail with an unknown or incorrect video id', async function () { 620 it('Should fail with an unknown or incorrect video id', async function () {
619 { 621 {
620 const params = getBase({ videoId: 'toto' }) 622 const params = getBase({ playlistElementId: 'toto' })
621 await removeVideoFromPlaylist(params) 623 await removeVideoFromPlaylist(params)
622 } 624 }
623 625
624 { 626 {
625 const params = getBase({ videoId: 42, expectedStatus: 404 }) 627 const params = getBase({ playlistElementId: 42, expectedStatus: 404 })
626 await removeVideoFromPlaylist(params) 628 await removeVideoFromPlaylist(params)
627 } 629 }
628 }) 630 })
629 631
630 it('Should fail with an unknown element', async function () { 632 it('Should fail with an unknown element', async function () {
631 const params = getBase({ videoId: videoId2, expectedStatus: 404 }) 633 const params = getBase({ playlistElementId: 888, expectedStatus: 404 })
632 await removeVideoFromPlaylist(params) 634 await removeVideoFromPlaylist(params)
633 }) 635 })
634 636
diff --git a/server/tests/api/check-params/videos-filter.ts b/server/tests/api/check-params/videos-filter.ts
index babef8223..5a5668665 100644
--- a/server/tests/api/check-params/videos-filter.ts
+++ b/server/tests/api/check-params/videos-filter.ts
@@ -15,13 +15,12 @@ import {
15import { UserRole } from '../../../../shared/models/users' 15import { UserRole } from '../../../../shared/models/users'
16import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model' 16import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model'
17 17
18async function testEndpoints (server: ServerInfo, token: string, filter: string, playlistUUID: string, statusCodeExpected: number) { 18async function testEndpoints (server: ServerInfo, token: string, filter: string, statusCodeExpected: number) {
19 const paths = [ 19 const paths = [
20 '/api/v1/video-channels/root_channel/videos', 20 '/api/v1/video-channels/root_channel/videos',
21 '/api/v1/accounts/root/videos', 21 '/api/v1/accounts/root/videos',
22 '/api/v1/videos', 22 '/api/v1/videos',
23 '/api/v1/search/videos', 23 '/api/v1/search/videos'
24 '/api/v1/video-playlists/' + playlistUUID + '/videos'
25 ] 24 ]
26 25
27 for (const path of paths) { 26 for (const path of paths) {
@@ -70,39 +69,28 @@ describe('Test videos filters', function () {
70 } 69 }
71 ) 70 )
72 moderatorAccessToken = await userLogin(server, moderator) 71 moderatorAccessToken = await userLogin(server, moderator)
73
74 const res = await createVideoPlaylist({
75 url: server.url,
76 token: server.accessToken,
77 playlistAttrs: {
78 displayName: 'super playlist',
79 privacy: VideoPlaylistPrivacy.PUBLIC,
80 videoChannelId: server.videoChannel.id
81 }
82 })
83 playlistUUID = res.body.videoPlaylist.uuid
84 }) 72 })
85 73
86 describe('When setting a video filter', function () { 74 describe('When setting a video filter', function () {
87 75
88 it('Should fail with a bad filter', async function () { 76 it('Should fail with a bad filter', async function () {
89 await testEndpoints(server, server.accessToken, 'bad-filter', playlistUUID, 400) 77 await testEndpoints(server, server.accessToken, 'bad-filter', 400)
90 }) 78 })
91 79
92 it('Should succeed with a good filter', async function () { 80 it('Should succeed with a good filter', async function () {
93 await testEndpoints(server, server.accessToken,'local', playlistUUID, 200) 81 await testEndpoints(server, server.accessToken,'local', 200)
94 }) 82 })
95 83
96 it('Should fail to list all-local with a simple user', async function () { 84 it('Should fail to list all-local with a simple user', async function () {
97 await testEndpoints(server, userAccessToken, 'all-local', playlistUUID, 401) 85 await testEndpoints(server, userAccessToken, 'all-local', 401)
98 }) 86 })
99 87
100 it('Should succeed to list all-local with a moderator', async function () { 88 it('Should succeed to list all-local with a moderator', async function () {
101 await testEndpoints(server, moderatorAccessToken, 'all-local', playlistUUID, 200) 89 await testEndpoints(server, moderatorAccessToken, 'all-local', 200)
102 }) 90 })
103 91
104 it('Should succeed to list all-local with an admin', async function () { 92 it('Should succeed to list all-local with an admin', async function () {
105 await testEndpoints(server, server.accessToken, 'all-local', playlistUUID, 200) 93 await testEndpoints(server, server.accessToken, 'all-local', 200)
106 }) 94 })
107 95
108 // Because we cannot authenticate the user on the RSS endpoint 96 // Because we cannot authenticate the user on the RSS endpoint
diff --git a/server/tests/api/check-params/videos.ts b/server/tests/api/check-params/videos.ts
index 51e592a15..fa6d6f622 100644
--- a/server/tests/api/check-params/videos.ts
+++ b/server/tests/api/check-params/videos.ts
@@ -62,7 +62,7 @@ describe('Test videos API validator', function () {
62 } 62 }
63 }) 63 })
64 64
65 describe('When listing a video', function () { 65 describe('When listing videos', function () {
66 it('Should fail with a bad start pagination', async function () { 66 it('Should fail with a bad start pagination', async function () {
67 await checkBadStartPagination(server.url, path) 67 await checkBadStartPagination(server.url, path)
68 }) 68 })
diff --git a/server/tests/api/videos/video-playlists.ts b/server/tests/api/videos/video-playlists.ts
index f82c8cbce..7d5e3914b 100644
--- a/server/tests/api/videos/video-playlists.ts
+++ b/server/tests/api/videos/video-playlists.ts
@@ -5,6 +5,7 @@ import 'mocha'
5import { 5import {
6 addVideoChannel, 6 addVideoChannel,
7 addVideoInPlaylist, 7 addVideoInPlaylist,
8 addVideoToBlacklist,
8 checkPlaylistFilesWereRemoved, 9 checkPlaylistFilesWereRemoved,
9 cleanupTests, 10 cleanupTests,
10 createUser, 11 createUser,
@@ -14,6 +15,8 @@ import {
14 doubleFollow, 15 doubleFollow,
15 doVideosExistInMyPlaylist, 16 doVideosExistInMyPlaylist,
16 flushAndRunMultipleServers, 17 flushAndRunMultipleServers,
18 generateUserAccessToken,
19 getAccessToken,
17 getAccountPlaylistsList, 20 getAccountPlaylistsList,
18 getAccountPlaylistsListWithToken, 21 getAccountPlaylistsListWithToken,
19 getMyUserInformation, 22 getMyUserInformation,
@@ -24,6 +27,7 @@ import {
24 getVideoPlaylistsList, 27 getVideoPlaylistsList,
25 getVideoPlaylistWithToken, 28 getVideoPlaylistWithToken,
26 removeUser, 29 removeUser,
30 removeVideoFromBlacklist,
27 removeVideoFromPlaylist, 31 removeVideoFromPlaylist,
28 reorderVideosPlaylist, 32 reorderVideosPlaylist,
29 ServerInfo, 33 ServerInfo,
@@ -31,23 +35,58 @@ import {
31 setDefaultVideoChannel, 35 setDefaultVideoChannel,
32 testImage, 36 testImage,
33 unfollow, 37 unfollow,
38 updateVideo,
34 updateVideoPlaylist, 39 updateVideoPlaylist,
35 updateVideoPlaylistElement, 40 updateVideoPlaylistElement,
36 uploadVideo, 41 uploadVideo,
37 uploadVideoAndGetId, 42 uploadVideoAndGetId,
38 userLogin, 43 userLogin,
39 waitJobs, 44 waitJobs
40 generateUserAccessToken
41} from '../../../../shared/extra-utils' 45} from '../../../../shared/extra-utils'
42import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model' 46import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model'
43import { VideoPlaylist } from '../../../../shared/models/videos/playlist/video-playlist.model' 47import { VideoPlaylist } from '../../../../shared/models/videos/playlist/video-playlist.model'
44import { Video } from '../../../../shared/models/videos' 48import { VideoPrivacy } from '../../../../shared/models/videos'
45import { VideoPlaylistType } from '../../../../shared/models/videos/playlist/video-playlist-type.model' 49import { VideoPlaylistType } from '../../../../shared/models/videos/playlist/video-playlist-type.model'
46import { VideoExistInPlaylist } from '../../../../shared/models/videos/playlist/video-exist-in-playlist.model' 50import { VideoExistInPlaylist } from '../../../../shared/models/videos/playlist/video-exist-in-playlist.model'
47import { User } from '../../../../shared/models/users' 51import { User } from '../../../../shared/models/users'
52import { VideoPlaylistElement, VideoPlaylistElementType } from '../../../../shared/models/videos/playlist/video-playlist-element.model'
53import {
54 addAccountToAccountBlocklist,
55 addAccountToServerBlocklist,
56 addServerToAccountBlocklist,
57 addServerToServerBlocklist,
58 removeAccountFromAccountBlocklist,
59 removeAccountFromServerBlocklist,
60 removeServerFromAccountBlocklist,
61 removeServerFromServerBlocklist
62} from '../../../../shared/extra-utils/users/blocklist'
48 63
49const expect = chai.expect 64const expect = chai.expect
50 65
66async function checkPlaylistElementType (
67 servers: ServerInfo[],
68 playlistId: string,
69 type: VideoPlaylistElementType,
70 position: number,
71 name: string,
72 total: number
73) {
74 for (const server of servers) {
75 const res = await getPlaylistVideos(server.url, server.accessToken, playlistId, 0, 10)
76 expect(res.body.total).to.equal(total)
77
78 const videoElement: VideoPlaylistElement = res.body.data.find((e: VideoPlaylistElement) => e.position === position)
79 expect(videoElement.type).to.equal(type, 'On server ' + server.url)
80
81 if (type === VideoPlaylistElementType.REGULAR) {
82 expect(videoElement.video).to.not.be.null
83 expect(videoElement.video.name).to.equal(name)
84 } else {
85 expect(videoElement.video).to.be.null
86 }
87 }
88}
89
51describe('Test video playlists', function () { 90describe('Test video playlists', function () {
52 let servers: ServerInfo[] = [] 91 let servers: ServerInfo[] = []
53 92
@@ -57,9 +96,16 @@ describe('Test video playlists', function () {
57 96
58 let playlistServer1Id: number 97 let playlistServer1Id: number
59 let playlistServer1UUID: string 98 let playlistServer1UUID: string
99 let playlistServer1UUID2: string
100
101 let playlistElementServer1Video4: number
102 let playlistElementServer1Video5: number
103 let playlistElementNSFW: number
60 104
61 let nsfwVideoServer1: number 105 let nsfwVideoServer1: number
62 106
107 let userAccessTokenServer1: string
108
63 before(async function () { 109 before(async function () {
64 this.timeout(120000) 110 this.timeout(120000)
65 111
@@ -97,814 +143,1039 @@ describe('Test video playlists', function () {
97 143
98 nsfwVideoServer1 = (await uploadVideoAndGetId({ server: servers[ 0 ], videoName: 'NSFW video', nsfw: true })).id 144 nsfwVideoServer1 = (await uploadVideoAndGetId({ server: servers[ 0 ], videoName: 'NSFW video', nsfw: true })).id
99 145
146 {
147 await createUser({
148 url: servers[ 0 ].url,
149 accessToken: servers[ 0 ].accessToken,
150 username: 'user1',
151 password: 'password'
152 })
153 userAccessTokenServer1 = await getAccessToken(servers[0].url, 'user1', 'password')
154 }
155
100 await waitJobs(servers) 156 await waitJobs(servers)
101 }) 157 })
102 158
103 it('Should list video playlist privacies', async function () { 159 describe('Get default playlists', function () {
104 const res = await getVideoPlaylistPrivacies(servers[0].url) 160 it('Should list video playlist privacies', async function () {
161 const res = await getVideoPlaylistPrivacies(servers[ 0 ].url)
105 162
106 const privacies = res.body 163 const privacies = res.body
107 expect(Object.keys(privacies)).to.have.length.at.least(3) 164 expect(Object.keys(privacies)).to.have.length.at.least(3)
108 165
109 expect(privacies[3]).to.equal('Private') 166 expect(privacies[ 3 ]).to.equal('Private')
110 }) 167 })
111 168
112 it('Should list watch later playlist', async function () { 169 it('Should list watch later playlist', async function () {
113 const url = servers[ 0 ].url 170 const url = servers[ 0 ].url
114 const accessToken = servers[ 0 ].accessToken 171 const accessToken = servers[ 0 ].accessToken
115 172
116 { 173 {
117 const res = await getAccountPlaylistsListWithToken(url, accessToken, 'root', 0, 5, VideoPlaylistType.WATCH_LATER) 174 const res = await getAccountPlaylistsListWithToken(url, accessToken, 'root', 0, 5, VideoPlaylistType.WATCH_LATER)
175
176 expect(res.body.total).to.equal(1)
177 expect(res.body.data).to.have.lengthOf(1)
178
179 const playlist: VideoPlaylist = res.body.data[ 0 ]
180 expect(playlist.displayName).to.equal('Watch later')
181 expect(playlist.type.id).to.equal(VideoPlaylistType.WATCH_LATER)
182 expect(playlist.type.label).to.equal('Watch later')
183 }
184
185 {
186 const res = await getAccountPlaylistsListWithToken(url, accessToken, 'root', 0, 5, VideoPlaylistType.REGULAR)
187
188 expect(res.body.total).to.equal(0)
189 expect(res.body.data).to.have.lengthOf(0)
190 }
191
192 {
193 const res = await getAccountPlaylistsList(url, 'root', 0, 5)
194 expect(res.body.total).to.equal(0)
195 expect(res.body.data).to.have.lengthOf(0)
196 }
197 })
198
199 it('Should get private playlist for a classic user', async function () {
200 const token = await generateUserAccessToken(servers[ 0 ], 'toto')
201
202 const res = await getAccountPlaylistsListWithToken(servers[ 0 ].url, token, 'toto', 0, 5)
118 203
119 expect(res.body.total).to.equal(1) 204 expect(res.body.total).to.equal(1)
120 expect(res.body.data).to.have.lengthOf(1) 205 expect(res.body.data).to.have.lengthOf(1)
121 206
122 const playlist: VideoPlaylist = res.body.data[ 0 ] 207 const playlistId = res.body.data[ 0 ].id
123 expect(playlist.displayName).to.equal('Watch later') 208 await getPlaylistVideos(servers[ 0 ].url, token, playlistId, 0, 5)
124 expect(playlist.type.id).to.equal(VideoPlaylistType.WATCH_LATER) 209 })
125 expect(playlist.type.label).to.equal('Watch later') 210 })
126 }
127 211
128 { 212 describe('Create and federate playlists', function () {
129 const res = await getAccountPlaylistsListWithToken(url, accessToken, 'root', 0, 5, VideoPlaylistType.REGULAR)
130 213
131 expect(res.body.total).to.equal(0) 214 it('Should create a playlist on server 1 and have the playlist on server 2 and 3', async function () {
132 expect(res.body.data).to.have.lengthOf(0) 215 this.timeout(30000)
133 }
134 216
135 { 217 await createVideoPlaylist({
136 const res = await getAccountPlaylistsList(url, 'root', 0, 5) 218 url: servers[ 0 ].url,
137 expect(res.body.total).to.equal(0) 219 token: servers[ 0 ].accessToken,
138 expect(res.body.data).to.have.lengthOf(0) 220 playlistAttrs: {
139 } 221 displayName: 'my super playlist',
140 }) 222 privacy: VideoPlaylistPrivacy.PUBLIC,
223 description: 'my super description',
224 thumbnailfile: 'thumbnail.jpg',
225 videoChannelId: servers[ 0 ].videoChannel.id
226 }
227 })
228
229 await waitJobs(servers)
230
231 for (const server of servers) {
232 const res = await getVideoPlaylistsList(server.url, 0, 5)
233 expect(res.body.total).to.equal(1)
234 expect(res.body.data).to.have.lengthOf(1)
235
236 const playlistFromList = res.body.data[ 0 ] as VideoPlaylist
237
238 const res2 = await getVideoPlaylist(server.url, playlistFromList.uuid)
239 const playlistFromGet = res2.body
240
241 for (const playlist of [ playlistFromGet, playlistFromList ]) {
242 expect(playlist.id).to.be.a('number')
243 expect(playlist.uuid).to.be.a('string')
244
245 expect(playlist.isLocal).to.equal(server.serverNumber === 1)
246
247 expect(playlist.displayName).to.equal('my super playlist')
248 expect(playlist.description).to.equal('my super description')
249 expect(playlist.privacy.id).to.equal(VideoPlaylistPrivacy.PUBLIC)
250 expect(playlist.privacy.label).to.equal('Public')
251 expect(playlist.type.id).to.equal(VideoPlaylistType.REGULAR)
252 expect(playlist.type.label).to.equal('Regular')
253
254 expect(playlist.videosLength).to.equal(0)
141 255
142 it('Should get private playlist for a classic user', async function () { 256 expect(playlist.ownerAccount.name).to.equal('root')
143 const token = await generateUserAccessToken(servers[0], 'toto') 257 expect(playlist.ownerAccount.displayName).to.equal('root')
258 expect(playlist.videoChannel.name).to.equal('root_channel')
259 expect(playlist.videoChannel.displayName).to.equal('Main root channel')
260 }
261 }
262 })
144 263
145 const res = await getAccountPlaylistsListWithToken(servers[0].url, token, 'toto', 0, 5) 264 it('Should create a playlist on server 2 and have the playlist on server 1 but not on server 3', async function () {
265 this.timeout(30000)
266
267 {
268 const res = await createVideoPlaylist({
269 url: servers[ 1 ].url,
270 token: servers[ 1 ].accessToken,
271 playlistAttrs: {
272 displayName: 'playlist 2',
273 privacy: VideoPlaylistPrivacy.PUBLIC,
274 videoChannelId: servers[ 1 ].videoChannel.id
275 }
276 })
277 playlistServer2Id1 = res.body.videoPlaylist.id
278 }
146 279
147 expect(res.body.total).to.equal(1) 280 {
148 expect(res.body.data).to.have.lengthOf(1) 281 const res = await createVideoPlaylist({
282 url: servers[ 1 ].url,
283 token: servers[ 1 ].accessToken,
284 playlistAttrs: {
285 displayName: 'playlist 3',
286 privacy: VideoPlaylistPrivacy.PUBLIC,
287 thumbnailfile: 'thumbnail.jpg',
288 videoChannelId: servers[ 1 ].videoChannel.id
289 }
290 })
291
292 playlistServer2Id2 = res.body.videoPlaylist.id
293 playlistServer2UUID2 = res.body.videoPlaylist.uuid
294 }
295
296 for (let id of [ playlistServer2Id1, playlistServer2Id2 ]) {
297 await addVideoInPlaylist({
298 url: servers[ 1 ].url,
299 token: servers[ 1 ].accessToken,
300 playlistId: id,
301 elementAttrs: { videoId: servers[ 1 ].videos[ 0 ].id, startTimestamp: 1, stopTimestamp: 2 }
302 })
303 await addVideoInPlaylist({
304 url: servers[ 1 ].url,
305 token: servers[ 1 ].accessToken,
306 playlistId: id,
307 elementAttrs: { videoId: servers[ 1 ].videos[ 1 ].id }
308 })
309 }
310
311 await waitJobs(servers)
312
313 for (const server of [ servers[ 0 ], servers[ 1 ] ]) {
314 const res = await getVideoPlaylistsList(server.url, 0, 5)
315
316 const playlist2 = res.body.data.find(p => p.displayName === 'playlist 2')
317 expect(playlist2).to.not.be.undefined
318 await testImage(server.url, 'thumbnail-playlist', playlist2.thumbnailPath)
319
320 const playlist3 = res.body.data.find(p => p.displayName === 'playlist 3')
321 expect(playlist3).to.not.be.undefined
322 await testImage(server.url, 'thumbnail', playlist3.thumbnailPath)
323 }
149 324
150 const playlistId = res.body.data[0].id 325 const res = await getVideoPlaylistsList(servers[ 2 ].url, 0, 5)
151 await getPlaylistVideos(servers[0].url, token, playlistId, 0, 5) 326 expect(res.body.data.find(p => p.displayName === 'playlist 2')).to.be.undefined
327 expect(res.body.data.find(p => p.displayName === 'playlist 3')).to.be.undefined
328 })
329
330 it('Should have the playlist on server 3 after a new follow', async function () {
331 this.timeout(30000)
332
333 // Server 2 and server 3 follow each other
334 await doubleFollow(servers[ 1 ], servers[ 2 ])
335
336 const res = await getVideoPlaylistsList(servers[ 2 ].url, 0, 5)
337
338 const playlist2 = res.body.data.find(p => p.displayName === 'playlist 2')
339 expect(playlist2).to.not.be.undefined
340 await testImage(servers[ 2 ].url, 'thumbnail-playlist', playlist2.thumbnailPath)
341
342 expect(res.body.data.find(p => p.displayName === 'playlist 3')).to.not.be.undefined
343 })
152 }) 344 })
153 345
154 it('Should create a playlist on server 1 and have the playlist on server 2 and 3', async function () { 346 describe('List playlists', function () {
155 this.timeout(30000) 347 it('Should correctly list the playlists', async function () {
348 this.timeout(30000)
349
350 {
351 const res = await getVideoPlaylistsList(servers[ 2 ].url, 1, 2, 'createdAt')
156 352
157 await createVideoPlaylist({ 353 expect(res.body.total).to.equal(3)
158 url: servers[0].url, 354
159 token: servers[0].accessToken, 355 const data: VideoPlaylist[] = res.body.data
160 playlistAttrs: { 356 expect(data).to.have.lengthOf(2)
161 displayName: 'my super playlist', 357 expect(data[ 0 ].displayName).to.equal('playlist 2')
162 privacy: VideoPlaylistPrivacy.PUBLIC, 358 expect(data[ 1 ].displayName).to.equal('playlist 3')
163 description: 'my super description', 359 }
164 thumbnailfile: 'thumbnail.jpg', 360
165 videoChannelId: servers[0].videoChannel.id 361 {
362 const res = await getVideoPlaylistsList(servers[ 2 ].url, 1, 2, '-createdAt')
363
364 expect(res.body.total).to.equal(3)
365
366 const data: VideoPlaylist[] = res.body.data
367 expect(data).to.have.lengthOf(2)
368 expect(data[ 0 ].displayName).to.equal('playlist 2')
369 expect(data[ 1 ].displayName).to.equal('my super playlist')
166 } 370 }
167 }) 371 })
168 372
169 await waitJobs(servers) 373 it('Should list video channel playlists', async function () {
374 this.timeout(30000)
170 375
171 for (const server of servers) { 376 {
172 const res = await getVideoPlaylistsList(server.url, 0, 5) 377 const res = await getVideoChannelPlaylistsList(servers[ 0 ].url, 'root_channel', 0, 2, '-createdAt')
173 expect(res.body.total).to.equal(1)
174 expect(res.body.data).to.have.lengthOf(1)
175 378
176 const playlistFromList = res.body.data[0] as VideoPlaylist 379 expect(res.body.total).to.equal(1)
177 380
178 const res2 = await getVideoPlaylist(server.url, playlistFromList.uuid) 381 const data: VideoPlaylist[] = res.body.data
179 const playlistFromGet = res2.body 382 expect(data).to.have.lengthOf(1)
383 expect(data[ 0 ].displayName).to.equal('my super playlist')
384 }
385 })
180 386
181 for (const playlist of [ playlistFromGet, playlistFromList ]) { 387 it('Should list account playlists', async function () {
182 expect(playlist.id).to.be.a('number') 388 this.timeout(30000)
183 expect(playlist.uuid).to.be.a('string')
184 389
185 expect(playlist.isLocal).to.equal(server.serverNumber === 1) 390 {
391 const res = await getAccountPlaylistsList(servers[ 1 ].url, 'root', 1, 2, '-createdAt')
186 392
187 expect(playlist.displayName).to.equal('my super playlist') 393 expect(res.body.total).to.equal(2)
188 expect(playlist.description).to.equal('my super description') 394
189 expect(playlist.privacy.id).to.equal(VideoPlaylistPrivacy.PUBLIC) 395 const data: VideoPlaylist[] = res.body.data
190 expect(playlist.privacy.label).to.equal('Public') 396 expect(data).to.have.lengthOf(1)
191 expect(playlist.type.id).to.equal(VideoPlaylistType.REGULAR) 397 expect(data[ 0 ].displayName).to.equal('playlist 2')
192 expect(playlist.type.label).to.equal('Regular') 398 }
193 399
194 expect(playlist.videosLength).to.equal(0) 400 {
401 const res = await getAccountPlaylistsList(servers[ 1 ].url, 'root', 1, 2, 'createdAt')
195 402
196 expect(playlist.ownerAccount.name).to.equal('root') 403 expect(res.body.total).to.equal(2)
197 expect(playlist.ownerAccount.displayName).to.equal('root') 404
198 expect(playlist.videoChannel.name).to.equal('root_channel') 405 const data: VideoPlaylist[] = res.body.data
199 expect(playlist.videoChannel.displayName).to.equal('Main root channel') 406 expect(data).to.have.lengthOf(1)
407 expect(data[ 0 ].displayName).to.equal('playlist 3')
200 } 408 }
201 } 409 })
202 })
203 410
204 it('Should create a playlist on server 2 and have the playlist on server 1 but not on server 3', async function () { 411 it('Should not list unlisted or private playlists', async function () {
205 this.timeout(30000) 412 this.timeout(30000)
206 413
207 { 414 await createVideoPlaylist({
208 const res = await createVideoPlaylist({ 415 url: servers[ 1 ].url,
209 url: servers[1].url, 416 token: servers[ 1 ].accessToken,
210 token: servers[1].accessToken,
211 playlistAttrs: { 417 playlistAttrs: {
212 displayName: 'playlist 2', 418 displayName: 'playlist unlisted',
213 privacy: VideoPlaylistPrivacy.PUBLIC, 419 privacy: VideoPlaylistPrivacy.UNLISTED
214 videoChannelId: servers[1].videoChannel.id
215 } 420 }
216 }) 421 })
217 playlistServer2Id1 = res.body.videoPlaylist.id
218 }
219 422
220 { 423 await createVideoPlaylist({
221 const res = await createVideoPlaylist({
222 url: servers[ 1 ].url, 424 url: servers[ 1 ].url,
223 token: servers[ 1 ].accessToken, 425 token: servers[ 1 ].accessToken,
224 playlistAttrs: { 426 playlistAttrs: {
225 displayName: 'playlist 3', 427 displayName: 'playlist private',
226 privacy: VideoPlaylistPrivacy.PUBLIC, 428 privacy: VideoPlaylistPrivacy.PRIVATE
227 thumbnailfile: 'thumbnail.jpg',
228 videoChannelId: servers[1].videoChannel.id
229 } 429 }
230 }) 430 })
231 431
232 playlistServer2Id2 = res.body.videoPlaylist.id 432 await waitJobs(servers)
233 playlistServer2UUID2 = res.body.videoPlaylist.uuid
234 }
235 433
236 for (let id of [ playlistServer2Id1, playlistServer2Id2 ]) { 434 for (const server of servers) {
237 await addVideoInPlaylist({ 435 const results = [
238 url: servers[ 1 ].url, 436 await getAccountPlaylistsList(server.url, 'root@localhost:' + servers[ 1 ].port, 0, 5, '-createdAt'),
239 token: servers[ 1 ].accessToken, 437 await getVideoPlaylistsList(server.url, 0, 2, '-createdAt')
240 playlistId: id, 438 ]
241 elementAttrs: { videoId: servers[ 1 ].videos[ 0 ].id, startTimestamp: 1, stopTimestamp: 2 } 439
242 }) 440 expect(results[ 0 ].body.total).to.equal(2)
243 await addVideoInPlaylist({ 441 expect(results[ 1 ].body.total).to.equal(3)
244 url: servers[ 1 ].url, 442
245 token: servers[ 1 ].accessToken, 443 for (const res of results) {
246 playlistId: id, 444 const data: VideoPlaylist[] = res.body.data
247 elementAttrs: { videoId: servers[ 1 ].videos[ 1 ].id } 445 expect(data).to.have.lengthOf(2)
248 }) 446 expect(data[ 0 ].displayName).to.equal('playlist 3')
249 } 447 expect(data[ 1 ].displayName).to.equal('playlist 2')
448 }
449 }
450 })
451 })
250 452
251 await waitJobs(servers) 453 describe('Update playlists', function () {
252 454
253 for (const server of [ servers[0], servers[1] ]) { 455 it('Should update a playlist', async function () {
254 const res = await getVideoPlaylistsList(server.url, 0, 5) 456 this.timeout(30000)
255 457
256 const playlist2 = res.body.data.find(p => p.displayName === 'playlist 2') 458 await updateVideoPlaylist({
257 expect(playlist2).to.not.be.undefined 459 url: servers[1].url,
258 await testImage(server.url, 'thumbnail-playlist', playlist2.thumbnailPath) 460 token: servers[1].accessToken,
461 playlistAttrs: {
462 displayName: 'playlist 3 updated',
463 description: 'description updated',
464 privacy: VideoPlaylistPrivacy.UNLISTED,
465 thumbnailfile: 'thumbnail.jpg',
466 videoChannelId: servers[1].videoChannel.id
467 },
468 playlistId: playlistServer2Id2
469 })
259 470
260 const playlist3 = res.body.data.find(p => p.displayName === 'playlist 3') 471 await waitJobs(servers)
261 expect(playlist3).to.not.be.undefined
262 await testImage(server.url, 'thumbnail', playlist3.thumbnailPath)
263 }
264 472
265 const res = await getVideoPlaylistsList(servers[2].url, 0, 5) 473 for (const server of servers) {
266 expect(res.body.data.find(p => p.displayName === 'playlist 2')).to.be.undefined 474 const res = await getVideoPlaylist(server.url, playlistServer2UUID2)
267 expect(res.body.data.find(p => p.displayName === 'playlist 3')).to.be.undefined 475 const playlist: VideoPlaylist = res.body
268 })
269 476
270 it('Should have the playlist on server 3 after a new follow', async function () { 477 expect(playlist.displayName).to.equal('playlist 3 updated')
271 this.timeout(30000) 478 expect(playlist.description).to.equal('description updated')
272 479
273 // Server 2 and server 3 follow each other 480 expect(playlist.privacy.id).to.equal(VideoPlaylistPrivacy.UNLISTED)
274 await doubleFollow(servers[1], servers[2]) 481 expect(playlist.privacy.label).to.equal('Unlisted')
275 482
276 const res = await getVideoPlaylistsList(servers[2].url, 0, 5) 483 expect(playlist.type.id).to.equal(VideoPlaylistType.REGULAR)
484 expect(playlist.type.label).to.equal('Regular')
277 485
278 const playlist2 = res.body.data.find(p => p.displayName === 'playlist 2') 486 expect(playlist.videosLength).to.equal(2)
279 expect(playlist2).to.not.be.undefined
280 await testImage(servers[2].url, 'thumbnail-playlist', playlist2.thumbnailPath)
281 487
282 expect(res.body.data.find(p => p.displayName === 'playlist 3')).to.not.be.undefined 488 expect(playlist.ownerAccount.name).to.equal('root')
489 expect(playlist.ownerAccount.displayName).to.equal('root')
490 expect(playlist.videoChannel.name).to.equal('root_channel')
491 expect(playlist.videoChannel.displayName).to.equal('Main root channel')
492 }
493 })
283 }) 494 })
284 495
285 it('Should correctly list the playlists', async function () { 496 describe('Element timestamps', function () {
286 this.timeout(30000)
287 497
288 { 498 it('Should create a playlist containing different startTimestamp/endTimestamp videos', async function () {
289 const res = await getVideoPlaylistsList(servers[ 2 ].url, 1, 2, 'createdAt') 499 this.timeout(30000)
290 500
291 expect(res.body.total).to.equal(3) 501 const addVideo = (elementAttrs: any) => {
502 return addVideoInPlaylist({ url: servers[ 0 ].url, token: servers[ 0 ].accessToken, playlistId: playlistServer1Id, elementAttrs })
503 }
292 504
293 const data: VideoPlaylist[] = res.body.data 505 const res = await createVideoPlaylist({
294 expect(data).to.have.lengthOf(2) 506 url: servers[ 0 ].url,
295 expect(data[ 0 ].displayName).to.equal('playlist 2') 507 token: servers[ 0 ].accessToken,
296 expect(data[ 1 ].displayName).to.equal('playlist 3') 508 playlistAttrs: {
297 } 509 displayName: 'playlist 4',
510 privacy: VideoPlaylistPrivacy.PUBLIC,
511 videoChannelId: servers[ 0 ].videoChannel.id
512 }
513 })
298 514
299 { 515 playlistServer1Id = res.body.videoPlaylist.id
300 const res = await getVideoPlaylistsList(servers[ 2 ].url, 1, 2, '-createdAt') 516 playlistServer1UUID = res.body.videoPlaylist.uuid
301 517
302 expect(res.body.total).to.equal(3) 518 await addVideo({ videoId: servers[ 0 ].videos[ 0 ].uuid, startTimestamp: 15, stopTimestamp: 28 })
519 await addVideo({ videoId: servers[ 2 ].videos[ 1 ].uuid, startTimestamp: 35 })
520 await addVideo({ videoId: servers[ 2 ].videos[ 2 ].uuid })
521 {
522 const res = await addVideo({ videoId: servers[ 0 ].videos[ 3 ].uuid, stopTimestamp: 35 })
523 playlistElementServer1Video4 = res.body.videoPlaylistElement.id
524 }
303 525
304 const data: VideoPlaylist[] = res.body.data 526 {
305 expect(data).to.have.lengthOf(2) 527 const res = await addVideo({ videoId: servers[ 0 ].videos[ 4 ].uuid, startTimestamp: 45, stopTimestamp: 60 })
306 expect(data[ 0 ].displayName).to.equal('playlist 2') 528 playlistElementServer1Video5 = res.body.videoPlaylistElement.id
307 expect(data[ 1 ].displayName).to.equal('my super playlist') 529 }
308 }
309 })
310 530
311 it('Should list video channel playlists', async function () { 531 {
312 this.timeout(30000) 532 const res = await addVideo({ videoId: nsfwVideoServer1, startTimestamp: 5 })
533 playlistElementNSFW = res.body.videoPlaylistElement.id
534 }
313 535
314 { 536 await waitJobs(servers)
315 const res = await getVideoChannelPlaylistsList(servers[ 0 ].url, 'root_channel', 0, 2, '-createdAt') 537 })
316 538
317 expect(res.body.total).to.equal(1) 539 it('Should correctly list playlist videos', async function () {
540 this.timeout(30000)
318 541
319 const data: VideoPlaylist[] = res.body.data 542 for (const server of servers) {
320 expect(data).to.have.lengthOf(1) 543 const res = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10)
321 expect(data[ 0 ].displayName).to.equal('my super playlist')
322 }
323 })
324 544
325 it('Should list account playlists', async function () { 545 expect(res.body.total).to.equal(6)
326 this.timeout(30000)
327 546
328 { 547 const videoElements: VideoPlaylistElement[] = res.body.data
329 const res = await getAccountPlaylistsList(servers[ 1 ].url, 'root', 1, 2, '-createdAt') 548 expect(videoElements).to.have.lengthOf(6)
330 549
331 expect(res.body.total).to.equal(2) 550 expect(videoElements[ 0 ].video.name).to.equal('video 0 server 1')
551 expect(videoElements[ 0 ].position).to.equal(1)
552 expect(videoElements[ 0 ].startTimestamp).to.equal(15)
553 expect(videoElements[ 0 ].stopTimestamp).to.equal(28)
332 554
333 const data: VideoPlaylist[] = res.body.data 555 expect(videoElements[ 1 ].video.name).to.equal('video 1 server 3')
334 expect(data).to.have.lengthOf(1) 556 expect(videoElements[ 1 ].position).to.equal(2)
335 expect(data[ 0 ].displayName).to.equal('playlist 2') 557 expect(videoElements[ 1 ].startTimestamp).to.equal(35)
336 } 558 expect(videoElements[ 1 ].stopTimestamp).to.be.null
337 559
338 { 560 expect(videoElements[ 2 ].video.name).to.equal('video 2 server 3')
339 const res = await getAccountPlaylistsList(servers[ 1 ].url, 'root', 1, 2, 'createdAt') 561 expect(videoElements[ 2 ].position).to.equal(3)
562 expect(videoElements[ 2 ].startTimestamp).to.be.null
563 expect(videoElements[ 2 ].stopTimestamp).to.be.null
340 564
341 expect(res.body.total).to.equal(2) 565 expect(videoElements[ 3 ].video.name).to.equal('video 3 server 1')
566 expect(videoElements[ 3 ].position).to.equal(4)
567 expect(videoElements[ 3 ].startTimestamp).to.be.null
568 expect(videoElements[ 3 ].stopTimestamp).to.equal(35)
342 569
343 const data: VideoPlaylist[] = res.body.data 570 expect(videoElements[ 4 ].video.name).to.equal('video 4 server 1')
344 expect(data).to.have.lengthOf(1) 571 expect(videoElements[ 4 ].position).to.equal(5)
345 expect(data[ 0 ].displayName).to.equal('playlist 3') 572 expect(videoElements[ 4 ].startTimestamp).to.equal(45)
346 } 573 expect(videoElements[ 4 ].stopTimestamp).to.equal(60)
347 })
348 574
349 it('Should not list unlisted or private playlists', async function () { 575 expect(videoElements[ 5 ].video.name).to.equal('NSFW video')
350 this.timeout(30000) 576 expect(videoElements[ 5 ].position).to.equal(6)
577 expect(videoElements[ 5 ].startTimestamp).to.equal(5)
578 expect(videoElements[ 5 ].stopTimestamp).to.be.null
351 579
352 await createVideoPlaylist({ 580 const res3 = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 2)
353 url: servers[ 1 ].url, 581 expect(res3.body.data).to.have.lengthOf(2)
354 token: servers[ 1 ].accessToken,
355 playlistAttrs: {
356 displayName: 'playlist unlisted',
357 privacy: VideoPlaylistPrivacy.UNLISTED
358 } 582 }
359 }) 583 })
584 })
360 585
361 await createVideoPlaylist({ 586 describe('Element type', function () {
362 url: servers[ 1 ].url, 587 let groupUser1: ServerInfo[]
363 token: servers[ 1 ].accessToken, 588 let groupWithoutToken1: ServerInfo[]
364 playlistAttrs: { 589 let group1: ServerInfo[]
365 displayName: 'playlist private', 590 let group2: ServerInfo[]
366 privacy: VideoPlaylistPrivacy.PRIVATE
367 }
368 })
369 591
370 await waitJobs(servers) 592 let video1: string
593 let video2: string
594 let video3: string
371 595
372 for (const server of servers) { 596 before(async function () {
373 const results = [ 597 this.timeout(30000)
374 await getAccountPlaylistsList(server.url, 'root@localhost:' + servers[1].port, 0, 5, '-createdAt'),
375 await getVideoPlaylistsList(server.url, 0, 2, '-createdAt')
376 ]
377 598
378 expect(results[0].body.total).to.equal(2) 599 groupUser1 = [ Object.assign({}, servers[ 0 ], { accessToken: userAccessTokenServer1 }) ]
379 expect(results[1].body.total).to.equal(3) 600 groupWithoutToken1 = [ Object.assign({}, servers[ 0 ], { accessToken: undefined }) ]
601 group1 = [ servers[ 0 ] ]
602 group2 = [ servers[ 1 ], servers[ 2 ] ]
380 603
381 for (const res of results) { 604 const res = await createVideoPlaylist({
382 const data: VideoPlaylist[] = res.body.data 605 url: servers[ 0 ].url,
383 expect(data).to.have.lengthOf(2) 606 token: userAccessTokenServer1,
384 expect(data[ 0 ].displayName).to.equal('playlist 3') 607 playlistAttrs: {
385 expect(data[ 1 ].displayName).to.equal('playlist 2') 608 displayName: 'playlist 56',
386 } 609 privacy: VideoPlaylistPrivacy.PUBLIC,
387 } 610 videoChannelId: servers[ 0 ].videoChannel.id
388 }) 611 }
612 })
389 613
390 it('Should update a playlist', async function () { 614 const playlistServer1Id2 = res.body.videoPlaylist.id
391 this.timeout(30000) 615 playlistServer1UUID2 = res.body.videoPlaylist.uuid
392
393 await updateVideoPlaylist({
394 url: servers[1].url,
395 token: servers[1].accessToken,
396 playlistAttrs: {
397 displayName: 'playlist 3 updated',
398 description: 'description updated',
399 privacy: VideoPlaylistPrivacy.UNLISTED,
400 thumbnailfile: 'thumbnail.jpg',
401 videoChannelId: servers[1].videoChannel.id
402 },
403 playlistId: playlistServer2Id2
404 })
405 616
406 await waitJobs(servers) 617 const addVideo = (elementAttrs: any) => {
618 return addVideoInPlaylist({ url: servers[ 0 ].url, token: userAccessTokenServer1, playlistId: playlistServer1Id2, elementAttrs })
619 }
407 620
408 for (const server of servers) { 621 video1 = (await uploadVideoAndGetId({ server: servers[0], videoName: 'video 89', token: userAccessTokenServer1 })).uuid
409 const res = await getVideoPlaylist(server.url, playlistServer2UUID2) 622 video2 = (await uploadVideoAndGetId({ server: servers[1], videoName: 'video 90' })).uuid
410 const playlist: VideoPlaylist = res.body 623 video3 = (await uploadVideoAndGetId({ server: servers[0], videoName: 'video 91', nsfw: true })).uuid
411 624
412 expect(playlist.displayName).to.equal('playlist 3 updated') 625 await addVideo({ videoId: video1, startTimestamp: 15, stopTimestamp: 28 })
413 expect(playlist.description).to.equal('description updated') 626 await addVideo({ videoId: video2, startTimestamp: 35 })
627 await addVideo({ videoId: video3 })
414 628
415 expect(playlist.privacy.id).to.equal(VideoPlaylistPrivacy.UNLISTED) 629 await waitJobs(servers)
416 expect(playlist.privacy.label).to.equal('Unlisted') 630 })
417 631
418 expect(playlist.type.id).to.equal(VideoPlaylistType.REGULAR) 632 it('Should update the element type if the video is private', async function () {
419 expect(playlist.type.label).to.equal('Regular') 633 this.timeout(20000)
420 634
421 expect(playlist.videosLength).to.equal(2) 635 const name = 'video 89'
636 const position = 1
422 637
423 expect(playlist.ownerAccount.name).to.equal('root') 638 {
424 expect(playlist.ownerAccount.displayName).to.equal('root') 639 await updateVideo(servers[ 0 ].url, servers[ 0 ].accessToken, video1, { privacy: VideoPrivacy.PRIVATE })
425 expect(playlist.videoChannel.name).to.equal('root_channel') 640 await waitJobs(servers)
426 expect(playlist.videoChannel.displayName).to.equal('Main root channel')
427 }
428 })
429 641
430 it('Should create a playlist containing different startTimestamp/endTimestamp videos', async function () { 642 await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
431 this.timeout(30000) 643 await checkPlaylistElementType(groupWithoutToken1, playlistServer1UUID2, VideoPlaylistElementType.PRIVATE, position, name, 3)
644 await checkPlaylistElementType(group1, playlistServer1UUID2, VideoPlaylistElementType.PRIVATE, position, name, 3)
645 await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.DELETED, position, name, 3)
646 }
432 647
433 const addVideo = (elementAttrs: any) => { 648 {
434 return addVideoInPlaylist({ url: servers[0].url, token: servers[0].accessToken, playlistId: playlistServer1Id, elementAttrs }) 649 await updateVideo(servers[ 0 ].url, servers[ 0 ].accessToken, video1, { privacy: VideoPrivacy.PUBLIC })
435 } 650 await waitJobs(servers)
436 651
437 const res = await createVideoPlaylist({ 652 await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
438 url: servers[ 0 ].url, 653 await checkPlaylistElementType(groupWithoutToken1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
439 token: servers[ 0 ].accessToken, 654 await checkPlaylistElementType(group1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
440 playlistAttrs: { 655 // We deleted the video, so even if we recreated it, the old entry is still deleted
441 displayName: 'playlist 4', 656 await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.DELETED, position, name, 3)
442 privacy: VideoPlaylistPrivacy.PUBLIC,
443 videoChannelId: servers[0].videoChannel.id
444 } 657 }
445 }) 658 })
446 659
447 playlistServer1Id = res.body.videoPlaylist.id 660 it('Should update the element type if the video is blacklisted', async function () {
448 playlistServer1UUID = res.body.videoPlaylist.uuid 661 this.timeout(20000)
449 662
450 await addVideo({ videoId: servers[0].videos[0].uuid, startTimestamp: 15, stopTimestamp: 28 }) 663 const name = 'video 89'
451 await addVideo({ videoId: servers[2].videos[1].uuid, startTimestamp: 35 }) 664 const position = 1
452 await addVideo({ videoId: servers[2].videos[2].uuid })
453 await addVideo({ videoId: servers[0].videos[3].uuid, stopTimestamp: 35 })
454 await addVideo({ videoId: servers[0].videos[4].uuid, startTimestamp: 45, stopTimestamp: 60 })
455 await addVideo({ videoId: nsfwVideoServer1, startTimestamp: 5 })
456 665
457 await waitJobs(servers) 666 {
458 }) 667 await addVideoToBlacklist(servers[ 0 ].url, servers[ 0 ].accessToken, video1, 'reason', true)
668 await waitJobs(servers)
459 669
460 it('Should correctly list playlist videos', async function () { 670 await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
461 this.timeout(30000) 671 await checkPlaylistElementType(groupWithoutToken1, playlistServer1UUID2, VideoPlaylistElementType.UNAVAILABLE, position, name, 3)
672 await checkPlaylistElementType(group1, playlistServer1UUID2, VideoPlaylistElementType.UNAVAILABLE, position, name, 3)
673 await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.DELETED, position, name, 3)
674 }
462 675
463 for (const server of servers) { 676 {
464 const res = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10) 677 await removeVideoFromBlacklist(servers[ 0 ].url, servers[ 0 ].accessToken, video1)
678 await waitJobs(servers)
465 679
466 expect(res.body.total).to.equal(6) 680 await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
681 await checkPlaylistElementType(groupWithoutToken1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
682 await checkPlaylistElementType(group1, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
683 // We deleted the video (because unfederated), so even if we recreated it, the old entry is still deleted
684 await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.DELETED, position, name, 3)
685 }
686 })
467 687
468 const videos: Video[] = res.body.data 688 it('Should update the element type if the account or server of the video is blocked', async function () {
469 expect(videos).to.have.lengthOf(6) 689 this.timeout(90000)
470 690
471 expect(videos[0].name).to.equal('video 0 server 1') 691 const name = 'video 90'
472 expect(videos[0].playlistElement.position).to.equal(1) 692 const position = 2
473 expect(videos[0].playlistElement.startTimestamp).to.equal(15)
474 expect(videos[0].playlistElement.stopTimestamp).to.equal(28)
475 693
476 expect(videos[1].name).to.equal('video 1 server 3') 694 {
477 expect(videos[1].playlistElement.position).to.equal(2) 695 await addAccountToAccountBlocklist(servers[ 0 ].url, userAccessTokenServer1, 'root@localhost:' + servers[1].port)
478 expect(videos[1].playlistElement.startTimestamp).to.equal(35) 696 await waitJobs(servers)
479 expect(videos[1].playlistElement.stopTimestamp).to.be.null
480 697
481 expect(videos[2].name).to.equal('video 2 server 3') 698 await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.UNAVAILABLE, position, name, 3)
482 expect(videos[2].playlistElement.position).to.equal(3) 699 await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
483 expect(videos[2].playlistElement.startTimestamp).to.be.null
484 expect(videos[2].playlistElement.stopTimestamp).to.be.null
485 700
486 expect(videos[3].name).to.equal('video 3 server 1') 701 await removeAccountFromAccountBlocklist(servers[ 0 ].url, userAccessTokenServer1, 'root@localhost:' + servers[1].port)
487 expect(videos[3].playlistElement.position).to.equal(4) 702 await waitJobs(servers)
488 expect(videos[3].playlistElement.startTimestamp).to.be.null
489 expect(videos[3].playlistElement.stopTimestamp).to.equal(35)
490 703
491 expect(videos[4].name).to.equal('video 4 server 1') 704 await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
492 expect(videos[4].playlistElement.position).to.equal(5) 705 }
493 expect(videos[4].playlistElement.startTimestamp).to.equal(45)
494 expect(videos[4].playlistElement.stopTimestamp).to.equal(60)
495 706
496 expect(videos[5].name).to.equal('NSFW video') 707 {
497 expect(videos[5].playlistElement.position).to.equal(6) 708 await addServerToAccountBlocklist(servers[ 0 ].url, userAccessTokenServer1, 'localhost:' + servers[1].port)
498 expect(videos[5].playlistElement.startTimestamp).to.equal(5) 709 await waitJobs(servers)
499 expect(videos[5].playlistElement.stopTimestamp).to.be.null
500 710
501 const res2 = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10, { nsfw: false }) 711 await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.UNAVAILABLE, position, name, 3)
502 expect(res2.body.total).to.equal(5) 712 await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
503 expect(res2.body.data.find(v => v.name === 'NSFW video')).to.be.undefined
504 713
505 const res3 = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 2) 714 await removeServerFromAccountBlocklist(servers[ 0 ].url, userAccessTokenServer1, 'localhost:' + servers[1].port)
506 expect(res3.body.data).to.have.lengthOf(2) 715 await waitJobs(servers)
507 }
508 })
509 716
510 it('Should reorder the playlist', async function () { 717 await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
511 this.timeout(30000) 718 }
512 719
513 { 720 {
514 await reorderVideosPlaylist({ 721 await addAccountToServerBlocklist(servers[ 0 ].url, servers[ 0 ].accessToken, 'root@localhost:' + servers[1].port)
515 url: servers[ 0 ].url, 722 await waitJobs(servers)
516 token: servers[ 0 ].accessToken,
517 playlistId: playlistServer1Id,
518 elementAttrs: {
519 startPosition: 2,
520 insertAfterPosition: 3
521 }
522 })
523 723
524 await waitJobs(servers) 724 await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.UNAVAILABLE, position, name, 3)
725 await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
525 726
526 for (const server of servers) { 727 await removeAccountFromServerBlocklist(servers[ 0 ].url, servers[ 0 ].accessToken, 'root@localhost:' + servers[1].port)
527 const res = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10) 728 await waitJobs(servers)
528 const names = res.body.data.map(v => v.name)
529 729
530 expect(names).to.deep.equal([ 730 await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
531 'video 0 server 1',
532 'video 2 server 3',
533 'video 1 server 3',
534 'video 3 server 1',
535 'video 4 server 1',
536 'NSFW video'
537 ])
538 } 731 }
539 }
540 732
541 { 733 {
542 await reorderVideosPlaylist({ 734 await addServerToServerBlocklist(servers[ 0 ].url, servers[ 0 ].accessToken, 'localhost:' + servers[1].port)
543 url: servers[0].url, 735 await waitJobs(servers)
544 token: servers[0].accessToken,
545 playlistId: playlistServer1Id,
546 elementAttrs: {
547 startPosition: 1,
548 reorderLength: 3,
549 insertAfterPosition: 4
550 }
551 })
552 736
553 await waitJobs(servers) 737 await checkPlaylistElementType(groupUser1, playlistServer1UUID2, VideoPlaylistElementType.UNAVAILABLE, position, name, 3)
738 await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
554 739
555 for (const server of servers) { 740 await removeServerFromServerBlocklist(servers[ 0 ].url, servers[ 0 ].accessToken, 'localhost:' + servers[1].port)
556 const res = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10) 741 await waitJobs(servers)
557 const names = res.body.data.map(v => v.name)
558 742
559 expect(names).to.deep.equal([ 743 await checkPlaylistElementType(group2, playlistServer1UUID2, VideoPlaylistElementType.REGULAR, position, name, 3)
560 'video 3 server 1',
561 'video 0 server 1',
562 'video 2 server 3',
563 'video 1 server 3',
564 'video 4 server 1',
565 'NSFW video'
566 ])
567 } 744 }
568 } 745 })
569
570 {
571 await reorderVideosPlaylist({
572 url: servers[0].url,
573 token: servers[0].accessToken,
574 playlistId: playlistServer1Id,
575 elementAttrs: {
576 startPosition: 6,
577 insertAfterPosition: 3
578 }
579 })
580 746
581 await waitJobs(servers) 747 it('Should hide the video if it is NSFW', async function () {
748 const res = await getPlaylistVideos(servers[0].url, userAccessTokenServer1, playlistServer1UUID2, 0, 10, { nsfw: false })
749 expect(res.body.total).to.equal(3)
582 750
583 for (const server of servers) { 751 const elements: VideoPlaylistElement[] = res.body.data
584 const res = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10) 752 const element = elements.find(e => e.position === 3)
585 const videos: Video[] = res.body.data
586 753
587 const names = videos.map(v => v.name) 754 expect(element).to.exist
755 expect(element.video).to.be.null
756 expect(element.type).to.equal(VideoPlaylistElementType.UNAVAILABLE)
757 })
588 758
589 expect(names).to.deep.equal([ 759 })
590 'video 3 server 1',
591 'video 0 server 1',
592 'video 2 server 3',
593 'NSFW video',
594 'video 1 server 3',
595 'video 4 server 1'
596 ])
597 760
598 for (let i = 1; i <= videos.length; i++) { 761 describe('Managing playlist elements', function () {
599 expect(videos[i - 1].playlistElement.position).to.equal(i) 762
763 it('Should reorder the playlist', async function () {
764 this.timeout(30000)
765
766 {
767 await reorderVideosPlaylist({
768 url: servers[ 0 ].url,
769 token: servers[ 0 ].accessToken,
770 playlistId: playlistServer1Id,
771 elementAttrs: {
772 startPosition: 2,
773 insertAfterPosition: 3
774 }
775 })
776
777 await waitJobs(servers)
778
779 for (const server of servers) {
780 const res = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10)
781 const names = (res.body.data as VideoPlaylistElement[]).map(v => v.video.name)
782
783 expect(names).to.deep.equal([
784 'video 0 server 1',
785 'video 2 server 3',
786 'video 1 server 3',
787 'video 3 server 1',
788 'video 4 server 1',
789 'NSFW video'
790 ])
600 } 791 }
601 } 792 }
602 }
603 })
604
605 it('Should update startTimestamp/endTimestamp of some elements', async function () {
606 this.timeout(30000)
607 793
608 await updateVideoPlaylistElement({ 794 {
609 url: servers[0].url, 795 await reorderVideosPlaylist({
610 token: servers[0].accessToken, 796 url: servers[ 0 ].url,
611 playlistId: playlistServer1Id, 797 token: servers[ 0 ].accessToken,
612 videoId: servers[0].videos[3].uuid, 798 playlistId: playlistServer1Id,
613 elementAttrs: { 799 elementAttrs: {
614 startTimestamp: 1 800 startPosition: 1,
801 reorderLength: 3,
802 insertAfterPosition: 4
803 }
804 })
805
806 await waitJobs(servers)
807
808 for (const server of servers) {
809 const res = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10)
810 const names = (res.body.data as VideoPlaylistElement[]).map(v => v.video.name)
811
812 expect(names).to.deep.equal([
813 'video 3 server 1',
814 'video 0 server 1',
815 'video 2 server 3',
816 'video 1 server 3',
817 'video 4 server 1',
818 'NSFW video'
819 ])
820 }
615 } 821 }
616 })
617 822
618 await updateVideoPlaylistElement({ 823 {
619 url: servers[0].url, 824 await reorderVideosPlaylist({
620 token: servers[0].accessToken, 825 url: servers[ 0 ].url,
621 playlistId: playlistServer1Id, 826 token: servers[ 0 ].accessToken,
622 videoId: servers[0].videos[4].uuid, 827 playlistId: playlistServer1Id,
623 elementAttrs: { 828 elementAttrs: {
624 stopTimestamp: null 829 startPosition: 6,
830 insertAfterPosition: 3
831 }
832 })
833
834 await waitJobs(servers)
835
836 for (const server of servers) {
837 const res = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10)
838 const elements: VideoPlaylistElement[] = res.body.data
839 const names = elements.map(v => v.video.name)
840
841 expect(names).to.deep.equal([
842 'video 3 server 1',
843 'video 0 server 1',
844 'video 2 server 3',
845 'NSFW video',
846 'video 1 server 3',
847 'video 4 server 1'
848 ])
849
850 for (let i = 1; i <= elements.length; i++) {
851 expect(elements[ i - 1 ].position).to.equal(i)
852 }
853 }
625 } 854 }
626 }) 855 })
627 856
628 await waitJobs(servers) 857 it('Should update startTimestamp/endTimestamp of some elements', async function () {
858 this.timeout(30000)
859
860 await updateVideoPlaylistElement({
861 url: servers[ 0 ].url,
862 token: servers[ 0 ].accessToken,
863 playlistId: playlistServer1Id,
864 playlistElementId: playlistElementServer1Video4,
865 elementAttrs: {
866 startTimestamp: 1
867 }
868 })
629 869
630 for (const server of servers) { 870 await updateVideoPlaylistElement({
631 const res = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10) 871 url: servers[ 0 ].url,
632 const videos: Video[] = res.body.data 872 token: servers[ 0 ].accessToken,
873 playlistId: playlistServer1Id,
874 playlistElementId: playlistElementServer1Video5,
875 elementAttrs: {
876 stopTimestamp: null
877 }
878 })
633 879
634 expect(videos[0].name).to.equal('video 3 server 1') 880 await waitJobs(servers)
635 expect(videos[0].playlistElement.position).to.equal(1)
636 expect(videos[0].playlistElement.startTimestamp).to.equal(1)
637 expect(videos[0].playlistElement.stopTimestamp).to.equal(35)
638 881
639 expect(videos[5].name).to.equal('video 4 server 1') 882 for (const server of servers) {
640 expect(videos[5].playlistElement.position).to.equal(6) 883 const res = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10)
641 expect(videos[5].playlistElement.startTimestamp).to.equal(45) 884 const elements: VideoPlaylistElement[] = res.body.data
642 expect(videos[5].playlistElement.stopTimestamp).to.be.null
643 }
644 })
645 885
646 it('Should check videos existence in my playlist', async function () { 886 expect(elements[ 0 ].video.name).to.equal('video 3 server 1')
647 const videoIds = [ 887 expect(elements[ 0 ].position).to.equal(1)
648 servers[0].videos[0].id, 888 expect(elements[ 0 ].startTimestamp).to.equal(1)
649 42000, 889 expect(elements[ 0 ].stopTimestamp).to.equal(35)
650 servers[0].videos[3].id,
651 43000,
652 servers[0].videos[4].id
653 ]
654 const res = await doVideosExistInMyPlaylist(servers[ 0 ].url, servers[ 0 ].accessToken, videoIds)
655 const obj = res.body as VideoExistInPlaylist
656 890
657 { 891 expect(elements[ 5 ].video.name).to.equal('video 4 server 1')
658 const elem = obj[servers[0].videos[0].id] 892 expect(elements[ 5 ].position).to.equal(6)
659 expect(elem).to.have.lengthOf(1) 893 expect(elements[ 5 ].startTimestamp).to.equal(45)
660 expect(elem[ 0 ].playlistId).to.equal(playlistServer1Id) 894 expect(elements[ 5 ].stopTimestamp).to.be.null
661 expect(elem[ 0 ].startTimestamp).to.equal(15) 895 }
662 expect(elem[ 0 ].stopTimestamp).to.equal(28) 896 })
663 }
664 897
665 { 898 it('Should check videos existence in my playlist', async function () {
666 const elem = obj[servers[0].videos[3].id] 899 const videoIds = [
667 expect(elem).to.have.lengthOf(1) 900 servers[ 0 ].videos[ 0 ].id,
668 expect(elem[ 0 ].playlistId).to.equal(playlistServer1Id) 901 42000,
669 expect(elem[ 0 ].startTimestamp).to.equal(1) 902 servers[ 0 ].videos[ 3 ].id,
670 expect(elem[ 0 ].stopTimestamp).to.equal(35) 903 43000,
671 } 904 servers[ 0 ].videos[ 4 ].id
905 ]
906 const res = await doVideosExistInMyPlaylist(servers[ 0 ].url, servers[ 0 ].accessToken, videoIds)
907 const obj = res.body as VideoExistInPlaylist
908
909 {
910 const elem = obj[ servers[ 0 ].videos[ 0 ].id ]
911 expect(elem).to.have.lengthOf(1)
912 expect(elem[ 0 ].playlistElementId).to.exist
913 expect(elem[ 0 ].playlistId).to.equal(playlistServer1Id)
914 expect(elem[ 0 ].startTimestamp).to.equal(15)
915 expect(elem[ 0 ].stopTimestamp).to.equal(28)
916 }
672 917
673 { 918 {
674 const elem = obj[servers[0].videos[4].id] 919 const elem = obj[ servers[ 0 ].videos[ 3 ].id ]
675 expect(elem).to.have.lengthOf(1) 920 expect(elem).to.have.lengthOf(1)
676 expect(elem[ 0 ].playlistId).to.equal(playlistServer1Id) 921 expect(elem[ 0 ].playlistElementId).to.equal(playlistElementServer1Video4)
677 expect(elem[ 0 ].startTimestamp).to.equal(45) 922 expect(elem[ 0 ].playlistId).to.equal(playlistServer1Id)
678 expect(elem[ 0 ].stopTimestamp).to.equal(null) 923 expect(elem[ 0 ].startTimestamp).to.equal(1)
679 } 924 expect(elem[ 0 ].stopTimestamp).to.equal(35)
925 }
680 926
681 expect(obj[42000]).to.have.lengthOf(0) 927 {
682 expect(obj[43000]).to.have.lengthOf(0) 928 const elem = obj[ servers[ 0 ].videos[ 4 ].id ]
683 }) 929 expect(elem).to.have.lengthOf(1)
930 expect(elem[ 0 ].playlistId).to.equal(playlistServer1Id)
931 expect(elem[ 0 ].startTimestamp).to.equal(45)
932 expect(elem[ 0 ].stopTimestamp).to.equal(null)
933 }
684 934
685 it('Should automatically update updatedAt field of playlists', async function () { 935 expect(obj[ 42000 ]).to.have.lengthOf(0)
686 const server = servers[1] 936 expect(obj[ 43000 ]).to.have.lengthOf(0)
687 const videoId = servers[1].videos[5].id 937 })
688 938
689 async function getPlaylistNames () { 939 it('Should automatically update updatedAt field of playlists', async function () {
690 const res = await getAccountPlaylistsListWithToken(server.url, server.accessToken, 'root', 0, 5, undefined, '-updatedAt') 940 const server = servers[ 1 ]
941 const videoId = servers[ 1 ].videos[ 5 ].id
691 942
692 return (res.body.data as VideoPlaylist[]).map(p => p.displayName) 943 async function getPlaylistNames () {
693 } 944 const res = await getAccountPlaylistsListWithToken(server.url, server.accessToken, 'root', 0, 5, undefined, '-updatedAt')
694 945
695 const elementAttrs = { videoId } 946 return (res.body.data as VideoPlaylist[]).map(p => p.displayName)
696 await addVideoInPlaylist({ url: server.url, token: server.accessToken, playlistId: playlistServer2Id1, elementAttrs }) 947 }
697 await addVideoInPlaylist({ url: server.url, token: server.accessToken, playlistId: playlistServer2Id2, elementAttrs })
698 948
699 const names1 = await getPlaylistNames() 949 const elementAttrs = { videoId }
700 expect(names1[0]).to.equal('playlist 3 updated') 950 const res1 = await addVideoInPlaylist({ url: server.url, token: server.accessToken, playlistId: playlistServer2Id1, elementAttrs })
701 expect(names1[1]).to.equal('playlist 2') 951 const res2 = await addVideoInPlaylist({ url: server.url, token: server.accessToken, playlistId: playlistServer2Id2, elementAttrs })
702 952
703 await removeVideoFromPlaylist({ url: server.url, token: server.accessToken, playlistId: playlistServer2Id1, videoId }) 953 const element1 = res1.body.videoPlaylistElement.id
954 const element2 = res2.body.videoPlaylistElement.id
704 955
705 const names2 = await getPlaylistNames() 956 const names1 = await getPlaylistNames()
706 expect(names2[0]).to.equal('playlist 2') 957 expect(names1[ 0 ]).to.equal('playlist 3 updated')
707 expect(names2[1]).to.equal('playlist 3 updated') 958 expect(names1[ 1 ]).to.equal('playlist 2')
708 959
709 await removeVideoFromPlaylist({ url: server.url, token: server.accessToken, playlistId: playlistServer2Id2, videoId }) 960 await removeVideoFromPlaylist({
961 url: server.url,
962 token: server.accessToken,
963 playlistId: playlistServer2Id1,
964 playlistElementId: element1
965 })
710 966
711 const names3 = await getPlaylistNames() 967 const names2 = await getPlaylistNames()
712 expect(names3[0]).to.equal('playlist 3 updated') 968 expect(names2[ 0 ]).to.equal('playlist 2')
713 expect(names3[1]).to.equal('playlist 2') 969 expect(names2[ 1 ]).to.equal('playlist 3 updated')
714 })
715 970
716 it('Should delete some elements', async function () { 971 await removeVideoFromPlaylist({
717 this.timeout(30000) 972 url: server.url,
973 token: server.accessToken,
974 playlistId: playlistServer2Id2,
975 playlistElementId: element2
976 })
718 977
719 await removeVideoFromPlaylist({ 978 const names3 = await getPlaylistNames()
720 url: servers[0].url, 979 expect(names3[ 0 ]).to.equal('playlist 3 updated')
721 token: servers[0].accessToken, 980 expect(names3[ 1 ]).to.equal('playlist 2')
722 playlistId: playlistServer1Id,
723 videoId: servers[0].videos[3].uuid
724 }) 981 })
725 982
726 await removeVideoFromPlaylist({ 983 it('Should delete some elements', async function () {
727 url: servers[0].url, 984 this.timeout(30000)
728 token: servers[0].accessToken,
729 playlistId: playlistServer1Id,
730 videoId: nsfwVideoServer1
731 })
732 985
733 await waitJobs(servers) 986 await removeVideoFromPlaylist({
987 url: servers[ 0 ].url,
988 token: servers[ 0 ].accessToken,
989 playlistId: playlistServer1Id,
990 playlistElementId: playlistElementServer1Video4
991 })
734 992
735 for (const server of servers) { 993 await removeVideoFromPlaylist({
736 const res = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10) 994 url: servers[ 0 ].url,
995 token: servers[ 0 ].accessToken,
996 playlistId: playlistServer1Id,
997 playlistElementId: playlistElementNSFW
998 })
737 999
738 expect(res.body.total).to.equal(4) 1000 await waitJobs(servers)
739 1001
740 const videos: Video[] = res.body.data 1002 for (const server of servers) {
741 expect(videos).to.have.lengthOf(4) 1003 const res = await getPlaylistVideos(server.url, server.accessToken, playlistServer1UUID, 0, 10)
742 1004
743 expect(videos[ 0 ].name).to.equal('video 0 server 1') 1005 expect(res.body.total).to.equal(4)
744 expect(videos[ 0 ].playlistElement.position).to.equal(1)
745 1006
746 expect(videos[ 1 ].name).to.equal('video 2 server 3') 1007 const elements: VideoPlaylistElement[] = res.body.data
747 expect(videos[ 1 ].playlistElement.position).to.equal(2) 1008 expect(elements).to.have.lengthOf(4)
748 1009
749 expect(videos[ 2 ].name).to.equal('video 1 server 3') 1010 expect(elements[ 0 ].video.name).to.equal('video 0 server 1')
750 expect(videos[ 2 ].playlistElement.position).to.equal(3) 1011 expect(elements[ 0 ].position).to.equal(1)
751 1012
752 expect(videos[ 3 ].name).to.equal('video 4 server 1') 1013 expect(elements[ 1 ].video.name).to.equal('video 2 server 3')
753 expect(videos[ 3 ].playlistElement.position).to.equal(4) 1014 expect(elements[ 1 ].position).to.equal(2)
754 }
755 })
756 1015
757 it('Should be able to create a public playlist, and set it to private', async function () { 1016 expect(elements[ 2 ].video.name).to.equal('video 1 server 3')
758 this.timeout(30000) 1017 expect(elements[ 2 ].position).to.equal(3)
759 1018
760 const res = await createVideoPlaylist({ 1019 expect(elements[ 3 ].video.name).to.equal('video 4 server 1')
761 url: servers[0].url, 1020 expect(elements[ 3 ].position).to.equal(4)
762 token: servers[0].accessToken,
763 playlistAttrs: {
764 displayName: 'my super public playlist',
765 privacy: VideoPlaylistPrivacy.PUBLIC,
766 videoChannelId: servers[0].videoChannel.id
767 } 1021 }
768 }) 1022 })
769 const videoPlaylistIds = res.body.videoPlaylist
770 1023
771 await waitJobs(servers) 1024 it('Should be able to create a public playlist, and set it to private', async function () {
1025 this.timeout(30000)
772 1026
773 for (const server of servers) { 1027 const res = await createVideoPlaylist({
774 await getVideoPlaylist(server.url, videoPlaylistIds.uuid, 200) 1028 url: servers[ 0 ].url,
775 } 1029 token: servers[ 0 ].accessToken,
1030 playlistAttrs: {
1031 displayName: 'my super public playlist',
1032 privacy: VideoPlaylistPrivacy.PUBLIC,
1033 videoChannelId: servers[ 0 ].videoChannel.id
1034 }
1035 })
1036 const videoPlaylistIds = res.body.videoPlaylist
776 1037
777 const playlistAttrs = { privacy: VideoPlaylistPrivacy.PRIVATE } 1038 await waitJobs(servers)
778 await updateVideoPlaylist({ url: servers[0].url, token: servers[0].accessToken, playlistId: videoPlaylistIds.id, playlistAttrs })
779 1039
780 await waitJobs(servers) 1040 for (const server of servers) {
1041 await getVideoPlaylist(server.url, videoPlaylistIds.uuid, 200)
1042 }
781 1043
782 for (const server of [ servers[1], servers[2] ]) { 1044 const playlistAttrs = { privacy: VideoPlaylistPrivacy.PRIVATE }
783 await getVideoPlaylist(server.url, videoPlaylistIds.uuid, 404) 1045 await updateVideoPlaylist({ url: servers[ 0 ].url, token: servers[ 0 ].accessToken, playlistId: videoPlaylistIds.id, playlistAttrs })
784 }
785 await getVideoPlaylist(servers[0].url, videoPlaylistIds.uuid, 401)
786 1046
787 await getVideoPlaylistWithToken(servers[0].url, servers[0].accessToken, videoPlaylistIds.uuid, 200) 1047 await waitJobs(servers)
788 })
789 1048
790 it('Should delete the playlist on server 1 and delete on server 2 and 3', async function () { 1049 for (const server of [ servers[ 1 ], servers[ 2 ] ]) {
791 this.timeout(30000) 1050 await getVideoPlaylist(server.url, videoPlaylistIds.uuid, 404)
1051 }
1052 await getVideoPlaylist(servers[ 0 ].url, videoPlaylistIds.uuid, 401)
792 1053
793 await deleteVideoPlaylist(servers[0].url, servers[0].accessToken, playlistServer1Id) 1054 await getVideoPlaylistWithToken(servers[ 0 ].url, servers[ 0 ].accessToken, videoPlaylistIds.uuid, 200)
1055 })
1056 })
794 1057
795 await waitJobs(servers) 1058 describe('Playlist deletion', function () {
796 1059
797 for (const server of servers) { 1060 it('Should delete the playlist on server 1 and delete on server 2 and 3', async function () {
798 await getVideoPlaylist(server.url, playlistServer1UUID, 404) 1061 this.timeout(30000)
799 }
800 })
801 1062
802 it('Should have deleted the thumbnail on server 1, 2 and 3', async function () { 1063 await deleteVideoPlaylist(servers[ 0 ].url, servers[ 0 ].accessToken, playlistServer1Id)
803 this.timeout(30000)
804 1064
805 for (const server of servers) { 1065 await waitJobs(servers)
806 await checkPlaylistFilesWereRemoved(playlistServer1UUID, server.internalServerNumber)
807 }
808 })
809 1066
810 it('Should unfollow servers 1 and 2 and hide their playlists', async function () { 1067 for (const server of servers) {
811 this.timeout(30000) 1068 await getVideoPlaylist(server.url, playlistServer1UUID, 404)
1069 }
1070 })
812 1071
813 const finder = data => data.find(p => p.displayName === 'my super playlist') 1072 it('Should have deleted the thumbnail on server 1, 2 and 3', async function () {
1073 this.timeout(30000)
814 1074
815 { 1075 for (const server of servers) {
816 const res = await getVideoPlaylistsList(servers[ 2 ].url, 0, 5) 1076 await checkPlaylistFilesWereRemoved(playlistServer1UUID, server.internalServerNumber)
817 expect(res.body.total).to.equal(2) 1077 }
818 expect(finder(res.body.data)).to.not.be.undefined 1078 })
819 }
820 1079
821 await unfollow(servers[2].url, servers[2].accessToken, servers[0]) 1080 it('Should unfollow servers 1 and 2 and hide their playlists', async function () {
1081 this.timeout(30000)
822 1082
823 { 1083 const finder = data => data.find(p => p.displayName === 'my super playlist')
824 const res = await getVideoPlaylistsList(servers[ 2 ].url, 0, 5)
825 expect(res.body.total).to.equal(1)
826 1084
827 expect(finder(res.body.data)).to.be.undefined 1085 {
828 } 1086 const res = await getVideoPlaylistsList(servers[ 2 ].url, 0, 5)
829 }) 1087 expect(res.body.total).to.equal(3)
1088 expect(finder(res.body.data)).to.not.be.undefined
1089 }
830 1090
831 it('Should delete a channel and put the associated playlist in private mode', async function () { 1091 await unfollow(servers[ 2 ].url, servers[ 2 ].accessToken, servers[ 0 ])
832 this.timeout(30000)
833 1092
834 const res = await addVideoChannel(servers[0].url, servers[0].accessToken, { name: 'super_channel', displayName: 'super channel' }) 1093 {
835 const videoChannelId = res.body.videoChannel.id 1094 const res = await getVideoPlaylistsList(servers[ 2 ].url, 0, 5)
1095 expect(res.body.total).to.equal(1)
836 1096
837 const res2 = await createVideoPlaylist({ 1097 expect(finder(res.body.data)).to.be.undefined
838 url: servers[0].url,
839 token: servers[0].accessToken,
840 playlistAttrs: {
841 displayName: 'channel playlist',
842 privacy: VideoPlaylistPrivacy.PUBLIC,
843 videoChannelId
844 } 1098 }
845 }) 1099 })
846 const videoPlaylistUUID = res2.body.videoPlaylist.uuid
847 1100
848 await waitJobs(servers) 1101 it('Should delete a channel and put the associated playlist in private mode', async function () {
1102 this.timeout(30000)
849 1103
850 await deleteVideoChannel(servers[0].url, servers[0].accessToken, 'super_channel') 1104 const res = await addVideoChannel(servers[ 0 ].url, servers[ 0 ].accessToken, { name: 'super_channel', displayName: 'super channel' })
1105 const videoChannelId = res.body.videoChannel.id
851 1106
852 await waitJobs(servers) 1107 const res2 = await createVideoPlaylist({
1108 url: servers[ 0 ].url,
1109 token: servers[ 0 ].accessToken,
1110 playlistAttrs: {
1111 displayName: 'channel playlist',
1112 privacy: VideoPlaylistPrivacy.PUBLIC,
1113 videoChannelId
1114 }
1115 })
1116 const videoPlaylistUUID = res2.body.videoPlaylist.uuid
1117
1118 await waitJobs(servers)
853 1119
854 const res3 = await getVideoPlaylistWithToken(servers[0].url, servers[0].accessToken, videoPlaylistUUID) 1120 await deleteVideoChannel(servers[ 0 ].url, servers[ 0 ].accessToken, 'super_channel')
855 expect(res3.body.displayName).to.equal('channel playlist')
856 expect(res3.body.privacy.id).to.equal(VideoPlaylistPrivacy.PRIVATE)
857 1121
858 await getVideoPlaylist(servers[1].url, videoPlaylistUUID, 404) 1122 await waitJobs(servers)
859 })
860 1123
861 it('Should delete an account and delete its playlists', async function () { 1124 const res3 = await getVideoPlaylistWithToken(servers[ 0 ].url, servers[ 0 ].accessToken, videoPlaylistUUID)
862 this.timeout(30000) 1125 expect(res3.body.displayName).to.equal('channel playlist')
1126 expect(res3.body.privacy.id).to.equal(VideoPlaylistPrivacy.PRIVATE)
863 1127
864 const user = { username: 'user_1', password: 'password' } 1128 await getVideoPlaylist(servers[ 1 ].url, videoPlaylistUUID, 404)
865 const res = await createUser({
866 url: servers[ 0 ].url,
867 accessToken: servers[ 0 ].accessToken,
868 username: user.username,
869 password: user.password
870 }) 1129 })
871 1130
872 const userId = res.body.user.id 1131 it('Should delete an account and delete its playlists', async function () {
873 const userAccessToken = await userLogin(servers[0], user) 1132 this.timeout(30000)
874 1133
875 const resChannel = await getMyUserInformation(servers[0].url, userAccessToken) 1134 const user = { username: 'user_1', password: 'password' }
876 const userChannel = (resChannel.body as User).videoChannels[0] 1135 const res = await createUser({
1136 url: servers[ 0 ].url,
1137 accessToken: servers[ 0 ].accessToken,
1138 username: user.username,
1139 password: user.password
1140 })
877 1141
878 await createVideoPlaylist({ 1142 const userId = res.body.user.id
879 url: servers[0].url, 1143 const userAccessToken = await userLogin(servers[ 0 ], user)
880 token: userAccessToken,
881 playlistAttrs: {
882 displayName: 'playlist to be deleted',
883 privacy: VideoPlaylistPrivacy.PUBLIC,
884 videoChannelId: userChannel.id
885 }
886 })
887 1144
888 await waitJobs(servers) 1145 const resChannel = await getMyUserInformation(servers[ 0 ].url, userAccessToken)
1146 const userChannel = (resChannel.body as User).videoChannels[ 0 ]
889 1147
890 const finder = data => data.find(p => p.displayName === 'playlist to be deleted') 1148 await createVideoPlaylist({
1149 url: servers[ 0 ].url,
1150 token: userAccessToken,
1151 playlistAttrs: {
1152 displayName: 'playlist to be deleted',
1153 privacy: VideoPlaylistPrivacy.PUBLIC,
1154 videoChannelId: userChannel.id
1155 }
1156 })
891 1157
892 { 1158 await waitJobs(servers)
893 for (const server of [ servers[0], servers[1] ]) { 1159
894 const res = await getVideoPlaylistsList(server.url, 0, 15) 1160 const finder = data => data.find(p => p.displayName === 'playlist to be deleted')
895 expect(finder(res.body.data)).to.not.be.undefined 1161
1162 {
1163 for (const server of [ servers[ 0 ], servers[ 1 ] ]) {
1164 const res = await getVideoPlaylistsList(server.url, 0, 15)
1165 expect(finder(res.body.data)).to.not.be.undefined
1166 }
896 } 1167 }
897 }
898 1168
899 await removeUser(servers[0].url, userId, servers[0].accessToken) 1169 await removeUser(servers[ 0 ].url, userId, servers[ 0 ].accessToken)
900 await waitJobs(servers) 1170 await waitJobs(servers)
901 1171
902 { 1172 {
903 for (const server of [ servers[0], servers[1] ]) { 1173 for (const server of [ servers[ 0 ], servers[ 1 ] ]) {
904 const res = await getVideoPlaylistsList(server.url, 0, 15) 1174 const res = await getVideoPlaylistsList(server.url, 0, 15)
905 expect(finder(res.body.data)).to.be.undefined 1175 expect(finder(res.body.data)).to.be.undefined
1176 }
906 } 1177 }
907 } 1178 })
908 }) 1179 })
909 1180
910 after(async function () { 1181 after(async function () {
diff --git a/shared/extra-utils/server/jobs.ts b/shared/extra-utils/server/jobs.ts
index 11b570f60..b3db885e8 100644
--- a/shared/extra-utils/server/jobs.ts
+++ b/shared/extra-utils/server/jobs.ts
@@ -2,7 +2,6 @@ import * as request from 'supertest'
2import { Job, JobState } from '../../models' 2import { Job, JobState } from '../../models'
3import { wait } from '../miscs/miscs' 3import { wait } from '../miscs/miscs'
4import { ServerInfo } from './servers' 4import { ServerInfo } from './servers'
5import { inspect } from 'util'
6 5
7function getJobsList (url: string, accessToken: string, state: JobState) { 6function getJobsList (url: string, accessToken: string, state: JobState) {
8 const path = '/api/v1/jobs/' + state 7 const path = '/api/v1/jobs/' + state
@@ -37,11 +36,10 @@ async function waitJobs (serversArg: ServerInfo[] | ServerInfo) {
37 else servers = serversArg as ServerInfo[] 36 else servers = serversArg as ServerInfo[]
38 37
39 const states: JobState[] = [ 'waiting', 'active', 'delayed' ] 38 const states: JobState[] = [ 'waiting', 'active', 'delayed' ]
40 let pendingRequests = false 39 let pendingRequests: boolean
41 40
42 function tasksBuilder () { 41 function tasksBuilder () {
43 const tasks: Promise<any>[] = [] 42 const tasks: Promise<any>[] = []
44 pendingRequests = false
45 43
46 // Check if each server has pending request 44 // Check if each server has pending request
47 for (const server of servers) { 45 for (const server of servers) {
@@ -62,6 +60,7 @@ async function waitJobs (serversArg: ServerInfo[] | ServerInfo) {
62 } 60 }
63 61
64 do { 62 do {
63 pendingRequests = false
65 await Promise.all(tasksBuilder()) 64 await Promise.all(tasksBuilder())
66 65
67 // Retry, in case of new jobs were created 66 // Retry, in case of new jobs were created
diff --git a/shared/extra-utils/videos/video-playlists.ts b/shared/extra-utils/videos/video-playlists.ts
index fd62bef19..cbb073fbc 100644
--- a/shared/extra-utils/videos/video-playlists.ts
+++ b/shared/extra-utils/videos/video-playlists.ts
@@ -196,11 +196,11 @@ function updateVideoPlaylistElement (options: {
196 url: string, 196 url: string,
197 token: string, 197 token: string,
198 playlistId: number | string, 198 playlistId: number | string,
199 videoId: number | string, 199 playlistElementId: number | string,
200 elementAttrs: VideoPlaylistElementUpdate, 200 elementAttrs: VideoPlaylistElementUpdate,
201 expectedStatus?: number 201 expectedStatus?: number
202}) { 202}) {
203 const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.videoId 203 const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.playlistElementId
204 204
205 return makePutBodyRequest({ 205 return makePutBodyRequest({
206 url: options.url, 206 url: options.url,
@@ -215,10 +215,10 @@ function removeVideoFromPlaylist (options: {
215 url: string, 215 url: string,
216 token: string, 216 token: string,
217 playlistId: number | string, 217 playlistId: number | string,
218 videoId: number | string, 218 playlistElementId: number,
219 expectedStatus?: number 219 expectedStatus?: number
220}) { 220}) {
221 const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.videoId 221 const path = '/api/v1/video-playlists/' + options.playlistId + '/videos/' + options.playlistElementId
222 222
223 return makeDeleteRequest({ 223 return makeDeleteRequest({
224 url: options.url, 224 url: options.url,
diff --git a/shared/models/videos/index.ts b/shared/models/videos/index.ts
index e3d78220e..194ae1b96 100644
--- a/shared/models/videos/index.ts
+++ b/shared/models/videos/index.ts
@@ -19,6 +19,7 @@ export * from './playlist/video-playlist-privacy.model'
19export * from './playlist/video-playlist-type.model' 19export * from './playlist/video-playlist-type.model'
20export * from './playlist/video-playlist-update.model' 20export * from './playlist/video-playlist-update.model'
21export * from './playlist/video-playlist.model' 21export * from './playlist/video-playlist.model'
22export * from './playlist/video-playlist-element.model'
22export * from './video-change-ownership.model' 23export * from './video-change-ownership.model'
23export * from './video-change-ownership-create.model' 24export * from './video-change-ownership-create.model'
24export * from './video-create.model' 25export * from './video-create.model'
diff --git a/shared/models/videos/playlist/video-exist-in-playlist.model.ts b/shared/models/videos/playlist/video-exist-in-playlist.model.ts
index 71240f51d..1b57257e2 100644
--- a/shared/models/videos/playlist/video-exist-in-playlist.model.ts
+++ b/shared/models/videos/playlist/video-exist-in-playlist.model.ts
@@ -1,5 +1,6 @@
1export type VideoExistInPlaylist = { 1export type VideoExistInPlaylist = {
2 [videoId: number ]: { 2 [videoId: number ]: {
3 playlistElementId: number
3 playlistId: number 4 playlistId: number
4 startTimestamp?: number 5 startTimestamp?: number
5 stopTimestamp?: number 6 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
index 000000000..9a1203892
--- /dev/null
+++ b/shared/models/videos/playlist/video-playlist-element.model.ts
@@ -0,0 +1,19 @@
1import { Video } from '../video.model'
2
3export enum VideoPlaylistElementType {
4 REGULAR = 0,
5 DELETED = 1,
6 PRIVATE = 2,
7 UNAVAILABLE = 3 // Blacklisted, blocked by the user/instance, NSFW...
8}
9
10export interface VideoPlaylistElement {
11 id: number
12 position: number
13 startTimestamp: number
14 stopTimestamp: number
15
16 type: VideoPlaylistElementType
17
18 video?: Video
19}
diff --git a/shared/models/videos/video.model.ts b/shared/models/videos/video.model.ts
index 0489147e4..e057b3e06 100644
--- a/shared/models/videos/video.model.ts
+++ b/shared/models/videos/video.model.ts
@@ -17,12 +17,6 @@ export interface VideoFile {
17 fps: number 17 fps: number
18} 18}
19 19
20export interface PlaylistElement {
21 position: number
22 startTimestamp: number
23 stopTimestamp: number
24}
25
26export interface Video { 20export interface Video {
27 id: number 21 id: number
28 uuid: string 22 uuid: string
@@ -59,8 +53,6 @@ export interface Video {
59 userHistory?: { 53 userHistory?: {
60 currentTime: number 54 currentTime: number
61 } 55 }
62
63 playlistElement?: PlaylistElement
64} 56}
65 57
66export interface VideoDetails extends Video { 58export interface VideoDetails extends Video {
diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml
index a6f61b3b2..39fa3cef5 100644
--- a/support/doc/api/openapi.yaml
+++ b/support/doc/api/openapi.yaml
@@ -1922,6 +1922,9 @@ components:
1922 type: number 1922 type: number
1923 stopTimestamp: 1923 stopTimestamp:
1924 type: number 1924 type: number
1925 video:
1926 nullable: true
1927 $ref: '#/components/schemas/Video'
1925 VideoFile: 1928 VideoFile:
1926 properties: 1929 properties:
1927 magnetUri: 1930 magnetUri:
@@ -2029,9 +2032,6 @@ components:
2029 properties: 2032 properties:
2030 currentTime: 2033 currentTime:
2031 type: number 2034 type: number
2032 playlistElement:
2033 nullable: true
2034 $ref: '#/components/schemas/PlaylistElement'
2035 VideoDetails: 2035 VideoDetails:
2036 allOf: 2036 allOf:
2037 - $ref: '#/components/schemas/Video' 2037 - $ref: '#/components/schemas/Video'