1 import { Transaction } from 'sequelize/types'
2 import { isTestInstance } from '@server/helpers/core-utils'
3 import { GeoIP } from '@server/helpers/geo-ip'
4 import { logger, loggerTagsFactory } from '@server/helpers/logger'
5 import { MAX_LOCAL_VIEWER_WATCH_SECTIONS, VIEW_LIFETIME } from '@server/initializers/constants'
6 import { sequelizeTypescript } from '@server/initializers/database'
7 import { sendCreateWatchAction } from '@server/lib/activitypub/send'
8 import { getLocalVideoViewerActivityPubUrl } from '@server/lib/activitypub/url'
9 import { PeerTubeSocket } from '@server/lib/peertube-socket'
10 import { Redis } from '@server/lib/redis'
11 import { VideoModel } from '@server/models/video/video'
12 import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer'
13 import { LocalVideoViewerWatchSectionModel } from '@server/models/view/local-video-viewer-watch-section'
14 import { MVideo } from '@server/types/models'
15 import { VideoViewEvent } from '@shared/models'
17 const lTags = loggerTagsFactory('views')
19 type LocalViewerStats = {
20 firstUpdated: number // Date.getTime()
21 lastUpdated: number // Date.getTime()
35 export class VideoViewers {
37 // Values are Date().getTime()
38 private readonly viewersPerVideo = new Map<number, number[]>()
40 private processingViewerCounters = false
41 private processingViewerStats = false
44 setInterval(() => this.cleanViewerCounters(), VIEW_LIFETIME.VIEWER)
46 setInterval(() => this.processViewerStats(), VIEW_LIFETIME.VIEWER_STATS)
49 // ---------------------------------------------------------------------------
51 getViewers (video: MVideo) {
52 const viewers = this.viewersPerVideo.get(video.id)
53 if (!viewers) return 0
58 buildViewerExpireTime () {
59 return new Date().getTime() + VIEW_LIFETIME.VIEWER
62 async getWatchTime (videoId: number, ip: string) {
63 const stats: LocalViewerStats = await Redis.Instance.getLocalVideoViewer({ ip, videoId })
65 return stats?.watchTime || 0
68 async addLocalViewer (options: {
72 viewEvent?: VideoViewEvent
74 const { video, ip, viewEvent, currentTime } = options
76 logger.debug('Adding local viewer to video %s.', video.uuid, { currentTime, viewEvent, ...lTags(video.uuid) })
78 await this.updateLocalViewerStats({ video, viewEvent, currentTime, ip })
80 const viewExists = await Redis.Instance.doesVideoIPViewerExist(ip, video.uuid)
81 if (viewExists) return false
83 await Redis.Instance.setIPVideoViewer(ip, video.uuid)
85 return this.addViewerToVideo({ video })
88 async addRemoteViewer (options: {
92 const { video, viewerExpires } = options
94 logger.debug('Adding remote viewer to video %s.', video.uuid, { ...lTags(video.uuid) })
96 return this.addViewerToVideo({ video, viewerExpires })
99 private async addViewerToVideo (options: {
103 const { video, viewerExpires } = options
105 let watchers = this.viewersPerVideo.get(video.id)
109 this.viewersPerVideo.set(video.id, watchers)
112 const expiration = viewerExpires
113 ? viewerExpires.getTime()
114 : this.buildViewerExpireTime()
116 watchers.push(expiration)
117 await this.notifyClients(video.id, watchers.length)
122 private async updateLocalViewerStats (options: {
126 viewEvent?: VideoViewEvent
128 const { video, ip, viewEvent, currentTime } = options
129 const nowMs = new Date().getTime()
131 let stats: LocalViewerStats = await Redis.Instance.getLocalVideoViewer({ ip, videoId: video.id })
133 if (stats && stats.watchSections.length >= MAX_LOCAL_VIEWER_WATCH_SECTIONS) {
134 logger.warn('Too much watch section to store for a viewer, skipping this one', { currentTime, viewEvent, ...lTags(video.uuid) })
139 const country = await GeoIP.Instance.safeCountryISOLookup(ip)
154 stats.lastUpdated = nowMs
156 if (viewEvent === 'seek' || stats.watchSections.length === 0) {
157 stats.watchSections.push({
162 const lastSection = stats.watchSections[stats.watchSections.length - 1]
163 lastSection.end = currentTime
166 stats.watchTime = this.buildWatchTimeFromSections(stats.watchSections)
168 logger.debug('Set local video viewer stats for video %s.', video.uuid, { stats, ...lTags(video.uuid) })
170 await Redis.Instance.setLocalVideoViewer(ip, video.id, stats)
173 private async cleanViewerCounters () {
174 if (this.processingViewerCounters) return
175 this.processingViewerCounters = true
177 if (!isTestInstance()) logger.info('Cleaning video viewers.', lTags())
180 for (const videoId of this.viewersPerVideo.keys()) {
181 const notBefore = new Date().getTime()
183 const viewers = this.viewersPerVideo.get(videoId)
185 // Only keep not expired viewers
186 const newViewers = viewers.filter(w => w > notBefore)
188 if (newViewers.length === 0) this.viewersPerVideo.delete(videoId)
189 else this.viewersPerVideo.set(videoId, newViewers)
191 await this.notifyClients(videoId, newViewers.length)
194 logger.error('Error in video clean viewers scheduler.', { err, ...lTags() })
197 this.processingViewerCounters = false
200 private async notifyClients (videoId: string | number, viewersLength: number) {
201 const video = await VideoModel.loadImmutableAttributes(videoId)
204 PeerTubeSocket.Instance.sendVideoViewsUpdate(video, viewersLength)
206 logger.debug('Video viewers update for %s is %d.', video.url, viewersLength, lTags())
209 async processViewerStats () {
210 if (this.processingViewerStats) return
211 this.processingViewerStats = true
213 if (!isTestInstance()) logger.info('Processing viewers.', lTags())
215 const now = new Date().getTime()
218 const allKeys = await Redis.Instance.listLocalVideoViewerKeys()
220 for (const key of allKeys) {
221 const stats: LocalViewerStats = await Redis.Instance.getLocalVideoViewer({ key })
223 if (stats.lastUpdated > now - VIEW_LIFETIME.VIEWER_STATS) {
228 await sequelizeTypescript.transaction(async t => {
229 const video = await VideoModel.load(stats.videoId, t)
231 const statsModel = await this.saveViewerStats(video, stats, t)
234 await sendCreateWatchAction(statsModel, t)
238 await Redis.Instance.deleteLocalVideoViewersKeys(key)
240 logger.error('Cannot process viewer stats for Redis key %s.', key, { err, ...lTags() })
244 logger.error('Error in video save viewers stats scheduler.', { err, ...lTags() })
247 this.processingViewerStats = false
250 private async saveViewerStats (video: MVideo, stats: LocalViewerStats, transaction: Transaction) {
251 const statsModel = new LocalVideoViewerModel({
252 startDate: new Date(stats.firstUpdated),
253 endDate: new Date(stats.lastUpdated),
254 watchTime: stats.watchTime,
255 country: stats.country,
259 statsModel.url = getLocalVideoViewerActivityPubUrl(statsModel)
260 statsModel.Video = video as VideoModel
262 await statsModel.save({ transaction })
264 statsModel.WatchSections = await LocalVideoViewerWatchSectionModel.bulkCreateSections({
265 localVideoViewerId: statsModel.id,
266 watchSections: stats.watchSections,
273 private buildWatchTimeFromSections (sections: { start: number, end: number }[]) {
274 return sections.reduce((p, current) => p + (current.end - current.start), 0)