]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video-channel.ts
Merge branch 'release/5.0.0' into develop
[github/Chocobozzz/PeerTube.git] / server / models / video / video-channel.ts
1 import { FindOptions, Includeable, literal, Op, QueryTypes, ScopeOptions, Transaction, WhereOptions } from 'sequelize'
2 import {
3 AllowNull,
4 BeforeDestroy,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 DataType,
9 Default,
10 DefaultScope,
11 ForeignKey,
12 HasMany,
13 Is,
14 Model,
15 Scopes,
16 Sequelize,
17 Table,
18 UpdatedAt
19 } from 'sequelize-typescript'
20 import { CONFIG } from '@server/initializers/config'
21 import { MAccountActor } from '@server/types/models'
22 import { forceNumber, pick } from '@shared/core-utils'
23 import { AttributesOnly } from '@shared/typescript-utils'
24 import { ActivityPubActor } from '../../../shared/models/activitypub'
25 import { VideoChannel, VideoChannelSummary } from '../../../shared/models/videos'
26 import {
27 isVideoChannelDescriptionValid,
28 isVideoChannelDisplayNameValid,
29 isVideoChannelSupportValid
30 } from '../../helpers/custom-validators/video-channels'
31 import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
32 import { sendDeleteActor } from '../../lib/activitypub/send'
33 import {
34 MChannel,
35 MChannelActor,
36 MChannelAP,
37 MChannelBannerAccountDefault,
38 MChannelFormattable,
39 MChannelSummaryFormattable
40 } from '../../types/models/video'
41 import { AccountModel, ScopeNames as AccountModelScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account'
42 import { ActorModel, unusedActorAttributesForAPI } from '../actor/actor'
43 import { ActorFollowModel } from '../actor/actor-follow'
44 import { ActorImageModel } from '../actor/actor-image'
45 import { ServerModel } from '../server/server'
46 import {
47 buildServerIdsFollowedBy,
48 buildTrigramSearchIndex,
49 createSimilarityAttribute,
50 getSort,
51 setAsUpdated,
52 throwIfNotValid
53 } from '../shared'
54 import { VideoModel } from './video'
55 import { VideoPlaylistModel } from './video-playlist'
56
57 export enum ScopeNames {
58 FOR_API = 'FOR_API',
59 SUMMARY = 'SUMMARY',
60 WITH_ACCOUNT = 'WITH_ACCOUNT',
61 WITH_ACTOR = 'WITH_ACTOR',
62 WITH_ACTOR_BANNER = 'WITH_ACTOR_BANNER',
63 WITH_VIDEOS = 'WITH_VIDEOS',
64 WITH_STATS = 'WITH_STATS'
65 }
66
67 type AvailableForListOptions = {
68 actorId: number
69 search?: string
70 host?: string
71 handles?: string[]
72 forCount?: boolean
73 }
74
75 type AvailableWithStatsOptions = {
76 daysPrior: number
77 }
78
79 export type SummaryOptions = {
80 actorRequired?: boolean // Default: true
81 withAccount?: boolean // Default: false
82 withAccountBlockerIds?: number[]
83 }
84
85 @DefaultScope(() => ({
86 include: [
87 {
88 model: ActorModel,
89 required: true
90 }
91 ]
92 }))
93 @Scopes(() => ({
94 [ScopeNames.FOR_API]: (options: AvailableForListOptions) => {
95 // Only list local channels OR channels that are on an instance followed by actorId
96 const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId)
97
98 const whereActorAnd: WhereOptions[] = [
99 {
100 [Op.or]: [
101 {
102 serverId: null
103 },
104 {
105 serverId: {
106 [Op.in]: Sequelize.literal(inQueryInstanceFollow)
107 }
108 }
109 ]
110 }
111 ]
112
113 let serverRequired = false
114 let whereServer: WhereOptions
115
116 if (options.host && options.host !== WEBSERVER.HOST) {
117 serverRequired = true
118 whereServer = { host: options.host }
119 }
120
121 if (options.host === WEBSERVER.HOST) {
122 whereActorAnd.push({
123 serverId: null
124 })
125 }
126
127 if (Array.isArray(options.handles) && options.handles.length !== 0) {
128 const or: string[] = []
129
130 for (const handle of options.handles || []) {
131 const [ preferredUsername, host ] = handle.split('@')
132
133 if (!host || host === WEBSERVER.HOST) {
134 or.push(`("preferredUsername" = ${VideoChannelModel.sequelize.escape(preferredUsername)} AND "serverId" IS NULL)`)
135 } else {
136 or.push(
137 `(` +
138 `"preferredUsername" = ${VideoChannelModel.sequelize.escape(preferredUsername)} ` +
139 `AND "host" = ${VideoChannelModel.sequelize.escape(host)}` +
140 `)`
141 )
142 }
143 }
144
145 whereActorAnd.push({
146 id: {
147 [Op.in]: literal(`(SELECT "actor".id FROM actor LEFT JOIN server on server.id = actor."serverId" WHERE ${or.join(' OR ')})`)
148 }
149 })
150 }
151
152 const channelActorInclude: Includeable[] = []
153 const accountActorInclude: Includeable[] = []
154
155 if (options.forCount !== true) {
156 accountActorInclude.push({
157 model: ServerModel,
158 required: false
159 })
160
161 accountActorInclude.push({
162 model: ActorImageModel,
163 as: 'Avatars',
164 required: false
165 })
166
167 channelActorInclude.push({
168 model: ActorImageModel,
169 as: 'Avatars',
170 required: false
171 })
172
173 channelActorInclude.push({
174 model: ActorImageModel,
175 as: 'Banners',
176 required: false
177 })
178 }
179
180 if (options.forCount !== true || serverRequired) {
181 channelActorInclude.push({
182 model: ServerModel,
183 duplicating: false,
184 required: serverRequired,
185 where: whereServer
186 })
187 }
188
189 return {
190 include: [
191 {
192 attributes: {
193 exclude: unusedActorAttributesForAPI
194 },
195 model: ActorModel.unscoped(),
196 where: {
197 [Op.and]: whereActorAnd
198 },
199 include: channelActorInclude
200 },
201 {
202 model: AccountModel.unscoped(),
203 required: true,
204 include: [
205 {
206 attributes: {
207 exclude: unusedActorAttributesForAPI
208 },
209 model: ActorModel.unscoped(),
210 required: true,
211 include: accountActorInclude
212 }
213 ]
214 }
215 ]
216 }
217 },
218 [ScopeNames.SUMMARY]: (options: SummaryOptions = {}) => {
219 const include: Includeable[] = [
220 {
221 attributes: [ 'id', 'preferredUsername', 'url', 'serverId' ],
222 model: ActorModel.unscoped(),
223 required: options.actorRequired ?? true,
224 include: [
225 {
226 attributes: [ 'host' ],
227 model: ServerModel.unscoped(),
228 required: false
229 },
230 {
231 model: ActorImageModel,
232 as: 'Avatars',
233 required: false
234 }
235 ]
236 }
237 ]
238
239 const base: FindOptions = {
240 attributes: [ 'id', 'name', 'description', 'actorId' ]
241 }
242
243 if (options.withAccount === true) {
244 include.push({
245 model: AccountModel.scope({
246 method: [ AccountModelScopeNames.SUMMARY, { withAccountBlockerIds: options.withAccountBlockerIds } as AccountSummaryOptions ]
247 }),
248 required: true
249 })
250 }
251
252 base.include = include
253
254 return base
255 },
256 [ScopeNames.WITH_ACCOUNT]: {
257 include: [
258 {
259 model: AccountModel,
260 required: true
261 }
262 ]
263 },
264 [ScopeNames.WITH_ACTOR]: {
265 include: [
266 ActorModel
267 ]
268 },
269 [ScopeNames.WITH_ACTOR_BANNER]: {
270 include: [
271 {
272 model: ActorModel,
273 include: [
274 {
275 model: ActorImageModel,
276 required: false,
277 as: 'Banners'
278 }
279 ]
280 }
281 ]
282 },
283 [ScopeNames.WITH_VIDEOS]: {
284 include: [
285 VideoModel
286 ]
287 },
288 [ScopeNames.WITH_STATS]: (options: AvailableWithStatsOptions = { daysPrior: 30 }) => {
289 const daysPrior = forceNumber(options.daysPrior)
290
291 return {
292 attributes: {
293 include: [
294 [
295 literal('(SELECT COUNT(*) FROM "video" WHERE "channelId" = "VideoChannelModel"."id")'),
296 'videosCount'
297 ],
298 [
299 literal(
300 '(' +
301 `SELECT string_agg(concat_ws('|', t.day, t.views), ',') ` +
302 'FROM ( ' +
303 'WITH ' +
304 'days AS ( ' +
305 `SELECT generate_series(date_trunc('day', now()) - '${daysPrior} day'::interval, ` +
306 `date_trunc('day', now()), '1 day'::interval) AS day ` +
307 ') ' +
308 'SELECT days.day AS day, COALESCE(SUM("videoView".views), 0) AS views ' +
309 'FROM days ' +
310 'LEFT JOIN (' +
311 '"videoView" INNER JOIN "video" ON "videoView"."videoId" = "video"."id" ' +
312 'AND "video"."channelId" = "VideoChannelModel"."id"' +
313 `) ON date_trunc('day', "videoView"."startDate") = date_trunc('day', days.day) ` +
314 'GROUP BY day ' +
315 'ORDER BY day ' +
316 ') t' +
317 ')'
318 ),
319 'viewsPerDay'
320 ],
321 [
322 literal(
323 '(' +
324 'SELECT COALESCE(SUM("video".views), 0) AS totalViews ' +
325 'FROM "video" ' +
326 'WHERE "video"."channelId" = "VideoChannelModel"."id"' +
327 ')'
328 ),
329 'totalViews'
330 ]
331 ]
332 }
333 }
334 }
335 }))
336 @Table({
337 tableName: 'videoChannel',
338 indexes: [
339 buildTrigramSearchIndex('video_channel_name_trigram', 'name'),
340
341 {
342 fields: [ 'accountId' ]
343 },
344 {
345 fields: [ 'actorId' ]
346 }
347 ]
348 })
349 export class VideoChannelModel extends Model<Partial<AttributesOnly<VideoChannelModel>>> {
350
351 @AllowNull(false)
352 @Is('VideoChannelName', value => throwIfNotValid(value, isVideoChannelDisplayNameValid, 'name'))
353 @Column
354 name: string
355
356 @AllowNull(true)
357 @Default(null)
358 @Is('VideoChannelDescription', value => throwIfNotValid(value, isVideoChannelDescriptionValid, 'description', true))
359 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.DESCRIPTION.max))
360 description: string
361
362 @AllowNull(true)
363 @Default(null)
364 @Is('VideoChannelSupport', value => throwIfNotValid(value, isVideoChannelSupportValid, 'support', true))
365 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.SUPPORT.max))
366 support: string
367
368 @CreatedAt
369 createdAt: Date
370
371 @UpdatedAt
372 updatedAt: Date
373
374 @ForeignKey(() => ActorModel)
375 @Column
376 actorId: number
377
378 @BelongsTo(() => ActorModel, {
379 foreignKey: {
380 allowNull: false
381 },
382 onDelete: 'cascade'
383 })
384 Actor: ActorModel
385
386 @ForeignKey(() => AccountModel)
387 @Column
388 accountId: number
389
390 @BelongsTo(() => AccountModel, {
391 foreignKey: {
392 allowNull: false
393 }
394 })
395 Account: AccountModel
396
397 @HasMany(() => VideoModel, {
398 foreignKey: {
399 name: 'channelId',
400 allowNull: false
401 },
402 onDelete: 'CASCADE',
403 hooks: true
404 })
405 Videos: VideoModel[]
406
407 @HasMany(() => VideoPlaylistModel, {
408 foreignKey: {
409 allowNull: true
410 },
411 onDelete: 'CASCADE',
412 hooks: true
413 })
414 VideoPlaylists: VideoPlaylistModel[]
415
416 @BeforeDestroy
417 static async sendDeleteIfOwned (instance: VideoChannelModel, options) {
418 if (!instance.Actor) {
419 instance.Actor = await instance.$get('Actor', { transaction: options.transaction })
420 }
421
422 await ActorFollowModel.removeFollowsOf(instance.Actor.id, options.transaction)
423
424 if (instance.Actor.isOwned()) {
425 return sendDeleteActor(instance.Actor, options.transaction)
426 }
427
428 return undefined
429 }
430
431 static countByAccount (accountId: number) {
432 const query = {
433 where: {
434 accountId
435 }
436 }
437
438 return VideoChannelModel.unscoped().count(query)
439 }
440
441 static async getStats () {
442
443 function getLocalVideoChannelStats (days?: number) {
444 const options = {
445 type: QueryTypes.SELECT as QueryTypes.SELECT,
446 raw: true
447 }
448
449 const videoJoin = days
450 ? `INNER JOIN "video" AS "Videos" ON "VideoChannelModel"."id" = "Videos"."channelId" ` +
451 `AND ("Videos"."publishedAt" > Now() - interval '${days}d')`
452 : ''
453
454 const query = `
455 SELECT COUNT(DISTINCT("VideoChannelModel"."id")) AS "count"
456 FROM "videoChannel" AS "VideoChannelModel"
457 ${videoJoin}
458 INNER JOIN "account" AS "Account" ON "VideoChannelModel"."accountId" = "Account"."id"
459 INNER JOIN "actor" AS "Account->Actor" ON "Account"."actorId" = "Account->Actor"."id"
460 AND "Account->Actor"."serverId" IS NULL`
461
462 return VideoChannelModel.sequelize.query<{ count: string }>(query, options)
463 .then(r => parseInt(r[0].count, 10))
464 }
465
466 const totalLocalVideoChannels = await getLocalVideoChannelStats()
467 const totalLocalDailyActiveVideoChannels = await getLocalVideoChannelStats(1)
468 const totalLocalWeeklyActiveVideoChannels = await getLocalVideoChannelStats(7)
469 const totalLocalMonthlyActiveVideoChannels = await getLocalVideoChannelStats(30)
470 const totalLocalHalfYearActiveVideoChannels = await getLocalVideoChannelStats(180)
471
472 return {
473 totalLocalVideoChannels,
474 totalLocalDailyActiveVideoChannels,
475 totalLocalWeeklyActiveVideoChannels,
476 totalLocalMonthlyActiveVideoChannels,
477 totalLocalHalfYearActiveVideoChannels
478 }
479 }
480
481 static listLocalsForSitemap (sort: string): Promise<MChannelActor[]> {
482 const query = {
483 attributes: [ ],
484 offset: 0,
485 order: getSort(sort),
486 include: [
487 {
488 attributes: [ 'preferredUsername', 'serverId' ],
489 model: ActorModel.unscoped(),
490 where: {
491 serverId: null
492 }
493 }
494 ]
495 }
496
497 return VideoChannelModel
498 .unscoped()
499 .findAll(query)
500 }
501
502 static listForApi (parameters: Pick<AvailableForListOptions, 'actorId'> & {
503 start: number
504 count: number
505 sort: string
506 }) {
507 const { actorId } = parameters
508
509 const query = {
510 offset: parameters.start,
511 limit: parameters.count,
512 order: getSort(parameters.sort)
513 }
514
515 const getScope = (forCount: boolean) => {
516 return { method: [ ScopeNames.FOR_API, { actorId, forCount } as AvailableForListOptions ] }
517 }
518
519 return Promise.all([
520 VideoChannelModel.scope(getScope(true)).count(),
521 VideoChannelModel.scope(getScope(false)).findAll(query)
522 ]).then(([ total, data ]) => ({ total, data }))
523 }
524
525 static searchForApi (options: Pick<AvailableForListOptions, 'actorId' | 'search' | 'host' | 'handles'> & {
526 start: number
527 count: number
528 sort: string
529 }) {
530 let attributesInclude: any[] = [ literal('0 as similarity') ]
531 let where: WhereOptions
532
533 if (options.search) {
534 const escapedSearch = VideoChannelModel.sequelize.escape(options.search)
535 const escapedLikeSearch = VideoChannelModel.sequelize.escape('%' + options.search + '%')
536 attributesInclude = [ createSimilarityAttribute('VideoChannelModel.name', options.search) ]
537
538 where = {
539 [Op.or]: [
540 Sequelize.literal(
541 'lower(immutable_unaccent("VideoChannelModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))'
542 ),
543 Sequelize.literal(
544 'lower(immutable_unaccent("VideoChannelModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))'
545 )
546 ]
547 }
548 }
549
550 const query = {
551 attributes: {
552 include: attributesInclude
553 },
554 offset: options.start,
555 limit: options.count,
556 order: getSort(options.sort),
557 where
558 }
559
560 const getScope = (forCount: boolean) => {
561 return {
562 method: [
563 ScopeNames.FOR_API, {
564 ...pick(options, [ 'actorId', 'host', 'handles' ]),
565
566 forCount
567 } as AvailableForListOptions
568 ]
569 }
570 }
571
572 return Promise.all([
573 VideoChannelModel.scope(getScope(true)).count(query),
574 VideoChannelModel.scope(getScope(false)).findAll(query)
575 ]).then(([ total, data ]) => ({ total, data }))
576 }
577
578 static listByAccountForAPI (options: {
579 accountId: number
580 start: number
581 count: number
582 sort: string
583 withStats?: boolean
584 search?: string
585 }) {
586 const escapedSearch = VideoModel.sequelize.escape(options.search)
587 const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%')
588 const where = options.search
589 ? {
590 [Op.or]: [
591 Sequelize.literal(
592 'lower(immutable_unaccent("VideoChannelModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))'
593 ),
594 Sequelize.literal(
595 'lower(immutable_unaccent("VideoChannelModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))'
596 )
597 ]
598 }
599 : null
600
601 const getQuery = (forCount: boolean) => {
602 const accountModel = forCount
603 ? AccountModel.unscoped()
604 : AccountModel
605
606 return {
607 offset: options.start,
608 limit: options.count,
609 order: getSort(options.sort),
610 include: [
611 {
612 model: accountModel,
613 where: {
614 id: options.accountId
615 },
616 required: true
617 }
618 ],
619 where
620 }
621 }
622
623 const findScopes: string | ScopeOptions | (string | ScopeOptions)[] = [ ScopeNames.WITH_ACTOR_BANNER ]
624
625 if (options.withStats === true) {
626 findScopes.push({
627 method: [ ScopeNames.WITH_STATS, { daysPrior: 30 } as AvailableWithStatsOptions ]
628 })
629 }
630
631 return Promise.all([
632 VideoChannelModel.unscoped().count(getQuery(true)),
633 VideoChannelModel.scope(findScopes).findAll(getQuery(false))
634 ]).then(([ total, data ]) => ({ total, data }))
635 }
636
637 static listAllByAccount (accountId: number): Promise<MChannel[]> {
638 const query = {
639 limit: CONFIG.VIDEO_CHANNELS.MAX_PER_USER,
640 include: [
641 {
642 attributes: [],
643 model: AccountModel.unscoped(),
644 where: {
645 id: accountId
646 },
647 required: true
648 }
649 ]
650 }
651
652 return VideoChannelModel.findAll(query)
653 }
654
655 static loadAndPopulateAccount (id: number, transaction?: Transaction): Promise<MChannelBannerAccountDefault> {
656 return VideoChannelModel.unscoped()
657 .scope([ ScopeNames.WITH_ACTOR_BANNER, ScopeNames.WITH_ACCOUNT ])
658 .findByPk(id, { transaction })
659 }
660
661 static loadByUrlAndPopulateAccount (url: string): Promise<MChannelBannerAccountDefault> {
662 const query = {
663 include: [
664 {
665 model: ActorModel,
666 required: true,
667 where: {
668 url
669 },
670 include: [
671 {
672 model: ActorImageModel,
673 required: false,
674 as: 'Banners'
675 }
676 ]
677 }
678 ]
679 }
680
681 return VideoChannelModel
682 .scope([ ScopeNames.WITH_ACCOUNT ])
683 .findOne(query)
684 }
685
686 static loadByNameWithHostAndPopulateAccount (nameWithHost: string) {
687 const [ name, host ] = nameWithHost.split('@')
688
689 if (!host || host === WEBSERVER.HOST) return VideoChannelModel.loadLocalByNameAndPopulateAccount(name)
690
691 return VideoChannelModel.loadByNameAndHostAndPopulateAccount(name, host)
692 }
693
694 static loadLocalByNameAndPopulateAccount (name: string): Promise<MChannelBannerAccountDefault> {
695 const query = {
696 include: [
697 {
698 model: ActorModel,
699 required: true,
700 where: {
701 preferredUsername: name,
702 serverId: null
703 },
704 include: [
705 {
706 model: ActorImageModel,
707 required: false,
708 as: 'Banners'
709 }
710 ]
711 }
712 ]
713 }
714
715 return VideoChannelModel.unscoped()
716 .scope([ ScopeNames.WITH_ACCOUNT ])
717 .findOne(query)
718 }
719
720 static loadByNameAndHostAndPopulateAccount (name: string, host: string): Promise<MChannelBannerAccountDefault> {
721 const query = {
722 include: [
723 {
724 model: ActorModel,
725 required: true,
726 where: {
727 preferredUsername: name
728 },
729 include: [
730 {
731 model: ServerModel,
732 required: true,
733 where: { host }
734 },
735 {
736 model: ActorImageModel,
737 required: false,
738 as: 'Banners'
739 }
740 ]
741 }
742 ]
743 }
744
745 return VideoChannelModel.unscoped()
746 .scope([ ScopeNames.WITH_ACCOUNT ])
747 .findOne(query)
748 }
749
750 toFormattedSummaryJSON (this: MChannelSummaryFormattable): VideoChannelSummary {
751 const actor = this.Actor.toFormattedSummaryJSON()
752
753 return {
754 id: this.id,
755 name: actor.name,
756 displayName: this.getDisplayName(),
757 url: actor.url,
758 host: actor.host,
759 avatars: actor.avatars,
760
761 // TODO: remove, deprecated in 4.2
762 avatar: actor.avatar
763 }
764 }
765
766 toFormattedJSON (this: MChannelFormattable): VideoChannel {
767 const viewsPerDayString = this.get('viewsPerDay') as string
768 const videosCount = this.get('videosCount') as number
769
770 let viewsPerDay: { date: Date, views: number }[]
771
772 if (viewsPerDayString) {
773 viewsPerDay = viewsPerDayString.split(',')
774 .map(v => {
775 const [ dateString, amount ] = v.split('|')
776
777 return {
778 date: new Date(dateString),
779 views: +amount
780 }
781 })
782 }
783
784 const totalViews = this.get('totalViews') as number
785
786 const actor = this.Actor.toFormattedJSON()
787 const videoChannel = {
788 id: this.id,
789 displayName: this.getDisplayName(),
790 description: this.description,
791 support: this.support,
792 isLocal: this.Actor.isOwned(),
793 updatedAt: this.updatedAt,
794
795 ownerAccount: undefined,
796
797 videosCount,
798 viewsPerDay,
799 totalViews,
800
801 avatars: actor.avatars,
802
803 // TODO: remove, deprecated in 4.2
804 avatar: actor.avatar
805 }
806
807 if (this.Account) videoChannel.ownerAccount = this.Account.toFormattedJSON()
808
809 return Object.assign(actor, videoChannel)
810 }
811
812 toActivityPubObject (this: MChannelAP): ActivityPubActor {
813 const obj = this.Actor.toActivityPubObject(this.name)
814
815 return Object.assign(obj, {
816 summary: this.description,
817 support: this.support,
818 attributedTo: [
819 {
820 type: 'Person' as 'Person',
821 id: this.Account.Actor.url
822 }
823 ]
824 })
825 }
826
827 getLocalUrl (this: MAccountActor | MChannelActor) {
828 return WEBSERVER.URL + `/video-channels/` + this.Actor.preferredUsername
829 }
830
831 getDisplayName () {
832 return this.name
833 }
834
835 isOutdated () {
836 return this.Actor.isOutdated()
837 }
838
839 setAsUpdated (transaction?: Transaction) {
840 return setAsUpdated({ sequelize: this.sequelize, table: 'videoChannel', id: this.id, transaction })
841 }
842 }