]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/models/view/local-video-viewer.ts
Translated using Weblate (Basque)
[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' ]
f008e9f3
C
24 },
25 {
26 fields: [ 'url' ],
27 unique: true
b2111066
C
28 }
29 ]
30})
31export class LocalVideoViewerModel extends Model<Partial<AttributesOnly<LocalVideoViewerModel>>> {
32 @CreatedAt
33 createdAt: Date
34
35 @AllowNull(false)
36 @Column(DataType.DATE)
37 startDate: Date
38
39 @AllowNull(false)
40 @Column(DataType.DATE)
41 endDate: Date
42
43 @AllowNull(false)
44 @Column
45 watchTime: number
46
47 @AllowNull(true)
48 @Column
49 country: string
50
51 @AllowNull(false)
52 @Default(DataType.UUIDV4)
53 @IsUUID(4)
54 @Column(DataType.UUID)
55 uuid: string
56
57 @AllowNull(false)
58 @Column
59 url: string
60
61 @ForeignKey(() => VideoModel)
62 @Column
63 videoId: number
64
65 @BelongsTo(() => VideoModel, {
66 foreignKey: {
67 allowNull: false
68 },
69 onDelete: 'CASCADE'
70 })
71 Video: VideoModel
72
73 @HasMany(() => LocalVideoViewerWatchSectionModel, {
74 foreignKey: {
75 allowNull: false
76 },
77 onDelete: 'cascade'
78 })
79 WatchSections: LocalVideoViewerWatchSectionModel[]
80
81 static loadByUrl (url: string): Promise<MLocalVideoViewer> {
82 return this.findOne({
83 where: {
84 url
85 }
86 })
87 }
88
89 static loadFullById (id: number): Promise<MLocalVideoViewerWithWatchSections> {
90 return this.findOne({
91 include: [
92 {
93 model: VideoModel.unscoped(),
94 required: true
95 },
96 {
97 model: LocalVideoViewerWatchSectionModel.unscoped(),
98 required: true
99 }
100 ],
101 where: {
102 id
103 }
104 })
105 }
106
49f0468d
C
107 static async getOverallStats (options: {
108 video: MVideo
109 startDate?: string
110 endDate?: string
111 }): Promise<VideoStatsOverall> {
112 const { video, startDate, endDate } = options
113
114 const queryOptions = {
b2111066 115 type: QueryTypes.SELECT as QueryTypes.SELECT,
49f0468d
C
116 replacements: { videoId: video.id } as any
117 }
118
624ea01b
C
119 if (startDate) queryOptions.replacements.startDate = startDate
120 if (endDate) queryOptions.replacements.endDate = endDate
49f0468d 121
624ea01b
C
122 const buildWatchTimePromise = () => {
123 let watchTimeDateWhere = ''
124
125 if (startDate) watchTimeDateWhere += ' AND "localVideoViewer"."startDate" >= :startDate'
126 if (endDate) watchTimeDateWhere += ' AND "localVideoViewer"."endDate" <= :endDate'
127
128 const watchTimeQuery = `SELECT ` +
129 `COUNT("localVideoViewer"."id") AS "totalViewers", ` +
130 `SUM("localVideoViewer"."watchTime") AS "totalWatchTime", ` +
131 `AVG("localVideoViewer"."watchTime") AS "averageWatchTime" ` +
132 `FROM "localVideoViewer" ` +
133 `INNER JOIN "video" ON "video"."id" = "localVideoViewer"."videoId" ` +
134 `WHERE "videoId" = :videoId ${watchTimeDateWhere}`
135
136 return LocalVideoViewerModel.sequelize.query<any>(watchTimeQuery, queryOptions)
49f0468d
C
137 }
138
624ea01b
C
139 const buildWatchPeakPromise = () => {
140 let watchPeakDateWhereStart = ''
141 let watchPeakDateWhereEnd = ''
142
143 if (startDate) {
144 watchPeakDateWhereStart += ' AND "localVideoViewer"."startDate" >= :startDate'
145 watchPeakDateWhereEnd += ' AND "localVideoViewer"."endDate" >= :startDate'
146 }
147
148 if (endDate) {
149 watchPeakDateWhereStart += ' AND "localVideoViewer"."startDate" <= :endDate'
150 watchPeakDateWhereEnd += ' AND "localVideoViewer"."endDate" <= :endDate'
151 }
152
153 // Add viewers that were already here, before our start date
154 const beforeWatchersQuery = startDate
155 // eslint-disable-next-line max-len
156 ? `SELECT COUNT(*) AS "total" FROM "localVideoViewer" WHERE "localVideoViewer"."startDate" < :startDate AND "localVideoViewer"."endDate" >= :startDate`
157 : `SELECT 0 AS "total"`
158
159 const watchPeakQuery = `WITH
160 "beforeWatchers" AS (${beforeWatchersQuery}),
161 "watchPeakValues" AS (
162 SELECT "startDate" AS "dateBreakpoint", 1 AS "inc"
163 FROM "localVideoViewer"
164 WHERE "videoId" = :videoId ${watchPeakDateWhereStart}
165 UNION ALL
166 SELECT "endDate" AS "dateBreakpoint", -1 AS "inc"
167 FROM "localVideoViewer"
168 WHERE "videoId" = :videoId ${watchPeakDateWhereEnd}
169 )
170 SELECT "dateBreakpoint", "concurrent"
171 FROM (
172 SELECT "dateBreakpoint", SUM(SUM("inc")) OVER (ORDER BY "dateBreakpoint") + (SELECT "total" FROM "beforeWatchers") AS "concurrent"
173 FROM "watchPeakValues"
174 GROUP BY "dateBreakpoint"
175 ) tmp
176 ORDER BY "concurrent" DESC
177 FETCH FIRST 1 ROW ONLY`
178
179 return LocalVideoViewerModel.sequelize.query<any>(watchPeakQuery, queryOptions)
b2111066
C
180 }
181
624ea01b
C
182 const buildCountriesPromise = () => {
183 let countryDateWhere = ''
184
185 if (startDate) countryDateWhere += ' AND "localVideoViewer"."endDate" >= :startDate'
186 if (endDate) countryDateWhere += ' AND "localVideoViewer"."startDate" <= :endDate'
187
188 const countriesQuery = `SELECT country, COUNT(country) as viewers ` +
189 `FROM "localVideoViewer" ` +
190 `WHERE "videoId" = :videoId AND country IS NOT NULL ${countryDateWhere} ` +
191 `GROUP BY country ` +
192 `ORDER BY viewers DESC`
193
194 return LocalVideoViewerModel.sequelize.query<any>(countriesQuery, queryOptions)
195 }
b2111066 196
f18a060a 197 const [ rowsWatchTime, rowsWatchPeak, rowsCountries ] = await Promise.all([
624ea01b
C
198 buildWatchTimePromise(),
199 buildWatchPeakPromise(),
200 buildCountriesPromise()
b2111066
C
201 ])
202
f40712ab
C
203 const viewersPeak = rowsWatchPeak.length !== 0
204 ? parseInt(rowsWatchPeak[0].concurrent) || 0
205 : 0
206
b2111066
C
207 return {
208 totalWatchTime: rowsWatchTime.length !== 0
209 ? Math.round(rowsWatchTime[0].totalWatchTime) || 0
210 : 0,
211 averageWatchTime: rowsWatchTime.length !== 0
212 ? Math.round(rowsWatchTime[0].averageWatchTime) || 0
213 : 0,
214
305ec384
C
215 totalViewers: rowsWatchTime.length !== 0
216 ? Math.round(rowsWatchTime[0].totalViewers) || 0
217 : 0,
218
f40712ab
C
219 viewersPeak,
220 viewersPeakDate: rowsWatchPeak.length !== 0 && viewersPeak !== 0
b2111066
C
221 ? rowsWatchPeak[0].dateBreakpoint || null
222 : null,
223
b2111066
C
224 countries: rowsCountries.map(r => ({
225 isoCode: r.country,
226 viewers: r.viewers
227 }))
228 }
229 }
230
231 static async getRetentionStats (video: MVideo): Promise<VideoStatsRetention> {
232 const step = Math.max(Math.round(video.duration / 100), 1)
233
234 const query = `WITH "total" AS (SELECT COUNT(*) AS viewers FROM "localVideoViewer" WHERE "videoId" = :videoId) ` +
235 `SELECT serie AS "second", ` +
236 `(COUNT("localVideoViewer".id)::float / (SELECT GREATEST("total"."viewers", 1) FROM "total")) AS "retention" ` +
237 `FROM generate_series(0, ${video.duration}, ${step}) serie ` +
238 `LEFT JOIN "localVideoViewer" ON "localVideoViewer"."videoId" = :videoId ` +
239 `AND EXISTS (` +
240 `SELECT 1 FROM "localVideoViewerWatchSection" ` +
241 `WHERE "localVideoViewer"."id" = "localVideoViewerWatchSection"."localVideoViewerId" ` +
242 `AND serie >= "localVideoViewerWatchSection"."watchStart" ` +
243 `AND serie <= "localVideoViewerWatchSection"."watchEnd"` +
244 `)` +
245 `GROUP BY serie ` +
246 `ORDER BY serie ASC`
247
248 const queryOptions = {
249 type: QueryTypes.SELECT as QueryTypes.SELECT,
250 replacements: { videoId: video.id }
251 }
252
253 const rows = await LocalVideoViewerModel.sequelize.query<any>(query, queryOptions)
254
255 return {
256 data: rows.map(r => ({
257 second: r.second,
258 retentionPercent: parseFloat(r.retention) * 100
259 }))
260 }
261 }
262
263 static async getTimeserieStats (options: {
264 video: MVideo
265 metric: VideoStatsTimeserieMetric
901bcf5c
C
266 startDate: string
267 endDate: string
b2111066
C
268 }): Promise<VideoStatsTimeserie> {
269 const { video, metric } = options
270
3eda9b77 271 const { groupInterval, startDate, endDate } = buildGroupByAndBoundaries(options.startDate, options.endDate)
901bcf5c 272
b2111066
C
273 const selectMetrics: { [ id in VideoStatsTimeserieMetric ]: string } = {
274 viewers: 'COUNT("localVideoViewer"."id")',
275 aggregateWatchTime: 'SUM("localVideoViewer"."watchTime")'
276 }
277
67ad83d0
C
278 const intervalWhere: { [ id in VideoStatsTimeserieMetric ]: string } = {
279 // Viewer is still in the interval. Overlap algorithm
280 viewers: '"localVideoViewer"."startDate" <= "intervals"."endDate" ' +
281 'AND "localVideoViewer"."endDate" >= "intervals"."startDate"',
282
283 // We do an aggregation, so only sum things once. Arbitrary we use the end date for that purpose
284 aggregateWatchTime: '"localVideoViewer"."endDate" >= "intervals"."startDate" ' +
285 'AND "localVideoViewer"."endDate" <= "intervals"."endDate"'
286 }
287
901bcf5c
C
288 const query = `WITH "intervals" AS (
289 SELECT
3eda9b77 290 "time" AS "startDate", "time" + :groupInterval::interval as "endDate"
901bcf5c 291 FROM
3eda9b77 292 generate_series(:startDate::timestamptz, :endDate::timestamptz, :groupInterval::interval) serie("time")
901bcf5c
C
293 )
294 SELECT "intervals"."startDate" as "date", COALESCE(${selectMetrics[metric]}, 0) AS value
295 FROM
296 intervals
297 LEFT JOIN "localVideoViewer" ON "localVideoViewer"."videoId" = :videoId
67ad83d0 298 AND ${intervalWhere[metric]}
901bcf5c
C
299 GROUP BY
300 "intervals"."startDate"
301 ORDER BY
302 "intervals"."startDate"`
b2111066
C
303
304 const queryOptions = {
305 type: QueryTypes.SELECT as QueryTypes.SELECT,
901bcf5c
C
306 replacements: {
307 startDate,
308 endDate,
3eda9b77 309 groupInterval,
901bcf5c
C
310 videoId: video.id
311 }
b2111066
C
312 }
313
314 const rows = await LocalVideoViewerModel.sequelize.query<any>(query, queryOptions)
315
316 return {
901bcf5c 317 groupInterval,
b2111066
C
318 data: rows.map(r => ({
319 date: r.date,
320 value: parseInt(r.value)
321 }))
322 }
323 }
324
325 toActivityPubObject (this: MLocalVideoViewerWithWatchSections): WatchActionObject {
326 const location = this.country
327 ? {
328 location: {
329 addressCountry: this.country
330 }
331 }
332 : {}
333
334 return {
335 id: this.url,
336 type: 'WatchAction',
337 duration: getActivityStreamDuration(this.watchTime),
338 startTime: this.startDate.toISOString(),
339 endTime: this.endDate.toISOString(),
340
341 object: this.Video.url,
342 uuid: this.uuid,
343 actionStatus: 'CompletedActionStatus',
344
345 watchSections: this.WatchSections.map(w => ({
346 startTimestamp: w.watchStart,
347 endTimestamp: w.watchEnd
348 })),
349
350 ...location
351 }
352 }
353}