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