1 import { isTestOrDevInstance } from '@server/helpers/core-utils'
2 import { logger, loggerTagsFactory } from '@server/helpers/logger'
3 import { VIEW_LIFETIME } from '@server/initializers/constants'
4 import { sendView } from '@server/lib/activitypub/send/send-view'
5 import { PeerTubeSocket } from '@server/lib/peertube-socket'
6 import { getServerActor } from '@server/models/application/application'
7 import { VideoModel } from '@server/models/video/video'
8 import { MVideo, MVideoImmutable } from '@server/types/models'
9 import { buildUUID, sha256 } from '@shared/extra-utils'
11 const lTags = loggerTagsFactory('views')
16 lastFederation?: number
19 export class VideoViewerCounters {
21 // expires is new Date().getTime()
22 private readonly viewersPerVideo = new Map<number, Viewer[]>()
23 private readonly idToViewer = new Map<string, Viewer>()
25 private readonly salt = buildUUID()
27 private processingViewerCounters = false
30 setInterval(() => this.cleanViewerCounters(), VIEW_LIFETIME.VIEWER_COUNTER)
33 // ---------------------------------------------------------------------------
35 async addLocalViewer (options: {
36 video: MVideoImmutable
39 const { video, ip } = options
41 logger.debug('Adding local viewer to video viewers counter %s.', video.uuid, { ...lTags(video.uuid) })
43 const viewerId = this.generateViewerId(ip, video.uuid)
44 const viewer = this.idToViewer.get(viewerId)
47 viewer.expires = this.buildViewerExpireTime()
48 await this.federateViewerIfNeeded(video, viewer)
53 const newViewer = await this.addViewerToVideo({ viewerId, video })
54 await this.federateViewerIfNeeded(video, newViewer)
59 async addRemoteViewer (options: {
64 const { video, viewerExpires, viewerId } = options
66 logger.debug('Adding remote viewer to video %s.', video.uuid, { ...lTags(video.uuid) })
68 await this.addViewerToVideo({ video, viewerExpires, viewerId })
73 // ---------------------------------------------------------------------------
75 getViewers (video: MVideo) {
76 const viewers = this.viewersPerVideo.get(video.id)
77 if (!viewers) return 0
82 buildViewerExpireTime () {
83 return new Date().getTime() + VIEW_LIFETIME.VIEWER_COUNTER
86 // ---------------------------------------------------------------------------
88 private async addViewerToVideo (options: {
89 video: MVideoImmutable
93 const { video, viewerExpires, viewerId } = options
95 let watchers = this.viewersPerVideo.get(video.id)
99 this.viewersPerVideo.set(video.id, watchers)
102 const expires = viewerExpires
103 ? viewerExpires.getTime()
104 : this.buildViewerExpireTime()
106 const viewer = { id: viewerId, expires }
107 watchers.push(viewer)
109 this.idToViewer.set(viewerId, viewer)
111 await this.notifyClients(video.id, watchers.length)
116 private async cleanViewerCounters () {
117 if (this.processingViewerCounters) return
118 this.processingViewerCounters = true
120 if (!isTestOrDevInstance()) logger.info('Cleaning video viewers.', lTags())
123 for (const videoId of this.viewersPerVideo.keys()) {
124 const notBefore = new Date().getTime()
126 const viewers = this.viewersPerVideo.get(videoId)
128 // Only keep not expired viewers
129 const newViewers: Viewer[] = []
131 // Filter new viewers
132 for (const viewer of viewers) {
133 if (viewer.expires > notBefore) {
134 newViewers.push(viewer)
136 this.idToViewer.delete(viewer.id)
140 if (newViewers.length === 0) this.viewersPerVideo.delete(videoId)
141 else this.viewersPerVideo.set(videoId, newViewers)
143 await this.notifyClients(videoId, newViewers.length)
146 logger.error('Error in video clean viewers scheduler.', { err, ...lTags() })
149 this.processingViewerCounters = false
152 private async notifyClients (videoId: string | number, viewersLength: number) {
153 const video = await VideoModel.loadImmutableAttributes(videoId)
156 PeerTubeSocket.Instance.sendVideoViewsUpdate(video, viewersLength)
158 logger.debug('Video viewers update for %s is %d.', video.url, viewersLength, lTags())
161 private generateViewerId (ip: string, videoUUID: string) {
162 return sha256(this.salt + '-' + ip + '-' + videoUUID)
165 private async federateViewerIfNeeded (video: MVideoImmutable, viewer: Viewer) {
166 // Federate the viewer if it's been a "long" time we did not
167 const now = new Date().getTime()
168 const federationLimit = now - (VIEW_LIFETIME.VIEWER_COUNTER * 0.75)
170 if (viewer.lastFederation && viewer.lastFederation > federationLimit) return
172 await sendView({ byActor: await getServerActor(), video, type: 'viewer', viewerIdentifier: viewer.id })
173 viewer.lastFederation = now