]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame_incremental - server/models/video/video-channel.ts
Refactor a little bit client canonical URL
[github/Chocobozzz/PeerTube.git] / server / models / video / video-channel.ts
... / ...
CommitLineData
1import * as Bluebird from 'bluebird'
2import { FindOptions, literal, Op, ScopeOptions } from 'sequelize'
3import {
4 AllowNull,
5 BeforeDestroy,
6 BelongsTo,
7 Column,
8 CreatedAt,
9 DataType,
10 Default,
11 DefaultScope,
12 ForeignKey,
13 HasMany,
14 Is,
15 Model,
16 Scopes,
17 Sequelize,
18 Table,
19 UpdatedAt
20} from 'sequelize-typescript'
21import { MAccountActor } from '@server/types/models'
22import { ActivityPubActor } from '../../../shared/models/activitypub'
23import { VideoChannel, VideoChannelSummary } from '../../../shared/models/videos'
24import {
25 isVideoChannelDescriptionValid,
26 isVideoChannelNameValid,
27 isVideoChannelSupportValid
28} from '../../helpers/custom-validators/video-channels'
29import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
30import { sendDeleteActor } from '../../lib/activitypub/send'
31import {
32 MChannelAccountDefault,
33 MChannelActor,
34 MChannelActorAccountDefaultVideos,
35 MChannelAP,
36 MChannelFormattable,
37 MChannelSummaryFormattable
38} from '../../types/models/video'
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'
47
48export enum ScopeNames {
49 FOR_API = 'FOR_API',
50 SUMMARY = 'SUMMARY',
51 WITH_ACCOUNT = 'WITH_ACCOUNT',
52 WITH_ACTOR = 'WITH_ACTOR',
53 WITH_VIDEOS = 'WITH_VIDEOS',
54 WITH_STATS = 'WITH_STATS'
55}
56
57type AvailableForListOptions = {
58 actorId: number
59 search?: string
60}
61
62type AvailableWithStatsOptions = {
63 daysPrior: number
64}
65
66export type SummaryOptions = {
67 actorRequired?: boolean // Default: true
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.FOR_API]: (options: AvailableForListOptions) => {
82 // Only list local channels OR channels that are on an instance followed by actorId
83 const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId)
84
85 return {
86 include: [
87 {
88 attributes: {
89 exclude: unusedActorAttributesForAPI
90 },
91 model: ActorModel,
92 where: {
93 [Op.or]: [
94 {
95 serverId: null
96 },
97 {
98 serverId: {
99 [Op.in]: Sequelize.literal(inQueryInstanceFollow)
100 }
101 }
102 ]
103 }
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 },
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(),
128 required: options.actorRequired ?? true,
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 },
155 [ScopeNames.WITH_ACCOUNT]: {
156 include: [
157 {
158 model: AccountModel,
159 required: true
160 }
161 ]
162 },
163 [ScopeNames.WITH_ACTOR]: {
164 include: [
165 ActorModel
166 ]
167 },
168 [ScopeNames.WITH_VIDEOS]: {
169 include: [
170 VideoModel
171 ]
172 },
173 [ScopeNames.WITH_STATS]: (options: AvailableWithStatsOptions = { daysPrior: 30 }) => {
174 const daysPrior = parseInt(options.daysPrior + '', 10)
175
176 return {
177 attributes: {
178 include: [
179 [
180 literal('(SELECT COUNT(*) FROM "video" WHERE "channelId" = "VideoChannelModel"."id")'),
181 'videosCount'
182 ],
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 ` +
192 ') ' +
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' +
202 ')'
203 ),
204 'viewsPerDay'
205 ]
206 ]
207 }
208 }
209 }
210}))
211@Table({
212 tableName: 'videoChannel',
213 indexes: [
214 buildTrigramSearchIndex('video_channel_name_trigram', 'name'),
215
216 {
217 fields: [ 'accountId' ]
218 },
219 {
220 fields: [ 'actorId' ]
221 }
222 ]
223})
224export class VideoChannelModel extends Model<VideoChannelModel> {
225
226 @AllowNull(false)
227 @Is('VideoChannelName', value => throwIfNotValid(value, isVideoChannelNameValid, 'name'))
228 @Column
229 name: string
230
231 @AllowNull(true)
232 @Default(null)
233 @Is('VideoChannelDescription', value => throwIfNotValid(value, isVideoChannelDescriptionValid, 'description', true))
234 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.DESCRIPTION.max))
235 description: string
236
237 @AllowNull(true)
238 @Default(null)
239 @Is('VideoChannelSupport', value => throwIfNotValid(value, isVideoChannelSupportValid, 'support', true))
240 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_CHANNELS.SUPPORT.max))
241 support: string
242
243 @CreatedAt
244 createdAt: Date
245
246 @UpdatedAt
247 updatedAt: Date
248
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
261 @ForeignKey(() => AccountModel)
262 @Column
263 accountId: number
264
265 @BelongsTo(() => AccountModel, {
266 foreignKey: {
267 allowNull: false
268 },
269 hooks: true
270 })
271 Account: AccountModel
272
273 @HasMany(() => VideoModel, {
274 foreignKey: {
275 name: 'channelId',
276 allowNull: false
277 },
278 onDelete: 'CASCADE',
279 hooks: true
280 })
281 Videos: VideoModel[]
282
283 @HasMany(() => VideoPlaylistModel, {
284 foreignKey: {
285 allowNull: true
286 },
287 onDelete: 'CASCADE',
288 hooks: true
289 })
290 VideoPlaylists: VideoPlaylistModel[]
291
292 @BeforeDestroy
293 static async sendDeleteIfOwned (instance: VideoChannelModel, options) {
294 if (!instance.Actor) {
295 instance.Actor = await instance.$get('Actor', { transaction: options.transaction })
296 }
297
298 await ActorFollowModel.removeFollowsOf(instance.Actor.id, options.transaction)
299
300 if (instance.Actor.isOwned()) {
301 return sendDeleteActor(instance.Actor, options.transaction)
302 }
303
304 return undefined
305 }
306
307 static countByAccount (accountId: number) {
308 const query = {
309 where: {
310 accountId
311 }
312 }
313
314 return VideoChannelModel.count(query)
315 }
316
317 static listForApi (parameters: {
318 actorId: number
319 start: number
320 count: number
321 sort: string
322 }) {
323 const { actorId } = parameters
324
325 const query = {
326 offset: parameters.start,
327 limit: parameters.count,
328 order: getSort(parameters.sort)
329 }
330
331 const scopes = {
332 method: [ ScopeNames.FOR_API, { actorId } as AvailableForListOptions ]
333 }
334 return VideoChannelModel
335 .scope(scopes)
336 .findAndCountAll(query)
337 .then(({ rows, count }) => {
338 return { total: count, data: rows }
339 })
340 }
341
342 static listLocalsForSitemap (sort: string): Bluebird<MChannelActor[]> {
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
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: {
383 [Op.or]: [
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 + '))'
389 )
390 ]
391 }
392 }
393
394 const scopes = {
395 method: [ ScopeNames.FOR_API, { actorId: options.actorId } as AvailableForListOptions ]
396 }
397 return VideoChannelModel
398 .scope(scopes)
399 .findAndCountAll(query)
400 .then(({ rows, count }) => {
401 return { total: count, data: rows }
402 })
403 }
404
405 static listByAccount (options: {
406 accountId: number
407 start: number
408 count: number
409 sort: string
410 withStats?: boolean
411 search?: string
412 }) {
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
428 const query = {
429 offset: options.start,
430 limit: options.count,
431 order: getSort(options.sort),
432 include: [
433 {
434 model: AccountModel,
435 where: {
436 id: options.accountId
437 },
438 required: true
439 }
440 ],
441 where
442 }
443
444 const scopes: string | ScopeOptions | (string | ScopeOptions)[] = [ ScopeNames.WITH_ACTOR ]
445
446 if (options.withStats === true) {
447 scopes.push({
448 method: [ ScopeNames.WITH_STATS, { daysPrior: 30 } as AvailableWithStatsOptions ]
449 })
450 }
451
452 return VideoChannelModel
453 .scope(scopes)
454 .findAndCountAll(query)
455 .then(({ rows, count }) => {
456 return { total: count, data: rows }
457 })
458 }
459
460 static loadByIdAndPopulateAccount (id: number): Bluebird<MChannelAccountDefault> {
461 return VideoChannelModel.unscoped()
462 .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
463 .findByPk(id)
464 }
465
466 static loadByIdAndAccount (id: number, accountId: number): Bluebird<MChannelAccountDefault> {
467 const query = {
468 where: {
469 id,
470 accountId
471 }
472 }
473
474 return VideoChannelModel.unscoped()
475 .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
476 .findOne(query)
477 }
478
479 static loadAndPopulateAccount (id: number): Bluebird<MChannelAccountDefault> {
480 return VideoChannelModel.unscoped()
481 .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
482 .findByPk(id)
483 }
484
485 static loadByUrlAndPopulateAccount (url: string): Bluebird<MChannelAccountDefault> {
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 ])
500 .findOne(query)
501 }
502
503 static loadByNameWithHostAndPopulateAccount (nameWithHost: string) {
504 const [ name, host ] = nameWithHost.split('@')
505
506 if (!host || host === WEBSERVER.HOST) return VideoChannelModel.loadLocalByNameAndPopulateAccount(name)
507
508 return VideoChannelModel.loadByNameAndHostAndPopulateAccount(name, host)
509 }
510
511 static loadLocalByNameAndPopulateAccount (name: string): Bluebird<MChannelAccountDefault> {
512 const query = {
513 include: [
514 {
515 model: ActorModel,
516 required: true,
517 where: {
518 preferredUsername: name,
519 serverId: null
520 }
521 }
522 ]
523 }
524
525 return VideoChannelModel.unscoped()
526 .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
527 .findOne(query)
528 }
529
530 static loadByNameAndHostAndPopulateAccount (name: string, host: string): Bluebird<MChannelAccountDefault> {
531 const query = {
532 include: [
533 {
534 model: ActorModel,
535 required: true,
536 where: {
537 preferredUsername: name
538 },
539 include: [
540 {
541 model: ServerModel,
542 required: true,
543 where: { host }
544 }
545 ]
546 }
547 ]
548 }
549
550 return VideoChannelModel.unscoped()
551 .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT ])
552 .findOne(query)
553 }
554
555 static loadAndPopulateAccountAndVideos (id: number): Bluebird<MChannelActorAccountDefaultVideos> {
556 const options = {
557 include: [
558 VideoModel
559 ]
560 }
561
562 return VideoChannelModel.unscoped()
563 .scope([ ScopeNames.WITH_ACTOR, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_VIDEOS ])
564 .findByPk(id, options)
565 }
566
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 {
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 }
597
598 const actor = this.Actor.toFormattedJSON()
599 const videoChannel = {
600 id: this.id,
601 displayName: this.getDisplayName(),
602 description: this.description,
603 support: this.support,
604 isLocal: this.Actor.isOwned(),
605 createdAt: this.createdAt,
606 updatedAt: this.updatedAt,
607 ownerAccount: undefined,
608 videosCount,
609 viewsPerDay
610 }
611
612 if (this.Account) videoChannel.ownerAccount = this.Account.toFormattedJSON()
613
614 return Object.assign(actor, videoChannel)
615 }
616
617 toActivityPubObject (this: MChannelAP): ActivityPubActor {
618 const obj = this.Actor.toActivityPubObject(this.name)
619
620 return Object.assign(obj, {
621 summary: this.description,
622 support: this.support,
623 attributedTo: [
624 {
625 type: 'Person' as 'Person',
626 id: this.Account.Actor.url
627 }
628 ]
629 })
630 }
631
632 getLocalUrl (this: MAccountActor | MChannelActor) {
633 return WEBSERVER.URL + `/video-channels/` + this.Actor.preferredUsername
634 }
635
636 getDisplayName () {
637 return this.name
638 }
639
640 isOutdated () {
641 return this.Actor.isOutdated()
642 }
643}