Merge 4dd7198b5a
into 017795cf45
This commit is contained in:
commit
ec830d5f11
|
@ -86,6 +86,10 @@ my-embed {
|
|||
|
||||
.nav-tabs {
|
||||
@include peertube-nav-tabs($border-width: 2px);
|
||||
|
||||
a.nav-link {
|
||||
padding: 0 10px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
|
|
|
@ -12,7 +12,8 @@ import {
|
|||
VideoStatsOverall,
|
||||
VideoStatsRetention,
|
||||
VideoStatsTimeserie,
|
||||
VideoStatsTimeserieMetric
|
||||
VideoStatsTimeserieMetric,
|
||||
VideoStatsUserAgent
|
||||
} from '@peertube/peertube-models'
|
||||
import { VideoStatsService } from './video-stats.service'
|
||||
import { ButtonComponent } from '../../shared/shared-main/buttons/button.component'
|
||||
|
@ -29,11 +30,13 @@ import { VideoDetails } from '@app/shared/shared-main/video/video-details.model'
|
|||
import { LiveVideoService } from '@app/shared/shared-video-live/live-video.service'
|
||||
import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component'
|
||||
|
||||
type ActiveGraphId = VideoStatsTimeserieMetric | 'retention' | 'countries' | 'regions'
|
||||
const BAR_GRAPHS = [ 'countries', 'regions', 'browser', 'device', 'operatingSystem' ] as const
|
||||
type BarGraphs = typeof BAR_GRAPHS[number]
|
||||
type ActiveGraphId = VideoStatsTimeserieMetric | 'retention' | BarGraphs
|
||||
|
||||
type GeoData = { name: string, viewers: number }[]
|
||||
|
||||
type ChartIngestData = VideoStatsTimeserie | VideoStatsRetention | GeoData
|
||||
type ChartIngestData = VideoStatsTimeserie | VideoStatsRetention | GeoData | VideoStatsUserAgent
|
||||
type ChartBuilderResult = {
|
||||
type: 'line' | 'bar'
|
||||
|
||||
|
@ -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')
|
||||
|
@ -137,6 +142,21 @@ export class VideoStatsComponent implements OnInit {
|
|||
id: 'regions',
|
||||
label: $localize`Regions`,
|
||||
zoomEnabled: false
|
||||
},
|
||||
{
|
||||
id: 'browser',
|
||||
label: $localize`Browser`,
|
||||
zoomEnabled: false
|
||||
},
|
||||
{
|
||||
id: 'device',
|
||||
label: $localize`Device`,
|
||||
zoomEnabled: false
|
||||
},
|
||||
{
|
||||
id: 'operatingSystem',
|
||||
label: $localize`Operating system`,
|
||||
zoomEnabled: false
|
||||
}
|
||||
]
|
||||
|
||||
|
@ -356,6 +376,9 @@ export class VideoStatsComponent implements OnInit {
|
|||
private loadChart () {
|
||||
const obsBuilders: { [id in ActiveGraphId]: Observable<ChartIngestData> } = {
|
||||
retention: this.statsService.getRetentionStats(this.video.uuid),
|
||||
browser: this.statsService.getUserAgentStats(this.video.uuid),
|
||||
device: this.statsService.getUserAgentStats(this.video.uuid),
|
||||
operatingSystem: this.statsService.getUserAgentStats(this.video.uuid),
|
||||
|
||||
aggregateWatchTime: this.statsService.getTimeserieStats({
|
||||
videoId: this.video.uuid,
|
||||
|
@ -390,6 +413,9 @@ export class VideoStatsComponent implements OnInit {
|
|||
const dataBuilders: {
|
||||
[id in ActiveGraphId]: (rawData: ChartIngestData) => ChartBuilderResult
|
||||
} = {
|
||||
browser: (rawData: VideoStatsUserAgent) => this.buildUserAgentChartOptions(rawData, 'browser'),
|
||||
device: (rawData: VideoStatsUserAgent) => this.buildUserAgentChartOptions(rawData, 'device'),
|
||||
operatingSystem: (rawData: VideoStatsUserAgent) => this.buildUserAgentChartOptions(rawData, 'operatingSystem'),
|
||||
retention: (rawData: VideoStatsRetention) => this.buildRetentionChartOptions(rawData),
|
||||
aggregateWatchTime: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData),
|
||||
viewers: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData),
|
||||
|
@ -413,6 +439,7 @@ export class VideoStatsComponent implements OnInit {
|
|||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
stepSize: isBarGraph(graphId) ? 1 : undefined,
|
||||
callback: function (value) {
|
||||
return self.formatXTick({
|
||||
graphId,
|
||||
|
@ -545,6 +572,43 @@ export class VideoStatsComponent implements OnInit {
|
|||
}
|
||||
}
|
||||
|
||||
private buildUserAgentChartOptions (rawData: VideoStatsUserAgent, type: 'browser' | 'device' | 'operatingSystem'): ChartBuilderResult {
|
||||
const labels: string[] = []
|
||||
const data: number[] = []
|
||||
|
||||
for (const d of rawData[type]) {
|
||||
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[] = []
|
||||
|
@ -625,7 +689,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,9 @@ export class VideoStatsService {
|
|||
return this.authHttp.get<VideoStatsRetention>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/retention')
|
||||
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
||||
}
|
||||
|
||||
getUserAgentStats (videoId: string) {
|
||||
return this.authHttp.get<VideoStatsUserAgent>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/user-agent')
|
||||
.pipe(catchError(err => this.restExtractor.handleError(err)))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -256,6 +256,7 @@
|
|||
"swagger-cli": "^4.0.2",
|
||||
"tsc-watch": "^6.0.0",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "~5.5.2"
|
||||
"typescript": "~5.5.2",
|
||||
"ua-parser-js": "^2.0.1"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -145,7 +145,10 @@ export const serverFilterHookObject = {
|
|||
// Peertube >= 7.1
|
||||
'filter:oauth.password-grant.get-user.params': true,
|
||||
'filter:api.email-verification.ask-send-verify-email.body': true,
|
||||
'filter:api.users.ask-reset-password.body': true
|
||||
'filter:api.users.ask-reset-password.body': true,
|
||||
|
||||
// Peertube >= 7.2
|
||||
'filter:api.video-view.parse-user-agent.get.result': true
|
||||
}
|
||||
|
||||
export type ServerFilterHookName = keyof typeof serverFilterHookObject
|
||||
|
|
|
@ -4,3 +4,4 @@ export * from './video-stats-retention.model.js'
|
|||
export * from './video-stats-timeserie-query.model.js'
|
||||
export * from './video-stats-timeserie-metric.type.js'
|
||||
export * from './video-stats-timeserie.model.js'
|
||||
export * from './video-stats-user-agent.model.js'
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
export type VideoStatsUserAgent = {
|
||||
[key in 'browser' | 'device' | 'operatingSystem']: {
|
||||
name: string
|
||||
viewers: number
|
||||
}[]
|
||||
}
|
|
@ -4,7 +4,8 @@ import {
|
|||
VideoStatsOverall,
|
||||
VideoStatsRetention,
|
||||
VideoStatsTimeserie,
|
||||
VideoStatsTimeserieMetric
|
||||
VideoStatsTimeserieMetric,
|
||||
VideoStatsUserAgent
|
||||
} from '@peertube/peertube-models'
|
||||
import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js'
|
||||
|
||||
|
@ -28,6 +29,20 @@ export class VideoStatsCommand extends AbstractCommand {
|
|||
})
|
||||
}
|
||||
|
||||
getUserAgentStats (options: OverrideCommandOptions & {
|
||||
videoId: number | string
|
||||
}) {
|
||||
const path = '/api/v1/videos/' + options.videoId + '/stats/user-agent'
|
||||
|
||||
return this.getRequestBody<VideoStatsUserAgent>({
|
||||
...options,
|
||||
path,
|
||||
|
||||
implicitToken: true,
|
||||
defaultExpectedStatus: HttpStatusCode.OK_200
|
||||
})
|
||||
}
|
||||
|
||||
getTimeserieStats (options: OverrideCommandOptions & {
|
||||
videoId: number | string
|
||||
metric: VideoStatsTimeserieMetric
|
||||
|
|
|
@ -10,15 +10,22 @@ export class ViewsCommand extends AbstractCommand {
|
|||
viewEvent?: VideoViewEvent
|
||||
xForwardedFor?: string
|
||||
sessionId?: string
|
||||
userAgent?: string
|
||||
}) {
|
||||
const { id, xForwardedFor, viewEvent, currentTime, sessionId } = options
|
||||
const { id, xForwardedFor, viewEvent, currentTime, sessionId, userAgent } = options
|
||||
const path = '/api/v1/videos/' + id + '/views'
|
||||
const headers = userAgent
|
||||
? {
|
||||
'User-Agent': userAgent
|
||||
}
|
||||
: undefined
|
||||
|
||||
return this.postBodyRequest({
|
||||
...options,
|
||||
|
||||
path,
|
||||
xForwardedFor,
|
||||
headers,
|
||||
fields: {
|
||||
currentTime,
|
||||
viewEvent,
|
||||
|
@ -33,6 +40,7 @@ export class ViewsCommand extends AbstractCommand {
|
|||
id: number | string
|
||||
xForwardedFor?: string
|
||||
sessionId?: string
|
||||
userAgent?: string
|
||||
}) {
|
||||
await this.view({ ...options, currentTime: 0 })
|
||||
await this.view({ ...options, currentTime: 5 })
|
||||
|
|
|
@ -310,6 +310,27 @@ async function register ({ registerHook, registerSetting, settingsManager, stora
|
|||
}
|
||||
})
|
||||
|
||||
registerHook({
|
||||
target: 'filter:api.video-view.parse-user-agent.get.result',
|
||||
handler: (parsedUserAgent, userAgentStr) => {
|
||||
if (userAgentStr === 'user agent string') {
|
||||
return {
|
||||
browser: {
|
||||
name: 'Custom browser'
|
||||
},
|
||||
device: {
|
||||
type: 'Custom device'
|
||||
},
|
||||
os: {
|
||||
name: 'Custom os'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parsedUserAgent
|
||||
}
|
||||
})
|
||||
|
||||
registerHook({
|
||||
target: 'filter:video.auto-blacklist.result',
|
||||
handler: (blacklisted, { video }) => {
|
||||
|
|
|
@ -222,6 +222,36 @@ describe('Test videos views API validators', function () {
|
|||
})
|
||||
})
|
||||
|
||||
describe('When getting user agent stats', function () {
|
||||
|
||||
it('Should fail with a remote video', async function () {
|
||||
await servers[0].videoStats.getUserAgentStats({
|
||||
videoId: remoteVideoId,
|
||||
expectedStatus: HttpStatusCode.FORBIDDEN_403
|
||||
})
|
||||
})
|
||||
|
||||
it('Should fail without token', async function () {
|
||||
await servers[0].videoStats.getUserAgentStats({
|
||||
videoId,
|
||||
token: null,
|
||||
expectedStatus: HttpStatusCode.UNAUTHORIZED_401
|
||||
})
|
||||
})
|
||||
|
||||
it('Should fail with another token', async function () {
|
||||
await servers[0].videoStats.getUserAgentStats({
|
||||
videoId,
|
||||
token: userAccessToken,
|
||||
expectedStatus: HttpStatusCode.FORBIDDEN_403
|
||||
})
|
||||
})
|
||||
|
||||
it('Should succeed with the correct parameters', async function () {
|
||||
await servers[0].videoStats.getUserAgentStats({ videoId })
|
||||
})
|
||||
})
|
||||
|
||||
after(async function () {
|
||||
await cleanupTests(servers)
|
||||
})
|
||||
|
|
|
@ -0,0 +1,62 @@
|
|||
import { buildUUID } from '@peertube/peertube-node-utils'
|
||||
import { PeerTubeServer, cleanupTests, waitJobs } from '@peertube/peertube-server-commands'
|
||||
import { prepareViewsServers, processViewersStats } from '@tests/shared/views.js'
|
||||
import { expect } from 'chai'
|
||||
|
||||
// eslint-disable-next-line max-len
|
||||
const EDGE_WINDOWS_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 Edg/132.0.0.0'
|
||||
// eslint-disable-next-line max-len
|
||||
const EDGE_ANDROID_USER_AGENT = 'Mozilla/5.0 (Linux; Android 10; HD1913) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.6943.49 Mobile Safari/537.36 EdgA/131.0.2903.87'
|
||||
const CHROME_LINUX_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36'
|
||||
|
||||
describe('Test views user agent stats', function () {
|
||||
let server: PeerTubeServer
|
||||
|
||||
before(async function () {
|
||||
this.timeout(120000)
|
||||
|
||||
const servers = await prepareViewsServers({ singleServer: true })
|
||||
server = servers[0]
|
||||
})
|
||||
|
||||
it('Should report browser, device and OS', async function () {
|
||||
this.timeout(240000)
|
||||
|
||||
const { uuid } = await server.videos.quickUpload({ name: 'video' })
|
||||
await waitJobs(server)
|
||||
|
||||
await server.views.simulateView({
|
||||
id: uuid,
|
||||
sessionId: buildUUID(),
|
||||
userAgent: EDGE_ANDROID_USER_AGENT
|
||||
})
|
||||
await server.views.simulateView({
|
||||
id: uuid,
|
||||
sessionId: buildUUID(),
|
||||
userAgent: EDGE_WINDOWS_USER_AGENT
|
||||
})
|
||||
await server.views.simulateView({
|
||||
id: uuid,
|
||||
sessionId: buildUUID(),
|
||||
userAgent: CHROME_LINUX_USER_AGENT
|
||||
})
|
||||
|
||||
await processViewersStats([ server ])
|
||||
|
||||
const stats = await server.videoStats.getUserAgentStats({ videoId: uuid })
|
||||
|
||||
expect(stats.browser).to.include.deep.members([ { name: 'Chrome', viewers: 1 } ])
|
||||
expect(stats.browser).to.include.deep.members([ { name: 'Edge', viewers: 2 } ])
|
||||
|
||||
expect(stats.device).to.include.deep.members([ { name: 'unknown', viewers: 2 } ])
|
||||
expect(stats.device).to.include.deep.members([ { name: 'mobile', viewers: 1 } ])
|
||||
|
||||
expect(stats.operatingSystem).to.include.deep.members([ { name: 'Android', viewers: 1 } ])
|
||||
expect(stats.operatingSystem).to.include.deep.members([ { name: 'Linux', viewers: 1 } ])
|
||||
expect(stats.operatingSystem).to.include.deep.members([ { name: 'Windows', viewers: 1 } ])
|
||||
})
|
||||
|
||||
after(async function () {
|
||||
await cleanupTests([ server ])
|
||||
})
|
||||
})
|
|
@ -26,6 +26,7 @@ import {
|
|||
import { expectEndWith } from '@tests/shared/checks.js'
|
||||
import { expect } from 'chai'
|
||||
import { FIXTURE_URLS } from '../shared/fixture-urls.js'
|
||||
import { processViewersStats } from '@tests/shared/views.js'
|
||||
|
||||
describe('Test plugin filter hooks', function () {
|
||||
let servers: PeerTubeServer[]
|
||||
|
@ -414,6 +415,32 @@ describe('Test plugin filter hooks', function () {
|
|||
})
|
||||
})
|
||||
|
||||
describe('filter:api.video-view.parse-user-agent.get.result', function () {
|
||||
let server
|
||||
let videoUUID
|
||||
|
||||
before(async function () {
|
||||
server = servers[0]
|
||||
const { uuid } = await server.videos.quickUpload({ name: 'video' })
|
||||
videoUUID = uuid
|
||||
await waitJobs(server)
|
||||
|
||||
await server.views.simulateView({
|
||||
id: uuid,
|
||||
userAgent: 'user agent string'
|
||||
})
|
||||
await processViewersStats([ server ])
|
||||
})
|
||||
|
||||
it('Should return custom browser, device and os', async function () {
|
||||
const stats = await server.videoStats.getUserAgentStats({ videoId: videoUUID })
|
||||
|
||||
expect(stats.browser[0].name).to.equal('Custom browser')
|
||||
expect(stats.device[0].name).to.equal('Custom device')
|
||||
expect(stats.operatingSystem[0].name).to.equal('Custom os')
|
||||
})
|
||||
})
|
||||
|
||||
describe('filter:video.auto-blacklist.result', function () {
|
||||
|
||||
async function checkIsBlacklisted (id: number | string, value: boolean) {
|
||||
|
|
|
@ -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 })
|
||||
|
||||
await doubleFollow(servers[0], servers[1])
|
||||
if (!singleServer) {
|
||||
await doubleFollow(servers[0], servers[1])
|
||||
}
|
||||
|
||||
return servers
|
||||
}
|
||||
|
|
|
@ -6,7 +6,8 @@ import {
|
|||
authenticate,
|
||||
videoOverallStatsValidator,
|
||||
videoRetentionStatsValidator,
|
||||
videoTimeserieStatsValidator
|
||||
videoTimeserieStatsValidator,
|
||||
videoUserAgentStatsValidator
|
||||
} from '../../../middlewares/index.js'
|
||||
|
||||
const statsRouter = express.Router()
|
||||
|
@ -29,6 +30,12 @@ statsRouter.get('/:videoId/stats/retention',
|
|||
asyncMiddleware(getRetentionStats)
|
||||
)
|
||||
|
||||
statsRouter.get('/:videoId/stats/user-agent',
|
||||
authenticate,
|
||||
asyncMiddleware(videoUserAgentStatsValidator),
|
||||
asyncMiddleware(getUserAgentStats)
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
|
@ -58,6 +65,14 @@ async function getRetentionStats (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 stats = await LocalVideoViewerModel.getUserAgentStats(video)
|
||||
|
||||
return res.json(stats)
|
||||
}
|
||||
|
||||
async function getTimeserieStats (req: express.Request, res: express.Response) {
|
||||
const video = res.locals.videoAll
|
||||
const metric = req.params.metric as VideoStatsTimeserieMetric
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import express from 'express'
|
||||
import { UAParser } from 'ua-parser-js'
|
||||
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'
|
||||
|
@ -37,7 +38,13 @@ async function viewVideo (req: express.Request, res: express.Response) {
|
|||
const body = req.body as VideoView
|
||||
|
||||
const ip = req.ip
|
||||
const userAgent = await Hooks.wrapFun(
|
||||
UAParser,
|
||||
req.headers['user-agent'],
|
||||
'filter:api.video-view.parse-user-agent.get.result'
|
||||
)
|
||||
const { successView } = await VideoViewsManager.Instance.processLocalView({
|
||||
userAgent,
|
||||
video,
|
||||
ip,
|
||||
currentTime: body.currentTime,
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
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', 'browser', {
|
||||
type: Sequelize.STRING,
|
||||
defaultValue: null,
|
||||
allowNull: true
|
||||
}, { transaction })
|
||||
|
||||
await utils.queryInterface.addIndex('localVideoViewer', [ 'browser' ], {
|
||||
transaction
|
||||
})
|
||||
}
|
||||
|
||||
{
|
||||
await utils.queryInterface.addColumn('localVideoViewer', 'device', {
|
||||
type: Sequelize.STRING,
|
||||
defaultValue: null,
|
||||
allowNull: true
|
||||
}, { transaction })
|
||||
|
||||
await utils.queryInterface.addIndex('localVideoViewer', [ 'device' ], {
|
||||
transaction
|
||||
})
|
||||
}
|
||||
|
||||
{
|
||||
await utils.queryInterface.addColumn('localVideoViewer', 'operatingSystem', {
|
||||
type: Sequelize.STRING,
|
||||
defaultValue: null,
|
||||
allowNull: true
|
||||
}, { transaction })
|
||||
|
||||
await utils.queryInterface.addIndex('localVideoViewer', [ 'operatingSystem' ], {
|
||||
transaction
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function down (options) {
|
||||
throw new Error('Not implemented.')
|
||||
}
|
||||
|
||||
export {
|
||||
down, up
|
||||
}
|
|
@ -12,6 +12,7 @@ import { LocalVideoViewerWatchSectionModel } from '@server/models/view/local-vid
|
|||
import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer.js'
|
||||
import { MVideo, MVideoImmutable } from '@server/types/models/index.js'
|
||||
import { Transaction } from 'sequelize'
|
||||
import { IResult } from 'ua-parser-js'
|
||||
|
||||
const lTags = loggerTagsFactory('views')
|
||||
|
||||
|
@ -26,6 +27,10 @@ type LocalViewerStats = {
|
|||
|
||||
watchTime: number
|
||||
|
||||
browser: string
|
||||
device: string
|
||||
operatingSystem: string
|
||||
|
||||
country: string
|
||||
subdivisionName: string
|
||||
|
||||
|
@ -47,13 +52,14 @@ export class VideoViewerStats {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
async addLocalViewer (options: {
|
||||
userAgent: IResult
|
||||
video: MVideoImmutable
|
||||
currentTime: number
|
||||
ip: string
|
||||
sessionId: string
|
||||
viewEvent?: VideoViewEvent
|
||||
}) {
|
||||
const { video, ip, viewEvent, currentTime, sessionId } = options
|
||||
const { video, ip, viewEvent, currentTime, sessionId, userAgent } = options
|
||||
|
||||
logger.debug(
|
||||
'Adding local viewer to video stats %s.', video.uuid,
|
||||
|
@ -83,6 +89,10 @@ export class VideoViewerStats {
|
|||
|
||||
watchTime: 0,
|
||||
|
||||
browser: userAgent.browser.name,
|
||||
device: userAgent.device.type || 'unknown',
|
||||
operatingSystem: userAgent.os.name,
|
||||
|
||||
country,
|
||||
subdivisionName,
|
||||
|
||||
|
@ -181,6 +191,9 @@ export class VideoViewerStats {
|
|||
startDate: new Date(stats.firstUpdated),
|
||||
endDate: new Date(stats.lastUpdated),
|
||||
watchTime: stats.watchTime,
|
||||
browser: stats.browser,
|
||||
device: stats.device,
|
||||
operatingSystem: stats.operatingSystem,
|
||||
country: stats.country,
|
||||
subdivisionName: stats.subdivisionName,
|
||||
videoId: video.id
|
||||
|
|
|
@ -4,6 +4,7 @@ import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
|
|||
import { CONFIG } from '@server/initializers/config.js'
|
||||
import { MVideo, MVideoImmutable } from '@server/types/models/index.js'
|
||||
import { VideoScope, VideoViewerCounters, VideoViewerStats, VideoViews, ViewerScope } from './shared/index.js'
|
||||
import { IResult } from 'ua-parser-js'
|
||||
|
||||
/**
|
||||
* If processing a local view:
|
||||
|
@ -43,13 +44,14 @@ export class VideoViewsManager {
|
|||
}
|
||||
|
||||
async processLocalView (options: {
|
||||
userAgent: IResult
|
||||
video: MVideoImmutable
|
||||
currentTime: number
|
||||
ip: string | null
|
||||
sessionId?: string
|
||||
viewEvent?: VideoViewEvent
|
||||
}) {
|
||||
const { video, ip, viewEvent, currentTime } = options
|
||||
const { video, ip, viewEvent, currentTime, userAgent } = options
|
||||
|
||||
let sessionId = options.sessionId
|
||||
if (!sessionId || CONFIG.VIEWS.VIDEOS.TRUST_VIEWER_SESSION_ID !== true) {
|
||||
|
@ -58,7 +60,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, userAgent })
|
||||
|
||||
const successViewer = await this.videoViewerCounters.addLocalViewer({ video, sessionId })
|
||||
|
||||
|
|
|
@ -43,6 +43,17 @@ const videoRetentionStatsValidator = [
|
|||
}
|
||||
]
|
||||
|
||||
const videoUserAgentStatsValidator = [
|
||||
isValidVideoIdParam('videoId'),
|
||||
|
||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
if (areValidationErrors(req, res)) return
|
||||
if (!await commonStatsCheck(req, res)) return
|
||||
|
||||
return next()
|
||||
}
|
||||
]
|
||||
|
||||
const videoTimeserieStatsValidator = [
|
||||
isValidVideoIdParam('videoId'),
|
||||
|
||||
|
@ -88,7 +99,8 @@ const videoTimeserieStatsValidator = [
|
|||
export {
|
||||
videoOverallStatsValidator,
|
||||
videoTimeserieStatsValidator,
|
||||
videoRetentionStatsValidator
|
||||
videoRetentionStatsValidator,
|
||||
videoUserAgentStatsValidator
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { QueryTypes } from 'sequelize'
|
||||
import { QueryOptionsWithType, 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'
|
||||
|
@ -8,6 +8,7 @@ import {
|
|||
VideoStatsRetention,
|
||||
VideoStatsTimeserie,
|
||||
VideoStatsTimeserieMetric,
|
||||
VideoStatsUserAgent,
|
||||
WatchActionObject
|
||||
} from '@peertube/peertube-models'
|
||||
import { VideoModel } from '../video/video.js'
|
||||
|
@ -31,6 +32,15 @@ import { SequelizeModel } from '../shared/index.js'
|
|||
{
|
||||
fields: [ 'url' ],
|
||||
unique: true
|
||||
},
|
||||
{
|
||||
fields: [ 'browser' ]
|
||||
},
|
||||
{
|
||||
fields: [ 'device' ]
|
||||
},
|
||||
{
|
||||
fields: [ 'operatingSystem' ]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
@ -50,6 +60,18 @@ export class LocalVideoViewerModel extends SequelizeModel<LocalVideoViewerModel>
|
|||
@Column
|
||||
watchTime: number
|
||||
|
||||
@AllowNull(true)
|
||||
@Column
|
||||
browser: string
|
||||
|
||||
@AllowNull(true)
|
||||
@Column
|
||||
device: string
|
||||
|
||||
@AllowNull(true)
|
||||
@Column
|
||||
operatingSystem: string
|
||||
|
||||
@AllowNull(true)
|
||||
@Column
|
||||
country: string
|
||||
|
@ -203,27 +225,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, queryOptions }),
|
||||
LocalVideoViewerModel.buildGroupBy({ groupByColumn: 'subdivisionName', startDate, endDate, queryOptions })
|
||||
])
|
||||
|
||||
const viewersPeak = rowsWatchPeak.length !== 0
|
||||
|
@ -259,6 +266,34 @@ export class LocalVideoViewerModel extends SequelizeModel<LocalVideoViewerModel>
|
|||
}
|
||||
}
|
||||
|
||||
static async getUserAgentStats (video: MVideo): Promise<VideoStatsUserAgent> {
|
||||
const queryOptions = {
|
||||
type: QueryTypes.SELECT as QueryTypes.SELECT,
|
||||
replacements: { videoId: video.id } as any
|
||||
}
|
||||
|
||||
const [ browser, device, operatingSystem ] = await Promise.all([
|
||||
LocalVideoViewerModel.buildGroupBy({ groupByColumn: 'browser', queryOptions }),
|
||||
LocalVideoViewerModel.buildGroupBy({ groupByColumn: 'device', queryOptions }),
|
||||
LocalVideoViewerModel.buildGroupBy({ groupByColumn: 'operatingSystem', queryOptions })
|
||||
])
|
||||
|
||||
return {
|
||||
browser: browser.map(r => ({
|
||||
name: r.browser,
|
||||
viewers: r.viewers
|
||||
})),
|
||||
device: device.map(r => ({
|
||||
name: r.device,
|
||||
viewers: r.viewers
|
||||
})),
|
||||
operatingSystem: operatingSystem.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)
|
||||
|
||||
|
@ -353,6 +388,27 @@ export class LocalVideoViewerModel extends SequelizeModel<LocalVideoViewerModel>
|
|||
}
|
||||
}
|
||||
|
||||
private static async buildGroupBy (options: {
|
||||
groupByColumn: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
queryOptions: QueryOptionsWithType<QueryTypes.SELECT>
|
||||
}) {
|
||||
const { groupByColumn, startDate, endDate, queryOptions } = options
|
||||
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
|
||||
? {
|
||||
|
|
24
yarn.lock
24
yarn.lock
|
@ -4846,6 +4846,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"
|
||||
|
@ -7044,6 +7049,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"
|
||||
|
@ -10884,6 +10894,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…
Reference in New Issue