mirror of https://github.com/Chocobozzz/PeerTube
				
				
				
			
		
			
				
	
	
		
			198 lines
		
	
	
		
			5.7 KiB
		
	
	
	
		
			TypeScript
		
	
	
			
		
		
	
	
			198 lines
		
	
	
		
			5.7 KiB
		
	
	
	
		
			TypeScript
		
	
	
| import { buildUUID, isTestOrDevInstance, sha256 } 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 { PeerTubeSocket } from '@server/lib/peertube-socket.js'
 | |
| import { getServerActor } from '@server/models/application/application.js'
 | |
| import { VideoModel } from '@server/models/video/video.js'
 | |
| import { MVideo, MVideoImmutable } from '@server/types/models/index.js'
 | |
| 
 | |
| const lTags = loggerTagsFactory('views')
 | |
| 
 | |
| export type ViewerScope = 'local' | 'remote'
 | |
| export type VideoScope = 'local' | 'remote'
 | |
| 
 | |
| type Viewer = {
 | |
|   expires: number
 | |
|   id: string
 | |
|   viewerScope: ViewerScope
 | |
|   videoScope: VideoScope
 | |
|   lastFederation?: number
 | |
| }
 | |
| 
 | |
| export class VideoViewerCounters {
 | |
| 
 | |
|   // expires is new Date().getTime()
 | |
|   private readonly viewersPerVideo = new Map<number, Viewer[]>()
 | |
|   private readonly idToViewer = new Map<string, Viewer>()
 | |
| 
 | |
|   private readonly salt = buildUUID()
 | |
| 
 | |
|   private processingViewerCounters = false
 | |
| 
 | |
|   constructor () {
 | |
|     setInterval(() => this.cleanViewerCounters(), VIEW_LIFETIME.VIEWER_COUNTER)
 | |
|   }
 | |
| 
 | |
|   // ---------------------------------------------------------------------------
 | |
| 
 | |
|   async addLocalViewer (options: {
 | |
|     video: MVideoImmutable
 | |
|     ip: string
 | |
|   }) {
 | |
|     const { video, ip } = options
 | |
| 
 | |
|     logger.debug('Adding local viewer to video viewers counter %s.', video.uuid, { ...lTags(video.uuid) })
 | |
| 
 | |
|     const viewerId = this.generateViewerId(ip, video.uuid)
 | |
|     const viewer = this.idToViewer.get(viewerId)
 | |
| 
 | |
|     if (viewer) {
 | |
|       viewer.expires = this.buildViewerExpireTime()
 | |
|       await this.federateViewerIfNeeded(video, viewer)
 | |
| 
 | |
|       return false
 | |
|     }
 | |
| 
 | |
|     const newViewer = await this.addViewerToVideo({ viewerId, video, viewerScope: 'local' })
 | |
|     await this.federateViewerIfNeeded(video, newViewer)
 | |
| 
 | |
|     return true
 | |
|   }
 | |
| 
 | |
|   async addRemoteViewer (options: {
 | |
|     video: MVideo
 | |
|     viewerId: string
 | |
|     viewerExpires: Date
 | |
|   }) {
 | |
|     const { video, viewerExpires, viewerId } = options
 | |
| 
 | |
|     logger.debug('Adding remote viewer to video %s.', video.uuid, { ...lTags(video.uuid) })
 | |
| 
 | |
|     await this.addViewerToVideo({ video, viewerExpires, viewerId, viewerScope: 'remote' })
 | |
| 
 | |
|     return true
 | |
|   }
 | |
| 
 | |
|   // ---------------------------------------------------------------------------
 | |
| 
 | |
|   getTotalViewers (options: {
 | |
|     viewerScope: ViewerScope
 | |
|     videoScope: VideoScope
 | |
|   }) {
 | |
|     let total = 0
 | |
| 
 | |
|     for (const viewers of this.viewersPerVideo.values()) {
 | |
|       total += viewers.filter(v => v.viewerScope === options.viewerScope && v.videoScope === options.videoScope).length
 | |
|     }
 | |
| 
 | |
|     return total
 | |
|   }
 | |
| 
 | |
|   getViewers (video: MVideo) {
 | |
|     const viewers = this.viewersPerVideo.get(video.id)
 | |
|     if (!viewers) return 0
 | |
| 
 | |
|     return viewers.length
 | |
|   }
 | |
| 
 | |
|   buildViewerExpireTime () {
 | |
|     return new Date().getTime() + VIEW_LIFETIME.VIEWER_COUNTER
 | |
|   }
 | |
| 
 | |
|   // ---------------------------------------------------------------------------
 | |
| 
 | |
|   private async addViewerToVideo (options: {
 | |
|     video: MVideoImmutable
 | |
|     viewerId: string
 | |
|     viewerScope: ViewerScope
 | |
|     viewerExpires?: Date
 | |
|   }) {
 | |
|     const { video, viewerExpires, viewerId, viewerScope } = options
 | |
| 
 | |
|     let watchers = this.viewersPerVideo.get(video.id)
 | |
| 
 | |
|     if (!watchers) {
 | |
|       watchers = []
 | |
|       this.viewersPerVideo.set(video.id, watchers)
 | |
|     }
 | |
| 
 | |
|     const expires = viewerExpires
 | |
|       ? viewerExpires.getTime()
 | |
|       : this.buildViewerExpireTime()
 | |
| 
 | |
|     const videoScope: VideoScope = video.remote
 | |
|       ? 'remote'
 | |
|       : 'local'
 | |
| 
 | |
|     const viewer = { id: viewerId, expires, videoScope, viewerScope }
 | |
|     watchers.push(viewer)
 | |
| 
 | |
|     this.idToViewer.set(viewerId, viewer)
 | |
| 
 | |
|     await this.notifyClients(video.id, watchers.length)
 | |
| 
 | |
|     return viewer
 | |
|   }
 | |
| 
 | |
|   private async cleanViewerCounters () {
 | |
|     if (this.processingViewerCounters) return
 | |
|     this.processingViewerCounters = true
 | |
| 
 | |
|     if (!isTestOrDevInstance()) logger.info('Cleaning video viewers.', lTags())
 | |
| 
 | |
|     try {
 | |
|       for (const videoId of this.viewersPerVideo.keys()) {
 | |
|         const notBefore = new Date().getTime()
 | |
| 
 | |
|         const viewers = this.viewersPerVideo.get(videoId)
 | |
| 
 | |
|         // Only keep not expired viewers
 | |
|         const newViewers: Viewer[] = []
 | |
| 
 | |
|         // Filter new viewers
 | |
|         for (const viewer of viewers) {
 | |
|           if (viewer.expires > notBefore) {
 | |
|             newViewers.push(viewer)
 | |
|           } else {
 | |
|             this.idToViewer.delete(viewer.id)
 | |
|           }
 | |
|         }
 | |
| 
 | |
|         if (newViewers.length === 0) this.viewersPerVideo.delete(videoId)
 | |
|         else this.viewersPerVideo.set(videoId, newViewers)
 | |
| 
 | |
|         await this.notifyClients(videoId, newViewers.length)
 | |
|       }
 | |
|     } catch (err) {
 | |
|       logger.error('Error in video clean viewers scheduler.', { err, ...lTags() })
 | |
|     }
 | |
| 
 | |
|     this.processingViewerCounters = false
 | |
|   }
 | |
| 
 | |
|   private async notifyClients (videoId: string | number, viewersLength: number) {
 | |
|     const video = await VideoModel.loadImmutableAttributes(videoId)
 | |
|     if (!video) return
 | |
| 
 | |
|     PeerTubeSocket.Instance.sendVideoViewsUpdate(video, viewersLength)
 | |
| 
 | |
|     logger.debug('Video viewers update for %s is %d.', video.url, viewersLength, 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()
 | |
|     const federationLimit = now - (VIEW_LIFETIME.VIEWER_COUNTER * 0.75)
 | |
| 
 | |
|     if (viewer.lastFederation && viewer.lastFederation > federationLimit) return
 | |
| 
 | |
|     await sendView({ byActor: await getServerActor(), video, type: 'viewer', viewerIdentifier: viewer.id })
 | |
|     viewer.lastFederation = now
 | |
|   }
 | |
| }
 |