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