]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video-playlist.ts
Fix multiple server tests
[github/Chocobozzz/PeerTube.git] / server / models / video / video-playlist.ts
1 import {
2 AllowNull,
3 BelongsTo,
4 Column,
5 CreatedAt,
6 DataType,
7 Default,
8 ForeignKey,
9 HasMany,
10 HasOne,
11 Is,
12 IsUUID,
13 Model,
14 Scopes,
15 Table,
16 UpdatedAt
17 } from 'sequelize-typescript'
18 import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
19 import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getSort, isOutdated, throwIfNotValid } from '../utils'
20 import {
21 isVideoPlaylistDescriptionValid,
22 isVideoPlaylistNameValid,
23 isVideoPlaylistPrivacyValid
24 } from '../../helpers/custom-validators/video-playlists'
25 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
26 import {
27 ACTIVITY_PUB,
28 CONSTRAINTS_FIELDS,
29 STATIC_PATHS,
30 THUMBNAILS_SIZE,
31 VIDEO_PLAYLIST_PRIVACIES,
32 VIDEO_PLAYLIST_TYPES,
33 WEBSERVER
34 } from '../../initializers/constants'
35 import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model'
36 import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
37 import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
38 import { join } from 'path'
39 import { VideoPlaylistElementModel } from './video-playlist-element'
40 import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
41 import { activityPubCollectionPagination } from '../../helpers/activitypub'
42 import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model'
43 import { ThumbnailModel } from './thumbnail'
44 import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
45 import { FindOptions, literal, Op, ScopeOptions, Transaction, WhereOptions } from 'sequelize'
46
47 enum ScopeNames {
48 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
49 WITH_VIDEOS_LENGTH = 'WITH_VIDEOS_LENGTH',
50 WITH_ACCOUNT_AND_CHANNEL_SUMMARY = 'WITH_ACCOUNT_AND_CHANNEL_SUMMARY',
51 WITH_ACCOUNT = 'WITH_ACCOUNT',
52 WITH_THUMBNAIL = 'WITH_THUMBNAIL',
53 WITH_ACCOUNT_AND_CHANNEL = 'WITH_ACCOUNT_AND_CHANNEL'
54 }
55
56 type AvailableForListOptions = {
57 followerActorId: number
58 type?: VideoPlaylistType
59 accountId?: number
60 videoChannelId?: number
61 privateAndUnlisted?: boolean
62 }
63
64 @Scopes(() => ({
65 [ ScopeNames.WITH_THUMBNAIL ]: {
66 include: [
67 {
68 model: ThumbnailModel,
69 required: false
70 }
71 ]
72 },
73 [ ScopeNames.WITH_VIDEOS_LENGTH ]: {
74 attributes: {
75 include: [
76 [
77 literal('(SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id")'),
78 'videosLength'
79 ]
80 ]
81 }
82 } as FindOptions,
83 [ ScopeNames.WITH_ACCOUNT ]: {
84 include: [
85 {
86 model: AccountModel,
87 required: true
88 }
89 ]
90 },
91 [ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY ]: {
92 include: [
93 {
94 model: AccountModel.scope(AccountScopeNames.SUMMARY),
95 required: true
96 },
97 {
98 model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
99 required: false
100 }
101 ]
102 },
103 [ ScopeNames.WITH_ACCOUNT_AND_CHANNEL ]: {
104 include: [
105 {
106 model: AccountModel,
107 required: true
108 },
109 {
110 model: VideoChannelModel,
111 required: false
112 }
113 ]
114 },
115 [ ScopeNames.AVAILABLE_FOR_LIST ]: (options: AvailableForListOptions) => {
116 // Only list local playlists OR playlists that are on an instance followed by actorId
117 const inQueryInstanceFollow = buildServerIdsFollowedBy(options.followerActorId)
118 const whereActor = {
119 [ Op.or ]: [
120 {
121 serverId: null
122 },
123 {
124 serverId: {
125 [ Op.in ]: literal(inQueryInstanceFollow)
126 }
127 }
128 ]
129 }
130
131 const whereAnd: WhereOptions[] = []
132
133 if (options.privateAndUnlisted !== true) {
134 whereAnd.push({
135 privacy: VideoPlaylistPrivacy.PUBLIC
136 })
137 }
138
139 if (options.accountId) {
140 whereAnd.push({
141 ownerAccountId: options.accountId
142 })
143 }
144
145 if (options.videoChannelId) {
146 whereAnd.push({
147 videoChannelId: options.videoChannelId
148 })
149 }
150
151 if (options.type) {
152 whereAnd.push({
153 type: options.type
154 })
155 }
156
157 const where = {
158 [Op.and]: whereAnd
159 }
160
161 const accountScope = {
162 method: [ AccountScopeNames.SUMMARY, { whereActor } as SummaryOptions ]
163 }
164
165 return {
166 where,
167 include: [
168 {
169 model: AccountModel.scope(accountScope),
170 required: true
171 },
172 {
173 model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY),
174 required: false
175 }
176 ]
177 } as FindOptions
178 }
179 }))
180
181 @Table({
182 tableName: 'videoPlaylist',
183 indexes: [
184 {
185 fields: [ 'ownerAccountId' ]
186 },
187 {
188 fields: [ 'videoChannelId' ]
189 },
190 {
191 fields: [ 'url' ],
192 unique: true
193 }
194 ]
195 })
196 export class VideoPlaylistModel extends Model<VideoPlaylistModel> {
197 @CreatedAt
198 createdAt: Date
199
200 @UpdatedAt
201 updatedAt: Date
202
203 @AllowNull(false)
204 @Is('VideoPlaylistName', value => throwIfNotValid(value, isVideoPlaylistNameValid, 'name'))
205 @Column
206 name: string
207
208 @AllowNull(true)
209 @Is('VideoPlaylistDescription', value => throwIfNotValid(value, isVideoPlaylistDescriptionValid, 'description', true))
210 @Column
211 description: string
212
213 @AllowNull(false)
214 @Is('VideoPlaylistPrivacy', value => throwIfNotValid(value, isVideoPlaylistPrivacyValid, 'privacy'))
215 @Column
216 privacy: VideoPlaylistPrivacy
217
218 @AllowNull(false)
219 @Is('VideoPlaylistUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
220 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.URL.max))
221 url: string
222
223 @AllowNull(false)
224 @Default(DataType.UUIDV4)
225 @IsUUID(4)
226 @Column(DataType.UUID)
227 uuid: string
228
229 @AllowNull(false)
230 @Default(VideoPlaylistType.REGULAR)
231 @Column
232 type: VideoPlaylistType
233
234 @ForeignKey(() => AccountModel)
235 @Column
236 ownerAccountId: number
237
238 @BelongsTo(() => AccountModel, {
239 foreignKey: {
240 allowNull: false
241 },
242 onDelete: 'CASCADE'
243 })
244 OwnerAccount: AccountModel
245
246 @ForeignKey(() => VideoChannelModel)
247 @Column
248 videoChannelId: number
249
250 @BelongsTo(() => VideoChannelModel, {
251 foreignKey: {
252 allowNull: true
253 },
254 onDelete: 'CASCADE'
255 })
256 VideoChannel: VideoChannelModel
257
258 @HasMany(() => VideoPlaylistElementModel, {
259 foreignKey: {
260 name: 'videoPlaylistId',
261 allowNull: false
262 },
263 onDelete: 'CASCADE'
264 })
265 VideoPlaylistElements: VideoPlaylistElementModel[]
266
267 @HasOne(() => ThumbnailModel, {
268
269 foreignKey: {
270 name: 'videoPlaylistId',
271 allowNull: true
272 },
273 onDelete: 'CASCADE',
274 hooks: true
275 })
276 Thumbnail: ThumbnailModel
277
278 static listForApi (options: {
279 followerActorId: number
280 start: number,
281 count: number,
282 sort: string,
283 type?: VideoPlaylistType,
284 accountId?: number,
285 videoChannelId?: number,
286 privateAndUnlisted?: boolean
287 }) {
288 const query = {
289 offset: options.start,
290 limit: options.count,
291 order: getSort(options.sort)
292 }
293
294 const scopes: (string | ScopeOptions)[] = [
295 {
296 method: [
297 ScopeNames.AVAILABLE_FOR_LIST,
298 {
299 type: options.type,
300 followerActorId: options.followerActorId,
301 accountId: options.accountId,
302 videoChannelId: options.videoChannelId,
303 privateAndUnlisted: options.privateAndUnlisted
304 } as AvailableForListOptions
305 ]
306 },
307 ScopeNames.WITH_VIDEOS_LENGTH,
308 ScopeNames.WITH_THUMBNAIL
309 ]
310
311 return VideoPlaylistModel
312 .scope(scopes)
313 .findAndCountAll(query)
314 .then(({ rows, count }) => {
315 return { total: count, data: rows }
316 })
317 }
318
319 static listPublicUrlsOfForAP (accountId: number, start: number, count: number) {
320 const query = {
321 attributes: [ 'url' ],
322 offset: start,
323 limit: count,
324 where: {
325 ownerAccountId: accountId,
326 privacy: VideoPlaylistPrivacy.PUBLIC
327 }
328 }
329
330 return VideoPlaylistModel.findAndCountAll(query)
331 .then(({ rows, count }) => {
332 return { total: count, data: rows.map(p => p.url) }
333 })
334 }
335
336 static listPlaylistIdsOf (accountId: number, videoIds: number[]) {
337 const query = {
338 attributes: [ 'id' ],
339 where: {
340 ownerAccountId: accountId
341 },
342 include: [
343 {
344 attributes: [ 'id', 'videoId', 'startTimestamp', 'stopTimestamp' ],
345 model: VideoPlaylistElementModel.unscoped(),
346 where: {
347 videoId: {
348 [Op.in]: videoIds // FIXME: sequelize ANY seems broken
349 }
350 },
351 required: true
352 }
353 ]
354 }
355
356 return VideoPlaylistModel.findAll(query)
357 }
358
359 static doesPlaylistExist (url: string) {
360 const query = {
361 attributes: [],
362 where: {
363 url
364 }
365 }
366
367 return VideoPlaylistModel
368 .findOne(query)
369 .then(e => !!e)
370 }
371
372 static loadWithAccountAndChannelSummary (id: number | string, transaction: Transaction) {
373 const where = buildWhereIdOrUUID(id)
374
375 const query = {
376 where,
377 transaction
378 }
379
380 return VideoPlaylistModel
381 .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
382 .findOne(query)
383 }
384
385 static loadWithAccountAndChannel (id: number | string, transaction: Transaction) {
386 const where = buildWhereIdOrUUID(id)
387
388 const query = {
389 where,
390 transaction
391 }
392
393 return VideoPlaylistModel
394 .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
395 .findOne(query)
396 }
397
398 static loadByUrlAndPopulateAccount (url: string) {
399 const query = {
400 where: {
401 url
402 }
403 }
404
405 return VideoPlaylistModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_THUMBNAIL ]).findOne(query)
406 }
407
408 static getPrivacyLabel (privacy: VideoPlaylistPrivacy) {
409 return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown'
410 }
411
412 static getTypeLabel (type: VideoPlaylistType) {
413 return VIDEO_PLAYLIST_TYPES[type] || 'Unknown'
414 }
415
416 static resetPlaylistsOfChannel (videoChannelId: number, transaction: Transaction) {
417 const query = {
418 where: {
419 videoChannelId
420 },
421 transaction
422 }
423
424 return VideoPlaylistModel.update({ privacy: VideoPlaylistPrivacy.PRIVATE, videoChannelId: null }, query)
425 }
426
427 async setAndSaveThumbnail (thumbnail: ThumbnailModel, t: Transaction) {
428 thumbnail.videoPlaylistId = this.id
429
430 this.Thumbnail = await thumbnail.save({ transaction: t })
431 }
432
433 hasThumbnail () {
434 return !!this.Thumbnail
435 }
436
437 generateThumbnailName () {
438 const extension = '.jpg'
439
440 return 'playlist-' + this.uuid + extension
441 }
442
443 getThumbnailUrl () {
444 if (!this.hasThumbnail()) return null
445
446 return WEBSERVER.URL + STATIC_PATHS.THUMBNAILS + this.Thumbnail.filename
447 }
448
449 getThumbnailStaticPath () {
450 if (!this.hasThumbnail()) return null
451
452 return join(STATIC_PATHS.THUMBNAILS, this.Thumbnail.filename)
453 }
454
455 setAsRefreshed () {
456 this.changed('updatedAt', true)
457
458 return this.save()
459 }
460
461 isOwned () {
462 return this.OwnerAccount.isOwned()
463 }
464
465 isOutdated () {
466 if (this.isOwned()) return false
467
468 return isOutdated(this, ACTIVITY_PUB.VIDEO_PLAYLIST_REFRESH_INTERVAL)
469 }
470
471 toFormattedJSON (): VideoPlaylist {
472 return {
473 id: this.id,
474 uuid: this.uuid,
475 isLocal: this.isOwned(),
476
477 displayName: this.name,
478 description: this.description,
479 privacy: {
480 id: this.privacy,
481 label: VideoPlaylistModel.getPrivacyLabel(this.privacy)
482 },
483
484 thumbnailPath: this.getThumbnailStaticPath(),
485
486 type: {
487 id: this.type,
488 label: VideoPlaylistModel.getTypeLabel(this.type)
489 },
490
491 videosLength: this.get('videosLength') as number,
492
493 createdAt: this.createdAt,
494 updatedAt: this.updatedAt,
495
496 ownerAccount: this.OwnerAccount.toFormattedSummaryJSON(),
497 videoChannel: this.VideoChannel ? this.VideoChannel.toFormattedSummaryJSON() : null
498 }
499 }
500
501 toActivityPubObject (page: number, t: Transaction): Promise<PlaylistObject> {
502 const handler = (start: number, count: number) => {
503 return VideoPlaylistElementModel.listUrlsOfForAP(this.id, start, count, t)
504 }
505
506 let icon: ActivityIconObject
507 if (this.hasThumbnail()) {
508 icon = {
509 type: 'Image' as 'Image',
510 url: this.getThumbnailUrl(),
511 mediaType: 'image/jpeg' as 'image/jpeg',
512 width: THUMBNAILS_SIZE.width,
513 height: THUMBNAILS_SIZE.height
514 }
515 }
516
517 return activityPubCollectionPagination(this.url, handler, page)
518 .then(o => {
519 return Object.assign(o, {
520 type: 'Playlist' as 'Playlist',
521 name: this.name,
522 content: this.description,
523 uuid: this.uuid,
524 published: this.createdAt.toISOString(),
525 updated: this.updatedAt.toISOString(),
526 attributedTo: this.VideoChannel ? [ this.VideoChannel.Actor.url ] : [],
527 icon
528 })
529 })
530 }
531 }