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