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