Create a dedicated table to track video thumbnails
[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 * as Sequelize from 'sequelize'
19 import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
20 import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getSort, isOutdated, throwIfNotValid } from '../utils'
21 import {
22   isVideoPlaylistDescriptionValid,
23   isVideoPlaylistNameValid,
24   isVideoPlaylistPrivacyValid
25 } from '../../helpers/custom-validators/video-playlists'
26 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
27 import {
28   ACTIVITY_PUB,
29   CONSTRAINTS_FIELDS,
30   STATIC_PATHS,
31   THUMBNAILS_SIZE,
32   VIDEO_PLAYLIST_PRIVACIES,
33   VIDEO_PLAYLIST_TYPES,
34   WEBSERVER
35 } from '../../initializers/constants'
36 import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model'
37 import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account'
38 import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
39 import { join } from 'path'
40 import { VideoPlaylistElementModel } from './video-playlist-element'
41 import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
42 import { activityPubCollectionPagination } from '../../helpers/activitypub'
43 import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model'
44 import { ThumbnailModel } from './thumbnail'
45 import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
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           Sequelize.literal('(SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id")'),
78           'videosLength'
79         ]
80       ]
81     }
82   },
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 actorWhere = {
119       [ Sequelize.Op.or ]: [
120         {
121           serverId: null
122         },
123         {
124           serverId: {
125             [ Sequelize.Op.in ]: Sequelize.literal(inQueryInstanceFollow)
126           }
127         }
128       ]
129     }
130
131     const whereAnd: any[] = []
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       [Sequelize.Op.and]: whereAnd
159     }
160
161     const accountScope = {
162       method: [ AccountScopeNames.SUMMARY, actorWhere ]
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     }
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'))
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     foreignKey: {
269       name: 'videoPlaylistId',
270       allowNull: true
271     },
272     onDelete: 'CASCADE',
273     hooks: true
274   })
275   Thumbnail: ThumbnailModel
276
277   static listForApi (options: {
278     followerActorId: number
279     start: number,
280     count: number,
281     sort: string,
282     type?: VideoPlaylistType,
283     accountId?: number,
284     videoChannelId?: number,
285     privateAndUnlisted?: boolean
286   }) {
287     const query = {
288       offset: options.start,
289       limit: options.count,
290       order: getSort(options.sort)
291     }
292
293     const scopes = [
294       {
295         method: [
296           ScopeNames.AVAILABLE_FOR_LIST,
297           {
298             type: options.type,
299             followerActorId: options.followerActorId,
300             accountId: options.accountId,
301             videoChannelId: options.videoChannelId,
302             privateAndUnlisted: options.privateAndUnlisted
303           } as AvailableForListOptions
304         ]
305       } as any, // FIXME: typings
306       ScopeNames.WITH_VIDEOS_LENGTH,
307       ScopeNames.WITH_THUMBNAIL
308     ]
309
310     return VideoPlaylistModel
311       .scope(scopes)
312       .findAndCountAll(query)
313       .then(({ rows, count }) => {
314         return { total: count, data: rows }
315       })
316   }
317
318   static listPublicUrlsOfForAP (accountId: number, start: number, count: number) {
319     const query = {
320       attributes: [ 'url' ],
321       offset: start,
322       limit: count,
323       where: {
324         ownerAccountId: accountId,
325         privacy: VideoPlaylistPrivacy.PUBLIC
326       }
327     }
328
329     return VideoPlaylistModel.findAndCountAll(query)
330                              .then(({ rows, count }) => {
331                                return { total: count, data: rows.map(p => p.url) }
332                              })
333   }
334
335   static listPlaylistIdsOf (accountId: number, videoIds: number[]) {
336     const query = {
337       attributes: [ 'id' ],
338       where: {
339         ownerAccountId: accountId
340       },
341       include: [
342         {
343           attributes: [ 'videoId', 'startTimestamp', 'stopTimestamp' ],
344           model: VideoPlaylistElementModel.unscoped(),
345           where: {
346             videoId: {
347               [Sequelize.Op.any]: videoIds
348             }
349           },
350           required: true
351         }
352       ]
353     }
354
355     return VideoPlaylistModel.findAll(query)
356   }
357
358   static doesPlaylistExist (url: string) {
359     const query = {
360       attributes: [],
361       where: {
362         url
363       }
364     }
365
366     return VideoPlaylistModel
367       .findOne(query)
368       .then(e => !!e)
369   }
370
371   static loadWithAccountAndChannelSummary (id: number | string, transaction: Sequelize.Transaction) {
372     const where = buildWhereIdOrUUID(id)
373
374     const query = {
375       where,
376       transaction
377     }
378
379     return VideoPlaylistModel
380       .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
381       .findOne(query)
382   }
383
384   static loadWithAccountAndChannel (id: number | string, transaction: Sequelize.Transaction) {
385     const where = buildWhereIdOrUUID(id)
386
387     const query = {
388       where,
389       transaction
390     }
391
392     return VideoPlaylistModel
393       .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
394       .findOne(query)
395   }
396
397   static loadByUrlAndPopulateAccount (url: string) {
398     const query = {
399       where: {
400         url
401       }
402     }
403
404     return VideoPlaylistModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_THUMBNAIL ]).findOne(query)
405   }
406
407   static getPrivacyLabel (privacy: VideoPlaylistPrivacy) {
408     return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown'
409   }
410
411   static getTypeLabel (type: VideoPlaylistType) {
412     return VIDEO_PLAYLIST_TYPES[type] || 'Unknown'
413   }
414
415   static resetPlaylistsOfChannel (videoChannelId: number, transaction: Sequelize.Transaction) {
416     const query = {
417       where: {
418         videoChannelId
419       },
420       transaction
421     }
422
423     return VideoPlaylistModel.update({ privacy: VideoPlaylistPrivacy.PRIVATE, videoChannelId: null }, query)
424   }
425
426   setThumbnail (thumbnail: ThumbnailModel) {
427     this.Thumbnail = thumbnail
428   }
429
430   getThumbnail () {
431     return this.Thumbnail
432   }
433
434   hasThumbnail () {
435     return !!this.Thumbnail
436   }
437
438   generateThumbnailName () {
439     const extension = '.jpg'
440
441     return 'playlist-' + this.uuid + extension
442   }
443
444   getThumbnailUrl () {
445     if (!this.hasThumbnail()) return null
446
447     return WEBSERVER.URL + STATIC_PATHS.THUMBNAILS + this.getThumbnail().filename
448   }
449
450   getThumbnailStaticPath () {
451     if (!this.hasThumbnail()) return null
452
453     return join(STATIC_PATHS.THUMBNAILS, this.getThumbnail().filename)
454   }
455
456   setAsRefreshed () {
457     this.changed('updatedAt', true)
458
459     return this.save()
460   }
461
462   isOwned () {
463     return this.OwnerAccount.isOwned()
464   }
465
466   isOutdated () {
467     if (this.isOwned()) return false
468
469     return isOutdated(this, ACTIVITY_PUB.VIDEO_PLAYLIST_REFRESH_INTERVAL)
470   }
471
472   toFormattedJSON (): VideoPlaylist {
473     return {
474       id: this.id,
475       uuid: this.uuid,
476       isLocal: this.isOwned(),
477
478       displayName: this.name,
479       description: this.description,
480       privacy: {
481         id: this.privacy,
482         label: VideoPlaylistModel.getPrivacyLabel(this.privacy)
483       },
484
485       thumbnailPath: this.getThumbnailStaticPath(),
486
487       type: {
488         id: this.type,
489         label: VideoPlaylistModel.getTypeLabel(this.type)
490       },
491
492       videosLength: this.get('videosLength'),
493
494       createdAt: this.createdAt,
495       updatedAt: this.updatedAt,
496
497       ownerAccount: this.OwnerAccount.toFormattedSummaryJSON(),
498       videoChannel: this.VideoChannel ? this.VideoChannel.toFormattedSummaryJSON() : null
499     }
500   }
501
502   toActivityPubObject (page: number, t: Sequelize.Transaction): Promise<PlaylistObject> {
503     const handler = (start: number, count: number) => {
504       return VideoPlaylistElementModel.listUrlsOfForAP(this.id, start, count, t)
505     }
506
507     let icon: ActivityIconObject
508     if (this.hasThumbnail()) {
509       icon = {
510         type: 'Image' as 'Image',
511         url: this.getThumbnailUrl(),
512         mediaType: 'image/jpeg' as 'image/jpeg',
513         width: THUMBNAILS_SIZE.width,
514         height: THUMBNAILS_SIZE.height
515       }
516     }
517
518     return activityPubCollectionPagination(this.url, handler, page)
519       .then(o => {
520         return Object.assign(o, {
521           type: 'Playlist' as 'Playlist',
522           name: this.name,
523           content: this.description,
524           uuid: this.uuid,
525           published: this.createdAt.toISOString(),
526           updated: this.updatedAt.toISOString(),
527           attributedTo: this.VideoChannel ? [ this.VideoChannel.Actor.url ] : [],
528           icon
529         })
530       })
531   }
532 }