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',
27 export class LocalVideoViewerModel extends Model<Partial<AttributesOnly<LocalVideoViewerModel>>> {
32 @Column(DataType.DATE)
36 @Column(DataType.DATE)
48 @Default(DataType.UUIDV4)
50 @Column(DataType.UUID)
57 @ForeignKey(() => VideoModel)
61 @BelongsTo(() => VideoModel, {
69 @HasMany(() => LocalVideoViewerWatchSectionModel, {
75 WatchSections: LocalVideoViewerWatchSectionModel[]
77 static loadByUrl (url: string): Promise<MLocalVideoViewer> {
85 static loadFullById (id: number): Promise<MLocalVideoViewerWithWatchSections> {
89 model: VideoModel.unscoped(),
93 model: LocalVideoViewerWatchSectionModel.unscoped(),
103 static async getOverallStats (video: MVideo): Promise<VideoStatsOverall> {
105 type: QueryTypes.SELECT as QueryTypes.SELECT,
106 replacements: { videoId: video.id }
109 const watchTimeQuery = `SELECT ` +
110 `SUM("localVideoViewer"."watchTime") AS "totalWatchTime", ` +
111 `AVG("localVideoViewer"."watchTime") AS "averageWatchTime" ` +
112 `FROM "localVideoViewer" ` +
113 `INNER JOIN "video" ON "video"."id" = "localVideoViewer"."videoId" ` +
114 `WHERE "videoId" = :videoId`
116 const watchTimePromise = LocalVideoViewerModel.sequelize.query<any>(watchTimeQuery, options)
118 const watchPeakQuery = `WITH "watchPeakValues" AS (
119 SELECT "startDate" AS "dateBreakpoint", 1 AS "inc"
120 FROM "localVideoViewer"
121 WHERE "videoId" = :videoId
123 SELECT "endDate" AS "dateBreakpoint", -1 AS "inc"
124 FROM "localVideoViewer"
125 WHERE "videoId" = :videoId
127 SELECT "dateBreakpoint", "concurrent"
129 SELECT "dateBreakpoint", SUM(SUM("inc")) OVER (ORDER BY "dateBreakpoint") AS "concurrent"
130 FROM "watchPeakValues"
131 GROUP BY "dateBreakpoint"
133 ORDER BY "concurrent" DESC
134 FETCH FIRST 1 ROW ONLY`
135 const watchPeakPromise = LocalVideoViewerModel.sequelize.query<any>(watchPeakQuery, options)
137 const countriesQuery = `SELECT country, COUNT(country) as viewers ` +
138 `FROM "localVideoViewer" ` +
139 `WHERE "videoId" = :videoId AND country IS NOT NULL ` +
140 `GROUP BY country ` +
141 `ORDER BY viewers DESC`
142 const countriesPromise = LocalVideoViewerModel.sequelize.query<any>(countriesQuery, options)
144 const [ rowsWatchTime, rowsWatchPeak, rowsCountries ] = await Promise.all([
151 totalWatchTime: rowsWatchTime.length !== 0
152 ? Math.round(rowsWatchTime[0].totalWatchTime) || 0
154 averageWatchTime: rowsWatchTime.length !== 0
155 ? Math.round(rowsWatchTime[0].averageWatchTime) || 0
158 viewersPeak: rowsWatchPeak.length !== 0
159 ? parseInt(rowsWatchPeak[0].concurrent) || 0
161 viewersPeakDate: rowsWatchPeak.length !== 0
162 ? rowsWatchPeak[0].dateBreakpoint || null
165 countries: rowsCountries.map(r => ({
172 static async getRetentionStats (video: MVideo): Promise<VideoStatsRetention> {
173 const step = Math.max(Math.round(video.duration / 100), 1)
175 const query = `WITH "total" AS (SELECT COUNT(*) AS viewers FROM "localVideoViewer" WHERE "videoId" = :videoId) ` +
176 `SELECT serie AS "second", ` +
177 `(COUNT("localVideoViewer".id)::float / (SELECT GREATEST("total"."viewers", 1) FROM "total")) AS "retention" ` +
178 `FROM generate_series(0, ${video.duration}, ${step}) serie ` +
179 `LEFT JOIN "localVideoViewer" ON "localVideoViewer"."videoId" = :videoId ` +
181 `SELECT 1 FROM "localVideoViewerWatchSection" ` +
182 `WHERE "localVideoViewer"."id" = "localVideoViewerWatchSection"."localVideoViewerId" ` +
183 `AND serie >= "localVideoViewerWatchSection"."watchStart" ` +
184 `AND serie <= "localVideoViewerWatchSection"."watchEnd"` +
189 const queryOptions = {
190 type: QueryTypes.SELECT as QueryTypes.SELECT,
191 replacements: { videoId: video.id }
194 const rows = await LocalVideoViewerModel.sequelize.query<any>(query, queryOptions)
197 data: rows.map(r => ({
199 retentionPercent: parseFloat(r.retention) * 100
204 static async getTimeserieStats (options: {
206 metric: VideoStatsTimeserieMetric
209 }): Promise<VideoStatsTimeserie> {
210 const { video, metric } = options
212 const { groupInterval, startDate, endDate } = buildGroupByAndBoundaries(options.startDate, options.endDate)
214 const selectMetrics: { [ id in VideoStatsTimeserieMetric ]: string } = {
215 viewers: 'COUNT("localVideoViewer"."id")',
216 aggregateWatchTime: 'SUM("localVideoViewer"."watchTime")'
219 const query = `WITH "intervals" AS (
221 "time" AS "startDate", "time" + :groupInterval::interval as "endDate"
223 generate_series(:startDate::timestamptz, :endDate::timestamptz, :groupInterval::interval) serie("time")
225 SELECT "intervals"."startDate" as "date", COALESCE(${selectMetrics[metric]}, 0) AS value
228 LEFT JOIN "localVideoViewer" ON "localVideoViewer"."videoId" = :videoId
229 AND "localVideoViewer"."startDate" >= "intervals"."startDate" AND "localVideoViewer"."startDate" <= "intervals"."endDate"
231 "intervals"."startDate"
233 "intervals"."startDate"`
235 const queryOptions = {
236 type: QueryTypes.SELECT as QueryTypes.SELECT,
245 const rows = await LocalVideoViewerModel.sequelize.query<any>(query, queryOptions)
249 data: rows.map(r => ({
251 value: parseInt(r.value)
256 toActivityPubObject (this: MLocalVideoViewerWithWatchSections): WatchActionObject {
257 const location = this.country
260 addressCountry: this.country
268 duration: getActivityStreamDuration(this.watchTime),
269 startTime: this.startDate.toISOString(),
270 endTime: this.endDate.toISOString(),
272 object: this.Video.url,
274 actionStatus: 'CompletedActionStatus',
276 watchSections: this.WatchSections.map(w => ({
277 startTimestamp: w.watchStart,
278 endTimestamp: w.watchEnd