Fix createdAt/updatedAt issues
[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 } 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 actorWhere = {
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, 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     } 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: [ '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 }