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