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