Create a dedicated table to track video thumbnails
[oweals/peertube.git] / server / models / video / video-channel.ts
1 import {
2   AllowNull,
3   BeforeDestroy,
4   BelongsTo,
5   Column,
6   CreatedAt,
7   DataType,
8   Default,
9   DefaultScope,
10   ForeignKey,
11   HasMany,
12   IFindOptions,
13   Is,
14   Model,
15   Scopes,
16   Sequelize,
17   Table,
18   UpdatedAt
19 } from 'sequelize-typescript'
20 import { ActivityPubActor } from '../../../shared/models/activitypub'
21 import { VideoChannel, VideoChannelSummary } from '../../../shared/models/videos'
22 import {
23   isVideoChannelDescriptionValid,
24   isVideoChannelNameValid,
25   isVideoChannelSupportValid
26 } from '../../helpers/custom-validators/video-channels'
27 import { sendDeleteActor } from '../../lib/activitypub/send'
28 import { AccountModel, ScopeNames as AccountModelScopeNames } from '../account/account'
29 import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor'
30 import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
31 import { VideoModel } from './video'
32 import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
33 import { ServerModel } from '../server/server'
34 import { DefineIndexesOptions } from 'sequelize'
35 import { AvatarModel } from '../avatar/avatar'
36 import { VideoPlaylistModel } from './video-playlist'
37
38 // FIXME: Define indexes here because there is an issue with TS and Sequelize.literal when called directly in the annotation
39 const indexes: DefineIndexesOptions[] = [
40   buildTrigramSearchIndex('video_channel_name_trigram', 'name'),
41
42   {
43     fields: [ 'accountId' ]
44   },
45   {
46     fields: [ 'actorId' ]
47   }
48 ]
49
50 export enum ScopeNames {
51   AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
52   WITH_ACCOUNT = 'WITH_ACCOUNT',
53   WITH_ACTOR = 'WITH_ACTOR',
54   WITH_VIDEOS = 'WITH_VIDEOS',
55   SUMMARY = 'SUMMARY'
56 }
57
58 type AvailableForListOptions = {
59   actorId: number
60 }
61
62 @DefaultScope({
63   include: [
64     {
65       model: () => ActorModel,
66       required: true
67     }
68   ]
69 })
70 @Scopes({
71   [ScopeNames.SUMMARY]: (withAccount = false) => {
72     const base: IFindOptions<VideoChannelModel> = {
73       attributes: [ 'name', 'description', 'id', 'actorId' ],
74       include: [
75         {
76           attributes: [ 'uuid', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
77           model: ActorModel.unscoped(),
78           required: true,
79           include: [
80             {
81               attributes: [ 'host' ],
82               model: ServerModel.unscoped(),
83               required: false
84             },
85             {
86               model: AvatarModel.unscoped(),
87               required: false
88             }
89           ]
90         }
91       ]
92     }
93
94     if (withAccount === true) {
95       base.include.push({
96         model: AccountModel.scope(AccountModelScopeNames.SUMMARY),
97         required: true
98       })
99     }
100
101     return base
102   },
103   [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => {
104     // Only list local channels OR channels that are on an instance followed by actorId
105     const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId)
106
107     return {
108       include: [
109         {
110           attributes: {
111             exclude: unusedActorAttributesForAPI
112           },
113           model: ActorModel,
114           where: {
115             [Sequelize.Op.or]: [
116               {
117                 serverId: null
118               },
119               {
120                 serverId: {
121                   [ Sequelize.Op.in ]: Sequelize.literal(inQueryInstanceFollow)
122                 }
123               }
124             ]
125           }
126         },
127         {
128           model: AccountModel,
129           required: true,
130           include: [
131             {
132               attributes: {
133                 exclude: unusedActorAttributesForAPI
134               },
135               model: ActorModel, // Default scope includes avatar and server
136               required: true
137             }
138           ]
139         }
140       ]
141     }
142   },
143   [ScopeNames.WITH_ACCOUNT]: {
144     include: [
145       {
146         model: () => AccountModel,
147         required: true
148       }
149     ]
150   },
151   [ScopeNames.WITH_VIDEOS]: {
152     include: [
153       () => VideoModel
154     ]
155   },
156   [ScopeNames.WITH_ACTOR]: {
157     include: [
158       () => ActorModel
159     ]
160   }
161 })
162 @Table({
163   tableName: 'videoChannel',
164   indexes
165 })
166 export class VideoChannelModel extends Model<VideoChannelModel> {
167
168   @AllowNull(false)
169   @Is('VideoChannelName', value => throwIfNotValid(value, isVideoChannelNameValid, 'name'))
170   @Column
171   name: string
172
173   @AllowNull(true)
174   @Default(null)
175   @Is('VideoChannelDescription', value => throwIfNotValid(value, isVideoChannelDescriptionValid, 'description'))
176   @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.DESCRIPTION.max))
177   description: string
178
179   @AllowNull(true)
180   @Default(null)
181   @Is('VideoChannelSupport', value => throwIfNotValid(value, isVideoChannelSupportValid, 'support'))
182   @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.SUPPORT.max))
183   support: string
184
185   @CreatedAt
186   createdAt: Date
187
188   @UpdatedAt
189   updatedAt: Date
190
191   @ForeignKey(() => ActorModel)
192   @Column
193   actorId: number
194
195   @BelongsTo(() => ActorModel, {
196     foreignKey: {
197       allowNull: false
198     },
199     onDelete: 'cascade'
200   })
201   Actor: ActorModel
202
203   @ForeignKey(() => AccountModel)
204   @Column
205   accountId: number
206
207   @BelongsTo(() => AccountModel, {
208     foreignKey: {
209       allowNull: false
210     },
211     hooks: true
212   })
213   Account: AccountModel
214
215   @HasMany(() => VideoModel, {
216     foreignKey: {
217       name: 'channelId',
218       allowNull: false
219     },
220     onDelete: 'CASCADE',
221     hooks: true
222   })
223   Videos: VideoModel[]
224
225   @HasMany(() => VideoPlaylistModel, {
226     foreignKey: {
227       allowNull: true
228     },
229     onDelete: 'CASCADE',
230     hooks: true
231   })
232   VideoPlaylists: VideoPlaylistModel[]
233
234   @BeforeDestroy
235   static async sendDeleteIfOwned (instance: VideoChannelModel, options) {
236     if (!instance.Actor) {
237       instance.Actor = await instance.$get('Actor', { transaction: options.transaction }) as ActorModel
238     }
239
240     if (instance.Actor.isOwned()) {
241       return sendDeleteActor(instance.Actor, options.transaction)
242     }
243
244     return undefined
245   }
246
247   static countByAccount (accountId: number) {
248     const query = {
249       where: {
250         accountId
251       }
252     }
253
254     return VideoChannelModel.count(query)
255   }
256
257   static listForApi (actorId: number, start: number, count: number, sort: string) {
258     const query = {
259       offset: start,
260       limit: count,
261       order: getSort(sort)
262     }
263
264     const scopes = {
265       method: [ ScopeNames.AVAILABLE_FOR_LIST, { actorId } as AvailableForListOptions ]
266     }
267     return VideoChannelModel
268       .scope(scopes)
269       .findAndCountAll(query)
270       .then(({ rows, count }) => {
271         return { total: count, data: rows }
272       })
273   }
274
275   static listLocalsForSitemap (sort: string) {
276     const query = {
277       attributes: [ ],
278       offset: 0,
279       order: getSort(sort),
280       include: [
281         {
282           attributes: [ 'preferredUsername', 'serverId' ],
283           model: ActorModel.unscoped(),
284           where: {
285             serverId: null
286           }
287         }
288       ]
289     }
290
291     return VideoChannelModel
292       .unscoped()
293       .findAll(query)
294   }
295
296   static searchForApi (options: {
297     actorId: number
298     search: string
299     start: number
300     count: number
301     sort: string
302   }) {
303     const attributesInclude = []
304     const escapedSearch = VideoModel.sequelize.escape(options.search)
305     const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%')
306     attributesInclude.push(createSimilarityAttribute('VideoChannelModel.name', options.search))
307
308     const query = {
309       attributes: {
310         include: attributesInclude
311       },
312       offset: options.start,
313       limit: options.count,
314       order: getSort(options.sort),
315       where: {
316         [Sequelize.Op.or]: [
317           Sequelize.literal(
318             'lower(immutable_unaccent("VideoChannelModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))'
319           ),
320           Sequelize.literal(
321             'lower(immutable_unaccent("VideoChannelModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))'
322           )
323         ]
324       }
325     }
326
327     const scopes = {
328       method: [ ScopeNames.AVAILABLE_FOR_LIST, { actorId: options.actorId } as AvailableForListOptions ]
329     }
330     return VideoChannelModel
331       .scope(scopes)
332       .findAndCountAll(query)
333       .then(({ rows, count }) => {
334         return { total: count, data: rows }
335       })
336   }
337
338   static listByAccount (accountId: number) {
339     const query = {
340       order: getSort('createdAt'),
341       include: [
342         {
343           model: AccountModel,
344           where: {
345             id: accountId
346           },
347           required: true
348         }
349       ]
350     }
351
352     return VideoChannelModel
353       .findAndCountAll(query)
354       .then(({ rows, count }) => {
355         return { total: count, data: rows }
356       })
357   }
358
359   static loadByIdAndPopulateAccount (id: number) {
360     return VideoChannelModel.unscoped()
361       .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
362       .findByPk(id)
363   }
364
365   static loadByIdAndAccount (id: number, accountId: number) {
366     const query = {
367       where: {
368         id,
369         accountId
370       }
371     }
372
373     return VideoChannelModel.unscoped()
374       .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
375       .findOne(query)
376   }
377
378   static loadAndPopulateAccount (id: number) {
379     return VideoChannelModel.unscoped()
380       .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
381       .findByPk(id)
382   }
383
384   static loadByUUIDAndPopulateAccount (uuid: string) {
385     const query = {
386       include: [
387         {
388           model: ActorModel,
389           required: true,
390           where: {
391             uuid
392           }
393         }
394       ]
395     }
396
397     return VideoChannelModel
398       .scope([ ScopeNames.WITH_ACCOUNT ])
399       .findOne(query)
400   }
401
402   static loadByUrlAndPopulateAccount (url: string) {
403     const query = {
404       include: [
405         {
406           model: ActorModel,
407           required: true,
408           where: {
409             url
410           }
411         }
412       ]
413     }
414
415     return VideoChannelModel
416       .scope([ ScopeNames.WITH_ACCOUNT ])
417       .findOne(query)
418   }
419
420   static loadByNameWithHostAndPopulateAccount (nameWithHost: string) {
421     const [ name, host ] = nameWithHost.split('@')
422
423     if (!host || host === WEBSERVER.HOST) return VideoChannelModel.loadLocalByNameAndPopulateAccount(name)
424
425     return VideoChannelModel.loadByNameAndHostAndPopulateAccount(name, host)
426   }
427
428   static loadLocalByNameAndPopulateAccount (name: string) {
429     const query = {
430       include: [
431         {
432           model: ActorModel,
433           required: true,
434           where: {
435             preferredUsername: name,
436             serverId: null
437           }
438         }
439       ]
440     }
441
442     return VideoChannelModel.unscoped()
443       .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
444       .findOne(query)
445   }
446
447   static loadByNameAndHostAndPopulateAccount (name: string, host: string) {
448     const query = {
449       include: [
450         {
451           model: ActorModel,
452           required: true,
453           where: {
454             preferredUsername: name
455           },
456           include: [
457             {
458               model: ServerModel,
459               required: true,
460               where: { host }
461             }
462           ]
463         }
464       ]
465     }
466
467     return VideoChannelModel.unscoped()
468       .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
469       .findOne(query)
470   }
471
472   static loadAndPopulateAccountAndVideos (id: number) {
473     const options = {
474       include: [
475         VideoModel
476       ]
477     }
478
479     return VideoChannelModel.unscoped()
480       .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_VIDEOS ])
481       .findByPk(id, options)
482   }
483
484   toFormattedJSON (): VideoChannel {
485     const actor = this.Actor.toFormattedJSON()
486     const videoChannel = {
487       id: this.id,
488       displayName: this.getDisplayName(),
489       description: this.description,
490       support: this.support,
491       isLocal: this.Actor.isOwned(),
492       createdAt: this.createdAt,
493       updatedAt: this.updatedAt,
494       ownerAccount: undefined
495     }
496
497     if (this.Account) videoChannel.ownerAccount = this.Account.toFormattedJSON()
498
499     return Object.assign(actor, videoChannel)
500   }
501
502   toFormattedSummaryJSON (): VideoChannelSummary {
503     const actor = this.Actor.toFormattedJSON()
504
505     return {
506       id: this.id,
507       uuid: actor.uuid,
508       name: actor.name,
509       displayName: this.getDisplayName(),
510       url: actor.url,
511       host: actor.host,
512       avatar: actor.avatar
513     }
514   }
515
516   toActivityPubObject (): ActivityPubActor {
517     const obj = this.Actor.toActivityPubObject(this.name, 'VideoChannel')
518
519     return Object.assign(obj, {
520       summary: this.description,
521       support: this.support,
522       attributedTo: [
523         {
524           type: 'Person' as 'Person',
525           id: this.Account.Actor.url
526         }
527       ]
528     })
529   }
530
531   getDisplayName () {
532     return this.name
533   }
534
535   isOutdated () {
536     return this.Actor.isOutdated()
537   }
538 }