Skip to content

Commit

Permalink
add user agent video stats
Browse files Browse the repository at this point in the history
  • Loading branch information
kontrollanten committed Feb 6, 2025
1 parent e6725e6 commit 12553d7
Show file tree
Hide file tree
Showing 23 changed files with 474 additions and 36 deletions.
4 changes: 4 additions & 0 deletions client/src/app/+stats/video/video-stats.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ my-embed {

.nav-tabs {
@include peertube-nav-tabs($border-width: 2px);

a.nav-link {
padding: 0 10px !important;
}
}

.chart-container {
Expand Down
71 changes: 67 additions & 4 deletions client/src/app/+stats/video/video-stats.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import {
VideoStatsOverall,
VideoStatsRetention,
VideoStatsTimeserie,
VideoStatsTimeserieMetric
VideoStatsTimeserieMetric,
VideoStatsUserAgent
} from '@peertube/peertube-models'
import { VideoStatsService } from './video-stats.service'
import { ButtonComponent } from '../../shared/shared-main/buttons/button.component'
Expand All @@ -29,11 +30,13 @@ import { VideoDetails } from '@app/shared/shared-main/video/video-details.model'
import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service'
import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component'

type ActiveGraphId = VideoStatsTimeserieMetric | 'retention' | 'countries' | 'regions'
const BAR_GRAPHS = [ 'countries', 'regions', 'browser', 'device', 'operatingSystem' ] 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'

Expand All @@ -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')
Expand Down Expand Up @@ -139,6 +144,21 @@ export class VideoStatsComponent implements OnInit {
id: 'regions',
label: $localize`Regions`,
zoomEnabled: false
},
{
id: 'browser',
label: $localize`Browser`,
zoomEnabled: false
},
{
id: 'device',
label: $localize`Device`,
zoomEnabled: false
},
{
id: 'operatingSystem',
label: $localize`Operating system`,
zoomEnabled: false
}
]

Expand Down Expand Up @@ -358,6 +378,9 @@ export class VideoStatsComponent implements OnInit {
private loadChart () {
const obsBuilders: { [ id in ActiveGraphId ]: Observable<ChartIngestData> } = {
retention: this.statsService.getRetentionStats(this.video.uuid),
browser: this.statsService.getUserAgentStats(this.video.uuid),
device: this.statsService.getUserAgentStats(this.video.uuid),
operatingSystem: this.statsService.getUserAgentStats(this.video.uuid),

aggregateWatchTime: this.statsService.getTimeserieStats({
videoId: this.video.uuid,
Expand Down Expand Up @@ -392,6 +415,9 @@ export class VideoStatsComponent implements OnInit {
const dataBuilders: {
[ id in ActiveGraphId ]: (rawData: ChartIngestData) => ChartBuilderResult
} = {
browser: (rawData: VideoStatsUserAgent) => this.buildUserAgentChartOptions(rawData, 'browser'),
device: (rawData: VideoStatsUserAgent) => this.buildUserAgentChartOptions(rawData, 'device'),
operatingSystem: (rawData: VideoStatsUserAgent) => this.buildUserAgentChartOptions(rawData, 'operatingSystem'),
retention: (rawData: VideoStatsRetention) => this.buildRetentionChartOptions(rawData),
aggregateWatchTime: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData),
viewers: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData),
Expand All @@ -415,6 +441,7 @@ export class VideoStatsComponent implements OnInit {
scales: {
x: {
ticks: {
stepSize: isBarGraph(graphId) ? 1 : undefined,
callback: function (value) {
return self.formatXTick({
graphId,
Expand Down Expand Up @@ -547,6 +574,42 @@ export class VideoStatsComponent implements OnInit {
}
}

private buildUserAgentChartOptions (rawData: VideoStatsUserAgent, type: 'browser' | 'device' | 'operatingSystem'): ChartBuilderResult {
const labels: string[] = []
const data: number[] = []

for (const d of rawData[type]) {
labels.push(d.name?.toUpperCase())
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[] = []
Expand Down Expand Up @@ -627,7 +690,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)
}
Expand Down
13 changes: 12 additions & 1 deletion client/src/app/+stats/video/video-stats.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import { environment } from 'src/environments/environment'
import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable } 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({
Expand Down Expand Up @@ -52,4 +58,9 @@ export class VideoStatsService {
return this.authHttp.get<VideoStatsRetention>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/retention')
.pipe(catchError(err => this.restExtractor.handleError(err)))
}

getUserAgentStats (videoId: string) {
return this.authHttp.get<VideoStatsUserAgent>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/user-agent')
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@
"swagger-cli": "^4.0.2",
"tsc-watch": "^6.0.0",
"tsx": "^4.7.1",
"typescript": "~5.5.2"
"typescript": "~5.5.2",
"ua-parser-js": "^2.0.1"
}
}
5 changes: 4 additions & 1 deletion packages/models/src/plugins/server/server-hook.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,10 @@ export const serverFilterHookObject = {
// Peertube >= 7.1
'filter:oauth.password-grant.get-user.params': true,
'filter:api.email-verification.ask-send-verify-email.body': true,
'filter:api.users.ask-reset-password.body': true
'filter:api.users.ask-reset-password.body': true,

// Peertube >= 7.2
'filter:api.video-view.parse-user-agent.get.result': true
}

export type ServerFilterHookName = keyof typeof serverFilterHookObject
Expand Down
1 change: 1 addition & 0 deletions packages/models/src/videos/stats/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ 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.model.js'
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export type VideoStatsUserAgent = {
[key in 'browser' | 'device' | 'operatingSystem']: {
name: string
viewers: number
}[]
}
17 changes: 16 additions & 1 deletion packages/server-commands/src/videos/video-stats-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import {
VideoStatsOverall,
VideoStatsRetention,
VideoStatsTimeserie,
VideoStatsTimeserieMetric
VideoStatsTimeserieMetric,
VideoStatsUserAgent
} from '@peertube/peertube-models'
import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'

Expand All @@ -28,6 +29,20 @@ export class VideoStatsCommand extends AbstractCommand {
})
}

getUserAgentStats (options: OverrideCommandOptions & {
videoId: number | string
}) {
const path = '/api/v1/videos/' + options.videoId + '/stats/user-agent'

return this.getRequestBody<VideoStatsUserAgent>({
...options,
path,

implicitToken: true,
defaultExpectedStatus: HttpStatusCode.OK_200
})
}

getTimeserieStats (options: OverrideCommandOptions & {
videoId: number | string
metric: VideoStatsTimeserieMetric
Expand Down
10 changes: 9 additions & 1 deletion packages/server-commands/src/videos/views-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,22 @@ export class ViewsCommand extends AbstractCommand {
viewEvent?: VideoViewEvent
xForwardedFor?: string
sessionId?: string
userAgent?: string
}) {
const { id, xForwardedFor, viewEvent, currentTime, sessionId } = options
const { id, xForwardedFor, viewEvent, currentTime, sessionId, userAgent } = options
const path = '/api/v1/videos/' + id + '/views'
const headers = userAgent
? {
'User-Agent': userAgent
}
: undefined

return this.postBodyRequest({
...options,

path,
xForwardedFor,
headers,
fields: {
currentTime,
viewEvent,
Expand All @@ -33,6 +40,7 @@ export class ViewsCommand extends AbstractCommand {
id: number | string
xForwardedFor?: string
sessionId?: string
userAgent?: string
}) {
await this.view({ ...options, currentTime: 0 })
await this.view({ ...options, currentTime: 5 })
Expand Down
21 changes: 21 additions & 0 deletions packages/tests/fixtures/peertube-plugin-test/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,27 @@ async function register ({ registerHook, registerSetting, settingsManager, stora
}
})

registerHook({
target: 'filter:api.video-view.parse-user-agent.get.result',
handler: (parsedUserAgent, userAgentStr) => {
if (userAgentStr === 'user agent string') {
return {
browser: {
name: 'Custom browser'
},
device: {
type: 'Custom device'
},
os: {
name: 'Custom os'
}
}
}

return parsedUserAgent
}
})

registerHook({
target: 'filter:video.auto-blacklist.result',
handler: (blacklisted, { video }) => {
Expand Down
30 changes: 30 additions & 0 deletions packages/tests/src/api/check-params/views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,36 @@ describe('Test videos views API validators', function () {
})
})

describe('When getting user agent stats', function () {

it('Should fail with a remote video', async function () {
await servers[0].videoStats.getUserAgentStats({
videoId: remoteVideoId,
expectedStatus: HttpStatusCode.FORBIDDEN_403
})
})

it('Should fail without token', async function () {
await servers[0].videoStats.getUserAgentStats({
videoId,
token: null,
expectedStatus: HttpStatusCode.UNAUTHORIZED_401
})
})

it('Should fail with another token', async function () {
await servers[0].videoStats.getUserAgentStats({
videoId,
token: userAccessToken,
expectedStatus: HttpStatusCode.FORBIDDEN_403
})
})

it('Should succeed with the correct parameters', async function () {
await servers[0].videoStats.getUserAgentStats({ videoId })
})
})

after(async function () {
await cleanupTests(servers)
})
Expand Down
62 changes: 62 additions & 0 deletions packages/tests/src/api/views/video-views-user-agent-stats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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'

// eslint-disable-next-line max-len
const EDGE_WINDOWS_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 Edg/132.0.0.0'
// eslint-disable-next-line max-len
const EDGE_ANDROID_USER_AGENT = 'Mozilla/5.0 (Linux; Android 10; HD1913) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.6943.49 Mobile Safari/537.36 EdgA/131.0.2903.87'
const CHROME_LINUX_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36'

describe('Test views user agent stats', function () {
let server: PeerTubeServer

before(async function () {
this.timeout(120000)

const servers = await prepareViewsServers({ singleServer: true })
server = servers[0]
})

it('Should report browser, device and OS', async function () {
this.timeout(240000)

const { uuid } = await server.videos.quickUpload({ name: 'video' })
await waitJobs(server)

await server.views.simulateView({
id: uuid,
sessionId: buildUUID(),
userAgent: EDGE_ANDROID_USER_AGENT
})
await server.views.simulateView({
id: uuid,
sessionId: buildUUID(),
userAgent: EDGE_WINDOWS_USER_AGENT
})
await server.views.simulateView({
id: uuid,
sessionId: buildUUID(),
userAgent: CHROME_LINUX_USER_AGENT
})

await processViewersStats([ server ])

const stats = await server.videoStats.getUserAgentStats({ videoId: uuid })

expect(stats.browser).to.include.deep.members([ { name: 'Chrome', viewers: 1 } ])
expect(stats.browser).to.include.deep.members([ { name: 'Edge', viewers: 2 } ])

expect(stats.device).to.include.deep.members([ { name: 'unknown', viewers: 2 } ])
expect(stats.device).to.include.deep.members([ { name: 'mobile', viewers: 1 } ])

expect(stats.operatingSystem).to.include.deep.members([ { name: 'Android', viewers: 1 } ])
expect(stats.operatingSystem).to.include.deep.members([ { name: 'Linux', viewers: 1 } ])
expect(stats.operatingSystem).to.include.deep.members([ { name: 'Windows', viewers: 1 } ])
})

after(async function () {
await cleanupTests([ server ])
})
})
Loading

0 comments on commit 12553d7

Please sign in to comment.