mirror of
https://github.com/Chocobozzz/PeerTube.git
synced 2025-10-03 09:49:20 +02:00
add user agent video stats (#6871)
* add user agent video stats closes #6832 * Disable indexes, support start/end dates * move ua parsing to client * Openapi, inline body request, update tests --------- Co-authored-by: Chocobozzz <me@florianbigard.com>
This commit is contained in:
parent
96380859ef
commit
a7be820abc
29 changed files with 765 additions and 147 deletions
|
@ -119,6 +119,7 @@
|
||||||
"tslib": "^2.4.0",
|
"tslib": "^2.4.0",
|
||||||
"type-fest": "^4.37.0",
|
"type-fest": "^4.37.0",
|
||||||
"typescript": "~5.7.3",
|
"typescript": "~5.7.3",
|
||||||
|
"ua-parser-js": "^2.0.3",
|
||||||
"video.js": "^7.19.2",
|
"video.js": "^7.19.2",
|
||||||
"vite": "^6.0.11",
|
"vite": "^6.0.11",
|
||||||
"vite-plugin-checker": "^0.8.0",
|
"vite-plugin-checker": "^0.8.0",
|
||||||
|
|
|
@ -86,10 +86,15 @@ my-embed {
|
||||||
|
|
||||||
.nav-tabs {
|
.nav-tabs {
|
||||||
@include peertube-nav-tabs($border-width: 2px);
|
@include peertube-nav-tabs($border-width: 2px);
|
||||||
|
|
||||||
|
a.nav-link {
|
||||||
|
padding: 0 10px !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-container {
|
.chart-container {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
min-height: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zoom-container {
|
.zoom-container {
|
||||||
|
|
|
@ -14,7 +14,8 @@ import {
|
||||||
VideoStatsOverall,
|
VideoStatsOverall,
|
||||||
VideoStatsRetention,
|
VideoStatsRetention,
|
||||||
VideoStatsTimeserie,
|
VideoStatsTimeserie,
|
||||||
VideoStatsTimeserieMetric
|
VideoStatsTimeserieMetric,
|
||||||
|
VideoStatsUserAgent
|
||||||
} from '@peertube/peertube-models'
|
} from '@peertube/peertube-models'
|
||||||
import { ChartConfiguration, ChartData, defaults as ChartJSDefaults, ChartOptions, PluginOptionsByType, Scale, TooltipItem } from 'chart.js'
|
import { ChartConfiguration, ChartData, defaults as ChartJSDefaults, ChartOptions, PluginOptionsByType, Scale, TooltipItem } from 'chart.js'
|
||||||
import zoomPlugin from 'chartjs-plugin-zoom'
|
import zoomPlugin from 'chartjs-plugin-zoom'
|
||||||
|
@ -29,11 +30,13 @@ import { VideoEdit } from '../common/video-edit.model'
|
||||||
import { VideoManageController } from '../video-manage-controller.service'
|
import { VideoManageController } from '../video-manage-controller.service'
|
||||||
import { VideoStatsService } from './video-stats.service'
|
import { VideoStatsService } from './video-stats.service'
|
||||||
|
|
||||||
type ActiveGraphId = VideoStatsTimeserieMetric | 'retention' | 'countries' | 'regions'
|
const BAR_GRAPHS = [ 'countries', 'regions', 'clients', 'devices', 'operatingSystems' ] as const
|
||||||
|
type BarGraphs = typeof BAR_GRAPHS[number]
|
||||||
|
type ActiveGraphId = VideoStatsTimeserieMetric | 'retention' | BarGraphs
|
||||||
|
|
||||||
type GeoData = { name: string, viewers: number }[]
|
type GeoData = { name: string, viewers: number }[]
|
||||||
|
|
||||||
type ChartIngestData = VideoStatsTimeserie | VideoStatsRetention | GeoData
|
type ChartIngestData = VideoStatsTimeserie | VideoStatsRetention | GeoData | VideoStatsUserAgent
|
||||||
type ChartBuilderResult = {
|
type ChartBuilderResult = {
|
||||||
type: 'line' | 'bar'
|
type: 'line' | 'bar'
|
||||||
|
|
||||||
|
@ -46,6 +49,8 @@ type ChartBuilderResult = {
|
||||||
|
|
||||||
type Card = { label: string, value: string | number, moreInfo?: string, help?: string }
|
type Card = { label: string, value: string | number, moreInfo?: string, help?: string }
|
||||||
|
|
||||||
|
const isBarGraph = (graphId: ActiveGraphId): graphId is BarGraphs => BAR_GRAPHS.some(graph => graph === graphId)
|
||||||
|
|
||||||
ChartJSDefaults.backgroundColor = getComputedStyle(document.body).getPropertyValue('--bg')
|
ChartJSDefaults.backgroundColor = getComputedStyle(document.body).getPropertyValue('--bg')
|
||||||
ChartJSDefaults.borderColor = getComputedStyle(document.body).getPropertyValue('--bg-secondary-500')
|
ChartJSDefaults.borderColor = getComputedStyle(document.body).getPropertyValue('--bg-secondary-500')
|
||||||
ChartJSDefaults.color = getComputedStyle(document.body).getPropertyValue('--fg')
|
ChartJSDefaults.color = getComputedStyle(document.body).getPropertyValue('--fg')
|
||||||
|
@ -140,6 +145,21 @@ export class VideoStatsComponent implements OnInit {
|
||||||
id: 'regions',
|
id: 'regions',
|
||||||
label: $localize`Regions`,
|
label: $localize`Regions`,
|
||||||
zoomEnabled: false
|
zoomEnabled: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'clients',
|
||||||
|
label: $localize`Clients`,
|
||||||
|
zoomEnabled: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'devices',
|
||||||
|
label: $localize`Devices`,
|
||||||
|
zoomEnabled: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'operatingSystems',
|
||||||
|
label: $localize`Operating systems`,
|
||||||
|
zoomEnabled: false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -359,17 +379,23 @@ export class VideoStatsComponent implements OnInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadChart () {
|
private loadChart () {
|
||||||
|
const videoId = this.videoEdit.getVideoAttributes().uuid
|
||||||
|
|
||||||
const obsBuilders: { [id in ActiveGraphId]: Observable<ChartIngestData> } = {
|
const obsBuilders: { [id in ActiveGraphId]: Observable<ChartIngestData> } = {
|
||||||
retention: this.statsService.getRetentionStats(this.videoEdit.getVideoAttributes().uuid),
|
retention: this.statsService.getRetentionStats(videoId),
|
||||||
|
|
||||||
|
clients: this.statsService.getUserAgentStats({ videoId }),
|
||||||
|
devices: this.statsService.getUserAgentStats({ videoId }),
|
||||||
|
operatingSystems: this.statsService.getUserAgentStats({ videoId }),
|
||||||
|
|
||||||
aggregateWatchTime: this.statsService.getTimeserieStats({
|
aggregateWatchTime: this.statsService.getTimeserieStats({
|
||||||
videoId: this.videoEdit.getVideoAttributes().uuid,
|
videoId,
|
||||||
startDate: this.statsStartDate,
|
startDate: this.statsStartDate,
|
||||||
endDate: this.statsEndDate,
|
endDate: this.statsEndDate,
|
||||||
metric: 'aggregateWatchTime'
|
metric: 'aggregateWatchTime'
|
||||||
}),
|
}),
|
||||||
viewers: this.statsService.getTimeserieStats({
|
viewers: this.statsService.getTimeserieStats({
|
||||||
videoId: this.videoEdit.getVideoAttributes().uuid,
|
videoId,
|
||||||
startDate: this.statsStartDate,
|
startDate: this.statsStartDate,
|
||||||
endDate: this.statsEndDate,
|
endDate: this.statsEndDate,
|
||||||
metric: 'viewers'
|
metric: 'viewers'
|
||||||
|
@ -395,6 +421,9 @@ export class VideoStatsComponent implements OnInit {
|
||||||
const dataBuilders: {
|
const dataBuilders: {
|
||||||
[id in ActiveGraphId]: (rawData: ChartIngestData) => ChartBuilderResult
|
[id in ActiveGraphId]: (rawData: ChartIngestData) => ChartBuilderResult
|
||||||
} = {
|
} = {
|
||||||
|
clients: (rawData: VideoStatsUserAgent) => this.buildUserAgentChartOptions(rawData, 'clients'),
|
||||||
|
devices: (rawData: VideoStatsUserAgent) => this.buildUserAgentChartOptions(rawData, 'devices'),
|
||||||
|
operatingSystems: (rawData: VideoStatsUserAgent) => this.buildUserAgentChartOptions(rawData, 'operatingSystems'),
|
||||||
retention: (rawData: VideoStatsRetention) => this.buildRetentionChartOptions(rawData),
|
retention: (rawData: VideoStatsRetention) => this.buildRetentionChartOptions(rawData),
|
||||||
aggregateWatchTime: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData),
|
aggregateWatchTime: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData),
|
||||||
viewers: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData),
|
viewers: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData),
|
||||||
|
@ -418,6 +447,7 @@ export class VideoStatsComponent implements OnInit {
|
||||||
scales: {
|
scales: {
|
||||||
x: {
|
x: {
|
||||||
ticks: {
|
ticks: {
|
||||||
|
stepSize: isBarGraph(graphId) ? 1 : undefined,
|
||||||
callback: function (value) {
|
callback: function (value) {
|
||||||
return self.formatXTick({
|
return self.formatXTick({
|
||||||
graphId,
|
graphId,
|
||||||
|
@ -550,6 +580,43 @@ export class VideoStatsComponent implements OnInit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildUserAgentChartOptions (rawData: VideoStatsUserAgent, type: 'clients' | 'devices' | 'operatingSystems'): ChartBuilderResult {
|
||||||
|
const labels: string[] = []
|
||||||
|
const data: number[] = []
|
||||||
|
|
||||||
|
for (const d of rawData[type]) {
|
||||||
|
const name = d.name?.charAt(0).toUpperCase() + d.name?.slice(1)
|
||||||
|
labels.push(name)
|
||||||
|
data.push(d.viewers)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'bar' as 'bar',
|
||||||
|
|
||||||
|
options: {
|
||||||
|
indexAxis: 'y'
|
||||||
|
},
|
||||||
|
|
||||||
|
displayLegend: true,
|
||||||
|
|
||||||
|
plugins: {
|
||||||
|
...this.buildDisabledZoomPlugin()
|
||||||
|
},
|
||||||
|
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: $localize`Viewers`,
|
||||||
|
backgroundColor: this.buildChartColor(),
|
||||||
|
maxBarThickness: 20,
|
||||||
|
data
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private buildGeoChartOptions (rawData: GeoData): ChartBuilderResult {
|
private buildGeoChartOptions (rawData: GeoData): ChartBuilderResult {
|
||||||
const labels: string[] = []
|
const labels: string[] = []
|
||||||
const data: number[] = []
|
const data: number[] = []
|
||||||
|
@ -630,7 +697,7 @@ export class VideoStatsComponent implements OnInit {
|
||||||
|
|
||||||
if (graphId === 'retention') return value + ' %'
|
if (graphId === 'retention') return value + ' %'
|
||||||
if (graphId === 'aggregateWatchTime') return secondsToTime(+value)
|
if (graphId === 'aggregateWatchTime') return secondsToTime(+value)
|
||||||
if ((graphId === 'countries' || graphId === 'regions') && scale) return scale.getLabelForValue(value as number)
|
if (isBarGraph(graphId) && scale) return scale.getLabelForValue(value as number)
|
||||||
|
|
||||||
return value.toLocaleString(this.localeId)
|
return value.toLocaleString(this.localeId)
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,13 @@ import { environment } from 'src/environments/environment'
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http'
|
import { HttpClient, HttpParams } from '@angular/common/http'
|
||||||
import { Injectable, inject } from '@angular/core'
|
import { Injectable, inject } from '@angular/core'
|
||||||
import { RestExtractor } from '@app/core'
|
import { RestExtractor } from '@app/core'
|
||||||
import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric } from '@peertube/peertube-models'
|
import {
|
||||||
|
VideoStatsOverall,
|
||||||
|
VideoStatsRetention,
|
||||||
|
VideoStatsTimeserie,
|
||||||
|
VideoStatsTimeserieMetric,
|
||||||
|
VideoStatsUserAgent
|
||||||
|
} from '@peertube/peertube-models'
|
||||||
import { VideoService } from '@app/shared/shared-main/video/video.service'
|
import { VideoService } from '@app/shared/shared-main/video/video.service'
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
|
@ -50,4 +56,19 @@ export class VideoStatsService {
|
||||||
return this.authHttp.get<VideoStatsRetention>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/retention')
|
return this.authHttp.get<VideoStatsRetention>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/retention')
|
||||||
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getUserAgentStats (options: {
|
||||||
|
videoId: string
|
||||||
|
startDate?: Date
|
||||||
|
endDate?: Date
|
||||||
|
}) {
|
||||||
|
const { videoId, startDate, endDate } = options
|
||||||
|
|
||||||
|
let params = new HttpParams()
|
||||||
|
if (startDate) params = params.append('startDate', startDate.toISOString())
|
||||||
|
if (endDate) params = params.append('endDate', endDate.toISOString())
|
||||||
|
|
||||||
|
return this.authHttp.get<VideoStatsUserAgent>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/user-agent', { params })
|
||||||
|
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import { timeToInt } from '@peertube/peertube-core-utils'
|
import { timeToInt } from '@peertube/peertube-core-utils'
|
||||||
import { VideoView, VideoViewEvent } from '@peertube/peertube-models'
|
import { VideoStatsUserAgentDevice, VideoView, VideoViewEvent } from '@peertube/peertube-models'
|
||||||
import { logger } from '@root-helpers/logger'
|
import { logger } from '@root-helpers/logger'
|
||||||
import { isIOS, isMobile, isSafari } from '@root-helpers/web-browser'
|
import { isIOS, isMobile, isSafari } from '@root-helpers/web-browser'
|
||||||
import debug from 'debug'
|
import debug from 'debug'
|
||||||
|
import { UAParser } from 'ua-parser-js'
|
||||||
import videojs from 'video.js'
|
import videojs from 'video.js'
|
||||||
import {
|
import {
|
||||||
getPlayerSessionId,
|
getPlayerSessionId,
|
||||||
|
@ -49,10 +50,17 @@ class PeerTubePlugin extends Plugin {
|
||||||
declare private stopTimeHandler: (...args: any[]) => void
|
declare private stopTimeHandler: (...args: any[]) => void
|
||||||
|
|
||||||
declare private resizeObserver: ResizeObserver
|
declare private resizeObserver: ResizeObserver
|
||||||
|
declare private userAgentInfo: {
|
||||||
|
client: string
|
||||||
|
device: VideoStatsUserAgentDevice
|
||||||
|
os: string
|
||||||
|
}
|
||||||
|
|
||||||
constructor (player: videojs.Player, private readonly options: PeerTubePluginOptions) {
|
constructor (player: videojs.Player, private readonly options: PeerTubePluginOptions) {
|
||||||
super(player)
|
super(player)
|
||||||
|
|
||||||
|
this.setUserAgentInfo()
|
||||||
|
|
||||||
this.menuOpened = false
|
this.menuOpened = false
|
||||||
this.mouseInControlBar = false
|
this.mouseInControlBar = false
|
||||||
this.mouseInSettings = false
|
this.mouseInSettings = false
|
||||||
|
@ -229,6 +237,16 @@ class PeerTubePlugin extends Plugin {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private setUserAgentInfo () {
|
||||||
|
const userAgent = UAParser(window.navigator.userAgent)
|
||||||
|
|
||||||
|
this.userAgentInfo = {
|
||||||
|
client: userAgent.browser.name,
|
||||||
|
os: userAgent.os.name,
|
||||||
|
device: userAgent.device.type || 'desktop'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private initializePlayer () {
|
private initializePlayer () {
|
||||||
if (isMobile()) this.player.addClass('vjs-is-mobile')
|
if (isMobile()) this.player.addClass('vjs-is-mobile')
|
||||||
|
|
||||||
|
@ -413,7 +431,14 @@ class PeerTubePlugin extends Plugin {
|
||||||
|
|
||||||
const sessionId = getPlayerSessionId()
|
const sessionId = getPlayerSessionId()
|
||||||
|
|
||||||
const body: VideoView = { currentTime, viewEvent, sessionId }
|
const body: VideoView = {
|
||||||
|
currentTime,
|
||||||
|
viewEvent,
|
||||||
|
sessionId,
|
||||||
|
client: this.userAgentInfo.client || undefined,
|
||||||
|
device: this.userAgentInfo.device || undefined,
|
||||||
|
operatingSystem: this.userAgentInfo.os || undefined
|
||||||
|
}
|
||||||
|
|
||||||
const headers = new Headers({ 'Content-type': 'application/json; charset=UTF-8' })
|
const headers = new Headers({ 'Content-type': 'application/json; charset=UTF-8' })
|
||||||
if (this.authorizationHeader()) headers.set('Authorization', this.authorizationHeader())
|
if (this.authorizationHeader()) headers.set('Authorization', this.authorizationHeader())
|
||||||
|
|
|
@ -1954,6 +1954,14 @@
|
||||||
resolved "https://registry.yarnpkg.com/@types/ms/-/ms-2.1.0.tgz#052aa67a48eccc4309d7f0191b7e41434b90bb78"
|
resolved "https://registry.yarnpkg.com/@types/ms/-/ms-2.1.0.tgz#052aa67a48eccc4309d7f0191b7e41434b90bb78"
|
||||||
integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==
|
integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==
|
||||||
|
|
||||||
|
"@types/node-fetch@^2.6.12":
|
||||||
|
version "2.6.12"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.12.tgz#8ab5c3ef8330f13100a7479e2cd56d3386830a03"
|
||||||
|
integrity sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==
|
||||||
|
dependencies:
|
||||||
|
"@types/node" "*"
|
||||||
|
form-data "^4.0.0"
|
||||||
|
|
||||||
"@types/node@*", "@types/node@^22.2.0":
|
"@types/node@*", "@types/node@^22.2.0":
|
||||||
version "22.13.0"
|
version "22.13.0"
|
||||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.13.0.tgz#d376dd9a0ee2f9382d86c2d5d7beb4d198b4ea8c"
|
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.13.0.tgz#d376dd9a0ee2f9382d86c2d5d7beb4d198b4ea8c"
|
||||||
|
@ -3810,6 +3818,11 @@ des.js@^1.0.0:
|
||||||
inherits "^2.0.1"
|
inherits "^2.0.1"
|
||||||
minimalistic-assert "^1.0.0"
|
minimalistic-assert "^1.0.0"
|
||||||
|
|
||||||
|
detect-europe-js@^0.1.2:
|
||||||
|
version "0.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/detect-europe-js/-/detect-europe-js-0.1.2.tgz#aa76642e05dae786efc2e01a23d4792cd24c7b88"
|
||||||
|
integrity sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==
|
||||||
|
|
||||||
detect-libc@^1.0.3:
|
detect-libc@^1.0.3:
|
||||||
version "1.0.3"
|
version "1.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
|
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
|
||||||
|
@ -5709,6 +5722,11 @@ is-shared-array-buffer@^1.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bound "^1.0.3"
|
call-bound "^1.0.3"
|
||||||
|
|
||||||
|
is-standalone-pwa@^0.1.1:
|
||||||
|
version "0.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/is-standalone-pwa/-/is-standalone-pwa-0.1.1.tgz#7a1b0459471a95378aa0764d5dc0a9cec95f2871"
|
||||||
|
integrity sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==
|
||||||
|
|
||||||
is-stream@^2.0.1:
|
is-stream@^2.0.1:
|
||||||
version "2.0.1"
|
version "2.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077"
|
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077"
|
||||||
|
@ -6768,7 +6786,7 @@ node-domexception@^2.0.1:
|
||||||
resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-2.0.1.tgz#83b0d101123b5bbf91018fd569a58b88ae985e5b"
|
resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-2.0.1.tgz#83b0d101123b5bbf91018fd569a58b88ae985e5b"
|
||||||
integrity sha512-M85rnSC7WQ7wnfQTARPT4LrK7nwCHLdDFOCcItZMhTQjyCebJH8GciKqYJNgaOFZs9nFmTmd/VMyi3OW5jA47w==
|
integrity sha512-M85rnSC7WQ7wnfQTARPT4LrK7nwCHLdDFOCcItZMhTQjyCebJH8GciKqYJNgaOFZs9nFmTmd/VMyi3OW5jA47w==
|
||||||
|
|
||||||
node-fetch@^2.6.12:
|
node-fetch@^2.6.12, node-fetch@^2.7.0:
|
||||||
version "2.7.0"
|
version "2.7.0"
|
||||||
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
|
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
|
||||||
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
|
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
|
||||||
|
@ -9258,6 +9276,22 @@ typescript@~5.7.3:
|
||||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.7.3.tgz#919b44a7dbb8583a9b856d162be24a54bf80073e"
|
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.7.3.tgz#919b44a7dbb8583a9b856d162be24a54bf80073e"
|
||||||
integrity sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==
|
integrity sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==
|
||||||
|
|
||||||
|
ua-is-frozen@^0.1.2:
|
||||||
|
version "0.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/ua-is-frozen/-/ua-is-frozen-0.1.2.tgz#bfbc5f06336e379590e36beca444188c7dc3a7f3"
|
||||||
|
integrity sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==
|
||||||
|
|
||||||
|
ua-parser-js@^2.0.3:
|
||||||
|
version "2.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-2.0.3.tgz#2f18f747c83d74c0902d14366bdf58cc14526088"
|
||||||
|
integrity sha512-LZyXZdNttONW8LjzEH3Z8+6TE7RfrEiJqDKyh0R11p/kxvrV2o9DrT2FGZO+KVNs3k+drcIQ6C3En6wLnzJGpw==
|
||||||
|
dependencies:
|
||||||
|
"@types/node-fetch" "^2.6.12"
|
||||||
|
detect-europe-js "^0.1.2"
|
||||||
|
is-standalone-pwa "^0.1.1"
|
||||||
|
node-fetch "^2.7.0"
|
||||||
|
ua-is-frozen "^0.1.2"
|
||||||
|
|
||||||
uc.micro@^2.0.0, uc.micro@^2.1.0:
|
uc.micro@^2.0.0, uc.micro@^2.1.0:
|
||||||
version "2.1.0"
|
version "2.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee"
|
resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee"
|
||||||
|
|
|
@ -258,6 +258,7 @@
|
||||||
"tsc-watch": "^6.0.0",
|
"tsc-watch": "^6.0.0",
|
||||||
"tsx": "^4.7.1",
|
"tsx": "^4.7.1",
|
||||||
"type-fest": "^4.37.0",
|
"type-fest": "^4.37.0",
|
||||||
"typescript": "~5.5.2"
|
"typescript": "~5.5.2",
|
||||||
|
"ua-parser-js": "^2.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,3 +4,5 @@ export * from './video-stats-retention.model.js'
|
||||||
export * from './video-stats-timeserie-query.model.js'
|
export * from './video-stats-timeserie-query.model.js'
|
||||||
export * from './video-stats-timeserie-metric.type.js'
|
export * from './video-stats-timeserie-metric.type.js'
|
||||||
export * from './video-stats-timeserie.model.js'
|
export * from './video-stats-timeserie.model.js'
|
||||||
|
export * from './video-stats-user-agent-query.model.js'
|
||||||
|
export * from './video-stats-user-agent.model.js'
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
export interface VideoStatsUserAgentQuery {
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
export type VideoStatsUserAgent = {
|
||||||
|
[key in 'clients' | 'devices' | 'operatingSystems']: {
|
||||||
|
name: string
|
||||||
|
viewers: number
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VideoStatsUserAgentDevice = 'console' | 'embedded' | 'mobile' | 'smarttv' | 'tablet' | 'wearable' | 'xr' | 'desktop'
|
|
@ -1,7 +1,13 @@
|
||||||
|
import { VideoStatsUserAgentDevice } from './stats/video-stats-user-agent.model.js'
|
||||||
|
|
||||||
export type VideoViewEvent = 'seek'
|
export type VideoViewEvent = 'seek'
|
||||||
|
|
||||||
export interface VideoView {
|
export interface VideoView {
|
||||||
currentTime: number
|
currentTime: number
|
||||||
viewEvent?: VideoViewEvent
|
viewEvent?: VideoViewEvent
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
|
|
||||||
|
client?: string
|
||||||
|
device?: VideoStatsUserAgentDevice
|
||||||
|
operatingSystem?: string
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,17 +4,19 @@ import {
|
||||||
VideoStatsOverall,
|
VideoStatsOverall,
|
||||||
VideoStatsRetention,
|
VideoStatsRetention,
|
||||||
VideoStatsTimeserie,
|
VideoStatsTimeserie,
|
||||||
VideoStatsTimeserieMetric
|
VideoStatsTimeserieMetric,
|
||||||
|
VideoStatsUserAgent
|
||||||
} from '@peertube/peertube-models'
|
} from '@peertube/peertube-models'
|
||||||
import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
|
import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
|
||||||
|
|
||||||
export class VideoStatsCommand extends AbstractCommand {
|
export class VideoStatsCommand extends AbstractCommand {
|
||||||
|
getOverallStats (
|
||||||
getOverallStats (options: OverrideCommandOptions & {
|
options: OverrideCommandOptions & {
|
||||||
videoId: number | string
|
videoId: number | string
|
||||||
startDate?: string
|
startDate?: string
|
||||||
endDate?: string
|
endDate?: string
|
||||||
}) {
|
}
|
||||||
|
) {
|
||||||
const path = '/api/v1/videos/' + options.videoId + '/stats/overall'
|
const path = '/api/v1/videos/' + options.videoId + '/stats/overall'
|
||||||
|
|
||||||
return this.getRequestBody<VideoStatsOverall>({
|
return this.getRequestBody<VideoStatsOverall>({
|
||||||
|
@ -28,12 +30,34 @@ export class VideoStatsCommand extends AbstractCommand {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
getTimeserieStats (options: OverrideCommandOptions & {
|
getUserAgentStats (
|
||||||
videoId: number | string
|
options: OverrideCommandOptions & {
|
||||||
metric: VideoStatsTimeserieMetric
|
videoId: number | string
|
||||||
startDate?: Date
|
startDate?: string
|
||||||
endDate?: Date
|
endDate?: string
|
||||||
}) {
|
}
|
||||||
|
) {
|
||||||
|
const path = '/api/v1/videos/' + options.videoId + '/stats/user-agent'
|
||||||
|
|
||||||
|
return this.getRequestBody<VideoStatsUserAgent>({
|
||||||
|
...options,
|
||||||
|
path,
|
||||||
|
|
||||||
|
query: pick(options, [ 'startDate', 'endDate' ]),
|
||||||
|
|
||||||
|
implicitToken: true,
|
||||||
|
defaultExpectedStatus: HttpStatusCode.OK_200
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
getTimeserieStats (
|
||||||
|
options: OverrideCommandOptions & {
|
||||||
|
videoId: number | string
|
||||||
|
metric: VideoStatsTimeserieMetric
|
||||||
|
startDate?: Date
|
||||||
|
endDate?: Date
|
||||||
|
}
|
||||||
|
) {
|
||||||
const path = '/api/v1/videos/' + options.videoId + '/stats/timeseries/' + options.metric
|
const path = '/api/v1/videos/' + options.videoId + '/stats/timeseries/' + options.metric
|
||||||
|
|
||||||
return this.getRequestBody<VideoStatsTimeserie>({
|
return this.getRequestBody<VideoStatsTimeserie>({
|
||||||
|
@ -46,9 +70,11 @@ export class VideoStatsCommand extends AbstractCommand {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
getRetentionStats (options: OverrideCommandOptions & {
|
getRetentionStats (
|
||||||
videoId: number | string
|
options: OverrideCommandOptions & {
|
||||||
}) {
|
videoId: number | string
|
||||||
|
}
|
||||||
|
) {
|
||||||
const path = '/api/v1/videos/' + options.videoId + '/stats/retention'
|
const path = '/api/v1/videos/' + options.videoId + '/stats/retention'
|
||||||
|
|
||||||
return this.getRequestBody<VideoStatsRetention>({
|
return this.getRequestBody<VideoStatsRetention>({
|
||||||
|
|
|
@ -1,17 +1,16 @@
|
||||||
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */
|
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/no-floating-promises */
|
||||||
import { HttpStatusCode, VideoViewEvent } from '@peertube/peertube-models'
|
import { pick } from '@peertube/peertube-core-utils'
|
||||||
|
import { HttpStatusCode, VideoView, VideoViewEvent } from '@peertube/peertube-models'
|
||||||
import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
|
import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
|
||||||
|
|
||||||
export class ViewsCommand extends AbstractCommand {
|
export class ViewsCommand extends AbstractCommand {
|
||||||
|
view (
|
||||||
view (options: OverrideCommandOptions & {
|
options: OverrideCommandOptions & VideoView & {
|
||||||
id: number | string
|
id: number | string
|
||||||
currentTime: number
|
xForwardedFor?: string
|
||||||
viewEvent?: VideoViewEvent
|
}
|
||||||
xForwardedFor?: string
|
) {
|
||||||
sessionId?: string
|
const { id, xForwardedFor } = options
|
||||||
}) {
|
|
||||||
const { id, xForwardedFor, viewEvent, currentTime, sessionId } = options
|
|
||||||
const path = '/api/v1/videos/' + id + '/views'
|
const path = '/api/v1/videos/' + id + '/views'
|
||||||
|
|
||||||
return this.postBodyRequest({
|
return this.postBodyRequest({
|
||||||
|
@ -19,31 +18,30 @@ export class ViewsCommand extends AbstractCommand {
|
||||||
|
|
||||||
path,
|
path,
|
||||||
xForwardedFor,
|
xForwardedFor,
|
||||||
fields: {
|
fields: pick(options, [ 'currentTime', 'viewEvent', 'sessionId', 'client', 'device', 'operatingSystem' ]),
|
||||||
currentTime,
|
|
||||||
viewEvent,
|
|
||||||
sessionId
|
|
||||||
},
|
|
||||||
implicitToken: false,
|
implicitToken: false,
|
||||||
defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
|
defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async simulateView (options: OverrideCommandOptions & {
|
async simulateView (
|
||||||
id: number | string
|
options: OverrideCommandOptions & Omit<VideoView, 'currentTime'> & {
|
||||||
xForwardedFor?: string
|
id: number | string
|
||||||
sessionId?: string
|
xForwardedFor?: string
|
||||||
}) {
|
}
|
||||||
|
) {
|
||||||
await this.view({ ...options, currentTime: 0 })
|
await this.view({ ...options, currentTime: 0 })
|
||||||
await this.view({ ...options, currentTime: 5 })
|
await this.view({ ...options, currentTime: 5 })
|
||||||
}
|
}
|
||||||
|
|
||||||
async simulateViewer (options: OverrideCommandOptions & {
|
async simulateViewer (
|
||||||
id: number | string
|
options: OverrideCommandOptions & {
|
||||||
currentTimes: number[]
|
id: number | string
|
||||||
xForwardedFor?: string
|
currentTimes: number[]
|
||||||
sessionId?: string
|
xForwardedFor?: string
|
||||||
}) {
|
sessionId?: string
|
||||||
|
}
|
||||||
|
) {
|
||||||
let viewEvent: VideoViewEvent = 'seek'
|
let viewEvent: VideoViewEvent = 'seek'
|
||||||
|
|
||||||
for (const currentTime of options.currentTimes) {
|
for (const currentTime of options.currentTimes) {
|
||||||
|
|
|
@ -24,11 +24,10 @@ describe('Test videos views API validators', function () {
|
||||||
await setAccessTokensToServers(servers)
|
await setAccessTokensToServers(servers)
|
||||||
await setDefaultVideoChannel(servers)
|
await setDefaultVideoChannel(servers)
|
||||||
|
|
||||||
await servers[0].config.enableLive({ allowReplay: false, transcoding: false });
|
await servers[0].config.enableLive({ allowReplay: false, transcoding: false })
|
||||||
|
;({ uuid: videoId } = await servers[0].videos.quickUpload({ name: 'video' }))
|
||||||
({ uuid: videoId } = await servers[0].videos.quickUpload({ name: 'video' }));
|
;({ uuid: remoteVideoId } = await servers[1].videos.quickUpload({ name: 'video' }))
|
||||||
({ uuid: remoteVideoId } = await servers[1].videos.quickUpload({ name: 'video' }));
|
;({ uuid: liveVideoId } = await servers[0].live.create({
|
||||||
({ uuid: liveVideoId } = await servers[0].live.create({
|
|
||||||
fields: {
|
fields: {
|
||||||
name: 'live',
|
name: 'live',
|
||||||
privacy: VideoPrivacy.PUBLIC,
|
privacy: VideoPrivacy.PUBLIC,
|
||||||
|
@ -42,7 +41,6 @@ describe('Test videos views API validators', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('When viewing a video', async function () {
|
describe('When viewing a video', async function () {
|
||||||
|
|
||||||
it('Should fail without current time', async function () {
|
it('Should fail without current time', async function () {
|
||||||
await servers[0].views.view({ id: videoId, currentTime: undefined, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
|
await servers[0].views.view({ id: videoId, currentTime: undefined, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
|
||||||
})
|
})
|
||||||
|
@ -53,23 +51,66 @@ describe('Test videos views API validators', function () {
|
||||||
await servers[0].views.view({ id: videoId, currentTime: 10, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
|
await servers[0].views.view({ id: videoId, currentTime: 10, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Should fail with an invalid view event', async function () {
|
||||||
|
await servers[0].views.view({
|
||||||
|
id: videoId,
|
||||||
|
currentTime: 1,
|
||||||
|
viewEvent: 'seeko' as any,
|
||||||
|
expectedStatus: HttpStatusCode.BAD_REQUEST_400
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with an invalid session id', async function () {
|
||||||
|
await servers[0].views.view({ id: videoId, currentTime: 1, sessionId: 'tito_t', expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with an invalid client', async function () {
|
||||||
|
await servers[0].views.view({
|
||||||
|
id: videoId,
|
||||||
|
currentTime: 1,
|
||||||
|
client: 'a'.repeat(1000),
|
||||||
|
expectedStatus: HttpStatusCode.BAD_REQUEST_400
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should fail with an invalid operating system', async function () {
|
||||||
|
await servers[0].views.view({
|
||||||
|
id: videoId,
|
||||||
|
currentTime: 1,
|
||||||
|
operatingSystem: 'a'.repeat(1000),
|
||||||
|
expectedStatus: HttpStatusCode.BAD_REQUEST_400
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('Should succeed with correct parameters', async function () {
|
it('Should succeed with correct parameters', async function () {
|
||||||
await servers[0].views.view({ id: videoId, currentTime: 1 })
|
await servers[0].views.view({
|
||||||
|
id: videoId,
|
||||||
|
sessionId: 'titot',
|
||||||
|
viewEvent: 'seek',
|
||||||
|
client: 'chrome',
|
||||||
|
device: 'super device' as any,
|
||||||
|
operatingSystem: 'linux',
|
||||||
|
currentTime: 1
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('When getting overall stats', function () {
|
describe('When getting overall/useragent stats', function () {
|
||||||
|
async function testEndpoint (options: Parameters<PeerTubeServer['videoStats']['getOverallStats']>[0]) {
|
||||||
|
await servers[0].videoStats.getOverallStats(options)
|
||||||
|
await servers[0].videoStats.getUserAgentStats(options)
|
||||||
|
}
|
||||||
|
|
||||||
it('Should fail with a remote video', async function () {
|
it('Should fail with a remote video', async function () {
|
||||||
await servers[0].videoStats.getOverallStats({ videoId: remoteVideoId, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
await testEndpoint({ videoId: remoteVideoId, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should fail without token', async function () {
|
it('Should fail without token', async function () {
|
||||||
await servers[0].videoStats.getOverallStats({ videoId, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
|
await testEndpoint({ videoId, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should fail with another token', async function () {
|
it('Should fail with another token', async function () {
|
||||||
await servers[0].videoStats.getOverallStats({
|
await testEndpoint({
|
||||||
videoId,
|
videoId,
|
||||||
token: userAccessToken,
|
token: userAccessToken,
|
||||||
expectedStatus: HttpStatusCode.FORBIDDEN_403
|
expectedStatus: HttpStatusCode.FORBIDDEN_403
|
||||||
|
@ -77,7 +118,7 @@ describe('Test videos views API validators', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should fail with an invalid start date', async function () {
|
it('Should fail with an invalid start date', async function () {
|
||||||
await servers[0].videoStats.getOverallStats({
|
await testEndpoint({
|
||||||
videoId,
|
videoId,
|
||||||
startDate: 'fake' as any,
|
startDate: 'fake' as any,
|
||||||
endDate: new Date().toISOString(),
|
endDate: new Date().toISOString(),
|
||||||
|
@ -86,7 +127,7 @@ describe('Test videos views API validators', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should fail with an invalid end date', async function () {
|
it('Should fail with an invalid end date', async function () {
|
||||||
await servers[0].videoStats.getOverallStats({
|
await testEndpoint({
|
||||||
videoId,
|
videoId,
|
||||||
startDate: new Date().toISOString(),
|
startDate: new Date().toISOString(),
|
||||||
endDate: 'fake' as any,
|
endDate: 'fake' as any,
|
||||||
|
@ -95,7 +136,7 @@ describe('Test videos views API validators', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should succeed with the correct parameters', async function () {
|
it('Should succeed with the correct parameters', async function () {
|
||||||
await servers[0].videoStats.getOverallStats({
|
await testEndpoint({
|
||||||
videoId,
|
videoId,
|
||||||
startDate: new Date().toISOString(),
|
startDate: new Date().toISOString(),
|
||||||
endDate: new Date().toISOString()
|
endDate: new Date().toISOString()
|
||||||
|
@ -104,7 +145,6 @@ describe('Test videos views API validators', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('When getting timeserie stats', function () {
|
describe('When getting timeserie stats', function () {
|
||||||
|
|
||||||
it('Should fail with a remote video', async function () {
|
it('Should fail with a remote video', async function () {
|
||||||
await servers[0].videoStats.getTimeserieStats({
|
await servers[0].videoStats.getTimeserieStats({
|
||||||
videoId: remoteVideoId,
|
videoId: remoteVideoId,
|
||||||
|
@ -189,7 +229,6 @@ describe('Test videos views API validators', function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('When getting retention stats', function () {
|
describe('When getting retention stats', function () {
|
||||||
|
|
||||||
it('Should fail with a remote video', async function () {
|
it('Should fail with a remote video', async function () {
|
||||||
await servers[0].videoStats.getRetentionStats({
|
await servers[0].videoStats.getRetentionStats({
|
||||||
videoId: remoteVideoId,
|
videoId: remoteVideoId,
|
||||||
|
|
|
@ -2,4 +2,5 @@ export * from './video-views-counter.js'
|
||||||
export * from './video-views-overall-stats.js'
|
export * from './video-views-overall-stats.js'
|
||||||
export * from './video-views-retention-stats.js'
|
export * from './video-views-retention-stats.js'
|
||||||
export * from './video-views-timeserie-stats.js'
|
export * from './video-views-timeserie-stats.js'
|
||||||
|
export * from './video-views-user-agent-stats.js'
|
||||||
export * from './videos-views-cleaner.js'
|
export * from './videos-views-cleaner.js'
|
||||||
|
|
101
packages/tests/src/api/views/video-views-user-agent-stats.ts
Normal file
101
packages/tests/src/api/views/video-views-user-agent-stats.ts
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-unused-expressions */
|
||||||
|
import { buildUUID } from '@peertube/peertube-node-utils'
|
||||||
|
import { PeerTubeServer, cleanupTests, waitJobs } from '@peertube/peertube-server-commands'
|
||||||
|
import { prepareViewsServers, processViewersStats } from '@tests/shared/views.js'
|
||||||
|
import { expect } from 'chai'
|
||||||
|
|
||||||
|
describe('Test views user agent stats', function () {
|
||||||
|
let server: PeerTubeServer
|
||||||
|
let beforeChromeView: Date
|
||||||
|
let videoUUID: string
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
|
this.timeout(120000)
|
||||||
|
|
||||||
|
const servers = await prepareViewsServers({ singleServer: true })
|
||||||
|
server = servers[0]
|
||||||
|
|
||||||
|
const { uuid } = await server.videos.quickUpload({ name: 'video' })
|
||||||
|
videoUUID = uuid
|
||||||
|
await waitJobs(server)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should report client, device and OS', async function () {
|
||||||
|
this.timeout(240000)
|
||||||
|
|
||||||
|
await server.views.simulateView({
|
||||||
|
id: videoUUID,
|
||||||
|
sessionId: buildUUID(),
|
||||||
|
client: 'Edge',
|
||||||
|
device: 'desktop',
|
||||||
|
operatingSystem: 'Android'
|
||||||
|
})
|
||||||
|
await server.views.simulateView({
|
||||||
|
id: videoUUID,
|
||||||
|
sessionId: buildUUID(),
|
||||||
|
client: 'Edge',
|
||||||
|
device: 'mobile',
|
||||||
|
operatingSystem: 'Windows'
|
||||||
|
})
|
||||||
|
|
||||||
|
await processViewersStats([ server ])
|
||||||
|
beforeChromeView = new Date()
|
||||||
|
|
||||||
|
await server.views.simulateView({
|
||||||
|
id: videoUUID,
|
||||||
|
sessionId: buildUUID(),
|
||||||
|
client: 'Chrome',
|
||||||
|
device: 'desktop',
|
||||||
|
operatingSystem: 'Ubuntu'
|
||||||
|
})
|
||||||
|
|
||||||
|
await processViewersStats([ server ])
|
||||||
|
|
||||||
|
const stats = await server.videoStats.getUserAgentStats({ videoId: videoUUID })
|
||||||
|
|
||||||
|
expect(stats.clients).to.include.deep.members([ { name: 'Chrome', viewers: 1 } ])
|
||||||
|
expect(stats.clients).to.include.deep.members([ { name: 'Edge', viewers: 2 } ])
|
||||||
|
|
||||||
|
expect(stats.devices).to.include.deep.members([ { name: 'desktop', viewers: 2 } ])
|
||||||
|
expect(stats.devices).to.include.deep.members([ { name: 'mobile', viewers: 1 } ])
|
||||||
|
|
||||||
|
expect(stats.operatingSystems).to.include.deep.members([ { name: 'Android', viewers: 1 } ])
|
||||||
|
expect(stats.operatingSystems).to.include.deep.members([ { name: 'Ubuntu', viewers: 1 } ])
|
||||||
|
expect(stats.operatingSystems).to.include.deep.members([ { name: 'Windows', viewers: 1 } ])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should filter by date', async function () {
|
||||||
|
{
|
||||||
|
const stats = await server.videoStats.getUserAgentStats({ videoId: videoUUID, startDate: beforeChromeView.toISOString() })
|
||||||
|
|
||||||
|
expect(stats.clients).to.include.deep.members([ { name: 'Chrome', viewers: 1 } ])
|
||||||
|
expect(stats.clients.find(e => e.name === 'Edge')).to.not.exist
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const stats = await server.videoStats.getUserAgentStats({ videoId: videoUUID, endDate: beforeChromeView.toISOString() })
|
||||||
|
|
||||||
|
expect(stats.clients.find(e => e.name === 'Chrome')).to.not.exist
|
||||||
|
expect(stats.clients).to.include.deep.members([ { name: 'Edge', viewers: 2 } ])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Should use a null value if device is not known by PeerTube', async function () {
|
||||||
|
await server.views.simulateView({
|
||||||
|
id: videoUUID,
|
||||||
|
sessionId: buildUUID(),
|
||||||
|
client: 'Chrome',
|
||||||
|
device: 'unknown' as any,
|
||||||
|
operatingSystem: 'Ubuntu'
|
||||||
|
})
|
||||||
|
|
||||||
|
await processViewersStats([ server ])
|
||||||
|
|
||||||
|
const stats = await server.videoStats.getUserAgentStats({ videoId: videoUUID, endDate: beforeChromeView.toISOString() })
|
||||||
|
expect(stats.devices.map(d => d.name)).to.deep.members([ 'desktop', 'mobile' ])
|
||||||
|
})
|
||||||
|
|
||||||
|
after(async function () {
|
||||||
|
await cleanupTests([ server ])
|
||||||
|
})
|
||||||
|
})
|
|
@ -3,6 +3,7 @@ import { wait } from '@peertube/peertube-core-utils'
|
||||||
import { VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models'
|
import { VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models'
|
||||||
import {
|
import {
|
||||||
createMultipleServers,
|
createMultipleServers,
|
||||||
|
createSingleServer,
|
||||||
doubleFollow,
|
doubleFollow,
|
||||||
PeerTubeServer,
|
PeerTubeServer,
|
||||||
setAccessTokensToServers,
|
setAccessTokensToServers,
|
||||||
|
@ -33,8 +34,9 @@ async function processViewsBuffer (servers: PeerTubeServer[]) {
|
||||||
async function prepareViewsServers (options: {
|
async function prepareViewsServers (options: {
|
||||||
viewExpiration?: string // default 1 second
|
viewExpiration?: string // default 1 second
|
||||||
trustViewerSessionId?: boolean // default true
|
trustViewerSessionId?: boolean // default true
|
||||||
|
singleServer?: boolean // default false
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const { viewExpiration = '1 second', trustViewerSessionId = true } = options
|
const { viewExpiration = '1 second', trustViewerSessionId = true, singleServer } = options
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
views: {
|
views: {
|
||||||
|
@ -46,14 +48,16 @@ async function prepareViewsServers (options: {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const servers = await createMultipleServers(2, config)
|
const servers = await (singleServer ? Promise.all([ createSingleServer(1, config) ]) : createMultipleServers(2, config))
|
||||||
await setAccessTokensToServers(servers)
|
await setAccessTokensToServers(servers)
|
||||||
await setDefaultVideoChannel(servers)
|
await setDefaultVideoChannel(servers)
|
||||||
|
|
||||||
await servers[0].config.enableMinimumTranscoding()
|
await servers[0].config.enableMinimumTranscoding()
|
||||||
await servers[0].config.enableLive({ allowReplay: true, transcoding: false })
|
await servers[0].config.enableLive({ allowReplay: true, transcoding: false })
|
||||||
|
|
||||||
await doubleFollow(servers[0], servers[1])
|
if (!singleServer) {
|
||||||
|
await doubleFollow(servers[0], servers[1])
|
||||||
|
}
|
||||||
|
|
||||||
return servers
|
return servers
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,34 +1,49 @@
|
||||||
import express from 'express'
|
import {
|
||||||
|
VideoStatsOverallQuery,
|
||||||
|
VideoStatsTimeserieMetric,
|
||||||
|
VideoStatsTimeserieQuery,
|
||||||
|
VideoStatsUserAgentQuery
|
||||||
|
} from '@peertube/peertube-models'
|
||||||
import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer.js'
|
import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer.js'
|
||||||
import { VideoStatsOverallQuery, VideoStatsTimeserieMetric, VideoStatsTimeserieQuery } from '@peertube/peertube-models'
|
import express from 'express'
|
||||||
import {
|
import {
|
||||||
asyncMiddleware,
|
asyncMiddleware,
|
||||||
authenticate,
|
authenticate,
|
||||||
videoOverallStatsValidator,
|
videoOverallOrUserAgentStatsValidator,
|
||||||
videoRetentionStatsValidator,
|
videoRetentionStatsValidator,
|
||||||
videoTimeserieStatsValidator
|
videoTimeseriesStatsValidator
|
||||||
} from '../../../middlewares/index.js'
|
} from '../../../middlewares/index.js'
|
||||||
|
|
||||||
const statsRouter = express.Router()
|
const statsRouter = express.Router()
|
||||||
|
|
||||||
statsRouter.get('/:videoId/stats/overall',
|
statsRouter.get(
|
||||||
|
'/:videoId/stats/overall',
|
||||||
authenticate,
|
authenticate,
|
||||||
asyncMiddleware(videoOverallStatsValidator),
|
asyncMiddleware(videoOverallOrUserAgentStatsValidator),
|
||||||
asyncMiddleware(getOverallStats)
|
asyncMiddleware(getOverallStats)
|
||||||
)
|
)
|
||||||
|
|
||||||
statsRouter.get('/:videoId/stats/timeseries/:metric',
|
statsRouter.get(
|
||||||
|
'/:videoId/stats/timeseries/:metric',
|
||||||
authenticate,
|
authenticate,
|
||||||
asyncMiddleware(videoTimeserieStatsValidator),
|
asyncMiddleware(videoTimeseriesStatsValidator),
|
||||||
asyncMiddleware(getTimeserieStats)
|
asyncMiddleware(getTimeseriesStats)
|
||||||
)
|
)
|
||||||
|
|
||||||
statsRouter.get('/:videoId/stats/retention',
|
statsRouter.get(
|
||||||
|
'/:videoId/stats/retention',
|
||||||
authenticate,
|
authenticate,
|
||||||
asyncMiddleware(videoRetentionStatsValidator),
|
asyncMiddleware(videoRetentionStatsValidator),
|
||||||
asyncMiddleware(getRetentionStats)
|
asyncMiddleware(getRetentionStats)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
statsRouter.get(
|
||||||
|
'/:videoId/stats/user-agent',
|
||||||
|
authenticate,
|
||||||
|
asyncMiddleware(videoOverallOrUserAgentStatsValidator),
|
||||||
|
asyncMiddleware(getUserAgentStats)
|
||||||
|
)
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
@ -50,6 +65,19 @@ async function getOverallStats (req: express.Request, res: express.Response) {
|
||||||
return res.json(stats)
|
return res.json(stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getUserAgentStats (req: express.Request, res: express.Response) {
|
||||||
|
const video = res.locals.videoAll
|
||||||
|
const query = req.query as VideoStatsUserAgentQuery
|
||||||
|
|
||||||
|
const stats = await LocalVideoViewerModel.getUserAgentStats({
|
||||||
|
video,
|
||||||
|
startDate: query.startDate,
|
||||||
|
endDate: query.endDate
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.json(stats)
|
||||||
|
}
|
||||||
|
|
||||||
async function getRetentionStats (req: express.Request, res: express.Response) {
|
async function getRetentionStats (req: express.Request, res: express.Response) {
|
||||||
const video = res.locals.videoAll
|
const video = res.locals.videoAll
|
||||||
|
|
||||||
|
@ -58,7 +86,7 @@ async function getRetentionStats (req: express.Request, res: express.Response) {
|
||||||
return res.json(stats)
|
return res.json(stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getTimeserieStats (req: express.Request, res: express.Response) {
|
async function getTimeseriesStats (req: express.Request, res: express.Response) {
|
||||||
const video = res.locals.videoAll
|
const video = res.locals.videoAll
|
||||||
const metric = req.params.metric as VideoStatsTimeserieMetric
|
const metric = req.params.metric as VideoStatsTimeserieMetric
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import express from 'express'
|
|
||||||
import { HttpStatusCode, VideoView } from '@peertube/peertube-models'
|
import { HttpStatusCode, VideoView } from '@peertube/peertube-models'
|
||||||
import { Hooks } from '@server/lib/plugins/hooks.js'
|
import { Hooks } from '@server/lib/plugins/hooks.js'
|
||||||
import { VideoViewsManager } from '@server/lib/views/video-views-manager.js'
|
import { VideoViewsManager } from '@server/lib/views/video-views-manager.js'
|
||||||
import { MVideoId } from '@server/types/models/index.js'
|
import { MVideoId } from '@server/types/models/index.js'
|
||||||
|
import express from 'express'
|
||||||
import {
|
import {
|
||||||
asyncMiddleware,
|
asyncMiddleware,
|
||||||
methodsValidator,
|
methodsValidator,
|
||||||
|
@ -35,14 +35,17 @@ async function viewVideo (req: express.Request, res: express.Response) {
|
||||||
const video = res.locals.onlyImmutableVideo
|
const video = res.locals.onlyImmutableVideo
|
||||||
|
|
||||||
const body = req.body as VideoView
|
const body = req.body as VideoView
|
||||||
|
|
||||||
const ip = req.ip
|
const ip = req.ip
|
||||||
|
|
||||||
const { successView } = await VideoViewsManager.Instance.processLocalView({
|
const { successView } = await VideoViewsManager.Instance.processLocalView({
|
||||||
video,
|
video,
|
||||||
ip,
|
ip,
|
||||||
currentTime: body.currentTime,
|
currentTime: body.currentTime,
|
||||||
viewEvent: body.viewEvent,
|
viewEvent: body.viewEvent,
|
||||||
sessionId: body.sessionId
|
sessionId: body.sessionId,
|
||||||
|
client: body.client,
|
||||||
|
operatingSystem: body.operatingSystem,
|
||||||
|
device: body.device
|
||||||
})
|
})
|
||||||
|
|
||||||
if (successView) {
|
if (successView) {
|
||||||
|
|
|
@ -1,12 +1,27 @@
|
||||||
|
import { VideoStatsUserAgentDevice } from '@peertube/peertube-models'
|
||||||
|
import { CONSTRAINTS_FIELDS } from '@server/initializers/constants.js'
|
||||||
|
import validator from 'validator'
|
||||||
import { exists } from './misc.js'
|
import { exists } from './misc.js'
|
||||||
|
|
||||||
function isVideoTimeValid (value: number, videoDuration?: number) {
|
export function isVideoTimeValid (value: number, videoDuration?: number) {
|
||||||
if (value < 0) return false
|
if (value < 0) return false
|
||||||
if (exists(videoDuration) && value > videoDuration) return false
|
if (exists(videoDuration) && value > videoDuration) return false
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export function isVideoViewEvent (value: string) {
|
||||||
isVideoTimeValid
|
return value === 'seek'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isVideoViewUAInfo (value: string) {
|
||||||
|
return validator.default.isLength(value, CONSTRAINTS_FIELDS.VIDEO_VIEW.UA_INFO)
|
||||||
|
}
|
||||||
|
|
||||||
|
// See https://docs.uaparser.dev/info/device/type.html
|
||||||
|
const devices = new Set<VideoStatsUserAgentDevice>([ 'console', 'embedded', 'mobile', 'smarttv', 'tablet', 'wearable', 'xr', 'desktop' ])
|
||||||
|
export function toVideoViewUADeviceOrNull (value: VideoStatsUserAgentDevice) {
|
||||||
|
return devices.has(value)
|
||||||
|
? value
|
||||||
|
: null
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,7 +46,7 @@ import { CONFIG, registerConfigChangedHandler } from './config.js'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const LAST_MIGRATION_VERSION = 880
|
export const LAST_MIGRATION_VERSION = 885
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@ -512,6 +512,9 @@ export const CONSTRAINTS_FIELDS = {
|
||||||
LIST_NAME: { min: 1, max: 100 }, // Length
|
LIST_NAME: { min: 1, max: 100 }, // Length
|
||||||
WORDS: { min: 1, max: 500 }, // Number of total words
|
WORDS: { min: 1, max: 500 }, // Number of total words
|
||||||
WORD: { min: 1, max: 100 } // Length
|
WORD: { min: 1, max: 100 } // Length
|
||||||
|
},
|
||||||
|
VIDEO_VIEW: {
|
||||||
|
UA_INFO: { min: 1, max: 200 } // Length
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,42 @@
|
||||||
|
import * as Sequelize from 'sequelize'
|
||||||
|
|
||||||
|
async function up (utils: {
|
||||||
|
transaction: Sequelize.Transaction
|
||||||
|
queryInterface: Sequelize.QueryInterface
|
||||||
|
sequelize: Sequelize.Sequelize
|
||||||
|
}): Promise<void> {
|
||||||
|
const { transaction } = utils
|
||||||
|
|
||||||
|
{
|
||||||
|
await utils.queryInterface.addColumn('localVideoViewer', 'client', {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
defaultValue: null,
|
||||||
|
allowNull: true
|
||||||
|
}, { transaction })
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
await utils.queryInterface.addColumn('localVideoViewer', 'device', {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
defaultValue: null,
|
||||||
|
allowNull: true
|
||||||
|
}, { transaction })
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
await utils.queryInterface.addColumn('localVideoViewer', 'operatingSystem', {
|
||||||
|
type: Sequelize.STRING,
|
||||||
|
defaultValue: null,
|
||||||
|
allowNull: true
|
||||||
|
}, { transaction })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function down (options) {
|
||||||
|
throw new Error('Not implemented.')
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
down,
|
||||||
|
up
|
||||||
|
}
|
|
@ -26,6 +26,10 @@ type LocalViewerStats = {
|
||||||
|
|
||||||
watchTime: number
|
watchTime: number
|
||||||
|
|
||||||
|
client: string
|
||||||
|
device: string
|
||||||
|
operatingSystem: string
|
||||||
|
|
||||||
country: string
|
country: string
|
||||||
subdivisionName: string
|
subdivisionName: string
|
||||||
|
|
||||||
|
@ -52,12 +56,16 @@ export class VideoViewerStats {
|
||||||
ip: string
|
ip: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
viewEvent?: VideoViewEvent
|
viewEvent?: VideoViewEvent
|
||||||
|
client: string
|
||||||
|
operatingSystem: string
|
||||||
|
device: string
|
||||||
}) {
|
}) {
|
||||||
const { video, ip, viewEvent, currentTime, sessionId } = options
|
const { video, ip, viewEvent, currentTime, sessionId, client, operatingSystem, device } = options
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
'Adding local viewer to video stats %s.', video.uuid,
|
'Adding local viewer to video stats %s.',
|
||||||
{ currentTime, viewEvent, sessionId, ...lTags(video.uuid) }
|
video.uuid,
|
||||||
|
{ currentTime, viewEvent, sessionId, client, operatingSystem, device, ...lTags(video.uuid) }
|
||||||
)
|
)
|
||||||
|
|
||||||
const nowMs = new Date().getTime()
|
const nowMs = new Date().getTime()
|
||||||
|
@ -67,7 +75,7 @@ export class VideoViewerStats {
|
||||||
if (stats && stats.watchSections.length >= MAX_LOCAL_VIEWER_WATCH_SECTIONS) {
|
if (stats && stats.watchSections.length >= MAX_LOCAL_VIEWER_WATCH_SECTIONS) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
'Too much watch section to store for a viewer, skipping this one',
|
'Too much watch section to store for a viewer, skipping this one',
|
||||||
{ sessionId, currentTime, viewEvent, ...lTags(video.uuid) }
|
{ currentTime, viewEvent, sessionId, client, operatingSystem, device, ...lTags(video.uuid) }
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -83,6 +91,10 @@ export class VideoViewerStats {
|
||||||
|
|
||||||
watchTime: 0,
|
watchTime: 0,
|
||||||
|
|
||||||
|
client,
|
||||||
|
device,
|
||||||
|
operatingSystem,
|
||||||
|
|
||||||
country,
|
country,
|
||||||
subdivisionName,
|
subdivisionName,
|
||||||
|
|
||||||
|
@ -181,6 +193,9 @@ export class VideoViewerStats {
|
||||||
startDate: new Date(stats.firstUpdated),
|
startDate: new Date(stats.firstUpdated),
|
||||||
endDate: new Date(stats.lastUpdated),
|
endDate: new Date(stats.lastUpdated),
|
||||||
watchTime: stats.watchTime,
|
watchTime: stats.watchTime,
|
||||||
|
client: stats.client,
|
||||||
|
device: stats.device,
|
||||||
|
operatingSystem: stats.operatingSystem,
|
||||||
country: stats.country,
|
country: stats.country,
|
||||||
subdivisionName: stats.subdivisionName,
|
subdivisionName: stats.subdivisionName,
|
||||||
videoId: video.id
|
videoId: video.id
|
||||||
|
@ -205,9 +220,7 @@ export class VideoViewerStats {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
|
||||||
* Redis calls can be expensive so try to cache things in front of it
|
* Redis calls can be expensive so try to cache things in front of it
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
private getLocalVideoViewer (options: {
|
private getLocalVideoViewer (options: {
|
||||||
|
|
|
@ -20,13 +20,11 @@ import { VideoScope, VideoViewerCounters, VideoViewerStats, VideoViews, ViewerSc
|
||||||
* A viewer is a someone that watched one or multiple sections of a video
|
* A viewer is a someone that watched one or multiple sections of a video
|
||||||
* A viewer that watched only a few seconds of a video may not increment the video views counter
|
* A viewer that watched only a few seconds of a video may not increment the video views counter
|
||||||
* Viewers statistics are sent to origin instance using the `WatchAction` ActivityPub object
|
* Viewers statistics are sent to origin instance using the `WatchAction` ActivityPub object
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const lTags = loggerTagsFactory('views')
|
const lTags = loggerTagsFactory('views')
|
||||||
|
|
||||||
export class VideoViewsManager {
|
export class VideoViewsManager {
|
||||||
|
|
||||||
private static instance: VideoViewsManager
|
private static instance: VideoViewsManager
|
||||||
|
|
||||||
private videoViewerStats: VideoViewerStats
|
private videoViewerStats: VideoViewerStats
|
||||||
|
@ -48,8 +46,11 @@ export class VideoViewsManager {
|
||||||
ip: string | null
|
ip: string | null
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
viewEvent?: VideoViewEvent
|
viewEvent?: VideoViewEvent
|
||||||
|
client: string
|
||||||
|
operatingSystem: string
|
||||||
|
device: string
|
||||||
}) {
|
}) {
|
||||||
const { video, ip, viewEvent, currentTime } = options
|
const { video, ip, viewEvent, currentTime, client, operatingSystem, device } = options
|
||||||
|
|
||||||
let sessionId = options.sessionId
|
let sessionId = options.sessionId
|
||||||
if (!sessionId || CONFIG.VIEWS.VIDEOS.TRUST_VIEWER_SESSION_ID !== true) {
|
if (!sessionId || CONFIG.VIEWS.VIDEOS.TRUST_VIEWER_SESSION_ID !== true) {
|
||||||
|
@ -58,7 +59,7 @@ export class VideoViewsManager {
|
||||||
|
|
||||||
logger.debug(`Processing local view for ${video.url}, ip ${ip} and session id ${sessionId}.`, lTags())
|
logger.debug(`Processing local view for ${video.url}, ip ${ip} and session id ${sessionId}.`, lTags())
|
||||||
|
|
||||||
await this.videoViewerStats.addLocalViewer({ video, ip, sessionId, viewEvent, currentTime })
|
await this.videoViewerStats.addLocalViewer({ video, ip, sessionId, viewEvent, currentTime, client, operatingSystem, device })
|
||||||
|
|
||||||
const successViewer = await this.videoViewerCounters.addLocalViewer({ video, sessionId })
|
const successViewer = await this.videoViewerCounters.addLocalViewer({ video, sessionId })
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { STATS_TIMESERIE } from '@server/initializers/constants.js'
|
||||||
import { HttpStatusCode, UserRight, VideoStatsTimeserieQuery } from '@peertube/peertube-models'
|
import { HttpStatusCode, UserRight, VideoStatsTimeserieQuery } from '@peertube/peertube-models'
|
||||||
import { areValidationErrors, checkUserCanManageVideo, doesVideoExist, isValidVideoIdParam } from '../shared/index.js'
|
import { areValidationErrors, checkUserCanManageVideo, doesVideoExist, isValidVideoIdParam } from '../shared/index.js'
|
||||||
|
|
||||||
const videoOverallStatsValidator = [
|
export const videoOverallOrUserAgentStatsValidator = [
|
||||||
isValidVideoIdParam('videoId'),
|
isValidVideoIdParam('videoId'),
|
||||||
|
|
||||||
query('startDate')
|
query('startDate')
|
||||||
|
@ -25,7 +25,7 @@ const videoOverallStatsValidator = [
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const videoRetentionStatsValidator = [
|
export const videoRetentionStatsValidator = [
|
||||||
isValidVideoIdParam('videoId'),
|
isValidVideoIdParam('videoId'),
|
||||||
|
|
||||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
@ -43,7 +43,7 @@ const videoRetentionStatsValidator = [
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const videoTimeserieStatsValidator = [
|
export const videoTimeseriesStatsValidator = [
|
||||||
isValidVideoIdParam('videoId'),
|
isValidVideoIdParam('videoId'),
|
||||||
|
|
||||||
param('metric')
|
param('metric')
|
||||||
|
@ -84,13 +84,7 @@ const videoTimeserieStatsValidator = [
|
||||||
]
|
]
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
// Private
|
||||||
export {
|
|
||||||
videoOverallStatsValidator,
|
|
||||||
videoTimeserieStatsValidator,
|
|
||||||
videoRetentionStatsValidator
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function commonStatsCheck (req: express.Request, res: express.Response) {
|
async function commonStatsCheck (req: express.Request, res: express.Response) {
|
||||||
|
|
|
@ -1,5 +1,10 @@
|
||||||
import { HttpStatusCode } from '@peertube/peertube-models'
|
import { HttpStatusCode } from '@peertube/peertube-models'
|
||||||
import { isVideoTimeValid } from '@server/helpers/custom-validators/video-view.js'
|
import {
|
||||||
|
isVideoTimeValid,
|
||||||
|
isVideoViewEvent,
|
||||||
|
isVideoViewUAInfo,
|
||||||
|
toVideoViewUADeviceOrNull
|
||||||
|
} from '@server/helpers/custom-validators/video-view.js'
|
||||||
import { getCachedVideoDuration } from '@server/lib/video.js'
|
import { getCachedVideoDuration } from '@server/lib/video.js'
|
||||||
import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer.js'
|
import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer.js'
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
|
@ -42,6 +47,20 @@ export const videoViewValidator = [
|
||||||
.optional()
|
.optional()
|
||||||
.isAlphanumeric(undefined, { ignore: '-' }),
|
.isAlphanumeric(undefined, { ignore: '-' }),
|
||||||
|
|
||||||
|
body('viewEvent')
|
||||||
|
.optional()
|
||||||
|
.custom(isVideoViewEvent),
|
||||||
|
|
||||||
|
body('client')
|
||||||
|
.optional()
|
||||||
|
.custom(isVideoViewUAInfo),
|
||||||
|
body('device')
|
||||||
|
.optional()
|
||||||
|
.customSanitizer(toVideoViewUADeviceOrNull),
|
||||||
|
body('operatingSystem')
|
||||||
|
.optional()
|
||||||
|
.custom(isVideoViewUAInfo),
|
||||||
|
|
||||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
if (areValidationErrors(req, res, { tags })) return
|
if (areValidationErrors(req, res, { tags })) return
|
||||||
if (!await doesVideoExist(req.params.videoId, res, 'unsafe-only-immutable-attributes')) return
|
if (!await doesVideoExist(req.params.videoId, res, 'unsafe-only-immutable-attributes')) return
|
||||||
|
|
|
@ -1,24 +1,23 @@
|
||||||
import { QueryTypes } from 'sequelize'
|
|
||||||
import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, HasMany, IsUUID, 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 {
|
import {
|
||||||
VideoStatsOverall,
|
VideoStatsOverall,
|
||||||
VideoStatsRetention,
|
VideoStatsRetention,
|
||||||
VideoStatsTimeserie,
|
VideoStatsTimeserie,
|
||||||
VideoStatsTimeserieMetric,
|
VideoStatsTimeserieMetric,
|
||||||
|
VideoStatsUserAgent,
|
||||||
WatchActionObject
|
WatchActionObject
|
||||||
} from '@peertube/peertube-models'
|
} from '@peertube/peertube-models'
|
||||||
|
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 { QueryOptionsWithType, QueryTypes } from 'sequelize'
|
||||||
|
import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, HasMany, IsUUID, Table } from 'sequelize-typescript'
|
||||||
|
import { SequelizeModel } from '../shared/index.js'
|
||||||
import { VideoModel } from '../video/video.js'
|
import { VideoModel } from '../video/video.js'
|
||||||
import { LocalVideoViewerWatchSectionModel } from './local-video-viewer-watch-section.js'
|
import { LocalVideoViewerWatchSectionModel } from './local-video-viewer-watch-section.js'
|
||||||
import { SequelizeModel } from '../shared/index.js'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
|
||||||
* Aggregate viewers of local videos only to display statistics to video owners
|
* 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
|
* A viewer is a user that watched one or multiple sections of a specific video inside a time window
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@Table({
|
@Table({
|
||||||
|
@ -50,6 +49,18 @@ export class LocalVideoViewerModel extends SequelizeModel<LocalVideoViewerModel>
|
||||||
@Column
|
@Column
|
||||||
watchTime: number
|
watchTime: number
|
||||||
|
|
||||||
|
@AllowNull(true)
|
||||||
|
@Column
|
||||||
|
client: string
|
||||||
|
|
||||||
|
@AllowNull(true)
|
||||||
|
@Column
|
||||||
|
device: string
|
||||||
|
|
||||||
|
@AllowNull(true)
|
||||||
|
@Column
|
||||||
|
operatingSystem: string
|
||||||
|
|
||||||
@AllowNull(true)
|
@AllowNull(true)
|
||||||
@Column
|
@Column
|
||||||
country: string
|
country: string
|
||||||
|
@ -203,27 +214,12 @@ export class LocalVideoViewerModel extends SequelizeModel<LocalVideoViewerModel>
|
||||||
return LocalVideoViewerModel.sequelize.query<any>(watchPeakQuery, queryOptions)
|
return LocalVideoViewerModel.sequelize.query<any>(watchPeakQuery, queryOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildGeoPromise = (type: 'country' | 'subdivisionName') => {
|
|
||||||
let dateWhere = ''
|
|
||||||
|
|
||||||
if (startDate) dateWhere += ' AND "localVideoViewer"."endDate" >= :startDate'
|
|
||||||
if (endDate) dateWhere += ' AND "localVideoViewer"."startDate" <= :endDate'
|
|
||||||
|
|
||||||
const query = `SELECT "${type}", COUNT("${type}") as viewers ` +
|
|
||||||
`FROM "localVideoViewer" ` +
|
|
||||||
`WHERE "videoId" = :videoId AND "${type}" IS NOT NULL ${dateWhere} ` +
|
|
||||||
`GROUP BY "${type}" ` +
|
|
||||||
`ORDER BY "viewers" DESC`
|
|
||||||
|
|
||||||
return LocalVideoViewerModel.sequelize.query<any>(query, queryOptions)
|
|
||||||
}
|
|
||||||
|
|
||||||
const [ rowsTotalViewers, rowsWatchTime, rowsWatchPeak, rowsCountries, rowsSubdivisions ] = await Promise.all([
|
const [ rowsTotalViewers, rowsWatchTime, rowsWatchPeak, rowsCountries, rowsSubdivisions ] = await Promise.all([
|
||||||
buildTotalViewersPromise(),
|
buildTotalViewersPromise(),
|
||||||
buildWatchTimePromise(),
|
buildWatchTimePromise(),
|
||||||
buildWatchPeakPromise(),
|
buildWatchPeakPromise(),
|
||||||
buildGeoPromise('country'),
|
LocalVideoViewerModel.buildGroupBy({ groupByColumn: 'country', startDate, endDate, videoId: video.id }),
|
||||||
buildGeoPromise('subdivisionName')
|
LocalVideoViewerModel.buildGroupBy({ groupByColumn: 'subdivisionName', startDate, endDate, videoId: video.id })
|
||||||
])
|
])
|
||||||
|
|
||||||
const viewersPeak = rowsWatchPeak.length !== 0
|
const viewersPeak = rowsWatchPeak.length !== 0
|
||||||
|
@ -259,20 +255,49 @@ export class LocalVideoViewerModel extends SequelizeModel<LocalVideoViewerModel>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async getUserAgentStats (options: {
|
||||||
|
video: MVideo
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
}): Promise<VideoStatsUserAgent> {
|
||||||
|
const { video, startDate, endDate } = options
|
||||||
|
|
||||||
|
const [ clients, devices, operatingSystems ] = await Promise.all([
|
||||||
|
LocalVideoViewerModel.buildGroupBy({ groupByColumn: 'client', startDate, endDate, videoId: video.id }),
|
||||||
|
LocalVideoViewerModel.buildGroupBy({ groupByColumn: 'device', startDate, endDate, videoId: video.id }),
|
||||||
|
LocalVideoViewerModel.buildGroupBy({ groupByColumn: 'operatingSystem', startDate, endDate, videoId: video.id })
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
clients: clients.map(r => ({
|
||||||
|
name: r.client,
|
||||||
|
viewers: r.viewers
|
||||||
|
})),
|
||||||
|
devices: devices.map(r => ({
|
||||||
|
name: r.device,
|
||||||
|
viewers: r.viewers
|
||||||
|
})),
|
||||||
|
operatingSystems: operatingSystems.map(r => ({
|
||||||
|
name: r.operatingSystem,
|
||||||
|
viewers: r.viewers
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static async getRetentionStats (video: MVideo): Promise<VideoStatsRetention> {
|
static async getRetentionStats (video: MVideo): Promise<VideoStatsRetention> {
|
||||||
const step = Math.max(Math.round(video.duration / 100), 1)
|
const step = Math.max(Math.round(video.duration / 100), 1)
|
||||||
|
|
||||||
const query = `WITH "total" AS (SELECT COUNT(*) AS viewers FROM "localVideoViewer" WHERE "videoId" = :videoId) ` +
|
const query = `WITH "total" AS (SELECT COUNT(*) AS viewers FROM "localVideoViewer" WHERE "videoId" = :videoId) ` +
|
||||||
`SELECT serie AS "second", ` +
|
`SELECT serie AS "second", ` +
|
||||||
`(COUNT("localVideoViewer".id)::float / (SELECT GREATEST("total"."viewers", 1) FROM "total")) AS "retention" ` +
|
`(COUNT("localVideoViewer".id)::float / (SELECT GREATEST("total"."viewers", 1) FROM "total")) AS "retention" ` +
|
||||||
`FROM generate_series(0, ${video.duration}, ${step}) serie ` +
|
`FROM generate_series(0, ${video.duration}, ${step}) serie ` +
|
||||||
`LEFT JOIN "localVideoViewer" ON "localVideoViewer"."videoId" = :videoId ` +
|
`LEFT JOIN "localVideoViewer" ON "localVideoViewer"."videoId" = :videoId ` +
|
||||||
`AND EXISTS (` +
|
`AND EXISTS (` +
|
||||||
`SELECT 1 FROM "localVideoViewerWatchSection" ` +
|
`SELECT 1 FROM "localVideoViewerWatchSection" ` +
|
||||||
`WHERE "localVideoViewer"."id" = "localVideoViewerWatchSection"."localVideoViewerId" ` +
|
`WHERE "localVideoViewer"."id" = "localVideoViewerWatchSection"."localVideoViewerId" ` +
|
||||||
`AND serie >= "localVideoViewerWatchSection"."watchStart" ` +
|
`AND serie >= "localVideoViewerWatchSection"."watchStart" ` +
|
||||||
`AND serie <= "localVideoViewerWatchSection"."watchEnd"` +
|
`AND serie <= "localVideoViewerWatchSection"."watchEnd"` +
|
||||||
`)` +
|
`)` +
|
||||||
`GROUP BY serie ` +
|
`GROUP BY serie ` +
|
||||||
`ORDER BY serie ASC`
|
`ORDER BY serie ASC`
|
||||||
|
|
||||||
|
@ -301,12 +326,12 @@ export class LocalVideoViewerModel extends SequelizeModel<LocalVideoViewerModel>
|
||||||
|
|
||||||
const { groupInterval, startDate, endDate } = buildGroupByAndBoundaries(options.startDate, options.endDate)
|
const { groupInterval, startDate, endDate } = buildGroupByAndBoundaries(options.startDate, options.endDate)
|
||||||
|
|
||||||
const selectMetrics: { [ id in VideoStatsTimeserieMetric ]: string } = {
|
const selectMetrics: { [id in VideoStatsTimeserieMetric]: string } = {
|
||||||
viewers: 'COUNT("localVideoViewer"."id")',
|
viewers: 'COUNT("localVideoViewer"."id")',
|
||||||
aggregateWatchTime: 'SUM("localVideoViewer"."watchTime")'
|
aggregateWatchTime: 'SUM("localVideoViewer"."watchTime")'
|
||||||
}
|
}
|
||||||
|
|
||||||
const intervalWhere: { [ id in VideoStatsTimeserieMetric ]: string } = {
|
const intervalWhere: { [id in VideoStatsTimeserieMetric]: string } = {
|
||||||
// Viewer is still in the interval. Overlap algorithm
|
// Viewer is still in the interval. Overlap algorithm
|
||||||
viewers: '"localVideoViewer"."startDate" <= "intervals"."endDate" ' +
|
viewers: '"localVideoViewer"."startDate" <= "intervals"."endDate" ' +
|
||||||
'AND "localVideoViewer"."endDate" >= "intervals"."startDate"',
|
'AND "localVideoViewer"."endDate" >= "intervals"."startDate"',
|
||||||
|
@ -353,6 +378,33 @@ export class LocalVideoViewerModel extends SequelizeModel<LocalVideoViewerModel>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async buildGroupBy (options: {
|
||||||
|
groupByColumn: 'country' | 'subdivisionName' | 'client' | 'device' | 'operatingSystem'
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
videoId: number
|
||||||
|
}) {
|
||||||
|
const { groupByColumn, startDate, endDate, videoId } = options
|
||||||
|
|
||||||
|
const queryOptions: QueryOptionsWithType<QueryTypes.SELECT> = {
|
||||||
|
type: QueryTypes.SELECT as QueryTypes.SELECT,
|
||||||
|
replacements: { videoId, startDate, endDate }
|
||||||
|
}
|
||||||
|
|
||||||
|
let dateWhere = ''
|
||||||
|
|
||||||
|
if (startDate) dateWhere += ' AND "localVideoViewer"."endDate" >= :startDate'
|
||||||
|
if (endDate) dateWhere += ' AND "localVideoViewer"."startDate" <= :endDate'
|
||||||
|
|
||||||
|
const query = `SELECT "${groupByColumn}", COUNT("${groupByColumn}") as viewers ` +
|
||||||
|
`FROM "localVideoViewer" ` +
|
||||||
|
`WHERE "videoId" = :videoId AND "${groupByColumn}" IS NOT NULL ${dateWhere} ` +
|
||||||
|
`GROUP BY "${groupByColumn}" ` +
|
||||||
|
`ORDER BY "viewers" DESC`
|
||||||
|
|
||||||
|
return LocalVideoViewerModel.sequelize.query<any>(query, queryOptions)
|
||||||
|
}
|
||||||
|
|
||||||
toActivityPubObject (this: MLocalVideoViewerWithWatchSections): WatchActionObject {
|
toActivityPubObject (this: MLocalVideoViewerWithWatchSections): WatchActionObject {
|
||||||
const location = this.country
|
const location = this.country
|
||||||
? {
|
? {
|
||||||
|
|
|
@ -3164,6 +3164,35 @@ paths:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/VideoStatsOverall'
|
$ref: '#/components/schemas/VideoStatsOverall'
|
||||||
|
|
||||||
|
'/api/v1/videos/{id}/stats/user-agent':
|
||||||
|
get:
|
||||||
|
summary: Get user agent stats of a video
|
||||||
|
tags:
|
||||||
|
- Video Stats
|
||||||
|
security:
|
||||||
|
- OAuth2: []
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/idOrUUID'
|
||||||
|
- name: startDate
|
||||||
|
in: query
|
||||||
|
description: Filter stats by start date
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
- name: endDate
|
||||||
|
in: query
|
||||||
|
description: Filter stats by end date
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: successful operation
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/VideoStatsUserAgent'
|
||||||
|
|
||||||
'/api/v1/videos/{id}/stats/retention':
|
'/api/v1/videos/{id}/stats/retention':
|
||||||
get:
|
get:
|
||||||
summary: Get retention stats of a video
|
summary: Get retention stats of a video
|
||||||
|
@ -9090,7 +9119,18 @@ components:
|
||||||
Optional param to represent the current viewer session.
|
Optional param to represent the current viewer session.
|
||||||
Used by the backend to properly count one view per session per video.
|
Used by the backend to properly count one view per session per video.
|
||||||
PeerTube admin can configure the server to not trust this `sessionId` parameter but use the request IP address instead to identify a viewer.
|
PeerTube admin can configure the server to not trust this `sessionId` parameter but use the request IP address instead to identify a viewer.
|
||||||
|
client:
|
||||||
|
type: string
|
||||||
|
description: >
|
||||||
|
Client software used to watch the video. For example "Firefox", "PeerTube Approval Android", etc.
|
||||||
|
device:
|
||||||
|
$ref: '#/components/schemas/VideoStatsUserAgentDevice'
|
||||||
|
description: >
|
||||||
|
Device used to watch the video. For example "desktop", "mobile", "smarttv", etc.
|
||||||
|
operatingSystem:
|
||||||
|
type: string
|
||||||
|
description: >
|
||||||
|
Operating system used to watch the video. For example "Windows", "Ubuntu", etc.
|
||||||
|
|
||||||
VideoStatsOverall:
|
VideoStatsOverall:
|
||||||
properties:
|
properties:
|
||||||
|
@ -9113,6 +9153,47 @@ components:
|
||||||
viewers:
|
viewers:
|
||||||
type: number
|
type: number
|
||||||
|
|
||||||
|
VideoStatsUserAgentDevice:
|
||||||
|
enum:
|
||||||
|
- 'console'
|
||||||
|
- 'embedded'
|
||||||
|
- 'mobile'
|
||||||
|
- 'smarttv'
|
||||||
|
- 'tablet'
|
||||||
|
- 'wearable'
|
||||||
|
- 'xr'
|
||||||
|
- 'desktop'
|
||||||
|
|
||||||
|
VideoStatsUserAgent:
|
||||||
|
properties:
|
||||||
|
clients:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
viewers:
|
||||||
|
type: number
|
||||||
|
devices:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
$ref: '#/components/schemas/VideoStatsUserAgentDevice'
|
||||||
|
viewers:
|
||||||
|
type: number
|
||||||
|
operatingSystem:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
viewers:
|
||||||
|
type: number
|
||||||
|
|
||||||
VideoStatsRetention:
|
VideoStatsRetention:
|
||||||
properties:
|
properties:
|
||||||
data:
|
data:
|
||||||
|
|
24
yarn.lock
24
yarn.lock
|
@ -4851,6 +4851,11 @@ destroy@1.2.0:
|
||||||
resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015"
|
resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015"
|
||||||
integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==
|
integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==
|
||||||
|
|
||||||
|
detect-europe-js@^0.1.2:
|
||||||
|
version "0.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/detect-europe-js/-/detect-europe-js-0.1.2.tgz#aa76642e05dae786efc2e01a23d4792cd24c7b88"
|
||||||
|
integrity sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==
|
||||||
|
|
||||||
detect-file@^1.0.0:
|
detect-file@^1.0.0:
|
||||||
version "1.0.0"
|
version "1.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-1.0.0.tgz#f0d66d03672a825cb1b73bdb3fe62310c8e552b7"
|
resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-1.0.0.tgz#f0d66d03672a825cb1b73bdb3fe62310c8e552b7"
|
||||||
|
@ -7049,6 +7054,11 @@ is-shared-array-buffer@^1.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bound "^1.0.3"
|
call-bound "^1.0.3"
|
||||||
|
|
||||||
|
is-standalone-pwa@^0.1.1:
|
||||||
|
version "0.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/is-standalone-pwa/-/is-standalone-pwa-0.1.1.tgz#7a1b0459471a95378aa0764d5dc0a9cec95f2871"
|
||||||
|
integrity sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==
|
||||||
|
|
||||||
is-stream@^1.1.0:
|
is-stream@^1.1.0:
|
||||||
version "1.1.0"
|
version "1.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
|
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
|
||||||
|
@ -10894,6 +10904,20 @@ typescript@~5.5.2:
|
||||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba"
|
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba"
|
||||||
integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==
|
integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==
|
||||||
|
|
||||||
|
ua-is-frozen@^0.1.2:
|
||||||
|
version "0.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/ua-is-frozen/-/ua-is-frozen-0.1.2.tgz#bfbc5f06336e379590e36beca444188c7dc3a7f3"
|
||||||
|
integrity sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==
|
||||||
|
|
||||||
|
ua-parser-js@^2.0.1:
|
||||||
|
version "2.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-2.0.1.tgz#82370485ab22639f529ceb8615cf224b176d1692"
|
||||||
|
integrity sha512-PgWLeyhIgff0Jomd3U2cYCdfp5iHbaCMlylG9NoV19tAlvXWUzM3bG2DIasLTI1PrbLtVutGr1CaezttVV2PeA==
|
||||||
|
dependencies:
|
||||||
|
detect-europe-js "^0.1.2"
|
||||||
|
is-standalone-pwa "^0.1.1"
|
||||||
|
ua-is-frozen "^0.1.2"
|
||||||
|
|
||||||
uc.micro@^2.0.0, uc.micro@^2.1.0:
|
uc.micro@^2.0.0, uc.micro@^2.1.0:
|
||||||
version "2.1.0"
|
version "2.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee"
|
resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue