1 import { QueryTypes } from 'sequelize'
2 import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, HasMany, IsUUID, Model, Table } from 'sequelize-typescript'
3 import { getActivityStreamDuration } from '@server/lib/activitypub/activity'
4 import { buildGroupByAndBoundaries } from '@server/lib/timeserie'
5 import { MLocalVideoViewer, MLocalVideoViewerWithWatchSections, MVideo } from '@server/types/models'
6 import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric, WatchActionObject } from '@shared/models'
7 import { AttributesOnly } from '@shared/typescript-utils'
8 import { VideoModel } from '../video/video'
9 import { LocalVideoViewerWatchSectionModel } from './local-video-viewer-watch-section'
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
19 tableName: 'localVideoViewer',
31 export class LocalVideoViewerModel extends Model<Partial<AttributesOnly<LocalVideoViewerModel>>> {
36 @Column(DataType.DATE)
40 @Column(DataType.DATE)
52 @Default(DataType.UUIDV4)
54 @Column(DataType.UUID)
61 @ForeignKey(() => VideoModel)
65 @BelongsTo(() => VideoModel, {
73 @HasMany(() => LocalVideoViewerWatchSectionModel, {
79 WatchSections: LocalVideoViewerWatchSectionModel[]
81 static loadByUrl (url: string): Promise<MLocalVideoViewer> {
89 static loadFullById (id: number): Promise<MLocalVideoViewerWithWatchSections> {
93 model: VideoModel.unscoped(),
97 model: LocalVideoViewerWatchSectionModel.unscoped(),
107 static async getOverallStats (options: {
111 }): Promise<VideoStatsOverall> {
112 const { video, startDate, endDate } = options
114 const queryOptions = {
115 type: QueryTypes.SELECT as QueryTypes.SELECT,
116 replacements: { videoId: video.id } as any
119 if (startDate) queryOptions.replacements.startDate = startDate
120 if (endDate) queryOptions.replacements.endDate = endDate
122 const buildTotalViewersPromise = () => {
123 let totalViewersDateWhere = ''
125 if (startDate) totalViewersDateWhere += ' AND "localVideoViewer"."endDate" >= :startDate'
126 if (endDate) totalViewersDateWhere += ' AND "localVideoViewer"."startDate" <= :endDate'
128 const totalViewersQuery = `SELECT ` +
129 `COUNT("localVideoViewer"."id") AS "totalViewers" ` +
130 `FROM "localVideoViewer" ` +
131 `WHERE "videoId" = :videoId ${totalViewersDateWhere}`
133 return LocalVideoViewerModel.sequelize.query<any>(totalViewersQuery, queryOptions)
136 const buildWatchTimePromise = () => {
137 let watchTimeDateWhere = ''
139 // We know this where is not exact
140 // But we prefer to take into account only watch section that started and ended **in** the interval
141 if (startDate) watchTimeDateWhere += ' AND "localVideoViewer"."startDate" >= :startDate'
142 if (endDate) watchTimeDateWhere += ' AND "localVideoViewer"."endDate" <= :endDate'
144 const watchTimeQuery = `SELECT ` +
145 `SUM("localVideoViewer"."watchTime") AS "totalWatchTime", ` +
146 `AVG("localVideoViewer"."watchTime") AS "averageWatchTime" ` +
147 `FROM "localVideoViewer" ` +
148 `WHERE "videoId" = :videoId ${watchTimeDateWhere}`
150 return LocalVideoViewerModel.sequelize.query<any>(watchTimeQuery, queryOptions)
153 const buildWatchPeakPromise = () => {
154 let watchPeakDateWhereStart = ''
155 let watchPeakDateWhereEnd = ''
158 watchPeakDateWhereStart += ' AND "localVideoViewer"."startDate" >= :startDate'
159 watchPeakDateWhereEnd += ' AND "localVideoViewer"."endDate" >= :startDate'
163 watchPeakDateWhereStart += ' AND "localVideoViewer"."startDate" <= :endDate'
164 watchPeakDateWhereEnd += ' AND "localVideoViewer"."endDate" <= :endDate'
167 // Add viewers that were already here, before our start date
168 const beforeWatchersQuery = startDate
169 // eslint-disable-next-line max-len
170 ? `SELECT COUNT(*) AS "total" FROM "localVideoViewer" WHERE "localVideoViewer"."startDate" < :startDate AND "localVideoViewer"."endDate" >= :startDate`
171 : `SELECT 0 AS "total"`
173 const watchPeakQuery = `WITH
174 "beforeWatchers" AS (${beforeWatchersQuery}),
175 "watchPeakValues" AS (
176 SELECT "startDate" AS "dateBreakpoint", 1 AS "inc"
177 FROM "localVideoViewer"
178 WHERE "videoId" = :videoId ${watchPeakDateWhereStart}
180 SELECT "endDate" AS "dateBreakpoint", -1 AS "inc"
181 FROM "localVideoViewer"
182 WHERE "videoId" = :videoId ${watchPeakDateWhereEnd}
184 SELECT "dateBreakpoint", "concurrent"
186 SELECT "dateBreakpoint", SUM(SUM("inc")) OVER (ORDER BY "dateBreakpoint") + (SELECT "total" FROM "beforeWatchers") AS "concurrent"
187 FROM "watchPeakValues"
188 GROUP BY "dateBreakpoint"
190 ORDER BY "concurrent" DESC
191 FETCH FIRST 1 ROW ONLY`
193 return LocalVideoViewerModel.sequelize.query<any>(watchPeakQuery, queryOptions)
196 const buildCountriesPromise = () => {
197 let countryDateWhere = ''
199 if (startDate) countryDateWhere += ' AND "localVideoViewer"."endDate" >= :startDate'
200 if (endDate) countryDateWhere += ' AND "localVideoViewer"."startDate" <= :endDate'
202 const countriesQuery = `SELECT country, COUNT(country) as viewers ` +
203 `FROM "localVideoViewer" ` +
204 `WHERE "videoId" = :videoId AND country IS NOT NULL ${countryDateWhere} ` +
205 `GROUP BY country ` +
206 `ORDER BY viewers DESC`
208 return LocalVideoViewerModel.sequelize.query<any>(countriesQuery, queryOptions)
211 const [ rowsTotalViewers, rowsWatchTime, rowsWatchPeak, rowsCountries ] = await Promise.all([
212 buildTotalViewersPromise(),
213 buildWatchTimePromise(),
214 buildWatchPeakPromise(),
215 buildCountriesPromise()
218 const viewersPeak = rowsWatchPeak.length !== 0
219 ? parseInt(rowsWatchPeak[0].concurrent) || 0
223 totalWatchTime: rowsWatchTime.length !== 0
224 ? Math.round(rowsWatchTime[0].totalWatchTime) || 0
226 averageWatchTime: rowsWatchTime.length !== 0
227 ? Math.round(rowsWatchTime[0].averageWatchTime) || 0
230 totalViewers: rowsTotalViewers.length !== 0
231 ? Math.round(rowsTotalViewers[0].totalViewers) || 0
235 viewersPeakDate: rowsWatchPeak.length !== 0 && viewersPeak !== 0
236 ? rowsWatchPeak[0].dateBreakpoint || null
239 countries: rowsCountries.map(r => ({
246 static async getRetentionStats (video: MVideo): Promise<VideoStatsRetention> {
247 const step = Math.max(Math.round(video.duration / 100), 1)
249 const query = `WITH "total" AS (SELECT COUNT(*) AS viewers FROM "localVideoViewer" WHERE "videoId" = :videoId) ` +
250 `SELECT serie AS "second", ` +
251 `(COUNT("localVideoViewer".id)::float / (SELECT GREATEST("total"."viewers", 1) FROM "total")) AS "retention" ` +
252 `FROM generate_series(0, ${video.duration}, ${step}) serie ` +
253 `LEFT JOIN "localVideoViewer" ON "localVideoViewer"."videoId" = :videoId ` +
255 `SELECT 1 FROM "localVideoViewerWatchSection" ` +
256 `WHERE "localVideoViewer"."id" = "localVideoViewerWatchSection"."localVideoViewerId" ` +
257 `AND serie >= "localVideoViewerWatchSection"."watchStart" ` +
258 `AND serie <= "localVideoViewerWatchSection"."watchEnd"` +
263 const queryOptions = {
264 type: QueryTypes.SELECT as QueryTypes.SELECT,
265 replacements: { videoId: video.id }
268 const rows = await LocalVideoViewerModel.sequelize.query<any>(query, queryOptions)
271 data: rows.map(r => ({
273 retentionPercent: parseFloat(r.retention) * 100
278 static async getTimeserieStats (options: {
280 metric: VideoStatsTimeserieMetric
283 }): Promise<VideoStatsTimeserie> {
284 const { video, metric } = options
286 const { groupInterval, startDate, endDate } = buildGroupByAndBoundaries(options.startDate, options.endDate)
288 const selectMetrics: { [ id in VideoStatsTimeserieMetric ]: string } = {
289 viewers: 'COUNT("localVideoViewer"."id")',
290 aggregateWatchTime: 'SUM("localVideoViewer"."watchTime")'
293 const intervalWhere: { [ id in VideoStatsTimeserieMetric ]: string } = {
294 // Viewer is still in the interval. Overlap algorithm
295 viewers: '"localVideoViewer"."startDate" <= "intervals"."endDate" ' +
296 'AND "localVideoViewer"."endDate" >= "intervals"."startDate"',
298 // We do an aggregation, so only sum things once. Arbitrary we use the end date for that purpose
299 aggregateWatchTime: '"localVideoViewer"."endDate" >= "intervals"."startDate" ' +
300 'AND "localVideoViewer"."endDate" <= "intervals"."endDate"'
303 const query = `WITH "intervals" AS (
305 "time" AS "startDate", "time" + :groupInterval::interval as "endDate"
307 generate_series(:startDate::timestamptz, :endDate::timestamptz, :groupInterval::interval) serie("time")
309 SELECT "intervals"."startDate" as "date", COALESCE(${selectMetrics[metric]}, 0) AS value
312 LEFT JOIN "localVideoViewer" ON "localVideoViewer"."videoId" = :videoId
313 AND ${intervalWhere[metric]}
315 "intervals"."startDate"
317 "intervals"."startDate"`
319 const queryOptions = {
320 type: QueryTypes.SELECT as QueryTypes.SELECT,
329 const rows = await LocalVideoViewerModel.sequelize.query<any>(query, queryOptions)
333 data: rows.map(r => ({
335 value: parseInt(r.value)
340 toActivityPubObject (this: MLocalVideoViewerWithWatchSections): WatchActionObject {
341 const location = this.country
344 addressCountry: this.country
352 duration: getActivityStreamDuration(this.watchTime),
353 startTime: this.startDate.toISOString(),
354 endTime: this.endDate.toISOString(),
356 object: this.Video.url,
358 actionStatus: 'CompletedActionStatus',
360 watchSections: this.WatchSections.map(w => ({
361 startTimestamp: w.watchStart,
362 endTimestamp: w.watchEnd