mirror of https://github.com/Chocobozzz/PeerTube
				
				
				
			
		
			
				
	
	
		
			369 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			TypeScript
		
	
	
			
		
		
	
	
			369 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			TypeScript
		
	
	
| import { QueryTypes } from 'sequelize'
 | |
| import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, HasMany, IsUUID, Model, Table } from 'sequelize-typescript'
 | |
| import { getActivityStreamDuration } from '@server/lib/activitypub/activity'
 | |
| import { buildGroupByAndBoundaries } from '@server/lib/timeserie'
 | |
| import { MLocalVideoViewer, MLocalVideoViewerWithWatchSections, MVideo } from '@server/types/models'
 | |
| import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric, WatchActionObject } from '@shared/models'
 | |
| import { AttributesOnly } from '@shared/typescript-utils'
 | |
| import { VideoModel } from '../video/video'
 | |
| import { LocalVideoViewerWatchSectionModel } from './local-video-viewer-watch-section'
 | |
| 
 | |
| /**
 | |
|  *
 | |
|  * Aggregate viewers of local videos only to display statistics to video owners
 | |
|  * A viewer is a user that watched one or multiple sections of a specific video inside a time window
 | |
|  *
 | |
|  */
 | |
| 
 | |
| @Table({
 | |
|   tableName: 'localVideoViewer',
 | |
|   updatedAt: false,
 | |
|   indexes: [
 | |
|     {
 | |
|       fields: [ 'videoId' ]
 | |
|     },
 | |
|     {
 | |
|       fields: [ 'url' ],
 | |
|       unique: true
 | |
|     }
 | |
|   ]
 | |
| })
 | |
| export class LocalVideoViewerModel extends Model<Partial<AttributesOnly<LocalVideoViewerModel>>> {
 | |
|   @CreatedAt
 | |
|   createdAt: Date
 | |
| 
 | |
|   @AllowNull(false)
 | |
|   @Column(DataType.DATE)
 | |
|   startDate: Date
 | |
| 
 | |
|   @AllowNull(false)
 | |
|   @Column(DataType.DATE)
 | |
|   endDate: Date
 | |
| 
 | |
|   @AllowNull(false)
 | |
|   @Column
 | |
|   watchTime: number
 | |
| 
 | |
|   @AllowNull(true)
 | |
|   @Column
 | |
|   country: string
 | |
| 
 | |
|   @AllowNull(false)
 | |
|   @Default(DataType.UUIDV4)
 | |
|   @IsUUID(4)
 | |
|   @Column(DataType.UUID)
 | |
|   uuid: string
 | |
| 
 | |
|   @AllowNull(false)
 | |
|   @Column
 | |
|   url: string
 | |
| 
 | |
|   @ForeignKey(() => VideoModel)
 | |
|   @Column
 | |
|   videoId: number
 | |
| 
 | |
|   @BelongsTo(() => VideoModel, {
 | |
|     foreignKey: {
 | |
|       allowNull: false
 | |
|     },
 | |
|     onDelete: 'CASCADE'
 | |
|   })
 | |
|   Video: VideoModel
 | |
| 
 | |
|   @HasMany(() => LocalVideoViewerWatchSectionModel, {
 | |
|     foreignKey: {
 | |
|       allowNull: false
 | |
|     },
 | |
|     onDelete: 'cascade'
 | |
|   })
 | |
|   WatchSections: LocalVideoViewerWatchSectionModel[]
 | |
| 
 | |
|   static loadByUrl (url: string): Promise<MLocalVideoViewer> {
 | |
|     return this.findOne({
 | |
|       where: {
 | |
|         url
 | |
|       }
 | |
|     })
 | |
|   }
 | |
| 
 | |
|   static loadFullById (id: number): Promise<MLocalVideoViewerWithWatchSections> {
 | |
|     return this.findOne({
 | |
|       include: [
 | |
|         {
 | |
|           model: VideoModel.unscoped(),
 | |
|           required: true
 | |
|         },
 | |
|         {
 | |
|           model: LocalVideoViewerWatchSectionModel.unscoped(),
 | |
|           required: true
 | |
|         }
 | |
|       ],
 | |
|       where: {
 | |
|         id
 | |
|       }
 | |
|     })
 | |
|   }
 | |
| 
 | |
|   static async getOverallStats (options: {
 | |
|     video: MVideo
 | |
|     startDate?: string
 | |
|     endDate?: string
 | |
|   }): Promise<VideoStatsOverall> {
 | |
|     const { video, startDate, endDate } = options
 | |
| 
 | |
|     const queryOptions = {
 | |
|       type: QueryTypes.SELECT as QueryTypes.SELECT,
 | |
|       replacements: { videoId: video.id } as any
 | |
|     }
 | |
| 
 | |
|     if (startDate) queryOptions.replacements.startDate = startDate
 | |
|     if (endDate) queryOptions.replacements.endDate = endDate
 | |
| 
 | |
|     const buildTotalViewersPromise = () => {
 | |
|       let totalViewersDateWhere = ''
 | |
| 
 | |
|       if (startDate) totalViewersDateWhere += ' AND "localVideoViewer"."endDate" >= :startDate'
 | |
|       if (endDate) totalViewersDateWhere += ' AND "localVideoViewer"."startDate" <= :endDate'
 | |
| 
 | |
|       const totalViewersQuery = `SELECT ` +
 | |
|         `COUNT("localVideoViewer"."id") AS "totalViewers" ` +
 | |
|         `FROM "localVideoViewer" ` +
 | |
|         `WHERE "videoId" = :videoId ${totalViewersDateWhere}`
 | |
| 
 | |
|       return LocalVideoViewerModel.sequelize.query<any>(totalViewersQuery, queryOptions)
 | |
|     }
 | |
| 
 | |
|     const buildWatchTimePromise = () => {
 | |
|       let watchTimeDateWhere = ''
 | |
| 
 | |
|       // We know this where is not exact
 | |
|       // But we prefer to take into account only watch section that started and ended **in** the interval
 | |
|       if (startDate) watchTimeDateWhere += ' AND "localVideoViewer"."startDate" >= :startDate'
 | |
|       if (endDate) watchTimeDateWhere += ' AND "localVideoViewer"."endDate" <= :endDate'
 | |
| 
 | |
|       const watchTimeQuery = `SELECT ` +
 | |
|         `SUM("localVideoViewer"."watchTime") AS "totalWatchTime", ` +
 | |
|         `AVG("localVideoViewer"."watchTime") AS "averageWatchTime" ` +
 | |
|         `FROM "localVideoViewer" ` +
 | |
|         `WHERE "videoId" = :videoId ${watchTimeDateWhere}`
 | |
| 
 | |
|       return LocalVideoViewerModel.sequelize.query<any>(watchTimeQuery, queryOptions)
 | |
|     }
 | |
| 
 | |
|     const buildWatchPeakPromise = () => {
 | |
|       let watchPeakDateWhereStart = ''
 | |
|       let watchPeakDateWhereEnd = ''
 | |
| 
 | |
|       if (startDate) {
 | |
|         watchPeakDateWhereStart += ' AND "localVideoViewer"."startDate" >= :startDate'
 | |
|         watchPeakDateWhereEnd += ' AND "localVideoViewer"."endDate" >= :startDate'
 | |
|       }
 | |
| 
 | |
|       if (endDate) {
 | |
|         watchPeakDateWhereStart += ' AND "localVideoViewer"."startDate" <= :endDate'
 | |
|         watchPeakDateWhereEnd += ' AND "localVideoViewer"."endDate" <= :endDate'
 | |
|       }
 | |
| 
 | |
|       // Add viewers that were already here, before our start date
 | |
|       const beforeWatchersQuery = startDate
 | |
|         // eslint-disable-next-line max-len
 | |
|         ? `SELECT COUNT(*) AS "total" FROM "localVideoViewer" WHERE "localVideoViewer"."startDate" < :startDate AND "localVideoViewer"."endDate" >= :startDate`
 | |
|         : `SELECT 0 AS "total"`
 | |
| 
 | |
|       const watchPeakQuery = `WITH
 | |
|         "beforeWatchers" AS (${beforeWatchersQuery}),
 | |
|         "watchPeakValues" AS (
 | |
|           SELECT "startDate" AS "dateBreakpoint", 1 AS "inc"
 | |
|           FROM "localVideoViewer"
 | |
|           WHERE "videoId" = :videoId ${watchPeakDateWhereStart}
 | |
|           UNION ALL
 | |
|           SELECT "endDate" AS "dateBreakpoint", -1 AS "inc"
 | |
|           FROM "localVideoViewer"
 | |
|           WHERE "videoId" = :videoId ${watchPeakDateWhereEnd}
 | |
|         )
 | |
|         SELECT "dateBreakpoint", "concurrent"
 | |
|         FROM (
 | |
|           SELECT "dateBreakpoint", SUM(SUM("inc")) OVER (ORDER BY "dateBreakpoint") + (SELECT "total" FROM "beforeWatchers") AS "concurrent"
 | |
|           FROM "watchPeakValues"
 | |
|           GROUP BY "dateBreakpoint"
 | |
|         ) tmp
 | |
|         ORDER BY "concurrent" DESC
 | |
|         FETCH FIRST 1 ROW ONLY`
 | |
| 
 | |
|       return LocalVideoViewerModel.sequelize.query<any>(watchPeakQuery, queryOptions)
 | |
|     }
 | |
| 
 | |
|     const buildCountriesPromise = () => {
 | |
|       let countryDateWhere = ''
 | |
| 
 | |
|       if (startDate) countryDateWhere += ' AND "localVideoViewer"."endDate" >= :startDate'
 | |
|       if (endDate) countryDateWhere += ' AND "localVideoViewer"."startDate" <= :endDate'
 | |
| 
 | |
|       const countriesQuery = `SELECT country, COUNT(country) as viewers ` +
 | |
|         `FROM "localVideoViewer" ` +
 | |
|         `WHERE "videoId" = :videoId AND country IS NOT NULL ${countryDateWhere} ` +
 | |
|         `GROUP BY country ` +
 | |
|         `ORDER BY viewers DESC`
 | |
| 
 | |
|       return LocalVideoViewerModel.sequelize.query<any>(countriesQuery, queryOptions)
 | |
|     }
 | |
| 
 | |
|     const [ rowsTotalViewers, rowsWatchTime, rowsWatchPeak, rowsCountries ] = await Promise.all([
 | |
|       buildTotalViewersPromise(),
 | |
|       buildWatchTimePromise(),
 | |
|       buildWatchPeakPromise(),
 | |
|       buildCountriesPromise()
 | |
|     ])
 | |
| 
 | |
|     const viewersPeak = rowsWatchPeak.length !== 0
 | |
|       ? parseInt(rowsWatchPeak[0].concurrent) || 0
 | |
|       : 0
 | |
| 
 | |
|     return {
 | |
|       totalWatchTime: rowsWatchTime.length !== 0
 | |
|         ? Math.round(rowsWatchTime[0].totalWatchTime) || 0
 | |
|         : 0,
 | |
|       averageWatchTime: rowsWatchTime.length !== 0
 | |
|         ? Math.round(rowsWatchTime[0].averageWatchTime) || 0
 | |
|         : 0,
 | |
| 
 | |
|       totalViewers: rowsTotalViewers.length !== 0
 | |
|         ? Math.round(rowsTotalViewers[0].totalViewers) || 0
 | |
|         : 0,
 | |
| 
 | |
|       viewersPeak,
 | |
|       viewersPeakDate: rowsWatchPeak.length !== 0 && viewersPeak !== 0
 | |
|         ? rowsWatchPeak[0].dateBreakpoint || null
 | |
|         : null,
 | |
| 
 | |
|       countries: rowsCountries.map(r => ({
 | |
|         isoCode: r.country,
 | |
|         viewers: r.viewers
 | |
|       }))
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   static async getRetentionStats (video: MVideo): Promise<VideoStatsRetention> {
 | |
|     const step = Math.max(Math.round(video.duration / 100), 1)
 | |
| 
 | |
|     const query = `WITH "total" AS (SELECT COUNT(*) AS viewers FROM "localVideoViewer" WHERE "videoId" = :videoId) ` +
 | |
|       `SELECT serie AS "second", ` +
 | |
|         `(COUNT("localVideoViewer".id)::float / (SELECT GREATEST("total"."viewers", 1) FROM "total")) AS "retention" ` +
 | |
|       `FROM generate_series(0, ${video.duration}, ${step}) serie ` +
 | |
|       `LEFT JOIN "localVideoViewer" ON "localVideoViewer"."videoId" = :videoId ` +
 | |
|         `AND EXISTS (` +
 | |
|           `SELECT 1 FROM "localVideoViewerWatchSection" ` +
 | |
|           `WHERE "localVideoViewer"."id" = "localVideoViewerWatchSection"."localVideoViewerId" ` +
 | |
|           `AND serie >= "localVideoViewerWatchSection"."watchStart" ` +
 | |
|           `AND serie <= "localVideoViewerWatchSection"."watchEnd"` +
 | |
|         `)` +
 | |
|       `GROUP BY serie ` +
 | |
|       `ORDER BY serie ASC`
 | |
| 
 | |
|     const queryOptions = {
 | |
|       type: QueryTypes.SELECT as QueryTypes.SELECT,
 | |
|       replacements: { videoId: video.id }
 | |
|     }
 | |
| 
 | |
|     const rows = await LocalVideoViewerModel.sequelize.query<any>(query, queryOptions)
 | |
| 
 | |
|     return {
 | |
|       data: rows.map(r => ({
 | |
|         second: r.second,
 | |
|         retentionPercent: parseFloat(r.retention) * 100
 | |
|       }))
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   static async getTimeserieStats (options: {
 | |
|     video: MVideo
 | |
|     metric: VideoStatsTimeserieMetric
 | |
|     startDate: string
 | |
|     endDate: string
 | |
|   }): Promise<VideoStatsTimeserie> {
 | |
|     const { video, metric } = options
 | |
| 
 | |
|     const { groupInterval, startDate, endDate } = buildGroupByAndBoundaries(options.startDate, options.endDate)
 | |
| 
 | |
|     const selectMetrics: { [ id in VideoStatsTimeserieMetric ]: string } = {
 | |
|       viewers: 'COUNT("localVideoViewer"."id")',
 | |
|       aggregateWatchTime: 'SUM("localVideoViewer"."watchTime")'
 | |
|     }
 | |
| 
 | |
|     const intervalWhere: { [ id in VideoStatsTimeserieMetric ]: string } = {
 | |
|       // Viewer is still in the interval. Overlap algorithm
 | |
|       viewers: '"localVideoViewer"."startDate" <= "intervals"."endDate" ' +
 | |
|         'AND "localVideoViewer"."endDate" >= "intervals"."startDate"',
 | |
| 
 | |
|       // We do an aggregation, so only sum things once. Arbitrary we use the end date for that purpose
 | |
|       aggregateWatchTime: '"localVideoViewer"."endDate" >= "intervals"."startDate" ' +
 | |
|         'AND "localVideoViewer"."endDate" <= "intervals"."endDate"'
 | |
|     }
 | |
| 
 | |
|     const query = `WITH "intervals" AS (
 | |
|       SELECT
 | |
|         "time" AS "startDate", "time" + :groupInterval::interval as "endDate"
 | |
|       FROM
 | |
|         generate_series(:startDate::timestamptz, :endDate::timestamptz, :groupInterval::interval) serie("time")
 | |
|     )
 | |
|     SELECT "intervals"."startDate" as "date", COALESCE(${selectMetrics[metric]}, 0) AS value
 | |
|     FROM
 | |
|       intervals
 | |
|       LEFT JOIN "localVideoViewer" ON "localVideoViewer"."videoId" = :videoId
 | |
|         AND ${intervalWhere[metric]}
 | |
|     GROUP BY
 | |
|       "intervals"."startDate"
 | |
|     ORDER BY
 | |
|       "intervals"."startDate"`
 | |
| 
 | |
|     const queryOptions = {
 | |
|       type: QueryTypes.SELECT as QueryTypes.SELECT,
 | |
|       replacements: {
 | |
|         startDate,
 | |
|         endDate,
 | |
|         groupInterval,
 | |
|         videoId: video.id
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     const rows = await LocalVideoViewerModel.sequelize.query<any>(query, queryOptions)
 | |
| 
 | |
|     return {
 | |
|       groupInterval,
 | |
|       data: rows.map(r => ({
 | |
|         date: r.date,
 | |
|         value: parseInt(r.value)
 | |
|       }))
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   toActivityPubObject (this: MLocalVideoViewerWithWatchSections): WatchActionObject {
 | |
|     const location = this.country
 | |
|       ? {
 | |
|         location: {
 | |
|           addressCountry: this.country
 | |
|         }
 | |
|       }
 | |
|       : {}
 | |
| 
 | |
|     return {
 | |
|       id: this.url,
 | |
|       type: 'WatchAction',
 | |
|       duration: getActivityStreamDuration(this.watchTime),
 | |
|       startTime: this.startDate.toISOString(),
 | |
|       endTime: this.endDate.toISOString(),
 | |
| 
 | |
|       object: this.Video.url,
 | |
|       uuid: this.uuid,
 | |
|       actionStatus: 'CompletedActionStatus',
 | |
| 
 | |
|       watchSections: this.WatchSections.map(w => ({
 | |
|         startTimestamp: w.watchStart,
 | |
|         endTimestamp: w.watchEnd
 | |
|       })),
 | |
| 
 | |
|       ...location
 | |
|     }
 | |
|   }
 | |
| }
 |