]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/models/view/local-video-viewer.ts
Add ability to filter overall video stats by date
[github/Chocobozzz/PeerTube.git] / server / models / view / local-video-viewer.ts
CommitLineData
b2111066
C
1import { QueryTypes } from 'sequelize'
2import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, HasMany, IsUUID, Model, Table } from 'sequelize-typescript'
b2111066 3import { getActivityStreamDuration } from '@server/lib/activitypub/activity'
901bcf5c 4import { buildGroupByAndBoundaries } from '@server/lib/timeserie'
b2111066
C
5import { MLocalVideoViewer, MLocalVideoViewerWithWatchSections, MVideo } from '@server/types/models'
6import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric, WatchActionObject } from '@shared/models'
7import { AttributesOnly } from '@shared/typescript-utils'
8import { VideoModel } from '../video/video'
9import { LocalVideoViewerWatchSectionModel } from './local-video-viewer-watch-section'
10
dfbcefc2
C
11/**
12 *
13 * Aggregate viewers of local videos only to display statistics to video owners
14 * A viewer is a user that watched one or multiple sections of a specific video inside a time window
15 *
16 */
17
b2111066
C
18@Table({
19 tableName: 'localVideoViewer',
20 updatedAt: false,
21 indexes: [
22 {
23 fields: [ 'videoId' ]
24 }
25 ]
26})
27export class LocalVideoViewerModel extends Model<Partial<AttributesOnly<LocalVideoViewerModel>>> {
28 @CreatedAt
29 createdAt: Date
30
31 @AllowNull(false)
32 @Column(DataType.DATE)
33 startDate: Date
34
35 @AllowNull(false)
36 @Column(DataType.DATE)
37 endDate: Date
38
39 @AllowNull(false)
40 @Column
41 watchTime: number
42
43 @AllowNull(true)
44 @Column
45 country: string
46
47 @AllowNull(false)
48 @Default(DataType.UUIDV4)
49 @IsUUID(4)
50 @Column(DataType.UUID)
51 uuid: string
52
53 @AllowNull(false)
54 @Column
55 url: string
56
57 @ForeignKey(() => VideoModel)
58 @Column
59 videoId: number
60
61 @BelongsTo(() => VideoModel, {
62 foreignKey: {
63 allowNull: false
64 },
65 onDelete: 'CASCADE'
66 })
67 Video: VideoModel
68
69 @HasMany(() => LocalVideoViewerWatchSectionModel, {
70 foreignKey: {
71 allowNull: false
72 },
73 onDelete: 'cascade'
74 })
75 WatchSections: LocalVideoViewerWatchSectionModel[]
76
77 static loadByUrl (url: string): Promise<MLocalVideoViewer> {
78 return this.findOne({
79 where: {
80 url
81 }
82 })
83 }
84
85 static loadFullById (id: number): Promise<MLocalVideoViewerWithWatchSections> {
86 return this.findOne({
87 include: [
88 {
89 model: VideoModel.unscoped(),
90 required: true
91 },
92 {
93 model: LocalVideoViewerWatchSectionModel.unscoped(),
94 required: true
95 }
96 ],
97 where: {
98 id
99 }
100 })
101 }
102
49f0468d
C
103 static async getOverallStats (options: {
104 video: MVideo
105 startDate?: string
106 endDate?: string
107 }): Promise<VideoStatsOverall> {
108 const { video, startDate, endDate } = options
109
110 const queryOptions = {
b2111066 111 type: QueryTypes.SELECT as QueryTypes.SELECT,
49f0468d
C
112 replacements: { videoId: video.id } as any
113 }
114
115 let dateWhere = ''
116
117 if (startDate) {
118 dateWhere += ' AND "localVideoViewer"."startDate" >= :startDate'
119 queryOptions.replacements.startDate = startDate
120 }
121
122 if (endDate) {
123 dateWhere += ' AND "localVideoViewer"."endDate" <= :endDate'
124 queryOptions.replacements.endDate = endDate
b2111066
C
125 }
126
127 const watchTimeQuery = `SELECT ` +
128 `SUM("localVideoViewer"."watchTime") AS "totalWatchTime", ` +
129 `AVG("localVideoViewer"."watchTime") AS "averageWatchTime" ` +
130 `FROM "localVideoViewer" ` +
131 `INNER JOIN "video" ON "video"."id" = "localVideoViewer"."videoId" ` +
49f0468d 132 `WHERE "videoId" = :videoId ${dateWhere}`
b2111066 133
49f0468d 134 const watchTimePromise = LocalVideoViewerModel.sequelize.query<any>(watchTimeQuery, queryOptions)
b2111066
C
135
136 const watchPeakQuery = `WITH "watchPeakValues" AS (
137 SELECT "startDate" AS "dateBreakpoint", 1 AS "inc"
138 FROM "localVideoViewer"
f40712ab 139 WHERE "videoId" = :videoId ${dateWhere}
b2111066
C
140 UNION ALL
141 SELECT "endDate" AS "dateBreakpoint", -1 AS "inc"
142 FROM "localVideoViewer"
49f0468d 143 WHERE "videoId" = :videoId ${dateWhere}
b2111066
C
144 )
145 SELECT "dateBreakpoint", "concurrent"
146 FROM (
147 SELECT "dateBreakpoint", SUM(SUM("inc")) OVER (ORDER BY "dateBreakpoint") AS "concurrent"
148 FROM "watchPeakValues"
149 GROUP BY "dateBreakpoint"
150 ) tmp
151 ORDER BY "concurrent" DESC
152 FETCH FIRST 1 ROW ONLY`
49f0468d 153 const watchPeakPromise = LocalVideoViewerModel.sequelize.query<any>(watchPeakQuery, queryOptions)
b2111066 154
b2111066
C
155 const countriesQuery = `SELECT country, COUNT(country) as viewers ` +
156 `FROM "localVideoViewer" ` +
49f0468d 157 `WHERE "videoId" = :videoId AND country IS NOT NULL ${dateWhere} ` +
b2111066
C
158 `GROUP BY country ` +
159 `ORDER BY viewers DESC`
49f0468d 160 const countriesPromise = LocalVideoViewerModel.sequelize.query<any>(countriesQuery, queryOptions)
b2111066 161
f18a060a 162 const [ rowsWatchTime, rowsWatchPeak, rowsCountries ] = await Promise.all([
b2111066
C
163 watchTimePromise,
164 watchPeakPromise,
b2111066
C
165 countriesPromise
166 ])
167
f40712ab
C
168 const viewersPeak = rowsWatchPeak.length !== 0
169 ? parseInt(rowsWatchPeak[0].concurrent) || 0
170 : 0
171
b2111066
C
172 return {
173 totalWatchTime: rowsWatchTime.length !== 0
174 ? Math.round(rowsWatchTime[0].totalWatchTime) || 0
175 : 0,
176 averageWatchTime: rowsWatchTime.length !== 0
177 ? Math.round(rowsWatchTime[0].averageWatchTime) || 0
178 : 0,
179
f40712ab
C
180 viewersPeak,
181 viewersPeakDate: rowsWatchPeak.length !== 0 && viewersPeak !== 0
b2111066
C
182 ? rowsWatchPeak[0].dateBreakpoint || null
183 : null,
184
b2111066
C
185 countries: rowsCountries.map(r => ({
186 isoCode: r.country,
187 viewers: r.viewers
188 }))
189 }
190 }
191
192 static async getRetentionStats (video: MVideo): Promise<VideoStatsRetention> {
193 const step = Math.max(Math.round(video.duration / 100), 1)
194
195 const query = `WITH "total" AS (SELECT COUNT(*) AS viewers FROM "localVideoViewer" WHERE "videoId" = :videoId) ` +
196 `SELECT serie AS "second", ` +
197 `(COUNT("localVideoViewer".id)::float / (SELECT GREATEST("total"."viewers", 1) FROM "total")) AS "retention" ` +
198 `FROM generate_series(0, ${video.duration}, ${step}) serie ` +
199 `LEFT JOIN "localVideoViewer" ON "localVideoViewer"."videoId" = :videoId ` +
200 `AND EXISTS (` +
201 `SELECT 1 FROM "localVideoViewerWatchSection" ` +
202 `WHERE "localVideoViewer"."id" = "localVideoViewerWatchSection"."localVideoViewerId" ` +
203 `AND serie >= "localVideoViewerWatchSection"."watchStart" ` +
204 `AND serie <= "localVideoViewerWatchSection"."watchEnd"` +
205 `)` +
206 `GROUP BY serie ` +
207 `ORDER BY serie ASC`
208
209 const queryOptions = {
210 type: QueryTypes.SELECT as QueryTypes.SELECT,
211 replacements: { videoId: video.id }
212 }
213
214 const rows = await LocalVideoViewerModel.sequelize.query<any>(query, queryOptions)
215
216 return {
217 data: rows.map(r => ({
218 second: r.second,
219 retentionPercent: parseFloat(r.retention) * 100
220 }))
221 }
222 }
223
224 static async getTimeserieStats (options: {
225 video: MVideo
226 metric: VideoStatsTimeserieMetric
901bcf5c
C
227 startDate: string
228 endDate: string
b2111066
C
229 }): Promise<VideoStatsTimeserie> {
230 const { video, metric } = options
231
3eda9b77 232 const { groupInterval, startDate, endDate } = buildGroupByAndBoundaries(options.startDate, options.endDate)
901bcf5c 233
b2111066
C
234 const selectMetrics: { [ id in VideoStatsTimeserieMetric ]: string } = {
235 viewers: 'COUNT("localVideoViewer"."id")',
236 aggregateWatchTime: 'SUM("localVideoViewer"."watchTime")'
237 }
238
901bcf5c
C
239 const query = `WITH "intervals" AS (
240 SELECT
3eda9b77 241 "time" AS "startDate", "time" + :groupInterval::interval as "endDate"
901bcf5c 242 FROM
3eda9b77 243 generate_series(:startDate::timestamptz, :endDate::timestamptz, :groupInterval::interval) serie("time")
901bcf5c
C
244 )
245 SELECT "intervals"."startDate" as "date", COALESCE(${selectMetrics[metric]}, 0) AS value
246 FROM
247 intervals
248 LEFT JOIN "localVideoViewer" ON "localVideoViewer"."videoId" = :videoId
249 AND "localVideoViewer"."startDate" >= "intervals"."startDate" AND "localVideoViewer"."startDate" <= "intervals"."endDate"
250 GROUP BY
251 "intervals"."startDate"
252 ORDER BY
253 "intervals"."startDate"`
b2111066
C
254
255 const queryOptions = {
256 type: QueryTypes.SELECT as QueryTypes.SELECT,
901bcf5c
C
257 replacements: {
258 startDate,
259 endDate,
3eda9b77 260 groupInterval,
901bcf5c
C
261 videoId: video.id
262 }
b2111066
C
263 }
264
265 const rows = await LocalVideoViewerModel.sequelize.query<any>(query, queryOptions)
266
267 return {
901bcf5c 268 groupInterval,
b2111066
C
269 data: rows.map(r => ({
270 date: r.date,
271 value: parseInt(r.value)
272 }))
273 }
274 }
275
276 toActivityPubObject (this: MLocalVideoViewerWithWatchSections): WatchActionObject {
277 const location = this.country
278 ? {
279 location: {
280 addressCountry: this.country
281 }
282 }
283 : {}
284
285 return {
286 id: this.url,
287 type: 'WatchAction',
288 duration: getActivityStreamDuration(this.watchTime),
289 startTime: this.startDate.toISOString(),
290 endTime: this.endDate.toISOString(),
291
292 object: this.Video.url,
293 uuid: this.uuid,
294 actionStatus: 'CompletedActionStatus',
295
296 watchSections: this.WatchSections.map(w => ({
297 startTimestamp: w.watchStart,
298 endTimestamp: w.watchEnd
299 })),
300
301 ...location
302 }
303 }
304}