1
0
Fork 0
mirror of https://github.com/Chocobozzz/PeerTube.git synced 2025-10-05 02:39:33 +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:
kontrollanten 2025-04-07 10:29:59 +02:00 committed by GitHub
parent 96380859ef
commit a7be820abc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 765 additions and 147 deletions

View file

@ -119,6 +119,7 @@
"tslib": "^2.4.0",
"type-fest": "^4.37.0",
"typescript": "~5.7.3",
"ua-parser-js": "^2.0.3",
"video.js": "^7.19.2",
"vite": "^6.0.11",
"vite-plugin-checker": "^0.8.0",

View file

@ -86,10 +86,15 @@ my-embed {
.nav-tabs {
@include peertube-nav-tabs($border-width: 2px);
a.nav-link {
padding: 0 10px !important;
}
}
.chart-container {
margin-bottom: 10px;
min-height: 300px;
}
.zoom-container {

View file

@ -14,7 +14,8 @@ import {
VideoStatsOverall,
VideoStatsRetention,
VideoStatsTimeserie,
VideoStatsTimeserieMetric
VideoStatsTimeserieMetric,
VideoStatsUserAgent
} from '@peertube/peertube-models'
import { ChartConfiguration, ChartData, defaults as ChartJSDefaults, ChartOptions, PluginOptionsByType, Scale, TooltipItem } from 'chart.js'
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 { 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 ChartIngestData = VideoStatsTimeserie | VideoStatsRetention | GeoData
type ChartIngestData = VideoStatsTimeserie | VideoStatsRetention | GeoData | VideoStatsUserAgent
type ChartBuilderResult = {
type: 'line' | 'bar'
@ -46,6 +49,8 @@ type ChartBuilderResult = {
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.borderColor = getComputedStyle(document.body).getPropertyValue('--bg-secondary-500')
ChartJSDefaults.color = getComputedStyle(document.body).getPropertyValue('--fg')
@ -140,6 +145,21 @@ export class VideoStatsComponent implements OnInit {
id: 'regions',
label: $localize`Regions`,
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 () {
const videoId = this.videoEdit.getVideoAttributes().uuid
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({
videoId: this.videoEdit.getVideoAttributes().uuid,
videoId,
startDate: this.statsStartDate,
endDate: this.statsEndDate,
metric: 'aggregateWatchTime'
}),
viewers: this.statsService.getTimeserieStats({
videoId: this.videoEdit.getVideoAttributes().uuid,
videoId,
startDate: this.statsStartDate,
endDate: this.statsEndDate,
metric: 'viewers'
@ -395,6 +421,9 @@ export class VideoStatsComponent implements OnInit {
const dataBuilders: {
[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),
aggregateWatchTime: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData),
viewers: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData),
@ -418,6 +447,7 @@ export class VideoStatsComponent implements OnInit {
scales: {
x: {
ticks: {
stepSize: isBarGraph(graphId) ? 1 : undefined,
callback: function (value) {
return self.formatXTick({
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 {
const labels: string[] = []
const data: number[] = []
@ -630,7 +697,7 @@ export class VideoStatsComponent implements OnInit {
if (graphId === 'retention') return 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)
}

View file

@ -3,7 +3,13 @@ import { environment } from 'src/environments/environment'
import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable, inject } from '@angular/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'
@Injectable({
@ -50,4 +56,19 @@ export class VideoStatsService {
return this.authHttp.get<VideoStatsRetention>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/retention')
.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)))
}
}

View file

@ -1,8 +1,9 @@
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 { isIOS, isMobile, isSafari } from '@root-helpers/web-browser'
import debug from 'debug'
import { UAParser } from 'ua-parser-js'
import videojs from 'video.js'
import {
getPlayerSessionId,
@ -49,10 +50,17 @@ class PeerTubePlugin extends Plugin {
declare private stopTimeHandler: (...args: any[]) => void
declare private resizeObserver: ResizeObserver
declare private userAgentInfo: {
client: string
device: VideoStatsUserAgentDevice
os: string
}
constructor (player: videojs.Player, private readonly options: PeerTubePluginOptions) {
super(player)
this.setUserAgentInfo()
this.menuOpened = false
this.mouseInControlBar = 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 () {
if (isMobile()) this.player.addClass('vjs-is-mobile')
@ -413,7 +431,14 @@ class PeerTubePlugin extends Plugin {
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' })
if (this.authorizationHeader()) headers.set('Authorization', this.authorizationHeader())

View file

@ -1954,6 +1954,14 @@
resolved "https://registry.yarnpkg.com/@types/ms/-/ms-2.1.0.tgz#052aa67a48eccc4309d7f0191b7e41434b90bb78"
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":
version "22.13.0"
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"
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:
version "1.0.3"
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:
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:
version "2.0.1"
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"
integrity sha512-M85rnSC7WQ7wnfQTARPT4LrK7nwCHLdDFOCcItZMhTQjyCebJH8GciKqYJNgaOFZs9nFmTmd/VMyi3OW5jA47w==
node-fetch@^2.6.12:
node-fetch@^2.6.12, node-fetch@^2.7.0:
version "2.7.0"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
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"
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:
version "2.1.0"
resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee"

View file

@ -258,6 +258,7 @@
"tsc-watch": "^6.0.0",
"tsx": "^4.7.1",
"type-fest": "^4.37.0",
"typescript": "~5.5.2"
"typescript": "~5.5.2",
"ua-parser-js": "^2.0.1"
}
}

View file

@ -4,3 +4,5 @@ export * from './video-stats-retention.model.js'
export * from './video-stats-timeserie-query.model.js'
export * from './video-stats-timeserie-metric.type.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'

View file

@ -0,0 +1,4 @@
export interface VideoStatsUserAgentQuery {
startDate?: string
endDate?: string
}

View file

@ -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'

View file

@ -1,7 +1,13 @@
import { VideoStatsUserAgentDevice } from './stats/video-stats-user-agent.model.js'
export type VideoViewEvent = 'seek'
export interface VideoView {
currentTime: number
viewEvent?: VideoViewEvent
sessionId?: string
client?: string
device?: VideoStatsUserAgentDevice
operatingSystem?: string
}

View file

@ -4,17 +4,19 @@ import {
VideoStatsOverall,
VideoStatsRetention,
VideoStatsTimeserie,
VideoStatsTimeserieMetric
VideoStatsTimeserieMetric,
VideoStatsUserAgent
} from '@peertube/peertube-models'
import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
export class VideoStatsCommand extends AbstractCommand {
getOverallStats (options: OverrideCommandOptions & {
getOverallStats (
options: OverrideCommandOptions & {
videoId: number | string
startDate?: string
endDate?: string
}) {
}
) {
const path = '/api/v1/videos/' + options.videoId + '/stats/overall'
return this.getRequestBody<VideoStatsOverall>({
@ -28,12 +30,34 @@ export class VideoStatsCommand extends AbstractCommand {
})
}
getTimeserieStats (options: OverrideCommandOptions & {
getUserAgentStats (
options: OverrideCommandOptions & {
videoId: number | string
startDate?: string
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
return this.getRequestBody<VideoStatsTimeserie>({
@ -46,9 +70,11 @@ export class VideoStatsCommand extends AbstractCommand {
})
}
getRetentionStats (options: OverrideCommandOptions & {
getRetentionStats (
options: OverrideCommandOptions & {
videoId: number | string
}) {
}
) {
const path = '/api/v1/videos/' + options.videoId + '/stats/retention'
return this.getRequestBody<VideoStatsRetention>({

View file

@ -1,17 +1,16 @@
/* 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'
export class ViewsCommand extends AbstractCommand {
view (options: OverrideCommandOptions & {
view (
options: OverrideCommandOptions & VideoView & {
id: number | string
currentTime: number
viewEvent?: VideoViewEvent
xForwardedFor?: string
sessionId?: string
}) {
const { id, xForwardedFor, viewEvent, currentTime, sessionId } = options
}
) {
const { id, xForwardedFor } = options
const path = '/api/v1/videos/' + id + '/views'
return this.postBodyRequest({
@ -19,31 +18,30 @@ export class ViewsCommand extends AbstractCommand {
path,
xForwardedFor,
fields: {
currentTime,
viewEvent,
sessionId
},
fields: pick(options, [ 'currentTime', 'viewEvent', 'sessionId', 'client', 'device', 'operatingSystem' ]),
implicitToken: false,
defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
})
}
async simulateView (options: OverrideCommandOptions & {
async simulateView (
options: OverrideCommandOptions & Omit<VideoView, 'currentTime'> & {
id: number | string
xForwardedFor?: string
sessionId?: string
}) {
}
) {
await this.view({ ...options, currentTime: 0 })
await this.view({ ...options, currentTime: 5 })
}
async simulateViewer (options: OverrideCommandOptions & {
async simulateViewer (
options: OverrideCommandOptions & {
id: number | string
currentTimes: number[]
xForwardedFor?: string
sessionId?: string
}) {
}
) {
let viewEvent: VideoViewEvent = 'seek'
for (const currentTime of options.currentTimes) {

View file

@ -24,11 +24,10 @@ describe('Test videos views API validators', function () {
await setAccessTokensToServers(servers)
await setDefaultVideoChannel(servers)
await servers[0].config.enableLive({ allowReplay: false, transcoding: false });
({ uuid: videoId } = await servers[0].videos.quickUpload({ name: 'video' }));
({ uuid: remoteVideoId } = await servers[1].videos.quickUpload({ name: 'video' }));
({ uuid: liveVideoId } = await servers[0].live.create({
await servers[0].config.enableLive({ allowReplay: false, transcoding: false })
;({ uuid: videoId } = await servers[0].videos.quickUpload({ name: 'video' }))
;({ uuid: remoteVideoId } = await servers[1].videos.quickUpload({ name: 'video' }))
;({ uuid: liveVideoId } = await servers[0].live.create({
fields: {
name: 'live',
privacy: VideoPrivacy.PUBLIC,
@ -42,7 +41,6 @@ describe('Test videos views API validators', function () {
})
describe('When viewing a video', async function () {
it('Should fail without current time', async function () {
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 })
})
it('Should succeed with correct parameters', async function () {
await servers[0].views.view({ id: videoId, currentTime: 1 })
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
})
})
describe('When getting overall stats', function () {
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 () {
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/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 () {
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 () {
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 () {
await servers[0].videoStats.getOverallStats({
await testEndpoint({
videoId,
token: userAccessToken,
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 () {
await servers[0].videoStats.getOverallStats({
await testEndpoint({
videoId,
startDate: 'fake' as any,
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 () {
await servers[0].videoStats.getOverallStats({
await testEndpoint({
videoId,
startDate: new Date().toISOString(),
endDate: 'fake' as any,
@ -95,7 +136,7 @@ describe('Test videos views API validators', function () {
})
it('Should succeed with the correct parameters', async function () {
await servers[0].videoStats.getOverallStats({
await testEndpoint({
videoId,
startDate: new Date().toISOString(),
endDate: new Date().toISOString()
@ -104,7 +145,6 @@ describe('Test videos views API validators', function () {
})
describe('When getting timeserie stats', function () {
it('Should fail with a remote video', async function () {
await servers[0].videoStats.getTimeserieStats({
videoId: remoteVideoId,
@ -189,7 +229,6 @@ describe('Test videos views API validators', function () {
})
describe('When getting retention stats', function () {
it('Should fail with a remote video', async function () {
await servers[0].videoStats.getRetentionStats({
videoId: remoteVideoId,

View file

@ -2,4 +2,5 @@ export * from './video-views-counter.js'
export * from './video-views-overall-stats.js'
export * from './video-views-retention-stats.js'
export * from './video-views-timeserie-stats.js'
export * from './video-views-user-agent-stats.js'
export * from './videos-views-cleaner.js'

View 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 ])
})
})

View file

@ -3,6 +3,7 @@ import { wait } from '@peertube/peertube-core-utils'
import { VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models'
import {
createMultipleServers,
createSingleServer,
doubleFollow,
PeerTubeServer,
setAccessTokensToServers,
@ -33,8 +34,9 @@ async function processViewsBuffer (servers: PeerTubeServer[]) {
async function prepareViewsServers (options: {
viewExpiration?: string // default 1 second
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 = {
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 setDefaultVideoChannel(servers)
await servers[0].config.enableMinimumTranscoding()
await servers[0].config.enableLive({ allowReplay: true, transcoding: false })
if (!singleServer) {
await doubleFollow(servers[0], servers[1])
}
return servers
}

View file

@ -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 { VideoStatsOverallQuery, VideoStatsTimeserieMetric, VideoStatsTimeserieQuery } from '@peertube/peertube-models'
import express from 'express'
import {
asyncMiddleware,
authenticate,
videoOverallStatsValidator,
videoOverallOrUserAgentStatsValidator,
videoRetentionStatsValidator,
videoTimeserieStatsValidator
videoTimeseriesStatsValidator
} from '../../../middlewares/index.js'
const statsRouter = express.Router()
statsRouter.get('/:videoId/stats/overall',
statsRouter.get(
'/:videoId/stats/overall',
authenticate,
asyncMiddleware(videoOverallStatsValidator),
asyncMiddleware(videoOverallOrUserAgentStatsValidator),
asyncMiddleware(getOverallStats)
)
statsRouter.get('/:videoId/stats/timeseries/:metric',
statsRouter.get(
'/:videoId/stats/timeseries/:metric',
authenticate,
asyncMiddleware(videoTimeserieStatsValidator),
asyncMiddleware(getTimeserieStats)
asyncMiddleware(videoTimeseriesStatsValidator),
asyncMiddleware(getTimeseriesStats)
)
statsRouter.get('/:videoId/stats/retention',
statsRouter.get(
'/:videoId/stats/retention',
authenticate,
asyncMiddleware(videoRetentionStatsValidator),
asyncMiddleware(getRetentionStats)
)
statsRouter.get(
'/:videoId/stats/user-agent',
authenticate,
asyncMiddleware(videoOverallOrUserAgentStatsValidator),
asyncMiddleware(getUserAgentStats)
)
// ---------------------------------------------------------------------------
export {
@ -50,6 +65,19 @@ async function getOverallStats (req: express.Request, res: express.Response) {
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) {
const video = res.locals.videoAll
@ -58,7 +86,7 @@ async function getRetentionStats (req: express.Request, res: express.Response) {
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 metric = req.params.metric as VideoStatsTimeserieMetric

View file

@ -1,8 +1,8 @@
import express from 'express'
import { HttpStatusCode, VideoView } from '@peertube/peertube-models'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { VideoViewsManager } from '@server/lib/views/video-views-manager.js'
import { MVideoId } from '@server/types/models/index.js'
import express from 'express'
import {
asyncMiddleware,
methodsValidator,
@ -35,14 +35,17 @@ async function viewVideo (req: express.Request, res: express.Response) {
const video = res.locals.onlyImmutableVideo
const body = req.body as VideoView
const ip = req.ip
const { successView } = await VideoViewsManager.Instance.processLocalView({
video,
ip,
currentTime: body.currentTime,
viewEvent: body.viewEvent,
sessionId: body.sessionId
sessionId: body.sessionId,
client: body.client,
operatingSystem: body.operatingSystem,
device: body.device
})
if (successView) {

View file

@ -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'
function isVideoTimeValid (value: number, videoDuration?: number) {
export function isVideoTimeValid (value: number, videoDuration?: number) {
if (value < 0) return false
if (exists(videoDuration) && value > videoDuration) return false
return true
}
export {
isVideoTimeValid
export function isVideoViewEvent (value: string) {
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
}

View file

@ -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
WORDS: { min: 1, max: 500 }, // Number of total words
WORD: { min: 1, max: 100 } // Length
},
VIDEO_VIEW: {
UA_INFO: { min: 1, max: 200 } // Length
}
}

View file

@ -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
}

View file

@ -26,6 +26,10 @@ type LocalViewerStats = {
watchTime: number
client: string
device: string
operatingSystem: string
country: string
subdivisionName: string
@ -52,12 +56,16 @@ export class VideoViewerStats {
ip: string
sessionId: string
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(
'Adding local viewer to video stats %s.', video.uuid,
{ currentTime, viewEvent, sessionId, ...lTags(video.uuid) }
'Adding local viewer to video stats %s.',
video.uuid,
{ currentTime, viewEvent, sessionId, client, operatingSystem, device, ...lTags(video.uuid) }
)
const nowMs = new Date().getTime()
@ -67,7 +75,7 @@ export class VideoViewerStats {
if (stats && stats.watchSections.length >= MAX_LOCAL_VIEWER_WATCH_SECTIONS) {
logger.warn(
'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
}
@ -83,6 +91,10 @@ export class VideoViewerStats {
watchTime: 0,
client,
device,
operatingSystem,
country,
subdivisionName,
@ -181,6 +193,9 @@ export class VideoViewerStats {
startDate: new Date(stats.firstUpdated),
endDate: new Date(stats.lastUpdated),
watchTime: stats.watchTime,
client: stats.client,
device: stats.device,
operatingSystem: stats.operatingSystem,
country: stats.country,
subdivisionName: stats.subdivisionName,
videoId: video.id
@ -205,9 +220,7 @@ export class VideoViewerStats {
}
/**
*
* Redis calls can be expensive so try to cache things in front of it
*
*/
private getLocalVideoViewer (options: {

View file

@ -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 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
*
*/
const lTags = loggerTagsFactory('views')
export class VideoViewsManager {
private static instance: VideoViewsManager
private videoViewerStats: VideoViewerStats
@ -48,8 +46,11 @@ export class VideoViewsManager {
ip: string | null
sessionId?: string
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
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())
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 })

View file

@ -6,7 +6,7 @@ import { STATS_TIMESERIE } from '@server/initializers/constants.js'
import { HttpStatusCode, UserRight, VideoStatsTimeserieQuery } from '@peertube/peertube-models'
import { areValidationErrors, checkUserCanManageVideo, doesVideoExist, isValidVideoIdParam } from '../shared/index.js'
const videoOverallStatsValidator = [
export const videoOverallOrUserAgentStatsValidator = [
isValidVideoIdParam('videoId'),
query('startDate')
@ -25,7 +25,7 @@ const videoOverallStatsValidator = [
}
]
const videoRetentionStatsValidator = [
export const videoRetentionStatsValidator = [
isValidVideoIdParam('videoId'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
@ -43,7 +43,7 @@ const videoRetentionStatsValidator = [
}
]
const videoTimeserieStatsValidator = [
export const videoTimeseriesStatsValidator = [
isValidVideoIdParam('videoId'),
param('metric')
@ -84,13 +84,7 @@ const videoTimeserieStatsValidator = [
]
// ---------------------------------------------------------------------------
export {
videoOverallStatsValidator,
videoTimeserieStatsValidator,
videoRetentionStatsValidator
}
// Private
// ---------------------------------------------------------------------------
async function commonStatsCheck (req: express.Request, res: express.Response) {

View file

@ -1,5 +1,10 @@
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 { LocalVideoViewerModel } from '@server/models/view/local-video-viewer.js'
import express from 'express'
@ -42,6 +47,20 @@ export const videoViewValidator = [
.optional()
.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) => {
if (areValidationErrors(req, res, { tags })) return
if (!await doesVideoExist(req.params.videoId, res, 'unsafe-only-immutable-attributes')) return

View file

@ -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 {
VideoStatsOverall,
VideoStatsRetention,
VideoStatsTimeserie,
VideoStatsTimeserieMetric,
VideoStatsUserAgent,
WatchActionObject
} 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 { 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
* A viewer is a user that watched one or multiple sections of a specific video inside a time window
*
*/
@Table({
@ -50,6 +49,18 @@ export class LocalVideoViewerModel extends SequelizeModel<LocalVideoViewerModel>
@Column
watchTime: number
@AllowNull(true)
@Column
client: string
@AllowNull(true)
@Column
device: string
@AllowNull(true)
@Column
operatingSystem: string
@AllowNull(true)
@Column
country: string
@ -203,27 +214,12 @@ export class LocalVideoViewerModel extends SequelizeModel<LocalVideoViewerModel>
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([
buildTotalViewersPromise(),
buildWatchTimePromise(),
buildWatchPeakPromise(),
buildGeoPromise('country'),
buildGeoPromise('subdivisionName')
LocalVideoViewerModel.buildGroupBy({ groupByColumn: 'country', startDate, endDate, videoId: video.id }),
LocalVideoViewerModel.buildGroupBy({ groupByColumn: 'subdivisionName', startDate, endDate, videoId: video.id })
])
const viewersPeak = rowsWatchPeak.length !== 0
@ -259,6 +255,35 @@ 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> {
const step = Math.max(Math.round(video.duration / 100), 1)
@ -301,12 +326,12 @@ export class LocalVideoViewerModel extends SequelizeModel<LocalVideoViewerModel>
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")',
aggregateWatchTime: 'SUM("localVideoViewer"."watchTime")'
}
const intervalWhere: { [ id in VideoStatsTimeserieMetric ]: string } = {
const intervalWhere: { [id in VideoStatsTimeserieMetric]: string } = {
// Viewer is still in the interval. Overlap algorithm
viewers: '"localVideoViewer"."startDate" <= "intervals"."endDate" ' +
'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 {
const location = this.country
? {

View file

@ -3164,6 +3164,35 @@ paths:
schema:
$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':
get:
summary: Get retention stats of a video
@ -9090,7 +9119,18 @@ components:
Optional param to represent the current viewer session.
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.
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:
properties:
@ -9113,6 +9153,47 @@ components:
viewers:
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:
properties:
data:

View file

@ -4851,6 +4851,11 @@ destroy@1.2.0:
resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015"
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:
version "1.0.0"
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:
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:
version "1.1.0"
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"
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:
version "2.1.0"
resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee"