diff options
author | Rigel Kent <sendmemail@rigelk.eu> | 2020-03-23 10:14:05 +0100 |
---|---|---|
committer | Chocobozzz <chocobozzz@cpy.re> | 2020-03-31 10:29:24 +0200 |
commit | 8165d00ac6263cf3c0d61d450960ef36635084ff (patch) | |
tree | c0587121cd8dbdfc246a5bc74c08805830140a77 /server | |
parent | 628c155338cf106365a06ca021b9f244b784c003 (diff) | |
download | PeerTube-8165d00ac6263cf3c0d61d450960ef36635084ff.tar.gz PeerTube-8165d00ac6263cf3c0d61d450960ef36635084ff.tar.zst PeerTube-8165d00ac6263cf3c0d61d450960ef36635084ff.zip |
View stats for channels
Diffstat (limited to 'server')
-rw-r--r-- | server/models/video/video-channel.ts | 147 |
1 files changed, 105 insertions, 42 deletions
diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts index 835216671..128915af3 100644 --- a/server/models/video/video-channel.ts +++ b/server/models/video/video-channel.ts | |||
@@ -30,7 +30,7 @@ import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttr | |||
30 | import { VideoModel } from './video' | 30 | import { VideoModel } from './video' |
31 | import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants' | 31 | import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants' |
32 | import { ServerModel } from '../server/server' | 32 | import { ServerModel } from '../server/server' |
33 | import { FindOptions, Op } from 'sequelize' | 33 | import { FindOptions, Op, literal, ScopeOptions } from 'sequelize' |
34 | import { AvatarModel } from '../avatar/avatar' | 34 | import { AvatarModel } from '../avatar/avatar' |
35 | import { VideoPlaylistModel } from './video-playlist' | 35 | import { VideoPlaylistModel } from './video-playlist' |
36 | import * as Bluebird from 'bluebird' | 36 | import * as Bluebird from 'bluebird' |
@@ -45,16 +45,21 @@ import { | |||
45 | 45 | ||
46 | export enum ScopeNames { | 46 | export enum ScopeNames { |
47 | FOR_API = 'FOR_API', | 47 | FOR_API = 'FOR_API', |
48 | SUMMARY = 'SUMMARY', | ||
48 | WITH_ACCOUNT = 'WITH_ACCOUNT', | 49 | WITH_ACCOUNT = 'WITH_ACCOUNT', |
49 | WITH_ACTOR = 'WITH_ACTOR', | 50 | WITH_ACTOR = 'WITH_ACTOR', |
50 | WITH_VIDEOS = 'WITH_VIDEOS', | 51 | WITH_VIDEOS = 'WITH_VIDEOS', |
51 | SUMMARY = 'SUMMARY' | 52 | WITH_STATS = 'WITH_STATS' |
52 | } | 53 | } |
53 | 54 | ||
54 | type AvailableForListOptions = { | 55 | type AvailableForListOptions = { |
55 | actorId: number | 56 | actorId: number |
56 | } | 57 | } |
57 | 58 | ||
59 | type AvailableWithStatsOptions = { | ||
60 | daysPrior: number | ||
61 | } | ||
62 | |||
58 | export type SummaryOptions = { | 63 | export type SummaryOptions = { |
59 | withAccount?: boolean // Default: false | 64 | withAccount?: boolean // Default: false |
60 | withAccountBlockerIds?: number[] | 65 | withAccountBlockerIds?: number[] |
@@ -69,40 +74,6 @@ export type SummaryOptions = { | |||
69 | ] | 74 | ] |
70 | })) | 75 | })) |
71 | @Scopes(() => ({ | 76 | @Scopes(() => ({ |
72 | [ScopeNames.SUMMARY]: (options: SummaryOptions = {}) => { | ||
73 | const base: FindOptions = { | ||
74 | attributes: [ 'id', 'name', 'description', 'actorId' ], | ||
75 | include: [ | ||
76 | { | ||
77 | attributes: [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ], | ||
78 | model: ActorModel.unscoped(), | ||
79 | required: true, | ||
80 | include: [ | ||
81 | { | ||
82 | attributes: [ 'host' ], | ||
83 | model: ServerModel.unscoped(), | ||
84 | required: false | ||
85 | }, | ||
86 | { | ||
87 | model: AvatarModel.unscoped(), | ||
88 | required: false | ||
89 | } | ||
90 | ] | ||
91 | } | ||
92 | ] | ||
93 | } | ||
94 | |||
95 | if (options.withAccount === true) { | ||
96 | base.include.push({ | ||
97 | model: AccountModel.scope({ | ||
98 | method: [ AccountModelScopeNames.SUMMARY, { withAccountBlockerIds: options.withAccountBlockerIds } as AccountSummaryOptions ] | ||
99 | }), | ||
100 | required: true | ||
101 | }) | ||
102 | } | ||
103 | |||
104 | return base | ||
105 | }, | ||
106 | [ScopeNames.FOR_API]: (options: AvailableForListOptions) => { | 77 | [ScopeNames.FOR_API]: (options: AvailableForListOptions) => { |
107 | // Only list local channels OR channels that are on an instance followed by actorId | 78 | // Only list local channels OR channels that are on an instance followed by actorId |
108 | const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId) | 79 | const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId) |
@@ -143,6 +114,40 @@ export type SummaryOptions = { | |||
143 | ] | 114 | ] |
144 | } | 115 | } |
145 | }, | 116 | }, |
117 | [ScopeNames.SUMMARY]: (options: SummaryOptions = {}) => { | ||
118 | const base: FindOptions = { | ||
119 | attributes: [ 'id', 'name', 'description', 'actorId' ], | ||
120 | include: [ | ||
121 | { | ||
122 | attributes: [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ], | ||
123 | model: ActorModel.unscoped(), | ||
124 | required: true, | ||
125 | include: [ | ||
126 | { | ||
127 | attributes: [ 'host' ], | ||
128 | model: ServerModel.unscoped(), | ||
129 | required: false | ||
130 | }, | ||
131 | { | ||
132 | model: AvatarModel.unscoped(), | ||
133 | required: false | ||
134 | } | ||
135 | ] | ||
136 | } | ||
137 | ] | ||
138 | } | ||
139 | |||
140 | if (options.withAccount === true) { | ||
141 | base.include.push({ | ||
142 | model: AccountModel.scope({ | ||
143 | method: [ AccountModelScopeNames.SUMMARY, { withAccountBlockerIds: options.withAccountBlockerIds } as AccountSummaryOptions ] | ||
144 | }), | ||
145 | required: true | ||
146 | }) | ||
147 | } | ||
148 | |||
149 | return base | ||
150 | }, | ||
146 | [ScopeNames.WITH_ACCOUNT]: { | 151 | [ScopeNames.WITH_ACCOUNT]: { |
147 | include: [ | 152 | include: [ |
148 | { | 153 | { |
@@ -151,16 +156,52 @@ export type SummaryOptions = { | |||
151 | } | 156 | } |
152 | ] | 157 | ] |
153 | }, | 158 | }, |
154 | [ScopeNames.WITH_VIDEOS]: { | 159 | [ScopeNames.WITH_ACTOR]: { |
155 | include: [ | 160 | include: [ |
156 | VideoModel | 161 | ActorModel |
157 | ] | 162 | ] |
158 | }, | 163 | }, |
159 | [ScopeNames.WITH_ACTOR]: { | 164 | [ScopeNames.WITH_VIDEOS]: { |
160 | include: [ | 165 | include: [ |
161 | ActorModel | 166 | VideoModel |
162 | ] | 167 | ] |
163 | } | 168 | }, |
169 | [ScopeNames.WITH_STATS]: (options: AvailableWithStatsOptions = { daysPrior: 30 }) => ({ | ||
170 | attributes: { | ||
171 | include: [ | ||
172 | [ | ||
173 | literal( | ||
174 | '(' + | ||
175 | `SELECT string_agg(concat_ws('|', t.day, t.views), ',') ` + | ||
176 | 'FROM ( ' + | ||
177 | 'WITH ' + | ||
178 | 'days AS ( ' + | ||
179 | `SELECT generate_series(date_trunc('day', now()) - '${options.daysPrior} day'::interval, ` + | ||
180 | `date_trunc('day', now()), '1 day'::interval) AS day ` + | ||
181 | '), ' + | ||
182 | 'views AS ( ' + | ||
183 | 'SELECT * ' + | ||
184 | 'FROM "videoView" ' + | ||
185 | 'WHERE "videoView"."videoId" IN ( ' + | ||
186 | 'SELECT "video"."id" ' + | ||
187 | 'FROM "video" ' + | ||
188 | 'WHERE "video"."channelId" = "VideoChannelModel"."id" ' + | ||
189 | ') ' + | ||
190 | ') ' + | ||
191 | 'SELECT days.day AS day, ' + | ||
192 | 'COALESCE(SUM(views.views), 0) AS views ' + | ||
193 | 'FROM days ' + | ||
194 | `LEFT JOIN views ON date_trunc('day', "views"."createdAt") = days.day ` + | ||
195 | 'GROUP BY 1 ' + | ||
196 | 'ORDER BY day ' + | ||
197 | ') t' + | ||
198 | ')' | ||
199 | ), | ||
200 | 'viewsPerDay' | ||
201 | ] | ||
202 | ] | ||
203 | } | ||
204 | }) | ||
164 | })) | 205 | })) |
165 | @Table({ | 206 | @Table({ |
166 | tableName: 'videoChannel', | 207 | tableName: 'videoChannel', |
@@ -352,6 +393,7 @@ export class VideoChannelModel extends Model<VideoChannelModel> { | |||
352 | start: number | 393 | start: number |
353 | count: number | 394 | count: number |
354 | sort: string | 395 | sort: string |
396 | withStats?: boolean | ||
355 | }) { | 397 | }) { |
356 | const query = { | 398 | const query = { |
357 | offset: options.start, | 399 | offset: options.start, |
@@ -368,7 +410,17 @@ export class VideoChannelModel extends Model<VideoChannelModel> { | |||
368 | ] | 410 | ] |
369 | } | 411 | } |
370 | 412 | ||
413 | const scopes: string | ScopeOptions | (string | ScopeOptions)[] = [ ScopeNames.WITH_ACTOR ] | ||
414 | |||
415 | options.withStats = true // TODO: remove beyond after initial tests | ||
416 | if (options.withStats) { | ||
417 | scopes.push({ | ||
418 | method: [ ScopeNames.WITH_STATS, { daysPrior: 30 } as AvailableWithStatsOptions ] | ||
419 | }) | ||
420 | } | ||
421 | |||
371 | return VideoChannelModel | 422 | return VideoChannelModel |
423 | .scope(scopes) | ||
372 | .findAndCountAll(query) | 424 | .findAndCountAll(query) |
373 | .then(({ rows, count }) => { | 425 | .then(({ rows, count }) => { |
374 | return { total: count, data: rows } | 426 | return { total: count, data: rows } |
@@ -496,6 +548,8 @@ export class VideoChannelModel extends Model<VideoChannelModel> { | |||
496 | } | 548 | } |
497 | 549 | ||
498 | toFormattedJSON (this: MChannelFormattable): VideoChannel { | 550 | toFormattedJSON (this: MChannelFormattable): VideoChannel { |
551 | const viewsPerDay = this.get('viewsPerDay') as string | ||
552 | |||
499 | const actor = this.Actor.toFormattedJSON() | 553 | const actor = this.Actor.toFormattedJSON() |
500 | const videoChannel = { | 554 | const videoChannel = { |
501 | id: this.id, | 555 | id: this.id, |
@@ -505,7 +559,16 @@ export class VideoChannelModel extends Model<VideoChannelModel> { | |||
505 | isLocal: this.Actor.isOwned(), | 559 | isLocal: this.Actor.isOwned(), |
506 | createdAt: this.createdAt, | 560 | createdAt: this.createdAt, |
507 | updatedAt: this.updatedAt, | 561 | updatedAt: this.updatedAt, |
508 | ownerAccount: undefined | 562 | ownerAccount: undefined, |
563 | viewsPerDay: viewsPerDay !== undefined | ||
564 | ? viewsPerDay.split(',').map(v => { | ||
565 | const o = v.split('|') | ||
566 | return { | ||
567 | date: new Date(o[0]), | ||
568 | views: +o[1] | ||
569 | } | ||
570 | }) | ||
571 | : undefined | ||
509 | } | 572 | } |
510 | 573 | ||
511 | if (this.Account) videoChannel.ownerAccount = this.Account.toFormattedJSON() | 574 | if (this.Account) videoChannel.ownerAccount = this.Account.toFormattedJSON() |