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