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