]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/models/video/video-channel.ts
Refactor AP playlists
[github/Chocobozzz/PeerTube.git] / server / models / video / video-channel.ts
CommitLineData
e024fd6a 1import { FindOptions, Includeable, literal, Op, QueryTypes, ScopeOptions, Transaction } from 'sequelize'
3fd3ab2d 2import {
06a05d5f
C
3 AllowNull,
4 BeforeDestroy,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 DataType,
9 Default,
10 DefaultScope,
11 ForeignKey,
6dd9de95 12 HasMany,
06a05d5f
C
13 Is,
14 Model,
15 Scopes,
f37dc0dd 16 Sequelize,
06a05d5f
C
17 Table,
18 UpdatedAt
3fd3ab2d 19} from 'sequelize-typescript'
e024fd6a 20import { setAsUpdated } from '@server/helpers/database-utils'
a59f210f 21import { MAccountActor } from '@server/types/models'
16c016e8 22import { AttributesOnly } from '@shared/core-utils'
50d6de9c 23import { ActivityPubActor } from '../../../shared/models/activitypub'
418d092a 24import { VideoChannel, VideoChannelSummary } from '../../../shared/models/videos'
2422c46b 25import {
06a05d5f
C
26 isVideoChannelDescriptionValid,
27 isVideoChannelNameValid,
2422c46b
C
28 isVideoChannelSupportValid
29} from '../../helpers/custom-validators/video-channels'
74dc3bca 30import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
2af337c8 31import { sendDeleteActor } from '../../lib/activitypub/send'
453e83ea 32import {
453e83ea 33 MChannelActor,
b5fecbf4 34 MChannelAP,
2cb03dc1 35 MChannelBannerAccountDefault,
b5fecbf4
C
36 MChannelFormattable,
37 MChannelSummaryFormattable
26d6bf65 38} from '../../types/models/video'
2af337c8 39import { AccountModel, ScopeNames as AccountModelScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account'
7d9ba5c0
C
40import { ActorModel, unusedActorAttributesForAPI } from '../actor/actor'
41import { ActorFollowModel } from '../actor/actor-follow'
42import { ActorImageModel } from '../actor/actor-image'
2af337c8
C
43import { ServerModel } from '../server/server'
44import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
45import { VideoModel } from './video'
46import { VideoPlaylistModel } from './video-playlist'
f37dc0dd 47
418d092a 48export enum ScopeNames {
453e83ea 49 FOR_API = 'FOR_API',
8165d00a 50 SUMMARY = 'SUMMARY',
d48ff09d 51 WITH_ACCOUNT = 'WITH_ACCOUNT',
50d6de9c 52 WITH_ACTOR = 'WITH_ACTOR',
2cb03dc1 53 WITH_ACTOR_BANNER = 'WITH_ACTOR_BANNER',
418d092a 54 WITH_VIDEOS = 'WITH_VIDEOS',
8165d00a 55 WITH_STATS = 'WITH_STATS'
d48ff09d
C
56}
57
f37dc0dd
C
58type AvailableForListOptions = {
59 actorId: number
bc99dfe5 60 search?: string
f37dc0dd
C
61}
62
8165d00a
RK
63type AvailableWithStatsOptions = {
64 daysPrior: number
65}
66
bfbd9128 67export type SummaryOptions = {
4f32032f 68 actorRequired?: boolean // Default: true
bfbd9128
C
69 withAccount?: boolean // Default: false
70 withAccountBlockerIds?: number[]
71}
72
3acc5084 73@DefaultScope(() => ({
50d6de9c
C
74 include: [
75 {
3acc5084 76 model: ActorModel,
50d6de9c
C
77 required: true
78 }
79 ]
3acc5084
C
80}))
81@Scopes(() => ({
453e83ea 82 [ScopeNames.FOR_API]: (options: AvailableForListOptions) => {
f37dc0dd 83 // Only list local channels OR channels that are on an instance followed by actorId
418d092a 84 const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId)
f37dc0dd
C
85
86 return {
87 include: [
88 {
89 attributes: {
90 exclude: unusedActorAttributesForAPI
91 },
92 model: ActorModel,
93 where: {
1735c825 94 [Op.or]: [
c305467c 95 {
f37dc0dd
C
96 serverId: null
97 },
98 {
99 serverId: {
a1587156 100 [Op.in]: Sequelize.literal(inQueryInstanceFollow)
f37dc0dd 101 }
c305467c
C
102 }
103 ]
213e30ef
C
104 },
105 include: [
106 {
107 model: ActorImageModel,
108 as: 'Banner',
109 required: false
110 }
111 ]
f37dc0dd
C
112 },
113 {
114 model: AccountModel,
115 required: true,
116 include: [
117 {
118 attributes: {
119 exclude: unusedActorAttributesForAPI
120 },
121 model: ActorModel, // Default scope includes avatar and server
122 required: true
123 }
124 ]
125 }
126 ]
127 }
128 },
8165d00a 129 [ScopeNames.SUMMARY]: (options: SummaryOptions = {}) => {
b49f22d8
C
130 const include: Includeable[] = [
131 {
132 attributes: [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
133 model: ActorModel.unscoped(),
134 required: options.actorRequired ?? true,
135 include: [
136 {
137 attributes: [ 'host' ],
138 model: ServerModel.unscoped(),
139 required: false
140 },
141 {
f4796856
C
142 model: ActorImageModel.unscoped(),
143 as: 'Avatar',
b49f22d8
C
144 required: false
145 }
146 ]
147 }
148 ]
149
8165d00a 150 const base: FindOptions = {
b49f22d8 151 attributes: [ 'id', 'name', 'description', 'actorId' ]
8165d00a
RK
152 }
153
154 if (options.withAccount === true) {
b49f22d8 155 include.push({
8165d00a
RK
156 model: AccountModel.scope({
157 method: [ AccountModelScopeNames.SUMMARY, { withAccountBlockerIds: options.withAccountBlockerIds } as AccountSummaryOptions ]
158 }),
159 required: true
160 })
161 }
162
b49f22d8
C
163 base.include = include
164
8165d00a
RK
165 return base
166 },
f37dc0dd
C
167 [ScopeNames.WITH_ACCOUNT]: {
168 include: [
169 {
3acc5084 170 model: AccountModel,
f37dc0dd 171 required: true
d48ff09d
C
172 }
173 ]
174 },
8165d00a 175 [ScopeNames.WITH_ACTOR]: {
d48ff09d 176 include: [
8165d00a 177 ActorModel
d48ff09d 178 ]
50d6de9c 179 },
2cb03dc1
C
180 [ScopeNames.WITH_ACTOR_BANNER]: {
181 include: [
182 {
183 model: ActorModel,
184 include: [
185 {
186 model: ActorImageModel,
187 required: false,
188 as: 'Banner'
189 }
190 ]
191 }
192 ]
193 },
8165d00a 194 [ScopeNames.WITH_VIDEOS]: {
50d6de9c 195 include: [
8165d00a 196 VideoModel
50d6de9c 197 ]
8165d00a 198 },
3d527ba1
RK
199 [ScopeNames.WITH_STATS]: (options: AvailableWithStatsOptions = { daysPrior: 30 }) => {
200 const daysPrior = parseInt(options.daysPrior + '', 10)
201
202 return {
203 attributes: {
204 include: [
1ba471c5
C
205 [
206 literal('(SELECT COUNT(*) FROM "video" WHERE "channelId" = "VideoChannelModel"."id")'),
207 'videosCount'
208 ],
3d527ba1
RK
209 [
210 literal(
211 '(' +
212 `SELECT string_agg(concat_ws('|', t.day, t.views), ',') ` +
213 'FROM ( ' +
214 'WITH ' +
215 'days AS ( ' +
216 `SELECT generate_series(date_trunc('day', now()) - '${daysPrior} day'::interval, ` +
217 `date_trunc('day', now()), '1 day'::interval) AS day ` +
8165d00a 218 ') ' +
5a61ffbb
C
219 'SELECT days.day AS day, COALESCE(SUM("videoView".views), 0) AS views ' +
220 'FROM days ' +
221 'LEFT JOIN (' +
222 '"videoView" INNER JOIN "video" ON "videoView"."videoId" = "video"."id" ' +
223 'AND "video"."channelId" = "VideoChannelModel"."id"' +
224 `) ON date_trunc('day', "videoView"."startDate") = date_trunc('day', days.day) ` +
225 'GROUP BY day ' +
226 'ORDER BY day ' +
227 ') t' +
3d527ba1
RK
228 ')'
229 ),
230 'viewsPerDay'
231 ]
8165d00a 232 ]
3d527ba1 233 }
8165d00a 234 }
3d527ba1 235 }
3acc5084 236}))
3fd3ab2d
C
237@Table({
238 tableName: 'videoChannel',
0374b6b5
C
239 indexes: [
240 buildTrigramSearchIndex('video_channel_name_trigram', 'name'),
241
242 {
243 fields: [ 'accountId' ]
244 },
245 {
246 fields: [ 'actorId' ]
247 }
248 ]
3fd3ab2d 249})
16c016e8 250export class VideoChannelModel extends Model<Partial<AttributesOnly<VideoChannelModel>>> {
72c7248b 251
3fd3ab2d
C
252 @AllowNull(false)
253 @Is('VideoChannelName', value => throwIfNotValid(value, isVideoChannelNameValid, 'name'))
254 @Column
255 name: string
72c7248b 256
3fd3ab2d 257 @AllowNull(true)
2422c46b 258 @Default(null)
1735c825 259 @Is('VideoChannelDescription', value => throwIfNotValid(value, isVideoChannelDescriptionValid, 'description', true))
a10fc78b 260 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.DESCRIPTION.max))
3fd3ab2d 261 description: string
72c7248b 262
2422c46b
C
263 @AllowNull(true)
264 @Default(null)
1735c825 265 @Is('VideoChannelSupport', value => throwIfNotValid(value, isVideoChannelSupportValid, 'support', true))
a10fc78b 266 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.SUPPORT.max))
2422c46b
C
267 support: string
268
3fd3ab2d
C
269 @CreatedAt
270 createdAt: Date
72c7248b 271
3fd3ab2d
C
272 @UpdatedAt
273 updatedAt: Date
4e50b6a1 274
fadf619a
C
275 @ForeignKey(() => ActorModel)
276 @Column
277 actorId: number
278
279 @BelongsTo(() => ActorModel, {
280 foreignKey: {
281 allowNull: false
282 },
283 onDelete: 'cascade'
284 })
285 Actor: ActorModel
286
3fd3ab2d
C
287 @ForeignKey(() => AccountModel)
288 @Column
289 accountId: number
4e50b6a1 290
3fd3ab2d
C
291 @BelongsTo(() => AccountModel, {
292 foreignKey: {
293 allowNull: false
294 },
6b738c7a 295 hooks: true
3fd3ab2d
C
296 })
297 Account: AccountModel
72c7248b 298
3fd3ab2d 299 @HasMany(() => VideoModel, {
72c7248b 300 foreignKey: {
3fd3ab2d 301 name: 'channelId',
72c7248b
C
302 allowNull: false
303 },
f05a1c30
C
304 onDelete: 'CASCADE',
305 hooks: true
72c7248b 306 })
3fd3ab2d 307 Videos: VideoModel[]
72c7248b 308
418d092a
C
309 @HasMany(() => VideoPlaylistModel, {
310 foreignKey: {
07b1a18a 311 allowNull: true
418d092a 312 },
df0b219d 313 onDelete: 'CASCADE',
418d092a
C
314 hooks: true
315 })
316 VideoPlaylists: VideoPlaylistModel[]
317
f05a1c30
C
318 @BeforeDestroy
319 static async sendDeleteIfOwned (instance: VideoChannelModel, options) {
320 if (!instance.Actor) {
e6122097 321 instance.Actor = await instance.$get('Actor', { transaction: options.transaction })
f05a1c30
C
322 }
323
2af337c8
C
324 await ActorFollowModel.removeFollowsOf(instance.Actor.id, options.transaction)
325
c5a893d5 326 if (instance.Actor.isOwned()) {
c5a893d5
C
327 return sendDeleteActor(instance.Actor, options.transaction)
328 }
329
330 return undefined
3fd3ab2d 331 }
72c7248b 332
3fd3ab2d
C
333 static countByAccount (accountId: number) {
334 const query = {
335 where: {
336 accountId
337 }
72c7248b 338 }
3fd3ab2d
C
339
340 return VideoChannelModel.count(query)
72c7248b
C
341 }
342
fe19f600
RK
343 static async getStats () {
344
345 function getActiveVideoChannels (days: number) {
346 const options = {
347 type: QueryTypes.SELECT as QueryTypes.SELECT,
348 raw: true
349 }
350
351 const query = `
352SELECT COUNT(DISTINCT("VideoChannelModel"."id")) AS "count"
353FROM "videoChannel" AS "VideoChannelModel"
354INNER JOIN "video" AS "Videos"
355ON "VideoChannelModel"."id" = "Videos"."channelId"
356AND ("Videos"."publishedAt" > Now() - interval '${days}d')
357INNER JOIN "account" AS "Account"
358ON "VideoChannelModel"."accountId" = "Account"."id"
359INNER JOIN "actor" AS "Account->Actor"
360ON "Account"."actorId" = "Account->Actor"."id"
361AND "Account->Actor"."serverId" IS NULL
362LEFT OUTER JOIN "server" AS "Account->Actor->Server"
363ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
364
365 return VideoChannelModel.sequelize.query<{ count: string }>(query, options)
366 .then(r => parseInt(r[0].count, 10))
367 }
368
369 const totalLocalVideoChannels = await VideoChannelModel.count()
370 const totalLocalDailyActiveVideoChannels = await getActiveVideoChannels(1)
371 const totalLocalWeeklyActiveVideoChannels = await getActiveVideoChannels(7)
372 const totalLocalMonthlyActiveVideoChannels = await getActiveVideoChannels(30)
373 const totalHalfYearActiveVideoChannels = await getActiveVideoChannels(180)
374
375 return {
376 totalLocalVideoChannels,
377 totalLocalDailyActiveVideoChannels,
378 totalLocalWeeklyActiveVideoChannels,
379 totalLocalMonthlyActiveVideoChannels,
380 totalHalfYearActiveVideoChannels
381 }
382 }
383
bc99dfe5
RK
384 static listForApi (parameters: {
385 actorId: number
386 start: number
387 count: number
388 sort: string
bc99dfe5 389 }) {
4f5d0459 390 const { actorId } = parameters
bc99dfe5 391
3fd3ab2d 392 const query = {
bc99dfe5
RK
393 offset: parameters.start,
394 limit: parameters.count,
395 order: getSort(parameters.sort)
3fd3ab2d 396 }
72c7248b 397
50d6de9c 398 return VideoChannelModel
b49f22d8
C
399 .scope({
400 method: [ ScopeNames.FOR_API, { actorId } as AvailableForListOptions ]
401 })
f37dc0dd
C
402 .findAndCountAll(query)
403 .then(({ rows, count }) => {
404 return { total: count, data: rows }
405 })
406 }
407
b49f22d8 408 static listLocalsForSitemap (sort: string): Promise<MChannelActor[]> {
2feebf3e
C
409 const query = {
410 attributes: [ ],
411 offset: 0,
412 order: getSort(sort),
413 include: [
414 {
415 attributes: [ 'preferredUsername', 'serverId' ],
416 model: ActorModel.unscoped(),
417 where: {
418 serverId: null
419 }
420 }
421 ]
422 }
423
424 return VideoChannelModel
425 .unscoped()
426 .findAll(query)
427 }
428
f37dc0dd
C
429 static searchForApi (options: {
430 actorId: number
431 search: string
432 start: number
433 count: number
434 sort: string
435 }) {
436 const attributesInclude = []
437 const escapedSearch = VideoModel.sequelize.escape(options.search)
438 const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%')
439 attributesInclude.push(createSimilarityAttribute('VideoChannelModel.name', options.search))
440
441 const query = {
442 attributes: {
443 include: attributesInclude
444 },
445 offset: options.start,
446 limit: options.count,
447 order: getSort(options.sort),
448 where: {
1735c825 449 [Op.or]: [
c3c2ab1c
C
450 Sequelize.literal(
451 'lower(immutable_unaccent("VideoChannelModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))'
452 ),
453 Sequelize.literal(
454 'lower(immutable_unaccent("VideoChannelModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))'
f37dc0dd 455 )
c3c2ab1c 456 ]
f37dc0dd
C
457 }
458 }
459
f37dc0dd 460 return VideoChannelModel
b49f22d8
C
461 .scope({
462 method: [ ScopeNames.FOR_API, { actorId: options.actorId } as AvailableForListOptions ]
463 })
50d6de9c 464 .findAndCountAll(query)
3fd3ab2d
C
465 .then(({ rows, count }) => {
466 return { total: count, data: rows }
467 })
72c7248b
C
468 }
469
91b66319 470 static listByAccount (options: {
a1587156
C
471 accountId: number
472 start: number
473 count: number
91b66319 474 sort: string
8165d00a 475 withStats?: boolean
4f5d0459 476 search?: string
91b66319 477 }) {
4f5d0459
RK
478 const escapedSearch = VideoModel.sequelize.escape(options.search)
479 const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%')
480 const where = options.search
481 ? {
482 [Op.or]: [
483 Sequelize.literal(
484 'lower(immutable_unaccent("VideoChannelModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))'
485 ),
486 Sequelize.literal(
487 'lower(immutable_unaccent("VideoChannelModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))'
488 )
489 ]
490 }
491 : null
492
3fd3ab2d 493 const query = {
91b66319
C
494 offset: options.start,
495 limit: options.count,
496 order: getSort(options.sort),
3fd3ab2d
C
497 include: [
498 {
499 model: AccountModel,
500 where: {
91b66319 501 id: options.accountId
3fd3ab2d 502 },
50d6de9c 503 required: true
3fd3ab2d 504 }
4f5d0459
RK
505 ],
506 where
3fd3ab2d 507 }
72c7248b 508
2cb03dc1 509 const scopes: string | ScopeOptions | (string | ScopeOptions)[] = [ ScopeNames.WITH_ACTOR_BANNER ]
8165d00a 510
5a61ffbb 511 if (options.withStats === true) {
8165d00a
RK
512 scopes.push({
513 method: [ ScopeNames.WITH_STATS, { daysPrior: 30 } as AvailableWithStatsOptions ]
514 })
515 }
516
50d6de9c 517 return VideoChannelModel
8165d00a 518 .scope(scopes)
50d6de9c 519 .findAndCountAll(query)
3fd3ab2d
C
520 .then(({ rows, count }) => {
521 return { total: count, data: rows }
522 })
72c7248b
C
523 }
524
2cb03dc1 525 static loadAndPopulateAccount (id: number): Promise<MChannelBannerAccountDefault> {
5cf84858 526 return VideoChannelModel.unscoped()
2cb03dc1 527 .scope([ ScopeNames.WITH_ACTOR_BANNER, ScopeNames.WITH_ACCOUNT ])
9b39106d 528 .findByPk(id)
3fd3ab2d 529 }
0d0e8dd0 530
2cb03dc1 531 static loadByUrlAndPopulateAccount (url: string): Promise<MChannelBannerAccountDefault> {
f37dc0dd
C
532 const query = {
533 include: [
534 {
535 model: ActorModel,
536 required: true,
537 where: {
538 url
2cb03dc1
C
539 },
540 include: [
541 {
542 model: ActorImageModel,
543 required: false,
544 as: 'Banner'
545 }
546 ]
f37dc0dd
C
547 }
548 ]
549 }
550
551 return VideoChannelModel
552 .scope([ ScopeNames.WITH_ACCOUNT ])
8a19bee1 553 .findOne(query)
72c7248b
C
554 }
555
92bf2f62
C
556 static loadByNameWithHostAndPopulateAccount (nameWithHost: string) {
557 const [ name, host ] = nameWithHost.split('@')
558
6dd9de95 559 if (!host || host === WEBSERVER.HOST) return VideoChannelModel.loadLocalByNameAndPopulateAccount(name)
92bf2f62
C
560
561 return VideoChannelModel.loadByNameAndHostAndPopulateAccount(name, host)
562 }
563
2cb03dc1 564 static loadLocalByNameAndPopulateAccount (name: string): Promise<MChannelBannerAccountDefault> {
8a19bee1 565 const query = {
3fd3ab2d 566 include: [
8a19bee1
C
567 {
568 model: ActorModel,
569 required: true,
570 where: {
571 preferredUsername: name,
572 serverId: null
2cb03dc1
C
573 },
574 include: [
575 {
576 model: ActorImageModel,
577 required: false,
578 as: 'Banner'
579 }
580 ]
8a19bee1 581 }
3fd3ab2d
C
582 ]
583 }
72c7248b 584
5cf84858 585 return VideoChannelModel.unscoped()
2cb03dc1 586 .scope([ ScopeNames.WITH_ACCOUNT ])
8a19bee1 587 .findOne(query)
72c7248b
C
588 }
589
2cb03dc1 590 static loadByNameAndHostAndPopulateAccount (name: string, host: string): Promise<MChannelBannerAccountDefault> {
06a05d5f
C
591 const query = {
592 include: [
593 {
594 model: ActorModel,
595 required: true,
596 where: {
8a19bee1
C
597 preferredUsername: name
598 },
599 include: [
600 {
601 model: ServerModel,
602 required: true,
603 where: { host }
2cb03dc1
C
604 },
605 {
606 model: ActorImageModel,
607 required: false,
608 as: 'Banner'
8a19bee1
C
609 }
610 ]
06a05d5f
C
611 }
612 ]
613 }
614
5cf84858 615 return VideoChannelModel.unscoped()
2cb03dc1 616 .scope([ ScopeNames.WITH_ACCOUNT ])
8a19bee1
C
617 .findOne(query)
618 }
619
1ca9f7c3
C
620 toFormattedSummaryJSON (this: MChannelSummaryFormattable): VideoChannelSummary {
621 const actor = this.Actor.toFormattedSummaryJSON()
622
623 return {
624 id: this.id,
625 name: actor.name,
626 displayName: this.getDisplayName(),
627 url: actor.url,
628 host: actor.host,
629 avatar: actor.avatar
630 }
631 }
632
633 toFormattedJSON (this: MChannelFormattable): VideoChannel {
1ba471c5
C
634 const viewsPerDayString = this.get('viewsPerDay') as string
635 const videosCount = this.get('videosCount') as number
636
637 let viewsPerDay: { date: Date, views: number }[]
638
639 if (viewsPerDayString) {
640 viewsPerDay = viewsPerDayString.split(',')
641 .map(v => {
642 const [ dateString, amount ] = v.split('|')
643
644 return {
645 date: new Date(dateString),
646 views: +amount
647 }
648 })
649 }
8165d00a 650
50d6de9c 651 const actor = this.Actor.toFormattedJSON()
6b738c7a 652 const videoChannel = {
3fd3ab2d 653 id: this.id,
749c7247 654 displayName: this.getDisplayName(),
3fd3ab2d 655 description: this.description,
2422c46b 656 support: this.support,
50d6de9c 657 isLocal: this.Actor.isOwned(),
e024fd6a 658 updatedAt: this.updatedAt,
8165d00a 659 ownerAccount: undefined,
1ba471c5
C
660 videosCount,
661 viewsPerDay
6b738c7a
C
662 }
663
a4f99a76 664 if (this.Account) videoChannel.ownerAccount = this.Account.toFormattedJSON()
72c7248b 665
6b738c7a 666 return Object.assign(actor, videoChannel)
72c7248b
C
667 }
668
b5fecbf4 669 toActivityPubObject (this: MChannelAP): ActivityPubActor {
8424c402 670 const obj = this.Actor.toActivityPubObject(this.name)
50d6de9c
C
671
672 return Object.assign(obj, {
673 summary: this.description,
2422c46b 674 support: this.support,
50d6de9c
C
675 attributedTo: [
676 {
677 type: 'Person' as 'Person',
678 id: this.Account.Actor.url
679 }
680 ]
681 })
72c7248b 682 }
749c7247 683
a59f210f
C
684 getLocalUrl (this: MAccountActor | MChannelActor) {
685 return WEBSERVER.URL + `/video-channels/` + this.Actor.preferredUsername
686 }
687
749c7247
C
688 getDisplayName () {
689 return this.name
690 }
744d0eca
C
691
692 isOutdated () {
693 return this.Actor.isOutdated()
694 }
e024fd6a
C
695
696 setAsUpdated (transaction: Transaction) {
697 return setAsUpdated('videoChannel', this.id, transaction)
698 }
72c7248b 699}