1
0
Fork 0
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:
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", "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",

View file

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

View file

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

View file

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

View file

@ -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())

View file

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

View file

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

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

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

View file

@ -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>({

View file

@ -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) {

View file

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

View file

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

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

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 { 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

View file

@ -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) {

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

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

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 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: {

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

View file

@ -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) {

View file

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

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 { 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
? { ? {

View file

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

View file

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