mirror of
https://github.com/Chocobozzz/PeerTube.git
synced 2025-10-03 09:49:20 +02:00
server/server -> server/core
This commit is contained in:
parent
114327d4ce
commit
5a3d0650c9
838 changed files with 111 additions and 111 deletions
374
server/core/models/view/local-video-viewer.ts
Normal file
374
server/core/models/view/local-video-viewer.ts
Normal file
|
@ -0,0 +1,374 @@
|
|||
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.js'
|
||||
import { buildGroupByAndBoundaries } from '@server/lib/timeserie.js'
|
||||
import { MLocalVideoViewer, MLocalVideoViewerWithWatchSections, MVideo } from '@server/types/models/index.js'
|
||||
import {
|
||||
VideoStatsOverall,
|
||||
VideoStatsRetention,
|
||||
VideoStatsTimeserie,
|
||||
VideoStatsTimeserieMetric,
|
||||
WatchActionObject
|
||||
} from '@peertube/peertube-models'
|
||||
import { AttributesOnly } from '@peertube/peertube-typescript-utils'
|
||||
import { VideoModel } from '../video/video.js'
|
||||
import { LocalVideoViewerWatchSectionModel } from './local-video-viewer-watch-section.js'
|
||||
|
||||
/**
|
||||
*
|
||||
* 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: Awaited<VideoModel>
|
||||
|
||||
@HasMany(() => LocalVideoViewerWatchSectionModel, {
|
||||
foreignKey: {
|
||||
allowNull: false
|
||||
},
|
||||
onDelete: 'cascade'
|
||||
})
|
||||
WatchSections: Awaited<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
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue