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