2 import { isTestInstance } from '@server/helpers/core-utils'
3 import { logger, loggerTagsFactory } from '@server/helpers/logger'
4 import { VIEW_LIFETIME } from '@server/initializers/constants'
5 import { sendView } from '@server/lib/activitypub/send/send-view'
6 import { PeerTubeSocket } from '@server/lib/peertube-socket'
7 import { getServerActor } from '@server/models/application/application'
8 import { VideoModel } from '@server/models/video/video'
9 import { MVideo } from '@server/types/models'
10 import { buildUUID, sha256 } from '@shared/extra-utils'
12 const lTags = loggerTagsFactory('views')
17 lastFederation?: number
20 export class VideoViewerCounters {
22 // expires is new Date().getTime()
23 private readonly viewersPerVideo = new Map<number, Viewer[]>()
24 private readonly idToViewer = new Map<string, Viewer>()
26 private readonly salt = buildUUID()
28 private processingViewerCounters = false
31 setInterval(() => this.cleanViewerCounters(), VIEW_LIFETIME.VIEWER_COUNTER)
34 // ---------------------------------------------------------------------------
36 async addLocalViewer (options: {
40 const { video, ip } = options
42 logger.debug('Adding local viewer to video viewers counter %s.', video.uuid, { ...lTags(video.uuid) })
44 const viewerId = this.generateViewerId(ip, video.uuid)
45 const viewer = this.idToViewer.get(viewerId)
48 viewer.expires = this.buildViewerExpireTime()
49 await this.federateViewerIfNeeded(video, viewer)
54 const newViewer = await this.addViewerToVideo({ viewerId, video })
55 await this.federateViewerIfNeeded(video, newViewer)
60 async addRemoteViewer (options: {
65 const { video, viewerExpires, viewerId } = options
67 logger.debug('Adding remote viewer to video %s.', video.uuid, { ...lTags(video.uuid) })
69 await this.addViewerToVideo({ video, viewerExpires, viewerId })
74 // ---------------------------------------------------------------------------
76 getViewers (video: MVideo) {
77 const viewers = this.viewersPerVideo.get(video.id)
78 if (!viewers) return 0
83 buildViewerExpireTime () {
84 return new Date().getTime() + VIEW_LIFETIME.VIEWER_COUNTER
87 // ---------------------------------------------------------------------------
89 private async addViewerToVideo (options: {
94 const { video, viewerExpires, viewerId } = options
96 let watchers = this.viewersPerVideo.get(video.id)
100 this.viewersPerVideo.set(video.id, watchers)
103 const expires = viewerExpires
104 ? viewerExpires.getTime()
105 : this.buildViewerExpireTime()
107 const viewer = { id: viewerId, expires }
108 watchers.push(viewer)
110 this.idToViewer.set(viewerId, viewer)
112 await this.notifyClients(video.id, watchers.length)
117 private async cleanViewerCounters () {
118 if (this.processingViewerCounters) return
119 this.processingViewerCounters = true
121 if (!isTestInstance()) logger.info('Cleaning video viewers.', lTags())
124 for (const videoId of this.viewersPerVideo.keys()) {
125 const notBefore = new Date().getTime()
127 const viewers = this.viewersPerVideo.get(videoId)
129 // Only keep not expired viewers
130 const newViewers: Viewer[] = []
132 // Filter new viewers
133 for (const viewer of viewers) {
134 if (viewer.expires > notBefore) {
135 newViewers.push(viewer)
137 this.idToViewer.delete(viewer.id)
141 if (newViewers.length === 0) this.viewersPerVideo.delete(videoId)
142 else this.viewersPerVideo.set(videoId, newViewers)
144 await this.notifyClients(videoId, newViewers.length)
147 logger.error('Error in video clean viewers scheduler.', { err, ...lTags() })
150 this.processingViewerCounters = false
153 private async notifyClients (videoId: string | number, viewersLength: number) {
154 const video = await VideoModel.loadImmutableAttributes(videoId)
157 PeerTubeSocket.Instance.sendVideoViewsUpdate(video, viewersLength)
159 logger.debug('Video viewers update for %s is %d.', video.url, viewersLength, lTags())
162 private generateViewerId (ip: string, videoUUID: string) {
163 return sha256(this.salt + '-' + ip + '-' + videoUUID)
166 private async federateViewerIfNeeded (video: MVideo, viewer: Viewer) {
167 // Federate the viewer if it's been a "long" time we did not
168 const now = new Date().getTime()
169 const federationLimit = now - (VIEW_LIFETIME.VIEWER_COUNTER / 2)
171 if (viewer.lastFederation && viewer.lastFederation > federationLimit) return
173 await sendView({ byActor: await getServerActor(), video, type: 'viewer', viewerIdentifier: viewer.id })
174 viewer.lastFederation = now