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