This commit is contained in:
kontrollanten 2025-02-19 10:02:11 +01:00 committed by GitHub
commit ec830d5f11
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 474 additions and 35 deletions

View File

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

View File

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

View File

@ -3,7 +3,13 @@ import { environment } from 'src/environments/environment'
import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable, inject } from '@angular/core'
import { RestExtractor } from '@app/core'
import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric } from '@peertube/peertube-models'
import {
VideoStatsOverall,
VideoStatsRetention,
VideoStatsTimeserie,
VideoStatsTimeserieMetric,
VideoStatsUserAgent
} from '@peertube/peertube-models'
import { VideoService } from '@app/shared/shared-main/video/video.service'
@Injectable({
@ -50,4 +56,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)))
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
export type VideoStatsUserAgent = {
[key in 'browser' | 'device' | 'operatingSystem']: {
name: string
viewers: number
}[]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@ import { wait } from '@peertube/peertube-core-utils'
import { VideoCreateResult, VideoPrivacy } from '@peertube/peertube-models'
import {
createMultipleServers,
createSingleServer,
doubleFollow,
PeerTubeServer,
setAccessTokensToServers,
@ -33,8 +34,9 @@ async function processViewsBuffer (servers: PeerTubeServer[]) {
async function prepareViewsServers (options: {
viewExpiration?: string // default 1 second
trustViewerSessionId?: boolean // default true
singleServer?: boolean // default false
} = {}) {
const { viewExpiration = '1 second', trustViewerSessionId = true } = options
const { viewExpiration = '1 second', trustViewerSessionId = true, singleServer } = options
const config = {
views: {
@ -46,14 +48,16 @@ async function prepareViewsServers (options: {
}
}
const servers = await createMultipleServers(2, config)
const servers = await (singleServer ? Promise.all([ createSingleServer(1, config) ]) : createMultipleServers(2, config))
await setAccessTokensToServers(servers)
await setDefaultVideoChannel(servers)
await servers[0].config.enableMinimumTranscoding()
await servers[0].config.enableLive({ allowReplay: true, transcoding: false })
await doubleFollow(servers[0], servers[1])
if (!singleServer) {
await doubleFollow(servers[0], servers[1])
}
return servers
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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