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:
parent
96380859ef
commit
a7be820abc
29 changed files with 765 additions and 147 deletions
|
@ -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",
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
export interface VideoStatsUserAgentQuery {
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
export type VideoStatsUserAgent = {
|
||||
[key in 'clients' | 'devices' | 'operatingSystems']: {
|
||||
name: string
|
||||
viewers: number
|
||||
}[]
|
||||
}
|
||||
|
||||
export type VideoStatsUserAgentDevice = 'console' | 'embedded' | 'mobile' | 'smarttv' | 'tablet' | 'wearable' | 'xr' | 'desktop'
|
|
@ -1,7 +1,13 @@
|
|||
import { VideoStatsUserAgentDevice } from './stats/video-stats-user-agent.model.js'
|
||||
|
||||
export type VideoViewEvent = 'seek'
|
||||
|
||||
export interface VideoView {
|
||||
currentTime: number
|
||||
viewEvent?: VideoViewEvent
|
||||
sessionId?: string
|
||||
|
||||
client?: string
|
||||
device?: VideoStatsUserAgentDevice
|
||||
operatingSystem?: string
|
||||
}
|
||||
|
|
|
@ -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>({
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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'
|
||||
|
|
101
packages/tests/src/api/views/video-views-user-agent-stats.ts
Normal file
101
packages/tests/src/api/views/video-views-user-agent-stats.ts
Normal file
|
@ -0,0 +1,101 @@
|
|||
/* eslint-disable @typescript-eslint/no-unused-expressions */
|
||||
import { buildUUID } from '@peertube/peertube-node-utils'
|
||||
import { PeerTubeServer, cleanupTests, waitJobs } from '@peertube/peertube-server-commands'
|
||||
import { prepareViewsServers, processViewersStats } from '@tests/shared/views.js'
|
||||
import { expect } from 'chai'
|
||||
|
||||
describe('Test views user agent stats', function () {
|
||||
let server: PeerTubeServer
|
||||
let beforeChromeView: Date
|
||||
let videoUUID: string
|
||||
|
||||
before(async function () {
|
||||
this.timeout(120000)
|
||||
|
||||
const servers = await prepareViewsServers({ singleServer: true })
|
||||
server = servers[0]
|
||||
|
||||
const { uuid } = await server.videos.quickUpload({ name: 'video' })
|
||||
videoUUID = uuid
|
||||
await waitJobs(server)
|
||||
})
|
||||
|
||||
it('Should report client, device and OS', async function () {
|
||||
this.timeout(240000)
|
||||
|
||||
await server.views.simulateView({
|
||||
id: videoUUID,
|
||||
sessionId: buildUUID(),
|
||||
client: 'Edge',
|
||||
device: 'desktop',
|
||||
operatingSystem: 'Android'
|
||||
})
|
||||
await server.views.simulateView({
|
||||
id: videoUUID,
|
||||
sessionId: buildUUID(),
|
||||
client: 'Edge',
|
||||
device: 'mobile',
|
||||
operatingSystem: 'Windows'
|
||||
})
|
||||
|
||||
await processViewersStats([ server ])
|
||||
beforeChromeView = new Date()
|
||||
|
||||
await server.views.simulateView({
|
||||
id: videoUUID,
|
||||
sessionId: buildUUID(),
|
||||
client: 'Chrome',
|
||||
device: 'desktop',
|
||||
operatingSystem: 'Ubuntu'
|
||||
})
|
||||
|
||||
await processViewersStats([ server ])
|
||||
|
||||
const stats = await server.videoStats.getUserAgentStats({ videoId: videoUUID })
|
||||
|
||||
expect(stats.clients).to.include.deep.members([ { name: 'Chrome', viewers: 1 } ])
|
||||
expect(stats.clients).to.include.deep.members([ { name: 'Edge', viewers: 2 } ])
|
||||
|
||||
expect(stats.devices).to.include.deep.members([ { name: 'desktop', viewers: 2 } ])
|
||||
expect(stats.devices).to.include.deep.members([ { name: 'mobile', viewers: 1 } ])
|
||||
|
||||
expect(stats.operatingSystems).to.include.deep.members([ { name: 'Android', viewers: 1 } ])
|
||||
expect(stats.operatingSystems).to.include.deep.members([ { name: 'Ubuntu', viewers: 1 } ])
|
||||
expect(stats.operatingSystems).to.include.deep.members([ { name: 'Windows', viewers: 1 } ])
|
||||
})
|
||||
|
||||
it('Should filter by date', async function () {
|
||||
{
|
||||
const stats = await server.videoStats.getUserAgentStats({ videoId: videoUUID, startDate: beforeChromeView.toISOString() })
|
||||
|
||||
expect(stats.clients).to.include.deep.members([ { name: 'Chrome', viewers: 1 } ])
|
||||
expect(stats.clients.find(e => e.name === 'Edge')).to.not.exist
|
||||
}
|
||||
|
||||
{
|
||||
const stats = await server.videoStats.getUserAgentStats({ videoId: videoUUID, endDate: beforeChromeView.toISOString() })
|
||||
|
||||
expect(stats.clients.find(e => e.name === 'Chrome')).to.not.exist
|
||||
expect(stats.clients).to.include.deep.members([ { name: 'Edge', viewers: 2 } ])
|
||||
}
|
||||
})
|
||||
|
||||
it('Should use a null value if device is not known by PeerTube', async function () {
|
||||
await server.views.simulateView({
|
||||
id: videoUUID,
|
||||
sessionId: buildUUID(),
|
||||
client: 'Chrome',
|
||||
device: 'unknown' as any,
|
||||
operatingSystem: 'Ubuntu'
|
||||
})
|
||||
|
||||
await processViewersStats([ server ])
|
||||
|
||||
const stats = await server.videoStats.getUserAgentStats({ videoId: videoUUID, endDate: beforeChromeView.toISOString() })
|
||||
expect(stats.devices.map(d => d.name)).to.deep.members([ 'desktop', 'mobile' ])
|
||||
})
|
||||
|
||||
after(async function () {
|
||||
await cleanupTests([ server ])
|
||||
})
|
||||
})
|
|
@ -3,6 +3,7 @@ import { wait } from '@peertube/peertube-core-utils'
|
|||
import { VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models'
|
||||
import {
|
||||
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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
import * as Sequelize from 'sequelize'
|
||||
|
||||
async function up (utils: {
|
||||
transaction: Sequelize.Transaction
|
||||
queryInterface: Sequelize.QueryInterface
|
||||
sequelize: Sequelize.Sequelize
|
||||
}): Promise<void> {
|
||||
const { transaction } = utils
|
||||
|
||||
{
|
||||
await utils.queryInterface.addColumn('localVideoViewer', 'client', {
|
||||
type: Sequelize.STRING,
|
||||
defaultValue: null,
|
||||
allowNull: true
|
||||
}, { transaction })
|
||||
}
|
||||
|
||||
{
|
||||
await utils.queryInterface.addColumn('localVideoViewer', 'device', {
|
||||
type: Sequelize.STRING,
|
||||
defaultValue: null,
|
||||
allowNull: true
|
||||
}, { transaction })
|
||||
}
|
||||
|
||||
{
|
||||
await utils.queryInterface.addColumn('localVideoViewer', 'operatingSystem', {
|
||||
type: Sequelize.STRING,
|
||||
defaultValue: null,
|
||||
allowNull: true
|
||||
}, { transaction })
|
||||
}
|
||||
}
|
||||
|
||||
function down (options) {
|
||||
throw new Error('Not implemented.')
|
||||
}
|
||||
|
||||
export {
|
||||
down,
|
||||
up
|
||||
}
|
|
@ -26,6 +26,10 @@ type LocalViewerStats = {
|
|||
|
||||
watchTime: number
|
||||
|
||||
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: {
|
||||
|
|
|
@ -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 })
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
? {
|
||||
|
|
|
@ -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:
|
||||
|
|
24
yarn.lock
24
yarn.lock
|
@ -4851,6 +4851,11 @@ destroy@1.2.0:
|
|||
resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015"
|
||||
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"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue