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