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
25 changed files with 913 additions and 660 deletions

View File

@@ -1,4 +1,4 @@
import { buildUUID, isTestOrDevInstance, isUsingViewersFederationV2, sha256 } from '@peertube/peertube-node-utils'
import { isTestOrDevInstance, isUsingViewersFederationV2 } from '@peertube/peertube-node-utils'
import { exists } from '@server/helpers/custom-validators/misc.js'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { VIEW_LIFETIME } from '@server/initializers/constants.js'
@@ -28,8 +28,6 @@ export class VideoViewerCounters {
private readonly viewersPerVideo = new Map<number, Viewer[]>()
private readonly idToViewer = new Map<string, Viewer>()
private readonly salt = buildUUID()
private processingViewerCounters = false
constructor () {
@@ -40,13 +38,13 @@ export class VideoViewerCounters {
async addLocalViewer (options: {
video: MVideoImmutable
ip: string
sessionId: string
}) {
const { video, ip } = options
const { video, sessionId } = options
logger.debug('Adding local viewer to video viewers counter %s.', video.uuid, { ...lTags(video.uuid) })
const viewerId = this.generateViewerId(ip, video.uuid)
const viewerId = sessionId + '-' + video.uuid
const viewer = this.idToViewer.get(viewerId)
if (viewer) {
@@ -217,10 +215,6 @@ export class VideoViewerCounters {
logger.debug('Video viewers update for %s is %d.', video.url, totalViewers, lTags())
}
private generateViewerId (ip: string, videoUUID: string) {
return sha256(this.salt + '-' + ip + '-' + videoUUID)
}
private async federateViewerIfNeeded (video: MVideoImmutable, viewer: Viewer) {
// Federate the viewer if it's been a "long" time we did not
const now = new Date().getTime()

View File

@@ -1,4 +1,3 @@
import { Transaction } from 'sequelize'
import { VideoViewEvent } from '@peertube/peertube-models'
import { isTestOrDevInstance } from '@peertube/peertube-node-utils'
import { GeoIP } from '@server/helpers/geo-ip.js'
@@ -12,6 +11,7 @@ import { VideoModel } from '@server/models/video/video.js'
import { LocalVideoViewerWatchSectionModel } from '@server/models/view/local-video-viewer-watch-section.js'
import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer.js'
import { MVideo, MVideoImmutable } from '@server/types/models/index.js'
import { Transaction } from 'sequelize'
const lTags = loggerTagsFactory('views')
@@ -37,7 +37,7 @@ export class VideoViewerStats {
private processingRedisWrites = false
private readonly viewerCache = new Map<string, LocalViewerStats>()
private readonly redisPendingWrites = new Map<string, { ip: string, videoId: number, stats: LocalViewerStats }>()
private readonly redisPendingWrites = new Map<string, { sessionId: string, videoId: number, stats: LocalViewerStats }>()
constructor () {
setInterval(() => this.processViewerStats(), VIEW_LIFETIME.VIEWER_STATS)
@@ -50,35 +50,19 @@ export class VideoViewerStats {
video: MVideoImmutable
currentTime: number
ip: string
sessionId: string
viewEvent?: VideoViewEvent
}) {
const { video, ip, viewEvent, currentTime } = options
const { video, ip, viewEvent, currentTime, sessionId } = options
logger.debug('Adding local viewer to video stats %s.', video.uuid, { currentTime, viewEvent, ...lTags(video.uuid) })
logger.debug(
'Adding local viewer to video stats %s.', video.uuid,
{ currentTime, viewEvent, sessionId, ...lTags(video.uuid) }
)
return this.updateLocalViewerStats({ video, viewEvent, currentTime, ip })
}
// ---------------------------------------------------------------------------
async getWatchTime (videoId: number, ip: string) {
const stats: LocalViewerStats = await this.getLocalVideoViewerByIP({ ip, videoId })
return stats?.watchTime || 0
}
// ---------------------------------------------------------------------------
private async updateLocalViewerStats (options: {
video: MVideoImmutable
ip: string
currentTime: number
viewEvent?: VideoViewEvent
}) {
const { video, ip, viewEvent, currentTime } = options
const nowMs = new Date().getTime()
let stats: LocalViewerStats = await this.getLocalVideoViewerByIP({ ip, videoId: video.id })
let stats: LocalViewerStats = await this.getLocalVideoViewer({ sessionId, videoId: video.id })
if (stats && stats.watchSections.length >= MAX_LOCAL_VIEWER_WATCH_SECTIONS) {
logger.warn('Too much watch section to store for a viewer, skipping this one', { currentTime, viewEvent, ...lTags(video.uuid) })
@@ -129,9 +113,19 @@ export class VideoViewerStats {
logger.debug('Set local video viewer stats for video %s.', video.uuid, { stats, ...lTags(video.uuid) })
this.setLocalVideoViewer(ip, video.id, stats)
this.setLocalVideoViewer(sessionId, video.id, stats)
}
// ---------------------------------------------------------------------------
async getWatchTime (videoId: number, sessionId: string) {
const stats: LocalViewerStats = await this.getLocalVideoViewer({ sessionId, videoId })
return stats?.watchTime || 0
}
// ---------------------------------------------------------------------------
async processViewerStats () {
if (this.processingViewersStats) return
this.processingViewersStats = true
@@ -213,11 +207,11 @@ export class VideoViewerStats {
*
*/
private getLocalVideoViewerByIP (options: {
ip: string
private getLocalVideoViewer (options: {
sessionId: string
videoId: number
}): Promise<LocalViewerStats> {
const { viewerKey } = Redis.Instance.generateLocalVideoViewerKeys(options.ip, options.videoId)
const { viewerKey } = Redis.Instance.generateLocalVideoViewerKeys(options.sessionId, options.videoId)
return this.getLocalVideoViewerByKey(viewerKey)
}
@@ -229,11 +223,11 @@ export class VideoViewerStats {
return Redis.Instance.getLocalVideoViewer({ key })
}
private setLocalVideoViewer (ip: string, videoId: number, stats: LocalViewerStats) {
const { viewerKey } = Redis.Instance.generateLocalVideoViewerKeys(ip, videoId)
private setLocalVideoViewer (sessionId: string, videoId: number, stats: LocalViewerStats) {
const { viewerKey } = Redis.Instance.generateLocalVideoViewerKeys(sessionId, videoId)
this.viewerCache.set(viewerKey, stats)
this.redisPendingWrites.set(viewerKey, { ip, videoId, stats })
this.redisPendingWrites.set(viewerKey, { sessionId, videoId, stats })
}
private deleteLocalVideoViewersKeys (key: string) {
@@ -248,13 +242,13 @@ export class VideoViewerStats {
this.processingRedisWrites = true
for (const [ key, pendingWrite ] of this.redisPendingWrites) {
const { ip, videoId, stats } = pendingWrite
const { sessionId, videoId, stats } = pendingWrite
this.redisPendingWrites.delete(key)
try {
await Redis.Instance.setLocalVideoViewer(ip, videoId, stats)
await Redis.Instance.setLocalVideoViewer(sessionId, videoId, stats)
} catch (err) {
logger.error('Cannot write viewer into redis', { ip, videoId, stats, err })
logger.error('Cannot write viewer into redis', { sessionId, videoId, stats, err })
}
}

View File

@@ -1,12 +1,13 @@
import { buildUUID } from '@peertube/peertube-node-utils'
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { VIEW_LIFETIME } from '@server/initializers/constants.js'
import { sendView } from '@server/lib/activitypub/send/send-view.js'
import { getCachedVideoDuration } from '@server/lib/video.js'
import { getServerActor } from '@server/models/application/application.js'
import { MVideo, MVideoImmutable } from '@server/types/models/index.js'
import { buildUUID } from '@peertube/peertube-node-utils'
import { Redis } from '../../redis.js'
import { LRUCache } from 'lru-cache'
import { VIEW_LIFETIME } from '@server/initializers/constants.js'
import { Redis } from '../../redis.js'
import { CONFIG } from '@server/initializers/config.js'
const lTags = loggerTagsFactory('views')
@@ -19,19 +20,19 @@ export class VideoViews {
async addLocalView (options: {
video: MVideoImmutable
ip: string
sessionId: string
watchTime: number
}) {
const { video, ip, watchTime } = options
const { video, sessionId, watchTime } = options
logger.debug('Adding local view to video %s.', video.uuid, { watchTime, ...lTags(video.uuid) })
if (!await this.hasEnoughWatchTime(video, watchTime)) return false
const viewExists = await this.doesVideoIPViewExist(ip, video.uuid)
const viewExists = await this.doesVideoSessionIdViewExist(sessionId, video.uuid)
if (viewExists) return false
await this.setIPVideoView(ip, video.uuid)
await this.setSessionIdVideoView(sessionId, video.uuid)
await this.addView(video)
@@ -69,24 +70,25 @@ export class VideoViews {
private async hasEnoughWatchTime (video: MVideoImmutable, watchTime: number) {
const { duration, isLive } = await getCachedVideoDuration(video.id)
if (isLive || duration >= 30) return watchTime >= 30
const countViewAfterSeconds = CONFIG.VIEWS.VIDEOS.COUNT_VIEW_AFTER / 1000 // Config is in ms
if (isLive || duration >= countViewAfterSeconds) return watchTime >= countViewAfterSeconds
// Check more than 50% of the video is watched
return duration / watchTime < 2
}
private doesVideoIPViewExist (ip: string, videoUUID: string) {
const key = Redis.Instance.generateIPViewKey(ip, videoUUID)
private doesVideoSessionIdViewExist (sessionId: string, videoUUID: string) {
const key = Redis.Instance.generateSessionIdViewKey(sessionId, videoUUID)
const value = this.viewsCache.has(key)
if (value === true) return Promise.resolve(true)
return Redis.Instance.doesVideoIPViewExist(ip, videoUUID)
return Redis.Instance.doesVideoSessionIdViewExist(sessionId, videoUUID)
}
private setIPVideoView (ip: string, videoUUID: string) {
const key = Redis.Instance.generateIPViewKey(ip, videoUUID)
private setSessionIdVideoView (sessionId: string, videoUUID: string) {
const key = Redis.Instance.generateSessionIdViewKey(sessionId, videoUUID)
this.viewsCache.set(key, true)
return Redis.Instance.setIPVideoView(ip, videoUUID)
return Redis.Instance.setSessionIdVideoView(sessionId, videoUUID)
}
}

View File

@@ -1,6 +1,8 @@
import { logger, loggerTagsFactory } from '@server/helpers/logger.js'
import { MVideo, MVideoImmutable } from '@server/types/models/index.js'
import { VideoViewEvent } from '@peertube/peertube-models'
import { sha256 } from '@peertube/peertube-node-utils'
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'
/**
@@ -44,20 +46,26 @@ export class VideoViewsManager {
video: MVideoImmutable
currentTime: number
ip: string | null
sessionId?: string
viewEvent?: VideoViewEvent
}) {
const { video, ip, viewEvent, currentTime } = options
logger.debug('Processing local view for %s and ip %s.', video.url, ip, lTags())
let sessionId = options.sessionId
if (!sessionId || CONFIG.VIEWS.VIDEOS.TRUST_VIEWER_SESSION_ID !== true) {
sessionId = sha256(CONFIG.SECRETS + '-' + ip)
}
await this.videoViewerStats.addLocalViewer({ video, ip, viewEvent, currentTime })
logger.debug(`Processing local view for ${video.url}, ip ${ip} and session id ${sessionId}.`, lTags())
const successViewer = await this.videoViewerCounters.addLocalViewer({ video, ip })
await this.videoViewerStats.addLocalViewer({ video, ip, sessionId, viewEvent, currentTime })
const successViewer = await this.videoViewerCounters.addLocalViewer({ video, sessionId })
// Do it after added local viewer to fetch updated information
const watchTime = await this.videoViewerStats.getWatchTime(video.id, ip)
const watchTime = await this.videoViewerStats.getWatchTime(video.id, sessionId)
const successView = await this.videoViews.addLocalView({ video, watchTime, ip })
const successView = await this.videoViews.addLocalView({ video, watchTime, sessionId })
return { successView, successViewer }
}