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')
13 export type ViewerScope = 'local' | 'remote'
14 export type VideoScope = 'local' | 'remote'
19 viewerScope: ViewerScope
20 videoScope: VideoScope
21 lastFederation?: number
24 export class VideoViewerCounters {
26 // expires is new Date().getTime()
27 private readonly viewersPerVideo = new Map<number, Viewer[]>()
28 private readonly idToViewer = new Map<string, Viewer>()
30 private readonly salt = buildUUID()
32 private processingViewerCounters = false
35 setInterval(() => this.cleanViewerCounters(), VIEW_LIFETIME.VIEWER_COUNTER)
38 // ---------------------------------------------------------------------------
40 async addLocalViewer (options: {
41 video: MVideoImmutable
44 const { video, ip } = options
46 logger.debug('Adding local viewer to video viewers counter %s.', video.uuid, { ...lTags(video.uuid) })
48 const viewerId = this.generateViewerId(ip, video.uuid)
49 const viewer = this.idToViewer.get(viewerId)
52 viewer.expires = this.buildViewerExpireTime()
53 await this.federateViewerIfNeeded(video, viewer)
58 const newViewer = await this.addViewerToVideo({ viewerId, video, viewerScope: 'local' })
59 await this.federateViewerIfNeeded(video, newViewer)
64 async addRemoteViewer (options: {
69 const { video, viewerExpires, viewerId } = options
71 logger.debug('Adding remote viewer to video %s.', video.uuid, { ...lTags(video.uuid) })
73 await this.addViewerToVideo({ video, viewerExpires, viewerId, viewerScope: 'remote' })
78 // ---------------------------------------------------------------------------
80 getTotalViewers (options: {
81 viewerScope: ViewerScope
82 videoScope: VideoScope
86 for (const viewers of this.viewersPerVideo.values()) {
87 total += viewers.filter(v => v.viewerScope === options.viewerScope && v.videoScope === options.videoScope).length
93 getViewers (video: MVideo) {
94 const viewers = this.viewersPerVideo.get(video.id)
95 if (!viewers) return 0
100 buildViewerExpireTime () {
101 return new Date().getTime() + VIEW_LIFETIME.VIEWER_COUNTER
104 // ---------------------------------------------------------------------------
106 private async addViewerToVideo (options: {
107 video: MVideoImmutable
109 viewerScope: ViewerScope
112 const { video, viewerExpires, viewerId, viewerScope } = options
114 let watchers = this.viewersPerVideo.get(video.id)
118 this.viewersPerVideo.set(video.id, watchers)
121 const expires = viewerExpires
122 ? viewerExpires.getTime()
123 : this.buildViewerExpireTime()
125 const videoScope: VideoScope = video.remote
129 const viewer = { id: viewerId, expires, videoScope, viewerScope }
130 watchers.push(viewer)
132 this.idToViewer.set(viewerId, viewer)
134 await this.notifyClients(video.id, watchers.length)
139 private async cleanViewerCounters () {
140 if (this.processingViewerCounters) return
141 this.processingViewerCounters = true
143 if (!isTestOrDevInstance()) logger.info('Cleaning video viewers.', lTags())
146 for (const videoId of this.viewersPerVideo.keys()) {
147 const notBefore = new Date().getTime()
149 const viewers = this.viewersPerVideo.get(videoId)
151 // Only keep not expired viewers
152 const newViewers: Viewer[] = []
154 // Filter new viewers
155 for (const viewer of viewers) {
156 if (viewer.expires > notBefore) {
157 newViewers.push(viewer)
159 this.idToViewer.delete(viewer.id)
163 if (newViewers.length === 0) this.viewersPerVideo.delete(videoId)
164 else this.viewersPerVideo.set(videoId, newViewers)
166 await this.notifyClients(videoId, newViewers.length)
169 logger.error('Error in video clean viewers scheduler.', { err, ...lTags() })
172 this.processingViewerCounters = false
175 private async notifyClients (videoId: string | number, viewersLength: number) {
176 const video = await VideoModel.loadImmutableAttributes(videoId)
179 PeerTubeSocket.Instance.sendVideoViewsUpdate(video, viewersLength)
181 logger.debug('Video viewers update for %s is %d.', video.url, viewersLength, lTags())
184 private generateViewerId (ip: string, videoUUID: string) {
185 return sha256(this.salt + '-' + ip + '-' + videoUUID)
188 private async federateViewerIfNeeded (video: MVideoImmutable, viewer: Viewer) {
189 // Federate the viewer if it's been a "long" time we did not
190 const now = new Date().getTime()
191 const federationLimit = now - (VIEW_LIFETIME.VIEWER_COUNTER * 0.75)
193 if (viewer.lastFederation && viewer.lastFederation > federationLimit) return
195 await sendView({ byActor: await getServerActor(), video, type: 'viewer', viewerIdentifier: viewer.id })
196 viewer.lastFederation = now