1
0
Fork 0

Use sessionId instead of IP to identify viewer

Breaking: YAML config `ip_view_expiration` is renamed `view_expiration`
Breaking: Views are taken into account after 10 seconds instead of 30
seconds (can be changed in YAML config)

Purpose of this commit is to get closer to other video platforms where
some platforms count views on play (mux, vimeo) or others use a very low
delay (instagram, tiktok)

We also want to improve the viewer identification, where we no longer
use the IP but the `sessionId` generated by the web browser. Multiple
viewers behind a NAT can now be able to be identified as independent
viewers (this method is also used by vimeo or mux)
This commit is contained in:
Chocobozzz 2024-04-04 11:30:30 +02:00
parent 6f6abcabfb
commit 5cb3e6a0b8
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
25 changed files with 913 additions and 660 deletions

View File

@ -1,6 +1,8 @@
import { logger } from '@root-helpers/logger'
import { peertubeLocalStorage, peertubeSessionStorage } from '@root-helpers/peertube-web-storage'
import { randomString } from '@root-helpers/string'
function getStoredVolume () {
export function getStoredVolume () {
const value = getLocalStorage('volume')
if (value !== null && value !== undefined) {
const valueNumber = parseFloat(value)
@ -12,38 +14,38 @@ function getStoredVolume () {
return undefined
}
function getStoredMute () {
export function getStoredMute () {
const value = getLocalStorage('mute')
if (value !== null && value !== undefined) return value === 'true'
return undefined
}
function getStoredTheater () {
export function getStoredTheater () {
const value = getLocalStorage('theater-enabled')
if (value !== null && value !== undefined) return value === 'true'
return false
}
function saveVolumeInStore (value: number) {
export function saveVolumeInStore (value: number) {
return setLocalStorage('volume', value.toString())
}
function saveMuteInStore (value: boolean) {
export function saveMuteInStore (value: boolean) {
return setLocalStorage('mute', value.toString())
}
function saveTheaterInStore (enabled: boolean) {
export function saveTheaterInStore (enabled: boolean) {
return setLocalStorage('theater-enabled', enabled.toString())
}
function saveAverageBandwidth (value: number) {
export function saveAverageBandwidth (value: number) {
/** used to choose the most fitting resolution */
return setLocalStorage('average-bandwidth', value.toString())
}
function getAverageBandwidthInStore () {
export function getAverageBandwidthInStore () {
const value = getLocalStorage('average-bandwidth')
if (value !== null && value !== undefined) {
const valueNumber = parseInt(value, 10)
@ -57,25 +59,25 @@ function getAverageBandwidthInStore () {
// ---------------------------------------------------------------------------
function saveLastSubtitle (language: string) {
export function saveLastSubtitle (language: string) {
return setLocalStorage('last-subtitle', language)
}
function getStoredLastSubtitle () {
export function getStoredLastSubtitle () {
return getLocalStorage('last-subtitle')
}
function savePreferredSubtitle (language: string) {
export function savePreferredSubtitle (language: string) {
return setLocalStorage('preferred-subtitle', language)
}
function getStoredPreferredSubtitle () {
export function getStoredPreferredSubtitle () {
return getLocalStorage('preferred-subtitle')
}
// ---------------------------------------------------------------------------
function saveVideoWatchHistory (videoUUID: string, duration: number) {
export function saveVideoWatchHistory (videoUUID: string, duration: number) {
return setLocalStorage(`video-watch-history`, JSON.stringify({
...getStoredVideoWatchHistory(),
@ -86,7 +88,7 @@ function saveVideoWatchHistory (videoUUID: string, duration: number) {
}))
}
function getStoredVideoWatchHistory (videoUUID?: string) {
export function getStoredVideoWatchHistory (videoUUID?: string) {
let data
try {
@ -105,7 +107,7 @@ function getStoredVideoWatchHistory (videoUUID?: string) {
return data
}
function cleanupVideoWatch () {
export function cleanupVideoWatch () {
const data = getStoredVideoWatchHistory()
if (!data) return
@ -127,39 +129,36 @@ function cleanupVideoWatch () {
// ---------------------------------------------------------------------------
export {
getStoredVolume,
getStoredMute,
getStoredTheater,
saveVolumeInStore,
saveMuteInStore,
saveTheaterInStore,
saveAverageBandwidth,
getAverageBandwidthInStore,
saveLastSubtitle,
getStoredLastSubtitle,
saveVideoWatchHistory,
getStoredVideoWatchHistory,
cleanupVideoWatch,
savePreferredSubtitle,
getStoredPreferredSubtitle
export function getPlayerSessionId () {
const key = 'session-id'
let sessionId = getSessionStorage(key)
if (sessionId) return sessionId
sessionId = randomString(32)
setSessionStorage(key, sessionId)
return sessionId
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
const KEY_PREFIX = 'peertube-videojs-'
function getLocalStorage (key: string) {
try {
return localStorage.getItem(KEY_PREFIX + key)
} catch {
return undefined
}
return peertubeLocalStorage.getItem(KEY_PREFIX + key)
}
function setLocalStorage (key: string, value: string) {
try {
localStorage.setItem(KEY_PREFIX + key, value)
} catch { /* empty */
}
peertubeLocalStorage.setItem(KEY_PREFIX + key, value)
}
function getSessionStorage (key: string) {
return peertubeSessionStorage.getItem(KEY_PREFIX + key)
}
function setSessionStorage (key: string, value: string) {
peertubeSessionStorage.setItem(KEY_PREFIX + key, value)
}

View File

@ -1,10 +1,11 @@
import debug from 'debug'
import videojs from 'video.js'
import { timeToInt } from '@peertube/peertube-core-utils'
import { 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 videojs from 'video.js'
import {
getPlayerSessionId,
getStoredLastSubtitle,
getStoredMute,
getStoredVolume,
@ -371,7 +372,9 @@ class PeerTubePlugin extends Plugin {
if (!this.videoViewUrl()) return Promise.resolve(true)
const body: VideoView = { currentTime, viewEvent }
const sessionId = getPlayerSessionId()
const body: VideoView = { currentTime, viewEvent, sessionId }
const headers = new Headers({ 'Content-type': 'application/json; charset=UTF-8' })
if (this.authorizationHeader()) headers.set('Authorization', this.authorizationHeader())

View File

@ -1,7 +1,17 @@
function capitalizeFirstLetter (str: string) {
export function capitalizeFirstLetter (str: string) {
return str.charAt(0).toUpperCase() + str.slice(1)
}
export {
capitalizeFirstLetter
export function randomString (length: number) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
const charsLength = chars.length
const randomArray = new Uint8Array(length)
let result = ''
for (const v of crypto.getRandomValues(randomArray)) {
result += chars[v % charsLength]
}
return result
}

View File

@ -381,7 +381,16 @@ views:
# PeerTube buffers local video views before updating and federating the video
local_buffer_update_interval: '30 minutes'
ip_view_expiration: '1 hour'
# How long does it take to count again a view from the same user
view_expiration: '1 hour'
# Minimum amount of time the viewer has to watch the video before PeerTube adds a view
count_view_after: '10 seconds'
# Player can send a session id string to track the user
# Since this can be spoofed by users to create fake views, you have the option to disable this feature
# If disabled, PeerTube will use the IP address to track the same user (default behavior before PeerTube 6.1)
trust_viewer_session_id: true
# How often the web browser sends "is watching" information to the server
# Increase the value or set null to disable it if you plan to have many viewers

View File

@ -379,7 +379,16 @@ views:
# PeerTube buffers local video views before updating and federating the video
local_buffer_update_interval: '30 minutes'
ip_view_expiration: '1 hour'
# How long does it take to count again a view from the same user
view_expiration: '1 hour'
# Minimum amount of time the viewer has to watch the video before PeerTube adds a view
count_view_after: '10 seconds'
# Player can send a session id string to track the user
# Since this can be spoofed by users to create fake views, you have the option to disable this feature
# If disabled, PeerTube will use the IP address to track the same user (default behavior before PeerTube 6.1)
trust_viewer_session_id: true
# How often the web browser sends "is watching" information to the server
# Increase the value or set null to disable it if you plan to have many viewers

View File

@ -150,7 +150,7 @@ views:
max_age: -1
local_buffer_update_interval: '5 seconds'
ip_view_expiration: '1 second'
view_expiration: '1 second'
geo_ip:
enabled: false

View File

@ -1,8 +1,4 @@
// high excluded
function randomInt (low: number, high: number) {
export function randomInt (low: number, high: number) {
return Math.floor(Math.random() * (high - low) + low)
}
export {
randomInt
}

View File

@ -3,4 +3,5 @@ export type VideoViewEvent = 'seek'
export interface VideoView {
currentTime: number
viewEvent?: VideoViewEvent
sessionId?: string
}

View File

@ -9,8 +9,9 @@ export class ViewsCommand extends AbstractCommand {
currentTime: number
viewEvent?: VideoViewEvent
xForwardedFor?: string
sessionId?: string
}) {
const { id, xForwardedFor, viewEvent, currentTime } = options
const { id, xForwardedFor, viewEvent, currentTime, sessionId } = options
const path = '/api/v1/videos/' + id + '/views'
return this.postBodyRequest({
@ -20,7 +21,8 @@ export class ViewsCommand extends AbstractCommand {
xForwardedFor,
fields: {
currentTime,
viewEvent
viewEvent,
sessionId
},
implicitToken: false,
defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
@ -30,6 +32,7 @@ export class ViewsCommand extends AbstractCommand {
async simulateView (options: OverrideCommandOptions & {
id: number | string
xForwardedFor?: string
sessionId?: string
}) {
await this.view({ ...options, currentTime: 0 })
await this.view({ ...options, currentTime: 5 })
@ -39,6 +42,7 @@ export class ViewsCommand extends AbstractCommand {
id: number | string
currentTimes: number[]
xForwardedFor?: string
sessionId?: string
}) {
let viewEvent: VideoViewEvent = 'seek'

View File

@ -1,10 +1,11 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { wait } from '@peertube/peertube-core-utils'
import { buildUUID } from '@peertube/peertube-node-utils'
import { PeerTubeServer, cleanupTests, stopFfmpeg, waitJobs } from '@peertube/peertube-server-commands'
import { prepareViewsServers, prepareViewsVideos, processViewsBuffer } from '@tests/shared/views.js'
import { expect } from 'chai'
import { FfmpegCommand } from 'fluent-ffmpeg'
import { prepareViewsServers, prepareViewsVideos, processViewsBuffer } from '@tests/shared/views.js'
import { wait } from '@peertube/peertube-core-utils'
import { cleanupTests, PeerTubeServer, stopFfmpeg, waitJobs } from '@peertube/peertube-server-commands'
describe('Test video views/viewers counters', function () {
let servers: PeerTubeServer[]
@ -21,7 +22,14 @@ describe('Test video views/viewers counters', function () {
}
}
function runTests () {
function runTests (options: { useSessionId: boolean }) {
const generateSession = () => {
if (!options.useSessionId) return undefined
return buildUUID()
}
describe('Test views counter on VOD', function () {
let videoUUID: string
@ -35,29 +43,35 @@ describe('Test video views/viewers counters', function () {
})
it('Should not view a video if watch time is below the threshold', async function () {
await servers[0].views.simulateViewer({ id: videoUUID, currentTimes: [ 1, 2 ] })
await servers[0].views.simulateViewer({ id: videoUUID, sessionId: generateSession(), currentTimes: [ 1, 2 ] })
await processViewsBuffer(servers)
await checkCounter('views', videoUUID, 0)
})
it('Should view a video if watch time is above the threshold', async function () {
await servers[0].views.simulateViewer({ id: videoUUID, currentTimes: [ 1, 4 ] })
await servers[0].views.simulateViewer({ id: videoUUID, sessionId: generateSession(), currentTimes: [ 1, 4 ] })
await processViewsBuffer(servers)
await checkCounter('views', videoUUID, 1)
})
it('Should not view again this video with the same IP', async function () {
await servers[0].views.simulateViewer({ id: videoUUID, xForwardedFor: '0.0.0.1,127.0.0.1', currentTimes: [ 1, 4 ] })
await servers[0].views.simulateViewer({ id: videoUUID, xForwardedFor: '0.0.0.1,127.0.0.1', currentTimes: [ 1, 4 ] })
it('Should not view again this video with the same IP/session Id', async function () {
const sessionId = generateSession()
const xForwardedFor = '0.0.0.1,127.0.0.1'
await servers[0].views.simulateViewer({ id: videoUUID, sessionId, xForwardedFor, currentTimes: [ 1, 4 ] })
await servers[0].views.simulateViewer({ id: videoUUID, sessionId, xForwardedFor, currentTimes: [ 1, 4 ] })
await processViewsBuffer(servers)
await checkCounter('views', videoUUID, 2)
})
it('Should view the video from server 2 and send the event', async function () {
await servers[1].views.simulateViewer({ id: videoUUID, currentTimes: [ 1, 4 ] })
const sessionId = generateSession()
await servers[1].views.simulateViewer({ id: videoUUID, sessionId, currentTimes: [ 1, 4 ] })
await waitJobs(servers)
await processViewsBuffer(servers)
@ -87,19 +101,28 @@ describe('Test video views/viewers counters', function () {
it('Should view twice and display 1 view/viewer', async function () {
this.timeout(30000)
for (let i = 0; i < 3; i++) {
await servers[0].views.simulateViewer({ id: liveVideoId, currentTimes: [ 0, 35 ] })
await servers[0].views.simulateViewer({ id: liveVideoId, currentTimes: [ 0, 35 ] })
await servers[0].views.simulateViewer({ id: vodVideoId, currentTimes: [ 0, 5 ] })
await servers[0].views.simulateViewer({ id: vodVideoId, currentTimes: [ 0, 5 ] })
const sessionId = generateSession()
await wait(1000)
for (let i = 0; i < 3; i++) {
await servers[0].views.simulateViewer({ id: liveVideoId, sessionId, currentTimes: [ 0, 35 ] })
await servers[0].views.simulateViewer({ id: liveVideoId, sessionId, currentTimes: [ 0, 35 ] })
await servers[0].views.simulateViewer({ id: vodVideoId, sessionId, currentTimes: [ 0, 5 ] })
await servers[0].views.simulateViewer({ id: vodVideoId, sessionId, currentTimes: [ 0, 5 ] })
}
await waitJobs(servers)
let doWhile = true
while (doWhile) {
try {
await checkCounter('viewers', liveVideoId, 1)
await checkCounter('viewers', vodVideoId, 1)
await checkCounter('viewers', liveVideoId, 1)
await checkCounter('viewers', vodVideoId, 1)
doWhile = false
} catch {
await wait(1000)
doWhile = true
}
}
await processViewsBuffer(servers)
@ -121,7 +144,7 @@ describe('Test video views/viewers counters', function () {
await checkCounter('viewers', vodVideoId, 0)
error = false
await wait(2500)
await wait(1000)
} catch {
error = true
}
@ -131,21 +154,42 @@ describe('Test video views/viewers counters', function () {
it('Should view on a remote and on local and display appropriate views/viewers', async function () {
this.timeout(30000)
await servers[0].views.simulateViewer({ id: vodVideoId, xForwardedFor: '0.0.0.1,127.0.0.1', currentTimes: [ 0, 5 ] })
await servers[0].views.simulateViewer({ id: vodVideoId, xForwardedFor: '0.0.0.1,127.0.0.1', currentTimes: [ 0, 5 ] })
await servers[0].views.simulateViewer({ id: vodVideoId, currentTimes: [ 0, 5 ] })
await servers[1].views.simulateViewer({ id: vodVideoId, currentTimes: [ 0, 5 ] })
await servers[1].views.simulateViewer({ id: vodVideoId, currentTimes: [ 0, 5 ] })
const xForwardedFor = '0.0.0.1,127.0.0.1'
const sessionId = generateSession()
const xForwardedFor2 = '0.0.0.2,127.0.0.1'
const sessionId2 = generateSession()
await servers[0].views.simulateViewer({ id: liveVideoId, currentTimes: [ 0, 35 ] })
await servers[1].views.simulateViewer({ id: liveVideoId, currentTimes: [ 0, 35 ] })
await servers[1].views.simulateViewer({ id: liveVideoId, currentTimes: [ 0, 35 ] })
{
const currentTimes = [ 0, 5 ]
await wait(3000) // Throttled federation
await waitJobs(servers)
await servers[0].views.simulateViewer({ id: vodVideoId, xForwardedFor, sessionId, currentTimes })
await servers[0].views.simulateViewer({ id: vodVideoId, xForwardedFor, sessionId, currentTimes })
await servers[0].views.simulateViewer({ id: vodVideoId, xForwardedFor: xForwardedFor2, sessionId: sessionId2, currentTimes })
await servers[1].views.simulateViewer({ id: vodVideoId, xForwardedFor, sessionId, currentTimes })
await servers[1].views.simulateViewer({ id: vodVideoId, xForwardedFor, sessionId, currentTimes })
}
await checkCounter('viewers', liveVideoId, 2)
await checkCounter('viewers', vodVideoId, 3)
{
const currentTimes = [ 0, 35 ]
await servers[0].views.simulateViewer({ id: liveVideoId, xForwardedFor: xForwardedFor2, sessionId: sessionId2, currentTimes })
await servers[1].views.simulateViewer({ id: liveVideoId, xForwardedFor, sessionId, currentTimes })
await servers[1].views.simulateViewer({ id: liveVideoId, xForwardedFor, sessionId, currentTimes })
}
let doWhile = true
while (doWhile) {
try {
await checkCounter('viewers', liveVideoId, 2)
await checkCounter('viewers', vodVideoId, 3)
doWhile = false
} catch {
await wait(1000)
doWhile = true
}
}
await processViewsBuffer(servers)
@ -167,7 +211,13 @@ describe('Test video views/viewers counters', function () {
servers = await prepareViewsServers({ viewExpiration: '5 seconds', viewersFederationV2: false })
})
runTests()
describe('Not using session id', function () {
runTests({ useSessionId: false })
})
describe('Using session id', function () {
runTests({ useSessionId: true })
})
after(async function () {
await cleanupTests(servers)
@ -182,10 +232,74 @@ describe('Test video views/viewers counters', function () {
servers = await prepareViewsServers({ viewExpiration: '5 seconds', viewersFederationV2: true })
})
runTests()
describe('Not using session id', function () {
runTests({ useSessionId: false })
})
describe('Using session id', function () {
runTests({ useSessionId: true })
})
describe('View minimum duration config', function () {
it('Should update "count_view_after" config', async function () {
this.timeout(120000)
const { uuid } = await servers[0].videos.quickUpload({ name: 'video' })
{
await servers[0].views.simulateViewer({ id: uuid, sessionId: buildUUID(), currentTimes: [ 1, 2 ] })
await processViewsBuffer(servers)
await checkCounter('views', uuid, 0)
}
await servers[0].kill()
await servers[0].run({ views: { videos: { count_view_after: '1 second' } } })
{
await servers[0].views.simulateViewer({ id: uuid, sessionId: buildUUID(), currentTimes: [ 1, 2 ] })
await processViewsBuffer(servers)
await checkCounter('views', uuid, 1)
}
})
})
after(async function () {
await cleanupTests(servers)
})
})
describe('Disabling session id trusting', function () {
let videoUUID: string
before(async function () {
this.timeout(120000)
servers = await prepareViewsServers({ viewExpiration: '5 seconds', viewersFederationV2: true, trustViewerSessionId: false });
({ uuid: videoUUID } = await servers[0].videos.quickUpload({ name: 'video' }))
await waitJobs(servers)
})
it('Should not take into account session id if the server does not trust it', async function () {
await servers[0].views.simulateViewer({ id: videoUUID, sessionId: buildUUID(), currentTimes: [ 1, 4 ] })
await servers[0].views.simulateViewer({ id: videoUUID, sessionId: buildUUID(), currentTimes: [ 1, 4 ] })
await processViewsBuffer(servers)
await checkCounter('views', videoUUID, 1)
const xForwardedFor = '0.0.0.1,127.0.0.1'
await servers[0].views.simulateViewer({ id: videoUUID, xForwardedFor, sessionId: buildUUID(), currentTimes: [ 1, 4 ] })
await processViewsBuffer(servers)
await checkCounter('views', videoUUID, 2)
})
after(async function () {
await cleanupTests(servers)
})
})
})

View File

@ -1,11 +1,12 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { expect } from 'chai'
import { FfmpegCommand } from 'fluent-ffmpeg'
import { prepareViewsServers, prepareViewsVideos, processViewersStats } from '@tests/shared/views.js'
import { cleanupTests, PeerTubeServer, stopFfmpeg, waitJobs } from '@peertube/peertube-server-commands'
import { wait } from '@peertube/peertube-core-utils'
import { VideoStatsOverall } from '@peertube/peertube-models'
import { buildUUID } from '@peertube/peertube-node-utils'
import { PeerTubeServer, cleanupTests, stopFfmpeg, waitJobs } from '@peertube/peertube-server-commands'
import { prepareViewsServers, prepareViewsVideos, processViewersStats } from '@tests/shared/views.js'
import { expect } from 'chai'
import { FfmpegCommand } from 'fluent-ffmpeg'
/**
*
@ -16,39 +17,43 @@ import { VideoStatsOverall } from '@peertube/peertube-models'
* * user3 started and ended in the interval
* * user4 started and ended after end date
*/
async function simulateComplexViewers (servers: PeerTubeServer[], videoUUID: string) {
async function simulateComplexViewers (servers: PeerTubeServer[], videoUUID: string, useSessionId: boolean) {
const user0 = '8.8.8.8,127.0.0.1'
const user1 = '8.8.8.8,127.0.0.1'
const user2 = '8.8.8.9,127.0.0.1'
const user3 = '8.8.8.10,127.0.0.1'
const user4 = '8.8.8.11,127.0.0.1'
await servers[0].views.view({ id: videoUUID, currentTime: 0, xForwardedFor: user0 }) // User 0 starts
const sessionIdField = useSessionId
? 'sessionId'
: 'xForwardedFor'
await servers[0].views.view({ id: videoUUID, currentTime: 0, [sessionIdField]: user0 }) // User 0 starts
await wait(500)
await servers[0].views.view({ id: videoUUID, currentTime: 0, xForwardedFor: user1 }) // User 1 starts
await servers[0].views.view({ id: videoUUID, currentTime: 2, xForwardedFor: user0 }) // User 0 ends
await servers[0].views.view({ id: videoUUID, currentTime: 0, [sessionIdField]: user1 }) // User 1 starts
await servers[0].views.view({ id: videoUUID, currentTime: 2, [sessionIdField]: user0 }) // User 0 ends
await wait(500)
const startDate = new Date().toISOString()
await servers[0].views.view({ id: videoUUID, currentTime: 0, xForwardedFor: user2 }) // User 2 starts
await servers[0].views.view({ id: videoUUID, currentTime: 0, [sessionIdField]: user2 }) // User 2 starts
await wait(500)
await servers[0].views.view({ id: videoUUID, currentTime: 0, xForwardedFor: user3 }) // User 3 starts
await servers[0].views.view({ id: videoUUID, currentTime: 0, [sessionIdField]: user3 }) // User 3 starts
await wait(500)
await servers[0].views.view({ id: videoUUID, currentTime: 4, xForwardedFor: user1 }) // User 1 ends
await servers[0].views.view({ id: videoUUID, currentTime: 4, [sessionIdField]: user1 }) // User 1 ends
await wait(500)
await servers[0].views.view({ id: videoUUID, currentTime: 3, xForwardedFor: user3 }) // User 3 ends
await servers[0].views.view({ id: videoUUID, currentTime: 3, [sessionIdField]: user3 }) // User 3 ends
await wait(500)
const endDate = new Date().toISOString()
await servers[0].views.view({ id: videoUUID, currentTime: 0, xForwardedFor: user4 }) // User 4 starts
await servers[0].views.view({ id: videoUUID, currentTime: 5, xForwardedFor: user2 }) // User 2 ends
await servers[0].views.view({ id: videoUUID, currentTime: 0, [sessionIdField]: user4 }) // User 4 starts
await servers[0].views.view({ id: videoUUID, currentTime: 5, [sessionIdField]: user2 }) // User 2 ends
await wait(500)
await servers[0].views.view({ id: videoUUID, currentTime: 1, xForwardedFor: user4 }) // User 4 ends
await servers[0].views.view({ id: videoUUID, currentTime: 1, [sessionIdField]: user4 }) // User 4 ends
await processViewersStats(servers)
@ -58,61 +63,101 @@ async function simulateComplexViewers (servers: PeerTubeServer[], videoUUID: str
describe('Test views overall stats', function () {
let servers: PeerTubeServer[]
before(async function () {
this.timeout(120000)
function runTests (options: { useSessionId: boolean }) {
const { useSessionId } = options
servers = await prepareViewsServers()
})
const generateSessionId = () => {
if (!options.useSessionId) return undefined
describe('Test watch time stats of local videos on live and VOD', function () {
let vodVideoId: string
let liveVideoId: string
let command: FfmpegCommand
return buildUUID()
}
before(async function () {
this.timeout(240000);
this.timeout(120000)
({ vodVideoId, liveVideoId, ffmpegCommand: command } = await prepareViewsVideos({ servers, live: true, vod: true }))
servers = await prepareViewsServers()
})
it('Should display overall stats of a video with no viewers', async function () {
for (const videoId of [ liveVideoId, vodVideoId ]) {
const stats = await servers[0].videoStats.getOverallStats({ videoId })
const video = await servers[0].videos.get({ id: videoId })
describe('Test watch time stats of local videos on live and VOD', function () {
let vodVideoId: string
let liveVideoId: string
let command: FfmpegCommand
expect(video.views).to.equal(0)
expect(stats.averageWatchTime).to.equal(0)
expect(stats.totalWatchTime).to.equal(0)
expect(stats.totalViewers).to.equal(0)
}
})
before(async function () {
this.timeout(240000);
it('Should display overall stats with 1 viewer below the watch time limit', async function () {
this.timeout(60000)
({ vodVideoId, liveVideoId, ffmpegCommand: command } = await prepareViewsVideos({ servers, live: true, vod: true }))
})
for (const videoId of [ liveVideoId, vodVideoId ]) {
await servers[0].views.simulateViewer({ id: videoId, currentTimes: [ 0, 1 ] })
}
it('Should display overall stats of a video with no viewers', async function () {
for (const videoId of [ liveVideoId, vodVideoId ]) {
const stats = await servers[0].videoStats.getOverallStats({ videoId })
const video = await servers[0].videos.get({ id: videoId })
await processViewersStats(servers)
expect(video.views).to.equal(0)
expect(stats.averageWatchTime).to.equal(0)
expect(stats.totalWatchTime).to.equal(0)
expect(stats.totalViewers).to.equal(0)
}
})
for (const videoId of [ liveVideoId, vodVideoId ]) {
const stats = await servers[0].videoStats.getOverallStats({ videoId })
const video = await servers[0].videos.get({ id: videoId })
it('Should display overall stats with 1 viewer below the watch time limit', async function () {
this.timeout(60000)
expect(video.views).to.equal(0)
expect(stats.averageWatchTime).to.equal(1)
expect(stats.totalWatchTime).to.equal(1)
expect(stats.totalViewers).to.equal(1)
}
})
for (const videoId of [ liveVideoId, vodVideoId ]) {
await servers[0].views.simulateViewer({ id: videoId, sessionId: generateSessionId(), currentTimes: [ 0, 1 ] })
}
it('Should display overall stats with 2 viewers', async function () {
this.timeout(60000)
await processViewersStats(servers)
{
await servers[0].views.simulateViewer({ id: vodVideoId, currentTimes: [ 0, 3 ] })
await servers[0].views.simulateViewer({ id: liveVideoId, currentTimes: [ 0, 35, 40 ] })
for (const videoId of [ liveVideoId, vodVideoId ]) {
const stats = await servers[0].videoStats.getOverallStats({ videoId })
const video = await servers[0].videos.get({ id: videoId })
expect(video.views).to.equal(0)
expect(stats.averageWatchTime).to.equal(1)
expect(stats.totalWatchTime).to.equal(1)
expect(stats.totalViewers).to.equal(1)
}
})
it('Should display overall stats with 2 viewers', async function () {
this.timeout(60000)
{
await servers[0].views.simulateViewer({ id: vodVideoId, sessionId: generateSessionId(), currentTimes: [ 0, 3 ] })
await servers[0].views.simulateViewer({ id: liveVideoId, sessionId: generateSessionId(), currentTimes: [ 0, 35, 40 ] })
await processViewersStats(servers)
{
const stats = await servers[0].videoStats.getOverallStats({ videoId: vodVideoId })
const video = await servers[0].videos.get({ id: vodVideoId })
expect(video.views).to.equal(1)
expect(stats.averageWatchTime).to.equal(2)
expect(stats.totalWatchTime).to.equal(4)
expect(stats.totalViewers).to.equal(2)
}
{
const stats = await servers[0].videoStats.getOverallStats({ videoId: liveVideoId })
const video = await servers[0].videos.get({ id: liveVideoId })
expect(video.views).to.equal(1)
expect(stats.averageWatchTime).to.equal(21)
expect(stats.totalWatchTime).to.equal(41)
expect(stats.totalViewers).to.equal(2)
}
}
})
it('Should display overall stats with a remote viewer below the watch time limit', async function () {
this.timeout(60000)
for (const videoId of [ liveVideoId, vodVideoId ]) {
await servers[1].views.simulateViewer({ id: videoId, sessionId: generateSessionId(), currentTimes: [ 0, 2 ] })
}
await processViewersStats(servers)
@ -122,8 +167,8 @@ describe('Test views overall stats', function () {
expect(video.views).to.equal(1)
expect(stats.averageWatchTime).to.equal(2)
expect(stats.totalWatchTime).to.equal(4)
expect(stats.totalViewers).to.equal(2)
expect(stats.totalWatchTime).to.equal(6)
expect(stats.totalViewers).to.equal(3)
}
{
@ -131,270 +176,280 @@ describe('Test views overall stats', function () {
const video = await servers[0].videos.get({ id: liveVideoId })
expect(video.views).to.equal(1)
expect(stats.averageWatchTime).to.equal(21)
expect(stats.totalWatchTime).to.equal(41)
expect(stats.totalViewers).to.equal(2)
expect(stats.averageWatchTime).to.equal(14)
expect(stats.totalWatchTime).to.equal(43)
expect(stats.totalViewers).to.equal(3)
}
}
})
it('Should display overall stats with a remote viewer above the watch time limit', async function () {
this.timeout(60000)
await servers[1].views.simulateViewer({ id: vodVideoId, sessionId: generateSessionId(), currentTimes: [ 0, 5 ] })
await servers[1].views.simulateViewer({ id: liveVideoId, sessionId: generateSessionId(), currentTimes: [ 0, 45 ] })
await processViewersStats(servers)
{
const stats = await servers[0].videoStats.getOverallStats({ videoId: vodVideoId })
const video = await servers[0].videos.get({ id: vodVideoId })
expect(video.views).to.equal(2)
expect(stats.averageWatchTime).to.equal(3)
expect(stats.totalWatchTime).to.equal(11)
expect(stats.totalViewers).to.equal(4)
}
{
const stats = await servers[0].videoStats.getOverallStats({ videoId: liveVideoId })
const video = await servers[0].videos.get({ id: liveVideoId })
expect(video.views).to.equal(2)
expect(stats.averageWatchTime).to.equal(22)
expect(stats.totalWatchTime).to.equal(88)
expect(stats.totalViewers).to.equal(4)
}
})
it('Should filter overall stats by date', async function () {
this.timeout(60000)
const beforeView = new Date()
await servers[0].views.simulateViewer({ id: vodVideoId, sessionId: generateSessionId(), currentTimes: [ 0, 3 ] })
await processViewersStats(servers)
{
const stats = await servers[0].videoStats.getOverallStats({ videoId: vodVideoId, startDate: beforeView.toISOString() })
expect(stats.averageWatchTime).to.equal(3)
expect(stats.totalWatchTime).to.equal(3)
expect(stats.totalViewers).to.equal(1)
}
{
const stats = await servers[0].videoStats.getOverallStats({ videoId: liveVideoId, endDate: beforeView.toISOString() })
expect(stats.averageWatchTime).to.equal(22)
expect(stats.totalWatchTime).to.equal(88)
expect(stats.totalViewers).to.equal(4)
}
})
after(async function () {
await stopFfmpeg(command)
})
})
it('Should display overall stats with a remote viewer below the watch time limit', async function () {
this.timeout(60000)
describe('Test watchers peak stats of local videos on VOD', function () {
let videoUUID: string
let before2Watchers: Date
for (const videoId of [ liveVideoId, vodVideoId ]) {
await servers[1].views.simulateViewer({ id: videoId, currentTimes: [ 0, 2 ] })
}
before(async function () {
this.timeout(240000);
await processViewersStats(servers)
({ vodVideoId: videoUUID } = await prepareViewsVideos({ servers, live: true, vod: true }))
})
{
const stats = await servers[0].videoStats.getOverallStats({ videoId: vodVideoId })
const video = await servers[0].videos.get({ id: vodVideoId })
it('Should not have watchers peak', async function () {
const stats = await servers[0].videoStats.getOverallStats({ videoId: videoUUID })
expect(video.views).to.equal(1)
expect(stats.averageWatchTime).to.equal(2)
expect(stats.totalWatchTime).to.equal(6)
expect(stats.totalViewers).to.equal(3)
}
expect(stats.viewersPeak).to.equal(0)
expect(stats.viewersPeakDate).to.be.null
})
{
const stats = await servers[0].videoStats.getOverallStats({ videoId: liveVideoId })
const video = await servers[0].videos.get({ id: liveVideoId })
it('Should have watcher peak with 1 watcher', async function () {
this.timeout(60000)
expect(video.views).to.equal(1)
expect(stats.averageWatchTime).to.equal(14)
expect(stats.totalWatchTime).to.equal(43)
expect(stats.totalViewers).to.equal(3)
}
const before = new Date()
await servers[0].views.simulateViewer({ id: videoUUID, sessionId: generateSessionId(), currentTimes: [ 0, 2 ] })
const after = new Date()
await processViewersStats(servers)
const stats = await servers[0].videoStats.getOverallStats({ videoId: videoUUID })
expect(stats.viewersPeak).to.equal(1)
expect(new Date(stats.viewersPeakDate)).to.be.above(before).and.below(after)
})
it('Should have watcher peak with 2 watchers', async function () {
this.timeout(60000)
const sessionId = generateSessionId()
before2Watchers = new Date()
await servers[0].views.view({ id: videoUUID, sessionId, currentTime: 0 })
await servers[1].views.view({ id: videoUUID, sessionId, currentTime: 0 })
await servers[0].views.view({ id: videoUUID, sessionId, currentTime: 2 })
await servers[1].views.view({ id: videoUUID, sessionId, currentTime: 2 })
const after = new Date()
await processViewersStats(servers)
const stats = await servers[0].videoStats.getOverallStats({ videoId: videoUUID })
expect(stats.viewersPeak).to.equal(2)
expect(new Date(stats.viewersPeakDate)).to.be.above(before2Watchers).and.below(after)
})
it('Should filter peak viewers stats by date', async function () {
{
const stats = await servers[0].videoStats.getOverallStats({ videoId: videoUUID, startDate: new Date().toISOString() })
expect(stats.viewersPeak).to.equal(0)
expect(stats.viewersPeakDate).to.not.exist
}
{
const stats = await servers[0].videoStats.getOverallStats({ videoId: videoUUID, endDate: before2Watchers.toISOString() })
expect(stats.viewersPeak).to.equal(1)
expect(new Date(stats.viewersPeakDate)).to.be.below(before2Watchers)
}
})
it('Should complex filter peak viewers by date', async function () {
this.timeout(60000)
const { startDate, endDate } = await simulateComplexViewers(servers, videoUUID, useSessionId)
const expectCorrect = (stats: VideoStatsOverall) => {
expect(stats.viewersPeak).to.equal(3)
expect(new Date(stats.viewersPeakDate)).to.be.above(new Date(startDate)).and.below(new Date(endDate))
}
expectCorrect(await servers[0].videoStats.getOverallStats({ videoId: videoUUID, startDate, endDate }))
expectCorrect(await servers[0].videoStats.getOverallStats({ videoId: videoUUID, startDate }))
expectCorrect(await servers[0].videoStats.getOverallStats({ videoId: videoUUID, endDate }))
expectCorrect(await servers[0].videoStats.getOverallStats({ videoId: videoUUID }))
})
})
it('Should display overall stats with a remote viewer above the watch time limit', async function () {
this.timeout(60000)
describe('Test countries/subdivisions', function () {
let videoUUID: string
await servers[1].views.simulateViewer({ id: vodVideoId, currentTimes: [ 0, 5 ] })
await servers[1].views.simulateViewer({ id: liveVideoId, currentTimes: [ 0, 45 ] })
await processViewersStats(servers)
it('Should not report countries/subdivisions if geoip is disabled', async function () {
this.timeout(120000)
{
const stats = await servers[0].videoStats.getOverallStats({ videoId: vodVideoId })
const video = await servers[0].videos.get({ id: vodVideoId })
const { uuid } = await servers[0].videos.quickUpload({ name: 'video' })
await waitJobs(servers)
expect(video.views).to.equal(2)
expect(stats.averageWatchTime).to.equal(3)
expect(stats.totalWatchTime).to.equal(11)
expect(stats.totalViewers).to.equal(4)
}
await servers[1].views.view({ id: uuid, sessionId: generateSessionId(), xForwardedFor: '8.8.8.8,127.0.0.1', currentTime: 1 })
{
const stats = await servers[0].videoStats.getOverallStats({ videoId: liveVideoId })
const video = await servers[0].videos.get({ id: liveVideoId })
await processViewersStats(servers)
expect(video.views).to.equal(2)
expect(stats.averageWatchTime).to.equal(22)
expect(stats.totalWatchTime).to.equal(88)
expect(stats.totalViewers).to.equal(4)
}
})
const stats = await servers[0].videoStats.getOverallStats({ videoId: uuid })
expect(stats.countries).to.have.lengthOf(0)
expect(stats.subdivisions).to.have.lengthOf(0)
})
it('Should filter overall stats by date', async function () {
this.timeout(60000)
it('Should not report subdivisions if database URL is not provided in the configuration', async function () {
this.timeout(240000)
const beforeView = new Date()
const { uuid } = await servers[0].videos.quickUpload({ name: 'video without subdivisions' })
await waitJobs(servers)
await servers[0].views.simulateViewer({ id: vodVideoId, currentTimes: [ 0, 3 ] })
await processViewersStats(servers)
await Promise.all([ servers[0].kill(), servers[1].kill() ])
{
const stats = await servers[0].videoStats.getOverallStats({ videoId: vodVideoId, startDate: beforeView.toISOString() })
expect(stats.averageWatchTime).to.equal(3)
expect(stats.totalWatchTime).to.equal(3)
expect(stats.totalViewers).to.equal(1)
}
const config = { geo_ip: { enabled: true, city: { database_url: '' } } }
await Promise.all([ servers[0].run(config), servers[1].run(config) ])
{
const stats = await servers[0].videoStats.getOverallStats({ videoId: liveVideoId, endDate: beforeView.toISOString() })
expect(stats.averageWatchTime).to.equal(22)
expect(stats.totalWatchTime).to.equal(88)
expect(stats.totalViewers).to.equal(4)
}
await servers[0].views.simulateViewer({
id: uuid,
sessionId: generateSessionId(),
xForwardedFor: '8.8.8.8,127.0.0.1',
currentTimes: [ 1, 2 ]
})
await servers[1].views.simulateViewer({
id: uuid,
sessionId: generateSessionId(),
xForwardedFor: '8.8.8.4,127.0.0.1',
currentTimes: [ 3, 4 ]
})
await servers[1].views.simulateViewer({
id: uuid,
sessionId: generateSessionId(),
xForwardedFor: '80.67.169.12,127.0.0.1',
currentTimes: [ 2, 3 ]
})
await processViewersStats(servers)
const stats = await servers[0].videoStats.getOverallStats({ videoId: uuid })
expect(stats.countries).to.have.lengthOf(2)
expect(stats.subdivisions).to.have.lengthOf(0)
})
it('Should report countries/subdivisions if geoip is enabled', async function () {
this.timeout(240000)
const { uuid } = await servers[0].videos.quickUpload({ name: 'video' })
videoUUID = uuid
await waitJobs(servers)
await Promise.all([
servers[0].kill(),
servers[1].kill()
])
const config = { geo_ip: { enabled: true } }
await Promise.all([
servers[0].run(config),
servers[1].run(config)
])
await servers[0].views.simulateViewer({
id: uuid,
sessionId: generateSessionId(),
xForwardedFor: '8.8.8.8,127.0.0.1',
currentTimes: [ 1, 2 ]
})
await servers[1].views.simulateViewer({
id: uuid,
sessionId: generateSessionId(),
xForwardedFor: '8.8.8.4,127.0.0.1',
currentTimes: [ 3, 4 ]
})
await servers[1].views.simulateViewer({
id: uuid,
sessionId: generateSessionId(),
xForwardedFor: '80.67.169.12,127.0.0.1',
currentTimes: [ 2, 3 ]
})
await processViewersStats(servers)
const stats = await servers[0].videoStats.getOverallStats({ videoId: uuid })
expect(stats.countries).to.have.lengthOf(2)
expect(stats.countries[0].isoCode).to.equal('US')
expect(stats.countries[0].viewers).to.equal(2)
expect(stats.countries[1].isoCode).to.equal('FR')
expect(stats.countries[1].viewers).to.equal(1)
expect(stats.subdivisions[0].name).to.equal('California')
expect(stats.subdivisions[0].viewers).to.equal(2)
expect(stats.subdivisions[1].name).to.equal('Brittany')
expect(stats.subdivisions[1].viewers).to.equal(1)
})
it('Should filter countries/subdivisions stats by date', async function () {
const stats = await servers[0].videoStats.getOverallStats({ videoId: videoUUID, startDate: new Date().toISOString() })
expect(stats.countries).to.have.lengthOf(0)
expect(stats.subdivisions).to.have.lengthOf(0)
})
})
after(async function () {
await stopFfmpeg(command)
await cleanupTests(servers)
})
}
describe('Not using session id', function () {
runTests({ useSessionId: false })
})
describe('Test watchers peak stats of local videos on VOD', function () {
let videoUUID: string
let before2Watchers: Date
before(async function () {
this.timeout(240000);
({ vodVideoId: videoUUID } = await prepareViewsVideos({ servers, live: true, vod: true }))
})
it('Should not have watchers peak', async function () {
const stats = await servers[0].videoStats.getOverallStats({ videoId: videoUUID })
expect(stats.viewersPeak).to.equal(0)
expect(stats.viewersPeakDate).to.be.null
})
it('Should have watcher peak with 1 watcher', async function () {
this.timeout(60000)
const before = new Date()
await servers[0].views.simulateViewer({ id: videoUUID, currentTimes: [ 0, 2 ] })
const after = new Date()
await processViewersStats(servers)
const stats = await servers[0].videoStats.getOverallStats({ videoId: videoUUID })
expect(stats.viewersPeak).to.equal(1)
expect(new Date(stats.viewersPeakDate)).to.be.above(before).and.below(after)
})
it('Should have watcher peak with 2 watchers', async function () {
this.timeout(60000)
before2Watchers = new Date()
await servers[0].views.view({ id: videoUUID, currentTime: 0 })
await servers[1].views.view({ id: videoUUID, currentTime: 0 })
await servers[0].views.view({ id: videoUUID, currentTime: 2 })
await servers[1].views.view({ id: videoUUID, currentTime: 2 })
const after = new Date()
await processViewersStats(servers)
const stats = await servers[0].videoStats.getOverallStats({ videoId: videoUUID })
expect(stats.viewersPeak).to.equal(2)
expect(new Date(stats.viewersPeakDate)).to.be.above(before2Watchers).and.below(after)
})
it('Should filter peak viewers stats by date', async function () {
{
const stats = await servers[0].videoStats.getOverallStats({ videoId: videoUUID, startDate: new Date().toISOString() })
expect(stats.viewersPeak).to.equal(0)
expect(stats.viewersPeakDate).to.not.exist
}
{
const stats = await servers[0].videoStats.getOverallStats({ videoId: videoUUID, endDate: before2Watchers.toISOString() })
expect(stats.viewersPeak).to.equal(1)
expect(new Date(stats.viewersPeakDate)).to.be.below(before2Watchers)
}
})
it('Should complex filter peak viewers by date', async function () {
this.timeout(60000)
const { startDate, endDate } = await simulateComplexViewers(servers, videoUUID)
const expectCorrect = (stats: VideoStatsOverall) => {
expect(stats.viewersPeak).to.equal(3)
expect(new Date(stats.viewersPeakDate)).to.be.above(new Date(startDate)).and.below(new Date(endDate))
}
expectCorrect(await servers[0].videoStats.getOverallStats({ videoId: videoUUID, startDate, endDate }))
expectCorrect(await servers[0].videoStats.getOverallStats({ videoId: videoUUID, startDate }))
expectCorrect(await servers[0].videoStats.getOverallStats({ videoId: videoUUID, endDate }))
expectCorrect(await servers[0].videoStats.getOverallStats({ videoId: videoUUID }))
})
})
describe('Test countries/subdivisions', function () {
let videoUUID: string
it('Should not report countries/subdivisions if geoip is disabled', async function () {
this.timeout(120000)
const { uuid } = await servers[0].videos.quickUpload({ name: 'video' })
await waitJobs(servers)
await servers[1].views.view({ id: uuid, xForwardedFor: '8.8.8.8,127.0.0.1', currentTime: 1 })
await processViewersStats(servers)
const stats = await servers[0].videoStats.getOverallStats({ videoId: uuid })
expect(stats.countries).to.have.lengthOf(0)
expect(stats.subdivisions).to.have.lengthOf(0)
})
it('Should not report subdivisions if database URL is not provided in the configuration', async function () {
this.timeout(240000)
const { uuid } = await servers[0].videos.quickUpload({ name: 'video without subdivisions' })
await waitJobs(servers)
await Promise.all([ servers[0].kill(), servers[1].kill() ])
const config = { geo_ip: { enabled: true, city: { database_url: '' } } }
await Promise.all([ servers[0].run(config), servers[1].run(config) ])
await servers[0].views.simulateViewer({ id: uuid, xForwardedFor: '8.8.8.8,127.0.0.1', currentTimes: [ 1, 2 ] })
await servers[1].views.simulateViewer({ id: uuid, xForwardedFor: '8.8.8.4,127.0.0.1', currentTimes: [ 3, 4 ] })
await servers[1].views.simulateViewer({ id: uuid, xForwardedFor: '80.67.169.12,127.0.0.1', currentTimes: [ 2, 3 ] })
await processViewersStats(servers)
const stats = await servers[0].videoStats.getOverallStats({ videoId: uuid })
expect(stats.countries).to.have.lengthOf(2)
expect(stats.subdivisions).to.have.lengthOf(0)
})
it('Should report countries/subdivisions if geoip is enabled', async function () {
this.timeout(240000)
const { uuid } = await servers[0].videos.quickUpload({ name: 'video' })
videoUUID = uuid
await waitJobs(servers)
await Promise.all([
servers[0].kill(),
servers[1].kill()
])
const config = { geo_ip: { enabled: true } }
await Promise.all([
servers[0].run(config),
servers[1].run(config)
])
await servers[0].views.simulateViewer({ id: uuid, xForwardedFor: '8.8.8.8,127.0.0.1', currentTimes: [ 1, 2 ] })
await servers[1].views.simulateViewer({ id: uuid, xForwardedFor: '8.8.8.4,127.0.0.1', currentTimes: [ 3, 4 ] })
await servers[1].views.simulateViewer({ id: uuid, xForwardedFor: '80.67.169.12,127.0.0.1', currentTimes: [ 2, 3 ] })
await processViewersStats(servers)
const stats = await servers[0].videoStats.getOverallStats({ videoId: uuid })
expect(stats.countries).to.have.lengthOf(2)
expect(stats.countries[0].isoCode).to.equal('US')
expect(stats.countries[0].viewers).to.equal(2)
expect(stats.countries[1].isoCode).to.equal('FR')
expect(stats.countries[1].viewers).to.equal(1)
expect(stats.subdivisions[0].name).to.equal('California')
expect(stats.subdivisions[0].viewers).to.equal(2)
expect(stats.subdivisions[1].name).to.equal('Brittany')
expect(stats.subdivisions[1].viewers).to.equal(1)
})