Support video views/viewers stats in server

* Add "currentTime" and "event" body params to view endpoint
 * Merge watching and view endpoints
 * Introduce WatchAction AP activity
 * Add tables to store viewer information of local videos
 * Add endpoints to fetch video views/viewers stats of local videos
 * Refactor views/viewers handlers
 * Support "views" and "viewers" counters for both VOD and live videos
pull/4903/head
Chocobozzz 2022-03-24 13:36:47 +01:00 committed by Chocobozzz
parent 69d48ee30c
commit b211106695
108 changed files with 2834 additions and 655 deletions

View File

@ -261,6 +261,13 @@ views:
ip_view_expiration: '1 hour' ip_view_expiration: '1 hour'
# Used to get country location of views of local videos
geo_ip:
enabled: true
country:
database_url: 'https://dbip.mirror.framasoft.org/files/dbip-country-lite-latest.mmdb'
plugins: plugins:
# The website PeerTube will ask for available PeerTube plugins and themes # The website PeerTube will ask for available PeerTube plugins and themes
# This is an unmoderated plugin index, so only install plugins/themes you trust # This is an unmoderated plugin index, so only install plugins/themes you trust

View File

@ -257,6 +257,13 @@ views:
ip_view_expiration: '1 hour' ip_view_expiration: '1 hour'
# Used to get country location of views of local videos
geo_ip:
enabled: true
country:
database_url: 'https://dbip.mirror.framasoft.org/files/dbip-country-lite-latest.mmdb'
plugins: plugins:
# The website PeerTube will ask for available PeerTube plugins and themes # The website PeerTube will ask for available PeerTube plugins and themes
# This is an unmoderated plugin index, so only install plugins/themes you trust # This is an unmoderated plugin index, so only install plugins/themes you trust

View File

@ -168,5 +168,8 @@ views:
local_buffer_update_interval: '5 seconds' local_buffer_update_interval: '5 seconds'
ip_view_expiration: '1 second' ip_view_expiration: '1 second'
geo_ip:
enabled: false
video_studio: video_studio:
enabled: true enabled: true

View File

@ -121,6 +121,7 @@
"magnet-uri": "^6.1.0", "magnet-uri": "^6.1.0",
"markdown-it": "^12.0.4", "markdown-it": "^12.0.4",
"markdown-it-emoji": "^2.0.0", "markdown-it-emoji": "^2.0.0",
"maxmind": "^4.3.6",
"memoizee": "^0.4.14", "memoizee": "^0.4.14",
"morgan": "^1.5.3", "morgan": "^1.5.3",
"multer": "^1.4.4", "multer": "^1.4.4",

View File

@ -153,21 +153,23 @@ async function run () {
} }
}, },
{ {
title: 'API - watching', title: 'API - views with token',
method: 'PUT', method: 'PUT',
headers: { headers: {
...buildAuthorizationHeader(), ...buildAuthorizationHeader(),
...buildJSONHeader() ...buildJSONHeader()
}, },
body: JSON.stringify({ currentTime: 2 }), body: JSON.stringify({ currentTime: 2 }),
path: '/api/v1/videos/' + video.uuid + '/watching', path: '/api/v1/videos/' + video.uuid + '/views',
expecter: (body, status) => { expecter: (body, status) => {
return status === 204 return status === 204
} }
}, },
{ {
title: 'API - views', title: 'API - views without token',
method: 'POST', method: 'POST',
headers: buildJSONHeader(),
body: JSON.stringify({ currentTime: 2 }),
path: '/api/v1/videos/' + video.uuid + '/views', path: '/api/v1/videos/' + video.uuid + '/views',
expecter: (body, status) => { expecter: (body, status) => {
return status === 204 return status === 204

View File

@ -84,8 +84,9 @@ elif [ "$1" = "api-3" ]; then
npm run build:server npm run build:server
videosFiles=$(findTestFiles ./dist/server/tests/api/videos) videosFiles=$(findTestFiles ./dist/server/tests/api/videos)
viewsFiles=$(findTestFiles ./dist/server/tests/api/views)
MOCHA_PARALLEL=true runTest "$1" $((3*$speedFactor)) $videosFiles MOCHA_PARALLEL=true runTest "$1" $((3*$speedFactor)) $viewsFiles $videosFiles
elif [ "$1" = "api-4" ]; then elif [ "$1" = "api-4" ]; then
npm run build:server npm run build:server

View File

@ -112,6 +112,7 @@ import { RemoveOldHistoryScheduler } from './server/lib/schedulers/remove-old-hi
import { AutoFollowIndexInstances } from './server/lib/schedulers/auto-follow-index-instances' import { AutoFollowIndexInstances } from './server/lib/schedulers/auto-follow-index-instances'
import { RemoveDanglingResumableUploadsScheduler } from './server/lib/schedulers/remove-dangling-resumable-uploads-scheduler' import { RemoveDanglingResumableUploadsScheduler } from './server/lib/schedulers/remove-dangling-resumable-uploads-scheduler'
import { VideoViewsBufferScheduler } from './server/lib/schedulers/video-views-buffer-scheduler' import { VideoViewsBufferScheduler } from './server/lib/schedulers/video-views-buffer-scheduler'
import { GeoIPUpdateScheduler } from './server/lib/schedulers/geo-ip-update-scheduler'
import { isHTTPSignatureDigestValid } from './server/helpers/peertube-crypto' import { isHTTPSignatureDigestValid } from './server/helpers/peertube-crypto'
import { PeerTubeSocket } from './server/lib/peertube-socket' import { PeerTubeSocket } from './server/lib/peertube-socket'
import { updateStreamingPlaylistsInfohashesIfNeeded } from './server/lib/hls' import { updateStreamingPlaylistsInfohashesIfNeeded } from './server/lib/hls'
@ -123,7 +124,7 @@ import { LiveManager } from './server/lib/live'
import { HttpStatusCode } from './shared/models/http/http-error-codes' import { HttpStatusCode } from './shared/models/http/http-error-codes'
import { VideosTorrentCache } from '@server/lib/files-cache/videos-torrent-cache' import { VideosTorrentCache } from '@server/lib/files-cache/videos-torrent-cache'
import { ServerConfigManager } from '@server/lib/server-config-manager' import { ServerConfigManager } from '@server/lib/server-config-manager'
import { VideoViews } from '@server/lib/video-views' import { VideoViewsManager } from '@server/lib/views/video-views-manager'
import { isTestInstance } from './server/helpers/core-utils' import { isTestInstance } from './server/helpers/core-utils'
// ----------- Command line ----------- // ----------- Command line -----------
@ -295,10 +296,11 @@ async function startApplication () {
AutoFollowIndexInstances.Instance.enable() AutoFollowIndexInstances.Instance.enable()
RemoveDanglingResumableUploadsScheduler.Instance.enable() RemoveDanglingResumableUploadsScheduler.Instance.enable()
VideoViewsBufferScheduler.Instance.enable() VideoViewsBufferScheduler.Instance.enable()
GeoIPUpdateScheduler.Instance.enable()
Redis.Instance.init() Redis.Instance.init()
PeerTubeSocket.Instance.init(server) PeerTubeSocket.Instance.init(server)
VideoViews.Instance.init() VideoViewsManager.Instance.init()
updateStreamingPlaylistsInfohashesIfNeeded() updateStreamingPlaylistsInfohashesIfNeeded()
.catch(err => logger.error('Cannot update streaming playlist infohashes.', { err })) .catch(err => logger.error('Cannot update streaming playlist infohashes.', { err }))

View File

@ -27,7 +27,7 @@ import {
videosShareValidator videosShareValidator
} from '../../middlewares' } from '../../middlewares'
import { cacheRoute } from '../../middlewares/cache/cache' import { cacheRoute } from '../../middlewares/cache/cache'
import { getAccountVideoRateValidatorFactory, videoCommentGetValidator } from '../../middlewares/validators' import { getAccountVideoRateValidatorFactory, getVideoLocalViewerValidator, videoCommentGetValidator } from '../../middlewares/validators'
import { videoFileRedundancyGetValidator, videoPlaylistRedundancyGetValidator } from '../../middlewares/validators/redundancy' import { videoFileRedundancyGetValidator, videoPlaylistRedundancyGetValidator } from '../../middlewares/validators/redundancy'
import { videoPlaylistElementAPGetValidator, videoPlaylistsGetValidator } from '../../middlewares/validators/videos/video-playlists' import { videoPlaylistElementAPGetValidator, videoPlaylistsGetValidator } from '../../middlewares/validators/videos/video-playlists'
import { AccountModel } from '../../models/account/account' import { AccountModel } from '../../models/account/account'
@ -175,6 +175,12 @@ activityPubClientRouter.get('/video-playlists/:playlistId/videos/:playlistElemen
videoPlaylistElementController videoPlaylistElementController
) )
activityPubClientRouter.get('/videos/local-viewer/:localViewerId',
executeIfActivityPub,
asyncMiddleware(getVideoLocalViewerValidator),
getVideoLocalViewerController
)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export { export {
@ -399,6 +405,12 @@ function videoPlaylistElementController (req: express.Request, res: express.Resp
return activityPubResponse(activityPubContextify(json, 'Playlist'), res) return activityPubResponse(activityPubContextify(json, 'Playlist'), res)
} }
function getVideoLocalViewerController (req: express.Request, res: express.Response) {
const localViewer = res.locals.localViewerFull
return activityPubResponse(activityPubContextify(localViewer.toActivityPubObject(), 'WatchAction'), res)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function actorFollowing (req: express.Request, actor: MActorId) { function actorFollowing (req: express.Request, actor: MActorId) {

View File

@ -1,6 +1,8 @@
import express from 'express' import express from 'express'
import { InboxManager } from '@server/lib/activitypub/inbox-manager' import { InboxManager } from '@server/lib/activitypub/inbox-manager'
import { RemoveDanglingResumableUploadsScheduler } from '@server/lib/schedulers/remove-dangling-resumable-uploads-scheduler' import { RemoveDanglingResumableUploadsScheduler } from '@server/lib/schedulers/remove-dangling-resumable-uploads-scheduler'
import { VideoViewsBufferScheduler } from '@server/lib/schedulers/video-views-buffer-scheduler'
import { VideoViewsManager } from '@server/lib/views/video-views-manager'
import { Debug, SendDebugCommand } from '@shared/models' import { Debug, SendDebugCommand } from '@shared/models'
import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
import { UserRight } from '../../../../shared/models/users' import { UserRight } from '../../../../shared/models/users'
@ -38,9 +40,13 @@ function getDebug (req: express.Request, res: express.Response) {
async function runCommand (req: express.Request, res: express.Response) { async function runCommand (req: express.Request, res: express.Response) {
const body: SendDebugCommand = req.body const body: SendDebugCommand = req.body
if (body.command === 'remove-dandling-resumable-uploads') { const processors: { [id in SendDebugCommand['command']]: () => Promise<any> } = {
await RemoveDanglingResumableUploadsScheduler.Instance.execute() 'remove-dandling-resumable-uploads': () => RemoveDanglingResumableUploadsScheduler.Instance.execute(),
'process-video-views-buffer': () => VideoViewsBufferScheduler.Instance.execute(),
'process-video-viewers': () => VideoViewsManager.Instance.processViewers()
} }
await processors[body.command]()
return res.status(HttpStatusCode.NO_CONTENT_204).end() return res.status(HttpStatusCode.NO_CONTENT_204).end()
} }

View File

@ -1,7 +1,6 @@
import express from 'express' import express from 'express'
import { pickCommonVideoQuery } from '@server/helpers/query' import { pickCommonVideoQuery } from '@server/helpers/query'
import { doJSONRequest } from '@server/helpers/requests' import { doJSONRequest } from '@server/helpers/requests'
import { VideoViews } from '@server/lib/video-views'
import { openapiOperationDoc } from '@server/middlewares/doc' import { openapiOperationDoc } from '@server/middlewares/doc'
import { getServerActor } from '@server/models/application/application' import { getServerActor } from '@server/models/application/application'
import { guessAdditionalAttributesFromQuery } from '@server/models/video/formatter/video-format-utils' import { guessAdditionalAttributesFromQuery } from '@server/models/video/formatter/video-format-utils'
@ -13,7 +12,6 @@ import { logger } from '../../../helpers/logger'
import { getFormattedObjects } from '../../../helpers/utils' import { getFormattedObjects } from '../../../helpers/utils'
import { REMOTE_SCHEME, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES } from '../../../initializers/constants' import { REMOTE_SCHEME, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES } from '../../../initializers/constants'
import { sequelizeTypescript } from '../../../initializers/database' import { sequelizeTypescript } from '../../../initializers/database'
import { sendView } from '../../../lib/activitypub/send/send-view'
import { JobQueue } from '../../../lib/job-queue' import { JobQueue } from '../../../lib/job-queue'
import { Hooks } from '../../../lib/plugins/hooks' import { Hooks } from '../../../lib/plugins/hooks'
import { import {
@ -35,28 +33,30 @@ import { VideoModel } from '../../../models/video/video'
import { blacklistRouter } from './blacklist' import { blacklistRouter } from './blacklist'
import { videoCaptionsRouter } from './captions' import { videoCaptionsRouter } from './captions'
import { videoCommentRouter } from './comment' import { videoCommentRouter } from './comment'
import { studioRouter } from './studio'
import { filesRouter } from './files' import { filesRouter } from './files'
import { videoImportsRouter } from './import' import { videoImportsRouter } from './import'
import { liveRouter } from './live' import { liveRouter } from './live'
import { ownershipVideoRouter } from './ownership' import { ownershipVideoRouter } from './ownership'
import { rateVideoRouter } from './rate' import { rateVideoRouter } from './rate'
import { statsRouter } from './stats'
import { studioRouter } from './studio'
import { transcodingRouter } from './transcoding' import { transcodingRouter } from './transcoding'
import { updateRouter } from './update' import { updateRouter } from './update'
import { uploadRouter } from './upload' import { uploadRouter } from './upload'
import { watchingRouter } from './watching' import { viewRouter } from './view'
const auditLogger = auditLoggerFactory('videos') const auditLogger = auditLoggerFactory('videos')
const videosRouter = express.Router() const videosRouter = express.Router()
videosRouter.use('/', blacklistRouter) videosRouter.use('/', blacklistRouter)
videosRouter.use('/', statsRouter)
videosRouter.use('/', rateVideoRouter) videosRouter.use('/', rateVideoRouter)
videosRouter.use('/', videoCommentRouter) videosRouter.use('/', videoCommentRouter)
videosRouter.use('/', studioRouter) videosRouter.use('/', studioRouter)
videosRouter.use('/', videoCaptionsRouter) videosRouter.use('/', videoCaptionsRouter)
videosRouter.use('/', videoImportsRouter) videosRouter.use('/', videoImportsRouter)
videosRouter.use('/', ownershipVideoRouter) videosRouter.use('/', ownershipVideoRouter)
videosRouter.use('/', watchingRouter) videosRouter.use('/', viewRouter)
videosRouter.use('/', liveRouter) videosRouter.use('/', liveRouter)
videosRouter.use('/', uploadRouter) videosRouter.use('/', uploadRouter)
videosRouter.use('/', updateRouter) videosRouter.use('/', updateRouter)
@ -103,11 +103,6 @@ videosRouter.get('/:id',
asyncMiddleware(checkVideoFollowConstraints), asyncMiddleware(checkVideoFollowConstraints),
getVideo getVideo
) )
videosRouter.post('/:id/views',
openapiOperationDoc({ operationId: 'addView' }),
asyncMiddleware(videosCustomGetValidator('only-video')),
asyncMiddleware(viewVideo)
)
videosRouter.delete('/:id', videosRouter.delete('/:id',
openapiOperationDoc({ operationId: 'delVideo' }), openapiOperationDoc({ operationId: 'delVideo' }),
@ -150,22 +145,6 @@ function getVideo (_req: express.Request, res: express.Response) {
return res.json(video.toFormattedDetailsJSON()) return res.json(video.toFormattedDetailsJSON())
} }
async function viewVideo (req: express.Request, res: express.Response) {
const video = res.locals.onlyVideo
const ip = req.ip
const success = await VideoViews.Instance.processView({ video, ip })
if (success) {
const serverActor = await getServerActor()
await sendView(serverActor, video, undefined)
Hooks.runAction('action:api.video.viewed', { video: video, ip, req, res })
}
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
async function getVideoDescription (req: express.Request, res: express.Response) { async function getVideoDescription (req: express.Request, res: express.Response) {
const videoInstance = res.locals.videoAll const videoInstance = res.locals.videoAll

View File

@ -0,0 +1,66 @@
import express from 'express'
import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer'
import { VideoStatsTimeserieMetric } from '@shared/models'
import {
asyncMiddleware,
authenticate,
videoOverallStatsValidator,
videoRetentionStatsValidator,
videoTimeserieStatsValidator
} from '../../../middlewares'
const statsRouter = express.Router()
statsRouter.get('/:videoId/stats/overall',
authenticate,
asyncMiddleware(videoOverallStatsValidator),
asyncMiddleware(getOverallStats)
)
statsRouter.get('/:videoId/stats/timeseries/:metric',
authenticate,
asyncMiddleware(videoTimeserieStatsValidator),
asyncMiddleware(getTimeserieStats)
)
statsRouter.get('/:videoId/stats/retention',
authenticate,
asyncMiddleware(videoRetentionStatsValidator),
asyncMiddleware(getRetentionStats)
)
// ---------------------------------------------------------------------------
export {
statsRouter
}
// ---------------------------------------------------------------------------
async function getOverallStats (req: express.Request, res: express.Response) {
const video = res.locals.videoAll
const stats = await LocalVideoViewerModel.getOverallStats(video)
return res.json(stats)
}
async function getRetentionStats (req: express.Request, res: express.Response) {
const video = res.locals.videoAll
const stats = await LocalVideoViewerModel.getRetentionStats(video)
return res.json(stats)
}
async function getTimeserieStats (req: express.Request, res: express.Response) {
const video = res.locals.videoAll
const metric = req.params.metric as VideoStatsTimeserieMetric
const stats = await LocalVideoViewerModel.getTimeserieStats({
video,
metric
})
return res.json(stats)
}

View File

@ -0,0 +1,68 @@
import express from 'express'
import { sendView } from '@server/lib/activitypub/send/send-view'
import { Hooks } from '@server/lib/plugins/hooks'
import { VideoViewsManager } from '@server/lib/views/video-views-manager'
import { getServerActor } from '@server/models/application/application'
import { MVideoId } from '@server/types/models'
import { HttpStatusCode, VideoView } from '@shared/models'
import { asyncMiddleware, methodsValidator, openapiOperationDoc, optionalAuthenticate, videoViewValidator } from '../../../middlewares'
import { UserVideoHistoryModel } from '../../../models/user/user-video-history'
const viewRouter = express.Router()
viewRouter.all(
[ '/:videoId/views', '/:videoId/watching' ],
openapiOperationDoc({ operationId: 'addView' }),
methodsValidator([ 'PUT', 'POST' ]),
optionalAuthenticate,
asyncMiddleware(videoViewValidator),
asyncMiddleware(viewVideo)
)
// ---------------------------------------------------------------------------
export {
viewRouter
}
// ---------------------------------------------------------------------------
async function viewVideo (req: express.Request, res: express.Response) {
const video = res.locals.onlyVideo
const body = req.body as VideoView
const ip = req.ip
const { successView, successViewer } = await VideoViewsManager.Instance.processLocalView({
video,
ip,
currentTime: body.currentTime,
viewEvent: body.viewEvent
})
if (successView) {
await sendView({ byActor: await getServerActor(), video, type: 'view' })
Hooks.runAction('action:api.video.viewed', { video: video, ip, req, res })
}
if (successViewer) {
await sendView({ byActor: await getServerActor(), video, type: 'viewer' })
}
await updateUserHistoryIfNeeded(body, video, res)
return res.status(HttpStatusCode.NO_CONTENT_204).end()
}
async function updateUserHistoryIfNeeded (body: VideoView, video: MVideoId, res: express.Response) {
const user = res.locals.oauth?.token.User
if (!user) return
if (user.videosHistoryEnabled !== true) return
await UserVideoHistoryModel.upsert({
videoId: video.id,
userId: user.id,
currentTime: body.currentTime
})
}

View File

@ -1,44 +0,0 @@
import express from 'express'
import { HttpStatusCode, UserWatchingVideo } from '@shared/models'
import {
asyncMiddleware,
asyncRetryTransactionMiddleware,
authenticate,
openapiOperationDoc,
videoWatchingValidator
} from '../../../middlewares'
import { UserVideoHistoryModel } from '../../../models/user/user-video-history'
const watchingRouter = express.Router()
watchingRouter.put('/:videoId/watching',
openapiOperationDoc({ operationId: 'setProgress' }),
authenticate,
asyncMiddleware(videoWatchingValidator),
asyncRetryTransactionMiddleware(userWatchVideo)
)
// ---------------------------------------------------------------------------
export {
watchingRouter
}
// ---------------------------------------------------------------------------
async function userWatchVideo (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.User
const body: UserWatchingVideo = req.body
const { id: videoId } = res.locals.videoId
await UserVideoHistoryModel.upsert({
videoId,
userId: user.id,
currentTime: body.currentTime
})
return res.type('json')
.status(HttpStatusCode.NO_CONTENT_204)
.end()
}

View File

@ -8,6 +8,7 @@ import { isActivityPubUrlValid, isBaseActivityValid, isObjectValid } from './mis
import { isPlaylistObjectValid } from './playlist' import { isPlaylistObjectValid } from './playlist'
import { sanitizeAndCheckVideoCommentObject } from './video-comments' import { sanitizeAndCheckVideoCommentObject } from './video-comments'
import { sanitizeAndCheckVideoTorrentObject } from './videos' import { sanitizeAndCheckVideoTorrentObject } from './videos'
import { isWatchActionObjectValid } from './watch-action'
function isRootActivityValid (activity: any) { function isRootActivityValid (activity: any) {
return isCollection(activity) || isActivity(activity) return isCollection(activity) || isActivity(activity)
@ -82,6 +83,7 @@ function isCreateActivityValid (activity: any) {
isDislikeActivityValid(activity.object) || isDislikeActivityValid(activity.object) ||
isFlagActivityValid(activity.object) || isFlagActivityValid(activity.object) ||
isPlaylistObjectValid(activity.object) || isPlaylistObjectValid(activity.object) ||
isWatchActionObjectValid(activity.object) ||
isCacheFileObjectValid(activity.object) || isCacheFileObjectValid(activity.object) ||
sanitizeAndCheckVideoCommentObject(activity.object) || sanitizeAndCheckVideoCommentObject(activity.object) ||

View File

@ -57,10 +57,19 @@ function setValidAttributedTo (obj: any) {
return true return true
} }
function isActivityPubVideoDurationValid (value: string) {
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
return exists(value) &&
typeof value === 'string' &&
value.startsWith('PT') &&
value.endsWith('S')
}
export { export {
isUrlValid, isUrlValid,
isActivityPubUrlValid, isActivityPubUrlValid,
isBaseActivityValid, isBaseActivityValid,
setValidAttributedTo, setValidAttributedTo,
isObjectValid isObjectValid,
isActivityPubVideoDurationValid
} }

View File

@ -4,7 +4,7 @@ import { ActivityTrackerUrlObject, ActivityVideoFileMetadataUrlObject } from '@s
import { LiveVideoLatencyMode, VideoState } from '../../../../shared/models/videos' import { LiveVideoLatencyMode, VideoState } from '../../../../shared/models/videos'
import { ACTIVITY_PUB, CONSTRAINTS_FIELDS } from '../../../initializers/constants' import { ACTIVITY_PUB, CONSTRAINTS_FIELDS } from '../../../initializers/constants'
import { peertubeTruncate } from '../../core-utils' import { peertubeTruncate } from '../../core-utils'
import { exists, isArray, isBooleanValid, isDateValid, isUUIDValid } from '../misc' import { isArray, isBooleanValid, isDateValid, isUUIDValid } from '../misc'
import { isLiveLatencyModeValid } from '../video-lives' import { isLiveLatencyModeValid } from '../video-lives'
import { import {
isVideoDurationValid, isVideoDurationValid,
@ -14,22 +14,13 @@ import {
isVideoTruncatedDescriptionValid, isVideoTruncatedDescriptionValid,
isVideoViewsValid isVideoViewsValid
} from '../videos' } from '../videos'
import { isActivityPubUrlValid, isBaseActivityValid, setValidAttributedTo } from './misc' import { isActivityPubUrlValid, isActivityPubVideoDurationValid, isBaseActivityValid, setValidAttributedTo } from './misc'
function sanitizeAndCheckVideoTorrentUpdateActivity (activity: any) { function sanitizeAndCheckVideoTorrentUpdateActivity (activity: any) {
return isBaseActivityValid(activity, 'Update') && return isBaseActivityValid(activity, 'Update') &&
sanitizeAndCheckVideoTorrentObject(activity.object) sanitizeAndCheckVideoTorrentObject(activity.object)
} }
function isActivityPubVideoDurationValid (value: string) {
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
return exists(value) &&
typeof value === 'string' &&
value.startsWith('PT') &&
value.endsWith('S') &&
isVideoDurationValid(value.replace(/[^0-9]+/g, ''))
}
function sanitizeAndCheckVideoTorrentObject (video: any) { function sanitizeAndCheckVideoTorrentObject (video: any) {
if (!video || video.type !== 'Video') return false if (!video || video.type !== 'Video') return false
@ -71,6 +62,7 @@ function sanitizeAndCheckVideoTorrentObject (video: any) {
return isActivityPubUrlValid(video.id) && return isActivityPubUrlValid(video.id) &&
isVideoNameValid(video.name) && isVideoNameValid(video.name) &&
isActivityPubVideoDurationValid(video.duration) && isActivityPubVideoDurationValid(video.duration) &&
isVideoDurationValid(video.duration.replace(/[^0-9]+/g, '')) &&
isUUIDValid(video.uuid) && isUUIDValid(video.uuid) &&
(!video.category || isRemoteNumberIdentifierValid(video.category)) && (!video.category || isRemoteNumberIdentifierValid(video.category)) &&
(!video.licence || isRemoteNumberIdentifierValid(video.licence)) && (!video.licence || isRemoteNumberIdentifierValid(video.licence)) &&

View File

@ -0,0 +1,37 @@
import { WatchActionObject } from '@shared/models'
import { exists, isDateValid, isUUIDValid } from '../misc'
import { isVideoTimeValid } from '../video-view'
import { isActivityPubVideoDurationValid, isObjectValid } from './misc'
function isWatchActionObjectValid (action: WatchActionObject) {
return exists(action) &&
action.type === 'WatchAction' &&
isObjectValid(action.id) &&
isActivityPubVideoDurationValid(action.duration) &&
isDateValid(action.startTime) &&
isDateValid(action.endTime) &&
isLocationValid(action.location) &&
isUUIDValid(action.uuid) &&
isObjectValid(action.object) &&
isWatchSectionsValid(action.watchSections)
}
// ---------------------------------------------------------------------------
export {
isWatchActionObjectValid
}
// ---------------------------------------------------------------------------
function isLocationValid (location: any) {
if (!location) return true
return typeof location === 'object' && typeof location.addressCountry === 'string'
}
function isWatchSectionsValid (sections: WatchActionObject['watchSections']) {
return Array.isArray(sections) && sections.every(s => {
return isVideoTimeValid(s.startTimestamp) && isVideoTimeValid(s.endTimestamp)
})
}

View File

@ -0,0 +1,16 @@
import { VideoStatsTimeserieMetric } from '@shared/models'
const validMetrics = new Set<VideoStatsTimeserieMetric>([
'viewers',
'aggregateWatchTime'
])
function isValidStatTimeserieMetric (value: VideoStatsTimeserieMetric) {
return validMetrics.has(value)
}
// ---------------------------------------------------------------------------
export {
isValidStatTimeserieMetric
}

View File

@ -0,0 +1,12 @@
import { exists } from './misc'
function isVideoTimeValid (value: number, videoDuration?: number) {
if (value < 0) return false
if (exists(videoDuration) && value > videoDuration) return false
return true
}
export {
isVideoTimeValid
}

78
server/helpers/geo-ip.ts Normal file
View File

@ -0,0 +1,78 @@
import { pathExists, writeFile } from 'fs-extra'
import maxmind, { CountryResponse, Reader } from 'maxmind'
import { join } from 'path'
import { CONFIG } from '@server/initializers/config'
import { logger, loggerTagsFactory } from './logger'
import { isBinaryResponse, peertubeGot } from './requests'
const lTags = loggerTagsFactory('geo-ip')
const mmbdFilename = 'dbip-country-lite-latest.mmdb'
const mmdbPath = join(CONFIG.STORAGE.BIN_DIR, mmbdFilename)
export class GeoIP {
private static instance: GeoIP
private reader: Reader<CountryResponse>
private constructor () {
}
async safeCountryISOLookup (ip: string): Promise<string> {
if (CONFIG.GEO_IP.ENABLED === false) return null
await this.initReaderIfNeeded()
try {
const result = this.reader.get(ip)
if (!result) return null
return result.country.iso_code
} catch (err) {
logger.error('Cannot get country from IP.', { err })
return null
}
}
async updateDatabase () {
if (CONFIG.GEO_IP.ENABLED === false) return
const url = CONFIG.GEO_IP.COUNTRY.DATABASE_URL
logger.info('Updating GeoIP database from %s.', url, lTags())
const gotOptions = { context: { bodyKBLimit: 200_000 }, responseType: 'buffer' as 'buffer' }
try {
const gotResult = await peertubeGot(url, gotOptions)
if (!isBinaryResponse(gotResult)) {
throw new Error('Not a binary response')
}
await writeFile(mmdbPath, gotResult.body)
// Reini reader
this.reader = undefined
logger.info('GeoIP database updated %s.', mmdbPath, lTags())
} catch (err) {
logger.error('Cannot update GeoIP database from %s.', url, { err, ...lTags() })
}
}
private async initReaderIfNeeded () {
if (!this.reader) {
if (!await pathExists(mmdbPath)) {
await this.updateDatabase()
}
this.reader = await maxmind.open(mmdbPath)
}
}
static get Instance () {
return this.instance || (this.instance = new this())
}
}

View File

@ -44,6 +44,7 @@ function checkMissedConfig () {
'history.videos.max_age', 'views.videos.remote.max_age', 'views.videos.local_buffer_update_interval', 'views.videos.ip_view_expiration', 'history.videos.max_age', 'views.videos.remote.max_age', 'views.videos.local_buffer_update_interval', 'views.videos.ip_view_expiration',
'rates_limit.login.window', 'rates_limit.login.max', 'rates_limit.ask_send_email.window', 'rates_limit.ask_send_email.max', 'rates_limit.login.window', 'rates_limit.login.max', 'rates_limit.ask_send_email.window', 'rates_limit.ask_send_email.max',
'theme.default', 'theme.default',
'geo_ip.enabled', 'geo_ip.country.database_url',
'remote_redundancy.videos.accept_from', 'remote_redundancy.videos.accept_from',
'federation.videos.federate_unlisted', 'federation.videos.cleanup_remote_interactions', 'federation.videos.federate_unlisted', 'federation.videos.cleanup_remote_interactions',
'peertube.check_latest_version.enabled', 'peertube.check_latest_version.url', 'peertube.check_latest_version.enabled', 'peertube.check_latest_version.url',

View File

@ -215,6 +215,12 @@ const CONFIG = {
IP_VIEW_EXPIRATION: parseDurationToMs(config.get('views.videos.ip_view_expiration')) IP_VIEW_EXPIRATION: parseDurationToMs(config.get('views.videos.ip_view_expiration'))
} }
}, },
GEO_IP: {
ENABLED: config.get<boolean>('geo_ip.enabled'),
COUNTRY: {
DATABASE_URL: config.get<string>('geo_ip.country.database_url')
}
},
PLUGINS: { PLUGINS: {
INDEX: { INDEX: {
ENABLED: config.get<boolean>('plugins.index.enabled'), ENABLED: config.get<boolean>('plugins.index.enabled'),

View File

@ -24,7 +24,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const LAST_MIGRATION_VERSION = 700 const LAST_MIGRATION_VERSION = 705
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -228,6 +228,7 @@ const SCHEDULER_INTERVALS_MS = {
REMOVE_OLD_JOBS: 60000 * 60, // 1 hour REMOVE_OLD_JOBS: 60000 * 60, // 1 hour
UPDATE_VIDEOS: 60000, // 1 minute UPDATE_VIDEOS: 60000, // 1 minute
YOUTUBE_DL_UPDATE: 60000 * 60 * 24, // 1 day YOUTUBE_DL_UPDATE: 60000 * 60 * 24, // 1 day
GEO_IP_UPDATE: 60000 * 60 * 24, // 1 day
VIDEO_VIEWS_BUFFER_UPDATE: CONFIG.VIEWS.VIDEOS.LOCAL_BUFFER_UPDATE_INTERVAL, VIDEO_VIEWS_BUFFER_UPDATE: CONFIG.VIEWS.VIDEOS.LOCAL_BUFFER_UPDATE_INTERVAL,
CHECK_PLUGINS: CONFIG.PLUGINS.INDEX.CHECK_LATEST_VERSIONS_INTERVAL, CHECK_PLUGINS: CONFIG.PLUGINS.INDEX.CHECK_LATEST_VERSIONS_INTERVAL,
CHECK_PEERTUBE_VERSION: 60000 * 60 * 24, // 1 day CHECK_PEERTUBE_VERSION: 60000 * 60 * 24, // 1 day
@ -366,9 +367,12 @@ const CONSTRAINTS_FIELDS = {
const VIEW_LIFETIME = { const VIEW_LIFETIME = {
VIEW: CONFIG.VIEWS.VIDEOS.IP_VIEW_EXPIRATION, VIEW: CONFIG.VIEWS.VIDEOS.IP_VIEW_EXPIRATION,
VIEWER: 60000 * 5 // 5 minutes VIEWER: 60000 * 5, // 5 minutes
VIEWER_STATS: 60000 * 60 // 1 hour
} }
const MAX_LOCAL_VIEWER_WATCH_SECTIONS = 10
let CONTACT_FORM_LIFETIME = 60000 * 60 // 1 hour let CONTACT_FORM_LIFETIME = 60000 * 60 // 1 hour
const VIDEO_TRANSCODING_FPS: VideoTranscodingFPS = { const VIDEO_TRANSCODING_FPS: VideoTranscodingFPS = {
@ -800,6 +804,12 @@ const SEARCH_INDEX = {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const STATS_TIMESERIE = {
MAX_DAYS: 30
}
// ---------------------------------------------------------------------------
// Special constants for a test instance // Special constants for a test instance
if (isTestInstance() === true) { if (isTestInstance() === true) {
PRIVATE_RSA_KEY_SIZE = 1024 PRIVATE_RSA_KEY_SIZE = 1024
@ -836,6 +846,7 @@ if (isTestInstance() === true) {
REDUNDANCY.VIDEOS.RANDOMIZED_FACTOR = 1 REDUNDANCY.VIDEOS.RANDOMIZED_FACTOR = 1
VIEW_LIFETIME.VIEWER = 1000 * 5 // 5 second VIEW_LIFETIME.VIEWER = 1000 * 5 // 5 second
VIEW_LIFETIME.VIEWER_STATS = 1000 * 5 // 5 second
CONTACT_FORM_LIFETIME = 1000 // 1 second CONTACT_FORM_LIFETIME = 1000 // 1 second
JOB_ATTEMPTS['email'] = 1 JOB_ATTEMPTS['email'] = 1
@ -907,6 +918,7 @@ export {
LAST_MIGRATION_VERSION, LAST_MIGRATION_VERSION,
OAUTH_LIFETIME, OAUTH_LIFETIME,
CUSTOM_HTML_TAG_COMMENTS, CUSTOM_HTML_TAG_COMMENTS,
STATS_TIMESERIE,
BROADCAST_CONCURRENCY, BROADCAST_CONCURRENCY,
AUDIT_LOG_FILENAME, AUDIT_LOG_FILENAME,
PAGINATION, PAGINATION,
@ -949,6 +961,7 @@ export {
ABUSE_STATES, ABUSE_STATES,
LRU_CACHE, LRU_CACHE,
REQUEST_TIMEOUTS, REQUEST_TIMEOUTS,
MAX_LOCAL_VIEWER_WATCH_SECTIONS,
USER_PASSWORD_RESET_LIFETIME, USER_PASSWORD_RESET_LIFETIME,
USER_PASSWORD_CREATE_LIFETIME, USER_PASSWORD_CREATE_LIFETIME,
MEMOIZE_TTL, MEMOIZE_TTL,

View File

@ -1,10 +1,14 @@
import { QueryTypes, Transaction } from 'sequelize' import { QueryTypes, Transaction } from 'sequelize'
import { Sequelize as SequelizeTypescript } from 'sequelize-typescript' import { Sequelize as SequelizeTypescript } from 'sequelize-typescript'
import { ActorCustomPageModel } from '@server/models/account/actor-custom-page'
import { TrackerModel } from '@server/models/server/tracker' import { TrackerModel } from '@server/models/server/tracker'
import { VideoTrackerModel } from '@server/models/server/video-tracker' import { VideoTrackerModel } from '@server/models/server/video-tracker'
import { UserModel } from '@server/models/user/user' import { UserModel } from '@server/models/user/user'
import { UserNotificationModel } from '@server/models/user/user-notification' import { UserNotificationModel } from '@server/models/user/user-notification'
import { UserVideoHistoryModel } from '@server/models/user/user-video-history' import { UserVideoHistoryModel } from '@server/models/user/user-video-history'
import { VideoJobInfoModel } from '@server/models/video/video-job-info'
import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer'
import { LocalVideoViewerWatchSectionModel } from '@server/models/view/local-video-viewer-watch-section'
import { isTestInstance } from '../helpers/core-utils' import { isTestInstance } from '../helpers/core-utils'
import { logger } from '../helpers/logger' import { logger } from '../helpers/logger'
import { AbuseModel } from '../models/abuse/abuse' import { AbuseModel } from '../models/abuse/abuse'
@ -42,10 +46,8 @@ import { VideoPlaylistElementModel } from '../models/video/video-playlist-elemen
import { VideoShareModel } from '../models/video/video-share' import { VideoShareModel } from '../models/video/video-share'
import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist' import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
import { VideoTagModel } from '../models/video/video-tag' import { VideoTagModel } from '../models/video/video-tag'
import { VideoViewModel } from '../models/video/video-view' import { VideoViewModel } from '../models/view/video-view'
import { CONFIG } from './config' import { CONFIG } from './config'
import { ActorCustomPageModel } from '@server/models/account/actor-custom-page'
import { VideoJobInfoModel } from '@server/models/video/video-job-info'
require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
@ -140,6 +142,8 @@ async function initDatabaseModels (silent: boolean) {
VideoStreamingPlaylistModel, VideoStreamingPlaylistModel,
VideoPlaylistModel, VideoPlaylistModel,
VideoPlaylistElementModel, VideoPlaylistElementModel,
LocalVideoViewerModel,
LocalVideoViewerWatchSectionModel,
ThumbnailModel, ThumbnailModel,
TrackerModel, TrackerModel,
VideoTrackerModel, VideoTrackerModel,

View File

@ -0,0 +1,52 @@
import * as Sequelize from 'sequelize'
async function up (utils: {
transaction: Sequelize.Transaction
queryInterface: Sequelize.QueryInterface
sequelize: Sequelize.Sequelize
db: any
}): Promise<void> {
const { transaction } = utils
{
const query = `
CREATE TABLE IF NOT EXISTS "localVideoViewer" (
"id" serial,
"startDate" timestamp with time zone NOT NULL,
"endDate" timestamp with time zone NOT NULL,
"watchTime" integer NOT NULL,
"country" varchar(255),
"uuid" uuid NOT NULL,
"url" varchar(255) NOT NULL,
"videoId" integer NOT NULL REFERENCES "video" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
"createdAt" timestamp with time zone NOT NULL,
PRIMARY KEY ("id")
);
`
await utils.sequelize.query(query, { transaction })
}
{
const query = `
CREATE TABLE IF NOT EXISTS "localVideoViewerWatchSection" (
"id" serial,
"watchStart" integer NOT NULL,
"watchEnd" integer NOT NULL,
"localVideoViewerId" integer NOT NULL REFERENCES "localVideoViewer" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
"createdAt" timestamp with time zone NOT NULL,
PRIMARY KEY ("id")
);
`
await utils.sequelize.query(query, { transaction })
}
}
function down () {
throw new Error('Not implemented.')
}
export {
up,
down
}

View File

@ -4,6 +4,17 @@ function getAPId (object: string | { id: string }) {
return object.id return object.id
} }
export { function getActivityStreamDuration (duration: number) {
getAPId // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
return 'PT' + duration + 'S'
}
function getDurationFromActivityStream (duration: string) {
return parseInt(duration.replace(/[^\d]+/, ''))
}
export {
getAPId,
getActivityStreamDuration,
getDurationFromActivityStream
} }

View File

@ -15,7 +15,7 @@ export {
type ContextValue = { [ id: string ]: (string | { '@type': string, '@id': string }) } type ContextValue = { [ id: string ]: (string | { '@type': string, '@id': string }) }
const contextStore = { const contextStore: { [ id in ContextType ]: (string | { [ id: string ]: string })[] } = {
Video: buildContext({ Video: buildContext({
Hashtag: 'as:Hashtag', Hashtag: 'as:Hashtag',
uuid: 'sc:identifier', uuid: 'sc:identifier',
@ -109,7 +109,8 @@ const contextStore = {
stopTimestamp: { stopTimestamp: {
'@type': 'sc:Number', '@type': 'sc:Number',
'@id': 'pt:stopTimestamp' '@id': 'pt:stopTimestamp'
} },
uuid: 'sc:identifier'
}), }),
CacheFile: buildContext({ CacheFile: buildContext({
@ -128,6 +129,24 @@ const contextStore = {
} }
}), }),
WatchAction: buildContext({
WatchAction: 'sc:WatchAction',
startTimestamp: {
'@type': 'sc:Number',
'@id': 'pt:startTimestamp'
},
stopTimestamp: {
'@type': 'sc:Number',
'@id': 'pt:stopTimestamp'
},
watchSection: {
'@type': 'sc:Number',
'@id': 'pt:stopTimestamp'
},
uuid: 'sc:identifier'
}),
Collection: buildContext(),
Follow: buildContext(), Follow: buildContext(),
Reject: buildContext(), Reject: buildContext(),
Accept: buildContext(), Accept: buildContext(),

View File

@ -0,0 +1,42 @@
import { Transaction } from 'sequelize'
import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer'
import { LocalVideoViewerWatchSectionModel } from '@server/models/view/local-video-viewer-watch-section'
import { MVideo } from '@server/types/models'
import { WatchActionObject } from '@shared/models'
import { getDurationFromActivityStream } from './activity'
async function createOrUpdateLocalVideoViewer (watchAction: WatchActionObject, video: MVideo, t: Transaction) {
const stats = await LocalVideoViewerModel.loadByUrl(watchAction.id)
if (stats) await stats.destroy({ transaction: t })
const localVideoViewer = await LocalVideoViewerModel.create({
url: watchAction.id,
uuid: watchAction.uuid,
watchTime: getDurationFromActivityStream(watchAction.duration),
startDate: new Date(watchAction.startTime),
endDate: new Date(watchAction.endTime),
country: watchAction.location
? watchAction.location.addressCountry
: null,
videoId: video.id
})
await LocalVideoViewerWatchSectionModel.bulkCreateSections({
localVideoViewerId: localVideoViewer.id,
watchSections: watchAction.watchSections.map(s => ({
start: s.startTimestamp,
end: s.endTimestamp
}))
})
}
// ---------------------------------------------------------------------------
export {
createOrUpdateLocalVideoViewer
}

View File

@ -1,6 +1,7 @@
import { isBlockedByServerOrAccount } from '@server/lib/blocklist' import { isBlockedByServerOrAccount } from '@server/lib/blocklist'
import { isRedundancyAccepted } from '@server/lib/redundancy' import { isRedundancyAccepted } from '@server/lib/redundancy'
import { ActivityCreate, CacheFileObject, PlaylistObject, VideoCommentObject, VideoObject } from '@shared/models' import { VideoModel } from '@server/models/video/video'
import { ActivityCreate, CacheFileObject, PlaylistObject, VideoCommentObject, VideoObject, WatchActionObject } from '@shared/models'
import { retryTransactionWrapper } from '../../../helpers/database-utils' import { retryTransactionWrapper } from '../../../helpers/database-utils'
import { logger } from '../../../helpers/logger' import { logger } from '../../../helpers/logger'
import { sequelizeTypescript } from '../../../initializers/database' import { sequelizeTypescript } from '../../../initializers/database'
@ -8,6 +9,7 @@ import { APProcessorOptions } from '../../../types/activitypub-processor.model'
import { MActorSignature, MCommentOwnerVideo, MVideoAccountLightBlacklistAllFiles } from '../../../types/models' import { MActorSignature, MCommentOwnerVideo, MVideoAccountLightBlacklistAllFiles } from '../../../types/models'
import { Notifier } from '../../notifier' import { Notifier } from '../../notifier'
import { createOrUpdateCacheFile } from '../cache-file' import { createOrUpdateCacheFile } from '../cache-file'
import { createOrUpdateLocalVideoViewer } from '../local-video-viewer'
import { createOrUpdateVideoPlaylist } from '../playlists' import { createOrUpdateVideoPlaylist } from '../playlists'
import { forwardVideoRelatedActivity } from '../send/shared/send-utils' import { forwardVideoRelatedActivity } from '../send/shared/send-utils'
import { resolveThread } from '../video-comments' import { resolveThread } from '../video-comments'
@ -32,6 +34,10 @@ async function processCreateActivity (options: APProcessorOptions<ActivityCreate
return retryTransactionWrapper(processCreateVideoComment, activity, byActor, notify) return retryTransactionWrapper(processCreateVideoComment, activity, byActor, notify)
} }
if (activityType === 'WatchAction') {
return retryTransactionWrapper(processCreateWatchAction, activity)
}
if (activityType === 'CacheFile') { if (activityType === 'CacheFile') {
return retryTransactionWrapper(processCreateCacheFile, activity, byActor) return retryTransactionWrapper(processCreateCacheFile, activity, byActor)
} }
@ -81,6 +87,19 @@ async function processCreateCacheFile (activity: ActivityCreate, byActor: MActor
} }
} }
async function processCreateWatchAction (activity: ActivityCreate) {
const watchAction = activity.object as WatchActionObject
if (watchAction.actionStatus !== 'CompletedActionStatus') return
const video = await VideoModel.loadByUrl(watchAction.object)
if (video.remote) return
await sequelizeTypescript.transaction(async t => {
return createOrUpdateLocalVideoViewer(watchAction, video, t)
})
}
async function processCreateVideoComment (activity: ActivityCreate, byActor: MActorSignature, notify: boolean) { async function processCreateVideoComment (activity: ActivityCreate, byActor: MActorSignature, notify: boolean) {
const commentObject = activity.object as VideoCommentObject const commentObject = activity.object as VideoCommentObject
const byAccount = byActor.Account const byAccount = byActor.Account

View File

@ -1,4 +1,4 @@
import { VideoViews } from '@server/lib/video-views' import { VideoViewsManager } from '@server/lib/views/video-views-manager'
import { ActivityView } from '../../../../shared/models/activitypub' import { ActivityView } from '../../../../shared/models/activitypub'
import { APProcessorOptions } from '../../../types/activitypub-processor.model' import { APProcessorOptions } from '../../../types/activitypub-processor.model'
import { MActorSignature } from '../../../types/models' import { MActorSignature } from '../../../types/models'
@ -32,7 +32,7 @@ async function processCreateView (activity: ActivityView, byActor: MActorSignatu
? new Date(activity.expires) ? new Date(activity.expires)
: undefined : undefined
await VideoViews.Instance.processView({ video, ip: null, viewerExpires }) await VideoViewsManager.Instance.processRemoteView({ video, viewerExpires })
if (video.isOwned()) { if (video.isOwned()) {
// Forward the view but don't resend the activity to the sender // Forward the view but don't resend the activity to the sender

View File

@ -6,6 +6,7 @@ import { VideoCommentModel } from '../../../models/video/video-comment'
import { import {
MActorLight, MActorLight,
MCommentOwnerVideo, MCommentOwnerVideo,
MLocalVideoViewerWithWatchSections,
MVideoAccountLight, MVideoAccountLight,
MVideoAP, MVideoAP,
MVideoPlaylistFull, MVideoPlaylistFull,
@ -19,6 +20,7 @@ import {
getActorsInvolvedInVideo, getActorsInvolvedInVideo,
getAudienceFromFollowersOf, getAudienceFromFollowersOf,
getVideoCommentAudience, getVideoCommentAudience,
sendVideoActivityToOrigin,
sendVideoRelatedActivity, sendVideoRelatedActivity,
unicastTo unicastTo
} from './shared' } from './shared'
@ -61,6 +63,18 @@ async function sendCreateCacheFile (
}) })
} }
async function sendCreateWatchAction (stats: MLocalVideoViewerWithWatchSections, transaction: Transaction) {
logger.info('Creating job to send create watch action %s.', stats.url, lTags(stats.uuid))
const byActor = await getServerActor()
const activityBuilder = (audience: ActivityAudience) => {
return buildCreateActivity(stats.url, byActor, stats.toActivityPubObject(), audience)
}
return sendVideoActivityToOrigin(activityBuilder, { byActor, video: stats.Video, transaction, contextType: 'WatchAction' })
}
async function sendCreateVideoPlaylist (playlist: MVideoPlaylistFull, transaction: Transaction) { async function sendCreateVideoPlaylist (playlist: MVideoPlaylistFull, transaction: Transaction) {
if (playlist.privacy === VideoPlaylistPrivacy.PRIVATE) return undefined if (playlist.privacy === VideoPlaylistPrivacy.PRIVATE) return undefined
@ -175,7 +189,8 @@ export {
buildCreateActivity, buildCreateActivity,
sendCreateVideoComment, sendCreateVideoComment,
sendCreateVideoPlaylist, sendCreateVideoPlaylist,
sendCreateCacheFile sendCreateCacheFile,
sendCreateWatchAction
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -1,38 +1,31 @@
import { Transaction } from 'sequelize' import { Transaction } from 'sequelize'
import { VideoViews } from '@server/lib/video-views' import { VideoViewsManager } from '@server/lib/views/video-views-manager'
import { MActorAudience, MVideoImmutable, MVideoUrl } from '@server/types/models' import { MActorAudience, MActorLight, MVideoImmutable, MVideoUrl } from '@server/types/models'
import { ActivityAudience, ActivityView } from '@shared/models' import { ActivityAudience, ActivityView } from '@shared/models'
import { logger } from '../../../helpers/logger' import { logger } from '../../../helpers/logger'
import { ActorModel } from '../../../models/actor/actor'
import { audiencify, getAudience } from '../audience' import { audiencify, getAudience } from '../audience'
import { getLocalVideoViewActivityPubUrl } from '../url' import { getLocalVideoViewActivityPubUrl } from '../url'
import { sendVideoRelatedActivity } from './shared/send-utils' import { sendVideoRelatedActivity } from './shared/send-utils'
async function sendView (byActor: ActorModel, video: MVideoImmutable, t: Transaction) { type ViewType = 'view' | 'viewer'
logger.info('Creating job to send view of %s.', video.url)
async function sendView (options: {
byActor: MActorLight
type: ViewType
video: MVideoImmutable
transaction?: Transaction
}) {
const { byActor, type, video, transaction } = options
logger.info('Creating job to send %s of %s.', type, video.url)
const activityBuilder = (audience: ActivityAudience) => { const activityBuilder = (audience: ActivityAudience) => {
const url = getLocalVideoViewActivityPubUrl(byActor, video) const url = getLocalVideoViewActivityPubUrl(byActor, video)
return buildViewActivity(url, byActor, video, audience) return buildViewActivity({ url, byActor, video, audience, type })
} }
return sendVideoRelatedActivity(activityBuilder, { byActor, video, transaction: t, contextType: 'View' }) return sendVideoRelatedActivity(activityBuilder, { byActor, video, transaction, contextType: 'View' })
}
function buildViewActivity (url: string, byActor: MActorAudience, video: MVideoUrl, audience?: ActivityAudience): ActivityView {
if (!audience) audience = getAudience(byActor)
return audiencify(
{
id: url,
type: 'View' as 'View',
actor: byActor.url,
object: video.url,
expires: new Date(VideoViews.Instance.buildViewerExpireTime()).toISOString()
},
audience
)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -40,3 +33,29 @@ function buildViewActivity (url: string, byActor: MActorAudience, video: MVideoU
export { export {
sendView sendView
} }
// ---------------------------------------------------------------------------
function buildViewActivity (options: {
url: string
byActor: MActorAudience
video: MVideoUrl
type: ViewType
audience?: ActivityAudience
}): ActivityView {
const { url, byActor, type, video, audience = getAudience(byActor) } = options
return audiencify(
{
id: url,
type: 'View' as 'View',
actor: byActor.url,
object: video.url,
expires: type === 'viewer'
? new Date(VideoViewsManager.Instance.buildViewerExpireTime()).toISOString()
: undefined
},
audience
)
}

View File

@ -7,6 +7,7 @@ import {
MActorId, MActorId,
MActorUrl, MActorUrl,
MCommentId, MCommentId,
MLocalVideoViewer,
MVideoId, MVideoId,
MVideoPlaylistElement, MVideoPlaylistElement,
MVideoUrl, MVideoUrl,
@ -59,6 +60,10 @@ function getLocalVideoViewActivityPubUrl (byActor: MActorUrl, video: MVideoId) {
return byActor.url + '/views/videos/' + video.id + '/' + new Date().toISOString() return byActor.url + '/views/videos/' + video.id + '/' + new Date().toISOString()
} }
function getLocalVideoViewerActivityPubUrl (stats: MLocalVideoViewer) {
return WEBSERVER.URL + '/videos/local-viewer/' + stats.uuid
}
function getVideoLikeActivityPubUrlByLocalActor (byActor: MActorUrl, video: MVideoId) { function getVideoLikeActivityPubUrlByLocalActor (byActor: MActorUrl, video: MVideoId) {
return byActor.url + '/likes/' + video.id return byActor.url + '/likes/' + video.id
} }
@ -167,6 +172,7 @@ export {
getLocalVideoCommentsActivityPubUrl, getLocalVideoCommentsActivityPubUrl,
getLocalVideoLikesActivityPubUrl, getLocalVideoLikesActivityPubUrl,
getLocalVideoDislikesActivityPubUrl, getLocalVideoDislikesActivityPubUrl,
getLocalVideoViewerActivityPubUrl,
getAbuseTargetUrl, getAbuseTargetUrl,
checkUrlsSameHost, checkUrlsSameHost,

View File

@ -24,6 +24,7 @@ import {
VideoPrivacy, VideoPrivacy,
VideoStreamingPlaylistType VideoStreamingPlaylistType
} from '@shared/models' } from '@shared/models'
import { getDurationFromActivityStream } from '../../activity'
function getThumbnailFromIcons (videoObject: VideoObject) { function getThumbnailFromIcons (videoObject: VideoObject) {
let validIcons = videoObject.icon.filter(i => i.width > THUMBNAILS_SIZE.minWidth) let validIcons = videoObject.icon.filter(i => i.width > THUMBNAILS_SIZE.minWidth)
@ -170,7 +171,6 @@ function getVideoAttributesFromObject (videoChannel: MChannelId, videoObject: Vi
? VideoPrivacy.PUBLIC ? VideoPrivacy.PUBLIC
: VideoPrivacy.UNLISTED : VideoPrivacy.UNLISTED
const duration = videoObject.duration.replace(/[^\d]+/, '')
const language = videoObject.language?.identifier const language = videoObject.language?.identifier
const category = videoObject.category const category = videoObject.category
@ -200,7 +200,7 @@ function getVideoAttributesFromObject (videoChannel: MChannelId, videoObject: Vi
isLive: videoObject.isLiveBroadcast, isLive: videoObject.isLiveBroadcast,
state: videoObject.state, state: videoObject.state,
channelId: videoChannel.id, channelId: videoChannel.id,
duration: parseInt(duration, 10), duration: getDurationFromActivityStream(videoObject.duration),
createdAt: new Date(videoObject.published), createdAt: new Date(videoObject.published),
publishedAt: new Date(videoObject.published), publishedAt: new Date(videoObject.published),

View File

@ -23,11 +23,11 @@ import {
WEBSERVER WEBSERVER
} from '../initializers/constants' } from '../initializers/constants'
import { AccountModel } from '../models/account/account' import { AccountModel } from '../models/account/account'
import { getActivityStreamDuration } from '../models/video/formatter/video-format-utils'
import { VideoModel } from '../models/video/video' import { VideoModel } from '../models/video/video'
import { VideoChannelModel } from '../models/video/video-channel' import { VideoChannelModel } from '../models/video/video-channel'
import { VideoPlaylistModel } from '../models/video/video-playlist' import { VideoPlaylistModel } from '../models/video/video-playlist'
import { MAccountActor, MChannelActor } from '../types/models' import { MAccountActor, MChannelActor } from '../types/models'
import { getActivityStreamDuration } from './activitypub/activity'
import { getBiggestActorImage } from './actor-image' import { getBiggestActorImage } from './actor-image'
import { ServerConfigManager } from './server-config-manager' import { ServerConfigManager } from './server-config-manager'

View File

@ -1,7 +1,7 @@
import { VideoViewModel } from '@server/models/view/video-view'
import { isTestInstance } from '../../../helpers/core-utils' import { isTestInstance } from '../../../helpers/core-utils'
import { logger } from '../../../helpers/logger' import { logger } from '../../../helpers/logger'
import { VideoModel } from '../../../models/video/video' import { VideoModel } from '../../../models/video/video'
import { VideoViewModel } from '../../../models/video/video-view'
import { Redis } from '../../redis' import { Redis } from '../../redis'
async function processVideosViewsStats () { async function processVideosViewsStats () {

View File

@ -249,6 +249,45 @@ class Redis {
]) ])
} }
/* ************ Video viewers stats ************ */
getLocalVideoViewer (options: {
key?: string
// Or
ip?: string
videoId?: number
}) {
if (options.key) return this.getObject(options.key)
const { viewerKey } = this.generateLocalVideoViewerKeys(options.ip, options.videoId)
return this.getObject(viewerKey)
}
setLocalVideoViewer (ip: string, videoId: number, object: any) {
const { setKey, viewerKey } = this.generateLocalVideoViewerKeys(ip, videoId)
return Promise.all([
this.addToSet(setKey, viewerKey),
this.setObject(viewerKey, object)
])
}
listLocalVideoViewerKeys () {
const { setKey } = this.generateLocalVideoViewerKeys()
return this.getSet(setKey)
}
deleteLocalVideoViewersKeys (key: string) {
const { setKey } = this.generateLocalVideoViewerKeys()
return Promise.all([
this.deleteFromSet(setKey, key),
this.deleteKey(key)
])
}
/* ************ Resumable uploads final responses ************ */ /* ************ Resumable uploads final responses ************ */
setUploadSession (uploadId: string, response?: { video: { id: number, shortUUID: string, uuid: string } }) { setUploadSession (uploadId: string, response?: { video: { id: number, shortUUID: string, uuid: string } }) {
@ -290,10 +329,18 @@ class Redis {
/* ************ Keys generation ************ */ /* ************ Keys generation ************ */
private generateLocalVideoViewsKeys (videoId?: Number) { private generateLocalVideoViewsKeys (videoId: number): { setKey: string, videoKey: string }
private generateLocalVideoViewsKeys (): { setKey: string }
private generateLocalVideoViewsKeys (videoId?: number) {
return { setKey: `local-video-views-buffer`, videoKey: `local-video-views-buffer-${videoId}` } return { setKey: `local-video-views-buffer`, videoKey: `local-video-views-buffer-${videoId}` }
} }
private generateLocalVideoViewerKeys (ip: string, videoId: number): { setKey: string, viewerKey: string }
private generateLocalVideoViewerKeys (): { setKey: string }
private generateLocalVideoViewerKeys (ip?: string, videoId?: number) {
return { setKey: `local-video-viewer-stats-keys`, viewerKey: `local-video-viewer-stats-${ip}-${videoId}` }
}
private generateVideoViewStatsKeys (options: { videoId?: number, hour?: number }) { private generateVideoViewStatsKeys (options: { videoId?: number, hour?: number }) {
const hour = exists(options.hour) const hour = exists(options.hour)
? options.hour ? options.hour
@ -352,8 +399,23 @@ class Redis {
return this.client.del(this.prefix + key) return this.client.del(this.prefix + key)
} }
private async setValue (key: string, value: string, expirationMilliseconds: number) { private async getObject (key: string) {
const result = await this.client.set(this.prefix + key, value, { PX: expirationMilliseconds }) const value = await this.getValue(key)
if (!value) return null
return JSON.parse(value)
}
private setObject (key: string, value: { [ id: string ]: number | string }) {
return this.setValue(key, JSON.stringify(value))
}
private async setValue (key: string, value: string, expirationMilliseconds?: number) {
const options = expirationMilliseconds
? { PX: expirationMilliseconds }
: {}
const result = await this.client.set(this.prefix + key, value, options)
if (result !== 'OK') throw new Error('Redis set result is not OK.') if (result !== 'OK') throw new Error('Redis set result is not OK.')
} }

View File

@ -0,0 +1,22 @@
import { GeoIP } from '@server/helpers/geo-ip'
import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants'
import { AbstractScheduler } from './abstract-scheduler'
export class GeoIPUpdateScheduler extends AbstractScheduler {
private static instance: AbstractScheduler
protected schedulerIntervalMs = SCHEDULER_INTERVALS_MS.YOUTUBE_DL_UPDATE
private constructor () {
super()
}
protected internalExecute () {
return GeoIP.Instance.updateDatabase()
}
static get Instance () {
return this.instance || (this.instance = new this())
}
}

View File

@ -1,8 +1,8 @@
import { VideoViewModel } from '@server/models/view/video-view'
import { logger } from '../../helpers/logger' import { logger } from '../../helpers/logger'
import { AbstractScheduler } from './abstract-scheduler'
import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants'
import { CONFIG } from '../../initializers/config' import { CONFIG } from '../../initializers/config'
import { VideoViewModel } from '../../models/video/video-view' import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants'
import { AbstractScheduler } from './abstract-scheduler'
export class RemoveOldViewsScheduler extends AbstractScheduler { export class RemoveOldViewsScheduler extends AbstractScheduler {

View File

@ -21,8 +21,6 @@ export class VideoViewsBufferScheduler extends AbstractScheduler {
const videoIds = await Redis.Instance.listLocalVideosViewed() const videoIds = await Redis.Instance.listLocalVideosViewed()
if (videoIds.length === 0) return if (videoIds.length === 0) return
logger.info('Processing local video views buffer.', { videoIds, ...lTags() })
for (const videoId of videoIds) { for (const videoId of videoIds) {
try { try {
const views = await Redis.Instance.getLocalVideoViews(videoId) const views = await Redis.Instance.getLocalVideoViews(videoId)
@ -34,6 +32,8 @@ export class VideoViewsBufferScheduler extends AbstractScheduler {
continue continue
} }
logger.info('Processing local video %s views buffer.', video.uuid, lTags(video.uuid))
// If this is a remote video, the origin instance will send us an update // If this is a remote video, the origin instance will send us an update
await VideoModel.incrementViews(videoId, views) await VideoModel.incrementViews(videoId, views)

View File

@ -1,131 +0,0 @@
import { isTestInstance } from '@server/helpers/core-utils'
import { logger, loggerTagsFactory } from '@server/helpers/logger'
import { VIEW_LIFETIME } from '@server/initializers/constants'
import { VideoModel } from '@server/models/video/video'
import { MVideo } from '@server/types/models'
import { PeerTubeSocket } from './peertube-socket'
import { Redis } from './redis'
const lTags = loggerTagsFactory('views')
export class VideoViews {
// Values are Date().getTime()
private readonly viewersPerVideo = new Map<number, number[]>()
private static instance: VideoViews
private constructor () {
}
init () {
setInterval(() => this.cleanViewers(), VIEW_LIFETIME.VIEWER)
}
async processView (options: {
video: MVideo
ip: string | null
viewerExpires?: Date
}) {
const { video, ip, viewerExpires } = options
logger.debug('Processing view for %s and ip %s.', video.url, ip, lTags())
let success = await this.addView(video, ip)
if (video.isLive) {
const successViewer = await this.addViewer(video, ip, viewerExpires)
success ||= successViewer
}
return success
}
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
}
private async addView (video: MVideo, ip: string | null) {
const promises: Promise<any>[] = []
if (ip !== null) {
const viewExists = await Redis.Instance.doesVideoIPViewExist(ip, video.uuid)
if (viewExists) return false
promises.push(Redis.Instance.setIPVideoView(ip, video.uuid))
}
if (video.isOwned()) {
promises.push(Redis.Instance.addLocalVideoView(video.id))
}
promises.push(Redis.Instance.addVideoViewStats(video.id))
await Promise.all(promises)
return true
}
private async addViewer (video: MVideo, ip: string | null, viewerExpires?: Date) {
if (ip !== null) {
const viewExists = await Redis.Instance.doesVideoIPViewerExist(ip, video.uuid)
if (viewExists) return false
await Redis.Instance.setIPVideoViewer(ip, video.uuid)
}
let watchers = this.viewersPerVideo.get(video.id)
if (!watchers) {
watchers = []
this.viewersPerVideo.set(video.id, watchers)
}
const expiration = viewerExpires
? viewerExpires.getTime()
: this.buildViewerExpireTime()
watchers.push(expiration)
await this.notifyClients(video.id, watchers.length)
return true
}
private async cleanViewers () {
if (!isTestInstance()) logger.info('Cleaning video viewers.', lTags())
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 = viewers.filter(w => w > notBefore)
if (newViewers.length === 0) this.viewersPerVideo.delete(videoId)
else this.viewersPerVideo.set(videoId, newViewers)
await this.notifyClients(videoId, newViewers.length)
}
}
private async notifyClients (videoId: string | number, viewersLength: number) {
const video = await VideoModel.loadImmutableAttributes(videoId)
if (!video) return
PeerTubeSocket.Instance.sendVideoViewsUpdate(video, viewersLength)
logger.debug('Live video views update for %s is %d.', video.url, viewersLength, lTags())
}
static get Instance () {
return this.instance || (this.instance = new this())
}
}

View File

@ -0,0 +1,2 @@
export * from './video-viewers'
export * from './video-views'

View File

@ -0,0 +1,276 @@
import { Transaction } from 'sequelize/types'
import { isTestInstance } from '@server/helpers/core-utils'
import { GeoIP } from '@server/helpers/geo-ip'
import { logger, loggerTagsFactory } from '@server/helpers/logger'
import { MAX_LOCAL_VIEWER_WATCH_SECTIONS, VIEW_LIFETIME } from '@server/initializers/constants'
import { sequelizeTypescript } from '@server/initializers/database'
import { sendCreateWatchAction } from '@server/lib/activitypub/send'
import { getLocalVideoViewerActivityPubUrl } from '@server/lib/activitypub/url'
import { PeerTubeSocket } from '@server/lib/peertube-socket'
import { Redis } from '@server/lib/redis'
import { VideoModel } from '@server/models/video/video'
import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer'
import { LocalVideoViewerWatchSectionModel } from '@server/models/view/local-video-viewer-watch-section'
import { MVideo } from '@server/types/models'
import { VideoViewEvent } from '@shared/models'
const lTags = loggerTagsFactory('views')
type LocalViewerStats = {
firstUpdated: number // Date.getTime()
lastUpdated: number // Date.getTime()
watchSections: {
start: number
end: number
}[]
watchTime: number
country: string
videoId: number
}
export class VideoViewers {
// Values are Date().getTime()
private readonly viewersPerVideo = new Map<number, number[]>()
private processingViewerCounters = false
private processingViewerStats = false
constructor () {
setInterval(() => this.cleanViewerCounters(), VIEW_LIFETIME.VIEWER)
setInterval(() => this.processViewerStats(), VIEW_LIFETIME.VIEWER_STATS)
}
// ---------------------------------------------------------------------------
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
}
async getWatchTime (videoId: number, ip: string) {
const stats: LocalViewerStats = await Redis.Instance.getLocalVideoViewer({ ip, videoId })
return stats?.watchTime || 0
}
async addLocalViewer (options: {
video: MVideo
currentTime: number
ip: string
viewEvent?: VideoViewEvent
}) {
const { video, ip, viewEvent, currentTime } = options
logger.debug('Adding local viewer to video %s.', video.uuid, { currentTime, viewEvent, ...lTags(video.uuid) })
await this.updateLocalViewerStats({ video, viewEvent, currentTime, ip })
const viewExists = await Redis.Instance.doesVideoIPViewerExist(ip, video.uuid)
if (viewExists) return false
await Redis.Instance.setIPVideoViewer(ip, video.uuid)
return this.addViewerToVideo({ video })
}
async addRemoteViewer (options: {
video: MVideo
viewerExpires: Date
}) {
const { video, viewerExpires } = options
logger.debug('Adding remote viewer to video %s.', video.uuid, { ...lTags(video.uuid) })
return this.addViewerToVideo({ video, viewerExpires })
}
private async addViewerToVideo (options: {
video: MVideo
viewerExpires?: Date
}) {
const { video, viewerExpires } = options
let watchers = this.viewersPerVideo.get(video.id)
if (!watchers) {
watchers = []
this.viewersPerVideo.set(video.id, watchers)
}
const expiration = viewerExpires
? viewerExpires.getTime()
: this.buildViewerExpireTime()
watchers.push(expiration)
await this.notifyClients(video.id, watchers.length)
return true
}
private async updateLocalViewerStats (options: {
video: MVideo
ip: string
currentTime: number
viewEvent?: VideoViewEvent
}) {
const { video, ip, viewEvent, currentTime } = options
const nowMs = new Date().getTime()
let stats: LocalViewerStats = await Redis.Instance.getLocalVideoViewer({ ip, videoId: video.id })
if (stats && stats.watchSections.length >= MAX_LOCAL_VIEWER_WATCH_SECTIONS) {
logger.warn('Too much watch section to store for a viewer, skipping this one', { currentTime, viewEvent, ...lTags(video.uuid) })
return
}
if (!stats) {
const country = await GeoIP.Instance.safeCountryISOLookup(ip)
stats = {
firstUpdated: nowMs,
lastUpdated: nowMs,
watchSections: [],
watchTime: 0,
country,
videoId: video.id
}
}
stats.lastUpdated = nowMs
if (viewEvent === 'seek' || stats.watchSections.length === 0) {
stats.watchSections.push({
start: currentTime,
end: currentTime
})
} else {
const lastSection = stats.watchSections[stats.watchSections.length - 1]
lastSection.end = currentTime
}
stats.watchTime = this.buildWatchTimeFromSections(stats.watchSections)
logger.debug('Set local video viewer stats for video %s.', video.uuid, { stats, ...lTags(video.uuid) })
await Redis.Instance.setLocalVideoViewer(ip, video.id, stats)
}
private async cleanViewerCounters () {
if (this.processingViewerCounters) return
this.processingViewerCounters = true
if (!isTestInstance()) 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 = viewers.filter(w => w > notBefore)
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())
}
async processViewerStats () {
if (this.processingViewerStats) return
this.processingViewerStats = true
if (!isTestInstance()) logger.info('Processing viewers.', lTags())
const now = new Date().getTime()
try {
const allKeys = await Redis.Instance.listLocalVideoViewerKeys()
for (const key of allKeys) {
const stats: LocalViewerStats = await Redis.Instance.getLocalVideoViewer({ key })
if (stats.lastUpdated > now - VIEW_LIFETIME.VIEWER_STATS) {
continue
}
try {
await sequelizeTypescript.transaction(async t => {
const video = await VideoModel.load(stats.videoId, t)
const statsModel = await this.saveViewerStats(video, stats, t)
if (video.remote) {
await sendCreateWatchAction(statsModel, t)
}
})
await Redis.Instance.deleteLocalVideoViewersKeys(key)
} catch (err) {
logger.error('Cannot process viewer stats for Redis key %s.', key, { err, ...lTags() })
}
}
} catch (err) {
logger.error('Error in video save viewers stats scheduler.', { err, ...lTags() })
}
this.processingViewerStats = false
}
private async saveViewerStats (video: MVideo, stats: LocalViewerStats, transaction: Transaction) {
const statsModel = new LocalVideoViewerModel({
startDate: new Date(stats.firstUpdated),
endDate: new Date(stats.lastUpdated),
watchTime: stats.watchTime,
country: stats.country,
videoId: video.id
})
statsModel.url = getLocalVideoViewerActivityPubUrl(statsModel)
statsModel.Video = video as VideoModel
await statsModel.save({ transaction })
statsModel.WatchSections = await LocalVideoViewerWatchSectionModel.bulkCreateSections({
localVideoViewerId: statsModel.id,
watchSections: stats.watchSections,
transaction
})
return statsModel
}
private buildWatchTimeFromSections (sections: { start: number, end: number }[]) {
return sections.reduce((p, current) => p + (current.end - current.start), 0)
}
}

View File

@ -0,0 +1,60 @@
import { logger, loggerTagsFactory } from '@server/helpers/logger'
import { MVideo } from '@server/types/models'
import { Redis } from '../../redis'
const lTags = loggerTagsFactory('views')
export class VideoViews {
async addLocalView (options: {
video: MVideo
ip: string
watchTime: number
}) {
const { video, ip, watchTime } = options
logger.debug('Adding local view to video %s.', video.uuid, { watchTime, ...lTags(video.uuid) })
if (!this.hasEnoughWatchTime(video, watchTime)) return false
const viewExists = await Redis.Instance.doesVideoIPViewExist(ip, video.uuid)
if (viewExists) return false
await Redis.Instance.setIPVideoView(ip, video.uuid)
await this.addView(video)
return true
}
async addRemoteView (options: {
video: MVideo
}) {
const { video } = options
logger.debug('Adding remote view to video %s.', video.uuid, { ...lTags(video.uuid) })
await this.addView(video)
return true
}
private async addView (video: MVideo) {
const promises: Promise<any>[] = []
if (video.isOwned()) {
promises.push(Redis.Instance.addLocalVideoView(video.id))
}
promises.push(Redis.Instance.addVideoViewStats(video.id))
await Promise.all(promises)
}
private hasEnoughWatchTime (video: MVideo, watchTime: number) {
if (video.isLive || video.duration >= 30) return watchTime >= 30
// Check more than 50% of the video is watched
return video.duration / watchTime < 2
}
}

View File

@ -0,0 +1,70 @@
import { logger, loggerTagsFactory } from '@server/helpers/logger'
import { MVideo } from '@server/types/models'
import { VideoViewEvent } from '@shared/models'
import { VideoViewers, VideoViews } from './shared'
const lTags = loggerTagsFactory('views')
export class VideoViewsManager {
private static instance: VideoViewsManager
private videoViewers: VideoViewers
private videoViews: VideoViews
private constructor () {
}
init () {
this.videoViewers = new VideoViewers()
this.videoViews = new VideoViews()
}
async processLocalView (options: {
video: MVideo
currentTime: number
ip: string | null
viewEvent?: VideoViewEvent
}) {
const { video, ip, viewEvent, currentTime } = options
logger.debug('Processing local view for %s and ip %s.', video.url, ip, lTags())
const successViewer = await this.videoViewers.addLocalViewer({ video, ip, viewEvent, currentTime })
// Do it after added local viewer to fetch updated information
const watchTime = await this.videoViewers.getWatchTime(video.id, ip)
const successView = await this.videoViews.addLocalView({ video, watchTime, ip })
return { successView, successViewer }
}
async processRemoteView (options: {
video: MVideo
viewerExpires?: Date
}) {
const { video, viewerExpires } = options
logger.debug('Processing remote view for %s.', video.url, { viewerExpires, ...lTags() })
if (viewerExpires) await this.videoViewers.addRemoteViewer({ video, viewerExpires })
else await this.videoViews.addRemoteView({ video })
}
getViewers (video: MVideo) {
return this.videoViewers.getViewers(video)
}
buildViewerExpireTime () {
return this.videoViewers.buildViewerExpireTime()
}
processViewers () {
return this.videoViewers.processViewerStats()
}
static get Instance () {
return this.instance || (this.instance = new this())
}
}

View File

@ -6,8 +6,8 @@ import { OutgoingHttpHeaders } from 'http'
import { isTestInstance, parseDurationToMs } from '@server/helpers/core-utils' import { isTestInstance, parseDurationToMs } from '@server/helpers/core-utils'
import { logger } from '@server/helpers/logger' import { logger } from '@server/helpers/logger'
import { Redis } from '@server/lib/redis' import { Redis } from '@server/lib/redis'
import { HttpStatusCode } from '@shared/models'
import { asyncMiddleware } from '@server/middlewares' import { asyncMiddleware } from '@server/middlewares'
import { HttpStatusCode } from '@shared/models'
export interface APICacheOptions { export interface APICacheOptions {
headerBlacklist?: string[] headerBlacklist?: string[]
@ -152,7 +152,7 @@ export class ApiCache {
end: res.end, end: res.end,
cacheable: true, cacheable: true,
content: undefined, content: undefined,
headers: {} headers: undefined
} }
// Patch express // Patch express

View File

@ -0,0 +1,15 @@
import * as express from 'express'
const methodsValidator = (methods: string[]) => {
return (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (methods.includes(req.method) !== true) {
return res.sendStatus(405)
}
return next()
}
}
export {
methodsValidator
}

View File

@ -1,17 +1,26 @@
export * from './activitypub'
export * from './videos'
export * from './abuse' export * from './abuse'
export * from './account' export * from './account'
export * from './actor-image' export * from './actor-image'
export * from './blocklist' export * from './blocklist'
export * from './oembed' export * from './bulk'
export * from './activitypub' export * from './config'
export * from './pagination' export * from './express'
export * from './follows'
export * from './feeds' export * from './feeds'
export * from './sort' export * from './follows'
export * from './users' export * from './jobs'
export * from './user-subscriptions' export * from './logs'
export * from './videos' export * from './oembed'
export * from './pagination'
export * from './plugins'
export * from './redundancy'
export * from './search' export * from './search'
export * from './server' export * from './server'
export * from './sort'
export * from './themes'
export * from './user-history' export * from './user-history'
export * from './user-notifications'
export * from './user-subscriptions'
export * from './users'
export * from './webfinger' export * from './webfinger'

View File

@ -6,9 +6,10 @@ export * from './video-files'
export * from './video-imports' export * from './video-imports'
export * from './video-live' export * from './video-live'
export * from './video-ownership-changes' export * from './video-ownership-changes'
export * from './video-watch' export * from './video-view'
export * from './video-rates' export * from './video-rates'
export * from './video-shares' export * from './video-shares'
export * from './video-stats'
export * from './video-studio' export * from './video-studio'
export * from './video-transcoding' export * from './video-transcoding'
export * from './videos' export * from './videos'

View File

@ -0,0 +1,73 @@
import express from 'express'
import { param } from 'express-validator'
import { isValidStatTimeserieMetric } from '@server/helpers/custom-validators/video-stats'
import { HttpStatusCode, UserRight } from '@shared/models'
import { logger } from '../../../helpers/logger'
import { areValidationErrors, checkUserCanManageVideo, doesVideoExist, isValidVideoIdParam } from '../shared'
const videoOverallStatsValidator = [
isValidVideoIdParam('videoId'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking videoOverallStatsValidator parameters', { parameters: req.body })
if (areValidationErrors(req, res)) return
if (!await commonStatsCheck(req, res)) return
return next()
}
]
const videoRetentionStatsValidator = [
isValidVideoIdParam('videoId'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking videoRetentionStatsValidator parameters', { parameters: req.body })
if (areValidationErrors(req, res)) return
if (!await commonStatsCheck(req, res)) return
if (res.locals.videoAll.isLive) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Cannot get retention stats of live video'
})
}
return next()
}
]
const videoTimeserieStatsValidator = [
isValidVideoIdParam('videoId'),
param('metric')
.custom(isValidStatTimeserieMetric)
.withMessage('Should have a valid timeserie metric'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking videoTimeserieStatsValidator parameters', { parameters: req.body })
if (areValidationErrors(req, res)) return
if (!await commonStatsCheck(req, res)) return
return next()
}
]
// ---------------------------------------------------------------------------
export {
videoOverallStatsValidator,
videoTimeserieStatsValidator,
videoRetentionStatsValidator
}
// ---------------------------------------------------------------------------
async function commonStatsCheck (req: express.Request, res: express.Response) {
if (!await doesVideoExist(req.params.videoId, res, 'all')) return false
if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.SEE_ALL_VIDEOS, res)) return false
return true
}

View File

@ -0,0 +1,74 @@
import express from 'express'
import { body, param } from 'express-validator'
import { isVideoTimeValid } from '@server/helpers/custom-validators/video-view'
import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer'
import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
import { exists, isIdValid, isIntOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc'
import { logger } from '../../../helpers/logger'
import { areValidationErrors, doesVideoExist, isValidVideoIdParam } from '../shared'
const getVideoLocalViewerValidator = [
param('localViewerId')
.custom(isIdValid).withMessage('Should have a valid local viewer id'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking getVideoLocalViewerValidator parameters', { parameters: req.params })
if (areValidationErrors(req, res)) return
const localViewer = await LocalVideoViewerModel.loadFullById(+req.params.localViewerId)
if (!localViewer) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Local viewer not found'
})
}
res.locals.localViewerFull = localViewer
return next()
}
]
const videoViewValidator = [
isValidVideoIdParam('videoId'),
body('currentTime')
.optional() // TODO: remove optional in a few versions, introduced in 4.2
.customSanitizer(toIntOrNull)
.custom(isIntOrNull).withMessage('Should have correct current time'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking videoView parameters', { parameters: req.body })
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res, 'only-video')) return
const video = res.locals.onlyVideo
const videoDuration = video.isLive
? undefined
: video.duration
if (!exists(req.body.currentTime)) { // TODO: remove in a few versions, introduced in 4.2
req.body.currentTime = Math.min(videoDuration ?? 0, 30)
}
const currentTime: number = req.body.currentTime
if (!isVideoTimeValid(currentTime, videoDuration)) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Current time is invalid'
})
}
return next()
}
]
// ---------------------------------------------------------------------------
export {
videoViewValidator,
getVideoLocalViewerValidator
}

View File

@ -1,38 +0,0 @@
import express from 'express'
import { body } from 'express-validator'
import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
import { toIntOrNull } from '../../../helpers/custom-validators/misc'
import { logger } from '../../../helpers/logger'
import { areValidationErrors, doesVideoExist, isValidVideoIdParam } from '../shared'
const videoWatchingValidator = [
isValidVideoIdParam('videoId'),
body('currentTime')
.customSanitizer(toIntOrNull)
.isInt().withMessage('Should have correct current time'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking videoWatching parameters', { parameters: req.body })
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res, 'id')) return
const user = res.locals.oauth.token.User
if (user.videosHistoryEnabled === false) {
logger.warn('Cannot set videos to watch by user %d: videos history is disabled.', user.id)
return res.fail({
status: HttpStatusCode.CONFLICT_409,
message: 'Video history is disabled'
})
}
return next()
}
]
// ---------------------------------------------------------------------------
export {
videoWatchingValidator
}

View File

@ -1,11 +1,19 @@
import { generateMagnetUri } from '@server/helpers/webtorrent' import { generateMagnetUri } from '@server/helpers/webtorrent'
import { getActivityStreamDuration } from '@server/lib/activitypub/activity'
import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls' import { getLocalVideoFileMetadataUrl } from '@server/lib/video-urls'
import { VideoViews } from '@server/lib/video-views' import { VideoViewsManager } from '@server/lib/views/video-views-manager'
import { uuidToShort } from '@shared/extra-utils' import { uuidToShort } from '@shared/extra-utils'
import { VideoFile, VideosCommonQueryAfterSanitize } from '@shared/models' import {
import { ActivityTagObject, ActivityUrlObject, VideoObject } from '../../../../shared/models/activitypub/objects' ActivityTagObject,
import { Video, VideoDetails, VideoInclude } from '../../../../shared/models/videos' ActivityUrlObject,
import { VideoStreamingPlaylist } from '../../../../shared/models/videos/video-streaming-playlist.model' Video,
VideoDetails,
VideoFile,
VideoInclude,
VideoObject,
VideosCommonQueryAfterSanitize,
VideoStreamingPlaylist
} from '@shared/models'
import { isArray } from '../../../helpers/custom-validators/misc' import { isArray } from '../../../helpers/custom-validators/misc'
import { import {
MIMETYPES, MIMETYPES,
@ -97,7 +105,10 @@ function videoModelToFormattedJSON (video: MVideoFormattable, options: VideoForm
isLocal: video.isOwned(), isLocal: video.isOwned(),
duration: video.duration, duration: video.duration,
views: video.views, views: video.views,
viewers: VideoViewsManager.Instance.getViewers(video),
likes: video.likes, likes: video.likes,
dislikes: video.dislikes, dislikes: video.dislikes,
thumbnailPath: video.getMiniatureStaticPath(), thumbnailPath: video.getMiniatureStaticPath(),
@ -121,10 +132,6 @@ function videoModelToFormattedJSON (video: MVideoFormattable, options: VideoForm
pluginData: (video as any).pluginData pluginData: (video as any).pluginData
} }
if (video.isLive) {
videoObject.viewers = VideoViews.Instance.getViewers(video)
}
const add = options.additionalAttributes const add = options.additionalAttributes
if (add?.state === true) { if (add?.state === true) {
videoObject.state = { videoObject.state = {
@ -459,11 +466,6 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
} }
} }
function getActivityStreamDuration (duration: number) {
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-duration
return 'PT' + duration + 'S'
}
function getCategoryLabel (id: number) { function getCategoryLabel (id: number) {
return VIDEO_CATEGORIES[id] || 'Misc' return VIDEO_CATEGORIES[id] || 'Misc'
} }
@ -489,7 +491,6 @@ export {
videoModelToFormattedDetailsJSON, videoModelToFormattedDetailsJSON,
videoFilesModelToFormattedJSON, videoFilesModelToFormattedJSON,
videoModelToActivityPubObject, videoModelToActivityPubObject,
getActivityStreamDuration,
guessAdditionalAttributesFromQuery, guessAdditionalAttributesFromQuery,

View File

@ -106,6 +106,7 @@ import { setAsUpdated } from '../shared'
import { UserModel } from '../user/user' import { UserModel } from '../user/user'
import { UserVideoHistoryModel } from '../user/user-video-history' import { UserVideoHistoryModel } from '../user/user-video-history'
import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils' import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils'
import { VideoViewModel } from '../view/video-view'
import { import {
videoFilesModelToFormattedJSON, videoFilesModelToFormattedJSON,
VideoFormattingJSONOptions, VideoFormattingJSONOptions,
@ -135,7 +136,6 @@ import { VideoPlaylistElementModel } from './video-playlist-element'
import { VideoShareModel } from './video-share' import { VideoShareModel } from './video-share'
import { VideoStreamingPlaylistModel } from './video-streaming-playlist' import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
import { VideoTagModel } from './video-tag' import { VideoTagModel } from './video-tag'
import { VideoViewModel } from './video-view'
export enum ScopeNames { export enum ScopeNames {
FOR_API = 'FOR_API', FOR_API = 'FOR_API',

View File

@ -0,0 +1,63 @@
import { Transaction } from 'sequelize'
import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Table } from 'sequelize-typescript'
import { MLocalVideoViewerWatchSection } from '@server/types/models'
import { AttributesOnly } from '@shared/typescript-utils'
import { LocalVideoViewerModel } from './local-video-viewer'
@Table({
tableName: 'localVideoViewerWatchSection',
updatedAt: false,
indexes: [
{
fields: [ 'localVideoViewerId' ]
}
]
})
export class LocalVideoViewerWatchSectionModel extends Model<Partial<AttributesOnly<LocalVideoViewerWatchSectionModel>>> {
@CreatedAt
createdAt: Date
@AllowNull(false)
@Column
watchStart: number
@AllowNull(false)
@Column
watchEnd: number
@ForeignKey(() => LocalVideoViewerModel)
@Column
localVideoViewerId: number
@BelongsTo(() => LocalVideoViewerModel, {
foreignKey: {
allowNull: false
},
onDelete: 'CASCADE'
})
LocalVideoViewer: LocalVideoViewerModel
static async bulkCreateSections (options: {
localVideoViewerId: number
watchSections: {
start: number
end: number
}[]
transaction?: Transaction
}) {
const { localVideoViewerId, watchSections, transaction } = options
const models: MLocalVideoViewerWatchSection[] = []
for (const section of watchSections) {
const model = await this.create({
watchStart: section.start,
watchEnd: section.end,
localVideoViewerId
}, { transaction })
models.push(model)
}
return models
}
}

View File

@ -0,0 +1,274 @@
import { QueryTypes } from 'sequelize'
import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, HasMany, IsUUID, Model, Table } from 'sequelize-typescript'
import { STATS_TIMESERIE } from '@server/initializers/constants'
import { getActivityStreamDuration } from '@server/lib/activitypub/activity'
import { MLocalVideoViewer, MLocalVideoViewerWithWatchSections, MVideo } from '@server/types/models'
import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric, WatchActionObject } from '@shared/models'
import { AttributesOnly } from '@shared/typescript-utils'
import { VideoModel } from '../video/video'
import { LocalVideoViewerWatchSectionModel } from './local-video-viewer-watch-section'
@Table({
tableName: 'localVideoViewer',
updatedAt: false,
indexes: [
{
fields: [ 'videoId' ]
}
]
})
export class LocalVideoViewerModel extends Model<Partial<AttributesOnly<LocalVideoViewerModel>>> {
@CreatedAt
createdAt: Date
@AllowNull(false)
@Column(DataType.DATE)
startDate: Date
@AllowNull(false)
@Column(DataType.DATE)
endDate: Date
@AllowNull(false)
@Column
watchTime: number
@AllowNull(true)
@Column
country: string
@AllowNull(false)
@Default(DataType.UUIDV4)
@IsUUID(4)
@Column(DataType.UUID)
uuid: string
@AllowNull(false)
@Column
url: string
@ForeignKey(() => VideoModel)
@Column
videoId: number
@BelongsTo(() => VideoModel, {
foreignKey: {
allowNull: false
},
onDelete: 'CASCADE'
})
Video: VideoModel
@HasMany(() => LocalVideoViewerWatchSectionModel, {
foreignKey: {
allowNull: false
},
onDelete: 'cascade'
})
WatchSections: LocalVideoViewerWatchSectionModel[]
static loadByUrl (url: string): Promise<MLocalVideoViewer> {
return this.findOne({
where: {
url
}
})
}
static loadFullById (id: number): Promise<MLocalVideoViewerWithWatchSections> {
return this.findOne({
include: [
{
model: VideoModel.unscoped(),
required: true
},
{
model: LocalVideoViewerWatchSectionModel.unscoped(),
required: true
}
],
where: {
id
}
})
}
static async getOverallStats (video: MVideo): Promise<VideoStatsOverall> {
const options = {
type: QueryTypes.SELECT as QueryTypes.SELECT,
replacements: { videoId: video.id }
}
const watchTimeQuery = `SELECT ` +
`SUM("localVideoViewer"."watchTime") AS "totalWatchTime", ` +
`AVG("localVideoViewer"."watchTime") AS "averageWatchTime" ` +
`FROM "localVideoViewer" ` +
`INNER JOIN "video" ON "video"."id" = "localVideoViewer"."videoId" ` +
`WHERE "videoId" = :videoId`
const watchTimePromise = LocalVideoViewerModel.sequelize.query<any>(watchTimeQuery, options)
const watchPeakQuery = `WITH "watchPeakValues" AS (
SELECT "startDate" AS "dateBreakpoint", 1 AS "inc"
FROM "localVideoViewer"
WHERE "videoId" = :videoId
UNION ALL
SELECT "endDate" AS "dateBreakpoint", -1 AS "inc"
FROM "localVideoViewer"
WHERE "videoId" = :videoId
)
SELECT "dateBreakpoint", "concurrent"
FROM (
SELECT "dateBreakpoint", SUM(SUM("inc")) OVER (ORDER BY "dateBreakpoint") AS "concurrent"
FROM "watchPeakValues"
GROUP BY "dateBreakpoint"
) tmp
ORDER BY "concurrent" DESC
FETCH FIRST 1 ROW ONLY`
const watchPeakPromise = LocalVideoViewerModel.sequelize.query<any>(watchPeakQuery, options)
const commentsQuery = `SELECT COUNT(*) AS comments FROM "videoComment" WHERE "videoId" = :videoId`
const commentsPromise = LocalVideoViewerModel.sequelize.query<any>(commentsQuery, options)
const countriesQuery = `SELECT country, COUNT(country) as viewers ` +
`FROM "localVideoViewer" ` +
`WHERE "videoId" = :videoId AND country IS NOT NULL ` +
`GROUP BY country ` +
`ORDER BY viewers DESC`
const countriesPromise = LocalVideoViewerModel.sequelize.query<any>(countriesQuery, options)
const [ rowsWatchTime, rowsWatchPeak, rowsComment, rowsCountries ] = await Promise.all([
watchTimePromise,
watchPeakPromise,
commentsPromise,
countriesPromise
])
return {
totalWatchTime: rowsWatchTime.length !== 0
? Math.round(rowsWatchTime[0].totalWatchTime) || 0
: 0,
averageWatchTime: rowsWatchTime.length !== 0
? Math.round(rowsWatchTime[0].averageWatchTime) || 0
: 0,
viewersPeak: rowsWatchPeak.length !== 0
? parseInt(rowsWatchPeak[0].concurrent) || 0
: 0,
viewersPeakDate: rowsWatchPeak.length !== 0
? rowsWatchPeak[0].dateBreakpoint || null
: null,
views: video.views,
likes: video.likes,
dislikes: video.dislikes,
comments: rowsComment.length !== 0
? parseInt(rowsComment[0].comments) || 0
: 0,
countries: rowsCountries.map(r => ({
isoCode: r.country,
viewers: r.viewers
}))
}
}
static async getRetentionStats (video: MVideo): Promise<VideoStatsRetention> {
const step = Math.max(Math.round(video.duration / 100), 1)
const query = `WITH "total" AS (SELECT COUNT(*) AS viewers FROM "localVideoViewer" WHERE "videoId" = :videoId) ` +
`SELECT serie AS "second", ` +
`(COUNT("localVideoViewer".id)::float / (SELECT GREATEST("total"."viewers", 1) FROM "total")) AS "retention" ` +
`FROM generate_series(0, ${video.duration}, ${step}) serie ` +
`LEFT JOIN "localVideoViewer" ON "localVideoViewer"."videoId" = :videoId ` +
`AND EXISTS (` +
`SELECT 1 FROM "localVideoViewerWatchSection" ` +
`WHERE "localVideoViewer"."id" = "localVideoViewerWatchSection"."localVideoViewerId" ` +
`AND serie >= "localVideoViewerWatchSection"."watchStart" ` +
`AND serie <= "localVideoViewerWatchSection"."watchEnd"` +
`)` +
`GROUP BY serie ` +
`ORDER BY serie ASC`
const queryOptions = {
type: QueryTypes.SELECT as QueryTypes.SELECT,
replacements: { videoId: video.id }
}
const rows = await LocalVideoViewerModel.sequelize.query<any>(query, queryOptions)
return {
data: rows.map(r => ({
second: r.second,
retentionPercent: parseFloat(r.retention) * 100
}))
}
}
static async getTimeserieStats (options: {
video: MVideo
metric: VideoStatsTimeserieMetric
}): Promise<VideoStatsTimeserie> {
const { video, metric } = options
const selectMetrics: { [ id in VideoStatsTimeserieMetric ]: string } = {
viewers: 'COUNT("localVideoViewer"."id")',
aggregateWatchTime: 'SUM("localVideoViewer"."watchTime")'
}
const query = `WITH days AS ( ` +
`SELECT (current_date::timestamp - (serie || ' days')::interval)::timestamptz AS day
FROM generate_series(0, ${STATS_TIMESERIE.MAX_DAYS - 1}) serie` +
`) ` +
`SELECT days.day AS date, COALESCE(${selectMetrics[metric]}, 0) AS value ` +
`FROM days ` +
`LEFT JOIN "localVideoViewer" ON "localVideoViewer"."videoId" = :videoId ` +
`AND date_trunc('day', "localVideoViewer"."startDate") = date_trunc('day', days.day) ` +
`GROUP BY day ` +
`ORDER BY day `
const queryOptions = {
type: QueryTypes.SELECT as QueryTypes.SELECT,
replacements: { videoId: video.id }
}
const rows = await LocalVideoViewerModel.sequelize.query<any>(query, queryOptions)
return {
data: rows.map(r => ({
date: r.date,
value: parseInt(r.value)
}))
}
}
toActivityPubObject (this: MLocalVideoViewerWithWatchSections): WatchActionObject {
const location = this.country
? {
location: {
addressCountry: this.country
}
}
: {}
return {
id: this.url,
type: 'WatchAction',
duration: getActivityStreamDuration(this.watchTime),
startTime: this.startDate.toISOString(),
endTime: this.endDate.toISOString(),
object: this.Video.url,
uuid: this.uuid,
actionStatus: 'CompletedActionStatus',
watchSections: this.WatchSections.map(w => ({
startTimestamp: w.watchStart,
endTimestamp: w.watchEnd
})),
...location
}
}
}

View File

@ -1,7 +1,7 @@
import { literal, Op } from 'sequelize' import { literal, Op } from 'sequelize'
import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Model, Table } from 'sequelize-typescript' import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Model, Table } from 'sequelize-typescript'
import { AttributesOnly } from '@shared/typescript-utils' import { AttributesOnly } from '@shared/typescript-utils'
import { VideoModel } from './video' import { VideoModel } from '../video/video'
@Table({ @Table({
tableName: 'videoView', tableName: 'videoView',

View File

@ -2,6 +2,8 @@
import 'mocha' import 'mocha'
import * as chai from 'chai' import * as chai from 'chai'
import { processViewersStats } from '@server/tests/shared'
import { HttpStatusCode, VideoPlaylistPrivacy, WatchActionObject } from '@shared/models'
import { import {
cleanupTests, cleanupTests,
createMultipleServers, createMultipleServers,
@ -11,7 +13,6 @@ import {
setAccessTokensToServers, setAccessTokensToServers,
setDefaultVideoChannel setDefaultVideoChannel
} from '@shared/server-commands' } from '@shared/server-commands'
import { HttpStatusCode, VideoPlaylistPrivacy } from '@shared/models'
const expect = chai.expect const expect = chai.expect
@ -115,6 +116,23 @@ describe('Test activitypub', function () {
expect(res.header.location).to.equal('http://localhost:' + servers[0].port + '/videos/watch/' + video.uuid) expect(res.header.location).to.equal('http://localhost:' + servers[0].port + '/videos/watch/' + video.uuid)
}) })
it('Should return the watch action', async function () {
this.timeout(50000)
await servers[0].views.simulateViewer({ id: video.uuid, currentTimes: [ 0, 2 ] })
await processViewersStats(servers)
const res = await makeActivityPubGetRequest(servers[0].url, '/videos/local-viewer/1', HttpStatusCode.OK_200)
const object: WatchActionObject = res.body
expect(object.type).to.equal('WatchAction')
expect(object.duration).to.equal('PT2S')
expect(object.actionStatus).to.equal('CompletedActionStatus')
expect(object.watchSections).to.have.lengthOf(1)
expect(object.watchSections[0].startTimestamp).to.equal(0)
expect(object.watchSections[0].endTimestamp).to.equal(2)
})
after(async function () { after(async function () {
await cleanupTests(servers) await cleanupTests(servers)
}) })

View File

@ -33,3 +33,4 @@ import './videos-common-filters'
import './video-files' import './video-files'
import './videos-history' import './videos-history'
import './videos-overviews' import './videos-overviews'
import './views'

View File

@ -17,7 +17,7 @@ import {
describe('Test videos history API validator', function () { describe('Test videos history API validator', function () {
const myHistoryPath = '/api/v1/users/me/history/videos' const myHistoryPath = '/api/v1/users/me/history/videos'
const myHistoryRemove = myHistoryPath + '/remove' const myHistoryRemove = myHistoryPath + '/remove'
let watchingPath: string let viewPath: string
let server: PeerTubeServer let server: PeerTubeServer
let videoId: number let videoId: number
@ -31,51 +31,15 @@ describe('Test videos history API validator', function () {
await setAccessTokensToServers([ server ]) await setAccessTokensToServers([ server ])
const { id, uuid } = await server.videos.upload() const { id, uuid } = await server.videos.upload()
watchingPath = '/api/v1/videos/' + uuid + '/watching' viewPath = '/api/v1/videos/' + uuid + '/views'
videoId = id videoId = id
}) })
describe('When notifying a user is watching a video', function () { describe('When notifying a user is watching a video', function () {
it('Should fail with an unauthenticated user', async function () { it('Should fail with a bad token', async function () {
const fields = { currentTime: 5 } const fields = { currentTime: 5 }
await makePutBodyRequest({ url: server.url, path: watchingPath, fields, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) await makePutBodyRequest({ url: server.url, path: viewPath, fields, token: 'bad', expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
})
it('Should fail with an incorrect video id', async function () {
const fields = { currentTime: 5 }
const path = '/api/v1/videos/blabla/watching'
await makePutBodyRequest({
url: server.url,
path,
fields,
token: server.accessToken,
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
})
it('Should fail with an unknown video', async function () {
const fields = { currentTime: 5 }
const path = '/api/v1/videos/d91fff41-c24d-4508-8e13-3bd5902c3b02/watching'
await makePutBodyRequest({
url: server.url,
path,
fields,
token: server.accessToken,
expectedStatus: HttpStatusCode.NOT_FOUND_404
})
})
it('Should fail with a bad current time', async function () {
const fields = { currentTime: 'hello' }
await makePutBodyRequest({
url: server.url,
path: watchingPath,
fields,
token: server.accessToken,
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
}) })
it('Should succeed with the correct parameters', async function () { it('Should succeed with the correct parameters', async function () {
@ -83,7 +47,7 @@ describe('Test videos history API validator', function () {
await makePutBodyRequest({ await makePutBodyRequest({
url: server.url, url: server.url,
path: watchingPath, path: viewPath,
fields, fields,
token: server.accessToken, token: server.accessToken,
expectedStatus: HttpStatusCode.NO_CONTENT_204 expectedStatus: HttpStatusCode.NO_CONTENT_204

View File

@ -0,0 +1,157 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import 'mocha'
import { HttpStatusCode, VideoPrivacy } from '@shared/models'
import {
cleanupTests,
createMultipleServers,
doubleFollow,
PeerTubeServer,
setAccessTokensToServers,
setDefaultVideoChannel
} from '@shared/server-commands'
describe('Test videos views', function () {
let servers: PeerTubeServer[]
let liveVideoId: string
let videoId: string
let remoteVideoId: string
let userAccessToken: string
before(async function () {
this.timeout(30000)
servers = await createMultipleServers(2)
await setAccessTokensToServers(servers)
await setDefaultVideoChannel(servers)
await servers[0].config.enableLive({ allowReplay: false, transcoding: false });
({ uuid: videoId } = await servers[0].videos.quickUpload({ name: 'video' }));
({ uuid: remoteVideoId } = await servers[1].videos.quickUpload({ name: 'video' }));
({ uuid: liveVideoId } = await servers[0].live.create({
fields: {
name: 'live',
privacy: VideoPrivacy.PUBLIC,
channelId: servers[0].store.channel.id
}
}))
userAccessToken = await servers[0].users.generateUserAndToken('user')
await doubleFollow(servers[0], servers[1])
})
describe('When viewing a video', async function () {
// TODO: implement it when we'll remove backward compatibility in REST API
it('Should fail without current time')
it('Should fail with an invalid current time', async function () {
await servers[0].views.view({ id: videoId, currentTime: -1, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
await servers[0].views.view({ id: videoId, currentTime: 10, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
})
it('Should succeed with correct parameters', async function () {
await servers[0].views.view({ id: videoId, currentTime: 1 })
})
})
describe('When getting overall stats', function () {
it('Should fail with a remote video', async function () {
await servers[0].videoStats.getOverallStats({ videoId: remoteVideoId, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
})
it('Should fail without token', async function () {
await servers[0].videoStats.getOverallStats({ videoId: videoId, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
})
it('Should fail with another token', async function () {
await servers[0].videoStats.getOverallStats({
videoId: videoId,
token: userAccessToken,
expectedStatus: HttpStatusCode.FORBIDDEN_403
})
})
it('Should succeed with the correct parameters', async function () {
await servers[0].videoStats.getOverallStats({ videoId })
})
})
describe('When getting timeserie stats', function () {
it('Should fail with a remote video', async function () {
await servers[0].videoStats.getTimeserieStats({
videoId: remoteVideoId,
metric: 'viewers',
expectedStatus: HttpStatusCode.FORBIDDEN_403
})
})
it('Should fail without token', async function () {
await servers[0].videoStats.getTimeserieStats({
videoId: videoId,
token: null,
metric: 'viewers',
expectedStatus: HttpStatusCode.UNAUTHORIZED_401
})
})
it('Should fail with another token', async function () {
await servers[0].videoStats.getTimeserieStats({
videoId: videoId,
token: userAccessToken,
metric: 'viewers',
expectedStatus: HttpStatusCode.FORBIDDEN_403
})
})
it('Should fail with an invalid metric', async function () {
await servers[0].videoStats.getTimeserieStats({ videoId, metric: 'hello' as any, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
})
it('Should succeed with the correct parameters', async function () {
await servers[0].videoStats.getTimeserieStats({ videoId, metric: 'viewers' })
})
})
describe('When getting retention stats', function () {
it('Should fail with a remote video', async function () {
await servers[0].videoStats.getRetentionStats({
videoId: remoteVideoId,
expectedStatus: HttpStatusCode.FORBIDDEN_403
})
})
it('Should fail without token', async function () {
await servers[0].videoStats.getRetentionStats({
videoId: videoId,
token: null,
expectedStatus: HttpStatusCode.UNAUTHORIZED_401
})
})
it('Should fail with another token', async function () {
await servers[0].videoStats.getRetentionStats({
videoId: videoId,
token: userAccessToken,
expectedStatus: HttpStatusCode.FORBIDDEN_403
})
})
it('Should fail on live video', async function () {
await servers[0].videoStats.getRetentionStats({ videoId: liveVideoId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
})
it('Should succeed with the correct parameters', async function () {
await servers[0].videoStats.getRetentionStats({ videoId })
})
})
after(async function () {
await cleanupTests(servers)
})
})

View File

@ -3,5 +3,4 @@ import './live-socket-messages'
import './live-permanent' import './live-permanent'
import './live-rtmps' import './live-rtmps'
import './live-save-replay' import './live-save-replay'
import './live-views'
import './live' import './live'

View File

@ -140,8 +140,8 @@ describe('Test live', function () {
expect(localLastVideoViews).to.equal(0) expect(localLastVideoViews).to.equal(0)
expect(remoteLastVideoViews).to.equal(0) expect(remoteLastVideoViews).to.equal(0)
await servers[0].videos.view({ id: liveVideoUUID }) await servers[0].views.simulateView({ id: liveVideoUUID })
await servers[1].videos.view({ id: liveVideoUUID }) await servers[1].views.simulateView({ id: liveVideoUUID })
await waitJobs(servers) await waitJobs(servers)

View File

@ -1,132 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import 'mocha'
import * as chai from 'chai'
import { FfmpegCommand } from 'fluent-ffmpeg'
import { wait } from '@shared/core-utils'
import { VideoPrivacy } from '@shared/models'
import {
cleanupTests,
createMultipleServers,
doubleFollow,
PeerTubeServer,
setAccessTokensToServers,
setDefaultVideoChannel,
stopFfmpeg,
waitJobs,
waitUntilLivePublishedOnAllServers
} from '@shared/server-commands'
const expect = chai.expect
describe('Live views', function () {
let servers: PeerTubeServer[] = []
before(async function () {
this.timeout(120000)
servers = await createMultipleServers(2)
// Get the access tokens
await setAccessTokensToServers(servers)
await setDefaultVideoChannel(servers)
await servers[0].config.updateCustomSubConfig({
newConfig: {
live: {
enabled: true,
allowReplay: true,
transcoding: {
enabled: false
}
}
}
})
// Server 1 and server 2 follow each other
await doubleFollow(servers[0], servers[1])
})
let liveVideoId: string
let command: FfmpegCommand
async function countViewers (expectedViewers: number) {
for (const server of servers) {
const video = await server.videos.get({ id: liveVideoId })
expect(video.viewers).to.equal(expectedViewers)
}
}
async function countViews (expectedViews: number) {
for (const server of servers) {
const video = await server.videos.get({ id: liveVideoId })
expect(video.views).to.equal(expectedViews)
}
}
before(async function () {
this.timeout(30000)
const liveAttributes = {
name: 'live video',
channelId: servers[0].store.channel.id,
privacy: VideoPrivacy.PUBLIC
}
const live = await servers[0].live.create({ fields: liveAttributes })
liveVideoId = live.uuid
command = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveVideoId })
await waitUntilLivePublishedOnAllServers(servers, liveVideoId)
await waitJobs(servers)
})
it('Should display no views and viewers for a live', async function () {
await countViews(0)
await countViewers(0)
})
it('Should view a live twice and display 1 view/viewer', async function () {
this.timeout(30000)
await servers[0].videos.view({ id: liveVideoId })
await servers[0].videos.view({ id: liveVideoId })
await waitJobs(servers)
await countViewers(1)
await wait(7000)
await countViews(1)
})
it('Should wait and display 0 viewers while still have 1 view', async function () {
this.timeout(30000)
await wait(12000)
await waitJobs(servers)
await countViews(1)
await countViewers(0)
})
it('Should view a live on a remote and on local and display 2 viewers and 3 views', async function () {
this.timeout(30000)
await servers[0].videos.view({ id: liveVideoId })
await servers[1].videos.view({ id: liveVideoId })
await servers[1].videos.view({ id: liveVideoId })
await waitJobs(servers)
await countViewers(2)
await wait(7000)
await waitJobs(servers)
await countViews(3)
})
after(async function () {
await stopFfmpeg(command)
await cleanupTests(servers)
})
})

View File

@ -87,7 +87,7 @@ async function createServers (strategy: VideoRedundancyStrategy | null, addition
const { id } = await servers[1].videos.upload({ attributes: { name: 'video 1 server 2' } }) const { id } = await servers[1].videos.upload({ attributes: { name: 'video 1 server 2' } })
video1Server2 = await servers[1].videos.get({ id }) video1Server2 = await servers[1].videos.get({ id })
await servers[1].videos.view({ id }) await servers[1].views.simulateView({ id })
} }
await waitJobs(servers) await waitJobs(servers)
@ -447,8 +447,8 @@ describe('Test videos redundancy', function () {
it('Should view 2 times the first video to have > min_views config', async function () { it('Should view 2 times the first video to have > min_views config', async function () {
this.timeout(80000) this.timeout(80000)
await servers[0].videos.view({ id: video1Server2.uuid }) await servers[0].views.simulateView({ id: video1Server2.uuid })
await servers[2].videos.view({ id: video1Server2.uuid }) await servers[2].views.simulateView({ id: video1Server2.uuid })
await wait(10000) await wait(10000)
await waitJobs(servers) await waitJobs(servers)
@ -516,8 +516,8 @@ describe('Test videos redundancy', function () {
it('Should have 1 redundancy on the first video', async function () { it('Should have 1 redundancy on the first video', async function () {
this.timeout(160000) this.timeout(160000)
await servers[0].videos.view({ id: video1Server2.uuid }) await servers[0].views.simulateView({ id: video1Server2.uuid })
await servers[2].videos.view({ id: video1Server2.uuid }) await servers[2].views.simulateView({ id: video1Server2.uuid })
await wait(10000) await wait(10000)
await waitJobs(servers) await waitJobs(servers)

View File

@ -41,8 +41,8 @@ describe('Test application behind a reverse proxy', function () {
it('Should view a video only once with the same IP by default', async function () { it('Should view a video only once with the same IP by default', async function () {
this.timeout(20000) this.timeout(20000)
await server.videos.view({ id: videoId }) await server.views.simulateView({ id: videoId })
await server.videos.view({ id: videoId }) await server.views.simulateView({ id: videoId })
// Wait the repeatable job // Wait the repeatable job
await wait(8000) await wait(8000)
@ -54,8 +54,8 @@ describe('Test application behind a reverse proxy', function () {
it('Should view a video 2 times with the X-Forwarded-For header set', async function () { it('Should view a video 2 times with the X-Forwarded-For header set', async function () {
this.timeout(20000) this.timeout(20000)
await server.videos.view({ id: videoId, xForwardedFor: '0.0.0.1,127.0.0.1' }) await server.views.simulateView({ id: videoId, xForwardedFor: '0.0.0.1,127.0.0.1' })
await server.videos.view({ id: videoId, xForwardedFor: '0.0.0.2,127.0.0.1' }) await server.views.simulateView({ id: videoId, xForwardedFor: '0.0.0.2,127.0.0.1' })
// Wait the repeatable job // Wait the repeatable job
await wait(8000) await wait(8000)
@ -67,8 +67,8 @@ describe('Test application behind a reverse proxy', function () {
it('Should view a video only once with the same client IP in the X-Forwarded-For header', async function () { it('Should view a video only once with the same client IP in the X-Forwarded-For header', async function () {
this.timeout(20000) this.timeout(20000)
await server.videos.view({ id: videoId, xForwardedFor: '0.0.0.4,0.0.0.3,::ffff:127.0.0.1' }) await server.views.simulateView({ id: videoId, xForwardedFor: '0.0.0.4,0.0.0.3,::ffff:127.0.0.1' })
await server.videos.view({ id: videoId, xForwardedFor: '0.0.0.5,0.0.0.3,127.0.0.1' }) await server.views.simulateView({ id: videoId, xForwardedFor: '0.0.0.5,0.0.0.3,127.0.0.1' })
// Wait the repeatable job // Wait the repeatable job
await wait(8000) await wait(8000)
@ -80,8 +80,8 @@ describe('Test application behind a reverse proxy', function () {
it('Should view a video two times with a different client IP in the X-Forwarded-For header', async function () { it('Should view a video two times with a different client IP in the X-Forwarded-For header', async function () {
this.timeout(20000) this.timeout(20000)
await server.videos.view({ id: videoId, xForwardedFor: '0.0.0.8,0.0.0.6,127.0.0.1' }) await server.views.simulateView({ id: videoId, xForwardedFor: '0.0.0.8,0.0.0.6,127.0.0.1' })
await server.videos.view({ id: videoId, xForwardedFor: '0.0.0.8,0.0.0.7,127.0.0.1' }) await server.views.simulateView({ id: videoId, xForwardedFor: '0.0.0.8,0.0.0.7,127.0.0.1' })
// Wait the repeatable job // Wait the repeatable job
await wait(8000) await wait(8000)

View File

@ -38,7 +38,7 @@ describe('Test stats (excluding redundancy)', function () {
await servers[0].comments.createThread({ videoId: uuid, text: 'comment' }) await servers[0].comments.createThread({ videoId: uuid, text: 'comment' })
await servers[0].videos.view({ id: uuid }) await servers[0].views.simulateView({ id: uuid })
// Wait the video views repeatable job // Wait the video views repeatable job
await wait(8000) await wait(8000)

View File

@ -16,4 +16,3 @@ import './video-schedule-update'
import './videos-common-filters' import './videos-common-filters'
import './videos-history' import './videos-history'
import './videos-overview' import './videos-overview'
import './videos-views-cleaner'

View File

@ -504,21 +504,22 @@ describe('Test multiple servers', function () {
it('Should view multiple videos on owned servers', async function () { it('Should view multiple videos on owned servers', async function () {
this.timeout(30000) this.timeout(30000)
await servers[2].videos.view({ id: localVideosServer3[0] }) await servers[2].views.simulateView({ id: localVideosServer3[0] })
await wait(1000) await wait(1000)
await servers[2].videos.view({ id: localVideosServer3[0] }) await servers[2].views.simulateView({ id: localVideosServer3[0] })
await servers[2].videos.view({ id: localVideosServer3[1] }) await servers[2].views.simulateView({ id: localVideosServer3[1] })
await wait(1000) await wait(1000)
await servers[2].videos.view({ id: localVideosServer3[0] }) await servers[2].views.simulateView({ id: localVideosServer3[0] })
await servers[2].videos.view({ id: localVideosServer3[0] }) await servers[2].views.simulateView({ id: localVideosServer3[0] })
await waitJobs(servers) await waitJobs(servers)
// Wait the repeatable job for (const server of servers) {
await wait(6000) await server.debug.sendCommand({ body: { command: 'process-video-views-buffer' } })
}
await waitJobs(servers) await waitJobs(servers)
@ -537,23 +538,24 @@ describe('Test multiple servers', function () {
this.timeout(45000) this.timeout(45000)
const tasks: Promise<any>[] = [] const tasks: Promise<any>[] = []
tasks.push(servers[0].videos.view({ id: remoteVideosServer1[0] })) tasks.push(servers[0].views.simulateView({ id: remoteVideosServer1[0] }))
tasks.push(servers[1].videos.view({ id: remoteVideosServer2[0] })) tasks.push(servers[1].views.simulateView({ id: remoteVideosServer2[0] }))
tasks.push(servers[1].videos.view({ id: remoteVideosServer2[0] })) tasks.push(servers[1].views.simulateView({ id: remoteVideosServer2[0] }))
tasks.push(servers[2].videos.view({ id: remoteVideosServer3[0] })) tasks.push(servers[2].views.simulateView({ id: remoteVideosServer3[0] }))
tasks.push(servers[2].videos.view({ id: remoteVideosServer3[1] })) tasks.push(servers[2].views.simulateView({ id: remoteVideosServer3[1] }))
tasks.push(servers[2].videos.view({ id: remoteVideosServer3[1] })) tasks.push(servers[2].views.simulateView({ id: remoteVideosServer3[1] }))
tasks.push(servers[2].videos.view({ id: remoteVideosServer3[1] })) tasks.push(servers[2].views.simulateView({ id: remoteVideosServer3[1] }))
tasks.push(servers[2].videos.view({ id: localVideosServer3[1] })) tasks.push(servers[2].views.simulateView({ id: localVideosServer3[1] }))
tasks.push(servers[2].videos.view({ id: localVideosServer3[1] })) tasks.push(servers[2].views.simulateView({ id: localVideosServer3[1] }))
tasks.push(servers[2].videos.view({ id: localVideosServer3[1] })) tasks.push(servers[2].views.simulateView({ id: localVideosServer3[1] }))
await Promise.all(tasks) await Promise.all(tasks)
await waitJobs(servers) await waitJobs(servers)
// Wait the repeatable job for (const server of servers) {
await wait(16000) await server.debug.sendCommand({ body: { command: 'process-video-views-buffer' } })
}
await waitJobs(servers) await waitJobs(servers)

View File

@ -179,22 +179,21 @@ describe('Test a single server', function () {
it('Should have the views updated', async function () { it('Should have the views updated', async function () {
this.timeout(20000) this.timeout(20000)
await server.videos.view({ id: videoId }) await server.views.simulateView({ id: videoId })
await server.videos.view({ id: videoId }) await server.views.simulateView({ id: videoId })
await server.videos.view({ id: videoId }) await server.views.simulateView({ id: videoId })
await wait(1500) await wait(1500)
await server.videos.view({ id: videoId }) await server.views.simulateView({ id: videoId })
await server.videos.view({ id: videoId }) await server.views.simulateView({ id: videoId })
await wait(1500) await wait(1500)
await server.videos.view({ id: videoId }) await server.views.simulateView({ id: videoId })
await server.videos.view({ id: videoId }) await server.views.simulateView({ id: videoId })
// Wait the repeatable job await server.debug.sendCommand({ body: { command: 'process-video-views-buffer' } })
await wait(8000)
const video = await server.videos.get({ id: videoId }) const video = await server.videos.get({ id: videoId })
expect(video.views).to.equal(3) expect(video.views).to.equal(3)

View File

@ -466,8 +466,8 @@ describe('Test video channels', function () {
{ {
// video has been posted on channel servers[0].store.videoChannel.id since last update // video has been posted on channel servers[0].store.videoChannel.id since last update
await servers[0].videos.view({ id: videoUUID, xForwardedFor: '0.0.0.1,127.0.0.1' }) await servers[0].views.simulateView({ id: videoUUID, xForwardedFor: '0.0.0.1,127.0.0.1' })
await servers[0].videos.view({ id: videoUUID, xForwardedFor: '0.0.0.2,127.0.0.1' }) await servers[0].views.simulateView({ id: videoUUID, xForwardedFor: '0.0.0.2,127.0.0.1' })
// Wait the repeatable job // Wait the repeatable job
await wait(8000) await wait(8000)

View File

@ -3,15 +3,8 @@
import 'mocha' import 'mocha'
import * as chai from 'chai' import * as chai from 'chai'
import { wait } from '@shared/core-utils' import { wait } from '@shared/core-utils'
import { HttpStatusCode, Video } from '@shared/models' import { Video } from '@shared/models'
import { import { cleanupTests, createSingleServer, killallServers, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
cleanupTests,
createSingleServer,
HistoryCommand,
killallServers,
PeerTubeServer,
setAccessTokensToServers
} from '@shared/server-commands'
const expect = chai.expect const expect = chai.expect
@ -23,7 +16,6 @@ describe('Test videos history', function () {
let video3UUID: string let video3UUID: string
let video3WatchedDate: Date let video3WatchedDate: Date
let userAccessToken: string let userAccessToken: string
let command: HistoryCommand
before(async function () { before(async function () {
this.timeout(30000) this.timeout(30000)
@ -32,30 +24,26 @@ describe('Test videos history', function () {
await setAccessTokensToServers([ server ]) await setAccessTokensToServers([ server ])
command = server.history // 10 seconds long
const fixture = 'video_59fps.mp4'
{ {
const { id, uuid } = await server.videos.upload({ attributes: { name: 'video 1' } }) const { id, uuid } = await server.videos.upload({ attributes: { name: 'video 1', fixture } })
video1UUID = uuid video1UUID = uuid
video1Id = id video1Id = id
} }
{ {
const { uuid } = await server.videos.upload({ attributes: { name: 'video 2' } }) const { uuid } = await server.videos.upload({ attributes: { name: 'video 2', fixture } })
video2UUID = uuid video2UUID = uuid
} }
{ {
const { uuid } = await server.videos.upload({ attributes: { name: 'video 3' } }) const { uuid } = await server.videos.upload({ attributes: { name: 'video 3', fixture } })
video3UUID = uuid video3UUID = uuid
} }
const user = { userAccessToken = await server.users.generateUserAndToken('user_1')
username: 'user_1',
password: 'super password'
}
await server.users.create({ username: user.username, password: user.password })
userAccessToken = await server.login.getAccessToken(user)
}) })
it('Should get videos, without watching history', async function () { it('Should get videos, without watching history', async function () {
@ -70,8 +58,8 @@ describe('Test videos history', function () {
}) })
it('Should watch the first and second video', async function () { it('Should watch the first and second video', async function () {
await command.watchVideo({ videoId: video2UUID, currentTime: 8 }) await server.views.view({ id: video2UUID, token: server.accessToken, currentTime: 8 })
await command.watchVideo({ videoId: video1UUID, currentTime: 3 }) await server.views.view({ id: video1UUID, token: server.accessToken, currentTime: 3 })
}) })
it('Should return the correct history when listing, searching and getting videos', async function () { it('Should return the correct history when listing, searching and getting videos', async function () {
@ -124,9 +112,9 @@ describe('Test videos history', function () {
it('Should have these videos when listing my history', async function () { it('Should have these videos when listing my history', async function () {
video3WatchedDate = new Date() video3WatchedDate = new Date()
await command.watchVideo({ videoId: video3UUID, currentTime: 2 }) await server.views.view({ id: video3UUID, token: server.accessToken, currentTime: 2 })
const body = await command.list() const body = await server.history.list()
expect(body.total).to.equal(3) expect(body.total).to.equal(3)
@ -137,14 +125,14 @@ describe('Test videos history', function () {
}) })
it('Should not have videos history on another user', async function () { it('Should not have videos history on another user', async function () {
const body = await command.list({ token: userAccessToken }) const body = await server.history.list({ token: userAccessToken })
expect(body.total).to.equal(0) expect(body.total).to.equal(0)
expect(body.data).to.have.lengthOf(0) expect(body.data).to.have.lengthOf(0)
}) })
it('Should be able to search through videos in my history', async function () { it('Should be able to search through videos in my history', async function () {
const body = await command.list({ search: '2' }) const body = await server.history.list({ search: '2' })
expect(body.total).to.equal(1) expect(body.total).to.equal(1)
const videos = body.data const videos = body.data
@ -152,11 +140,11 @@ describe('Test videos history', function () {
}) })
it('Should clear my history', async function () { it('Should clear my history', async function () {
await command.removeAll({ beforeDate: video3WatchedDate.toISOString() }) await server.history.removeAll({ beforeDate: video3WatchedDate.toISOString() })
}) })
it('Should have my history cleared', async function () { it('Should have my history cleared', async function () {
const body = await command.list() const body = await server.history.list()
expect(body.total).to.equal(1) expect(body.total).to.equal(1)
const videos = body.data const videos = body.data
@ -168,7 +156,10 @@ describe('Test videos history', function () {
videosHistoryEnabled: false videosHistoryEnabled: false
}) })
await command.watchVideo({ videoId: video2UUID, currentTime: 8, expectedStatus: HttpStatusCode.CONFLICT_409 }) await server.views.view({ id: video2UUID, token: server.accessToken, currentTime: 8 })
const { data } = await server.history.list()
expect(data[0].name).to.not.equal('video 2')
}) })
it('Should re-enable videos history', async function () { it('Should re-enable videos history', async function () {
@ -176,14 +167,10 @@ describe('Test videos history', function () {
videosHistoryEnabled: true videosHistoryEnabled: true
}) })
await command.watchVideo({ videoId: video1UUID, currentTime: 8 }) await server.views.view({ id: video2UUID, token: server.accessToken, currentTime: 8 })
const body = await command.list() const { data } = await server.history.list()
expect(body.total).to.equal(2) expect(data[0].name).to.equal('video 2')
const videos = body.data
expect(videos[0].name).to.equal('video 1')
expect(videos[1].name).to.equal('video 3')
}) })
it('Should not clean old history', async function () { it('Should not clean old history', async function () {
@ -197,7 +184,7 @@ describe('Test videos history', function () {
// Should still have history // Should still have history
const body = await command.list() const body = await server.history.list()
expect(body.total).to.equal(2) expect(body.total).to.equal(2)
}) })
@ -210,25 +197,25 @@ describe('Test videos history', function () {
await wait(6000) await wait(6000)
const body = await command.list() const body = await server.history.list()
expect(body.total).to.equal(0) expect(body.total).to.equal(0)
}) })
it('Should delete a specific history element', async function () { it('Should delete a specific history element', async function () {
{ {
await command.watchVideo({ videoId: video1UUID, currentTime: 4 }) await server.views.view({ id: video1UUID, token: server.accessToken, currentTime: 4 })
await command.watchVideo({ videoId: video2UUID, currentTime: 8 }) await server.views.view({ id: video2UUID, token: server.accessToken, currentTime: 8 })
} }
{ {
const body = await command.list() const body = await server.history.list()
expect(body.total).to.equal(2) expect(body.total).to.equal(2)
} }
{ {
await command.removeElement({ videoId: video1Id }) await server.history.removeElement({ videoId: video1Id })
const body = await command.list() const body = await server.history.list()
expect(body.total).to.equal(1) expect(body.total).to.equal(1)
expect(body.data[0].uuid).to.equal(video2UUID) expect(body.data[0].uuid).to.equal(video2UUID)
} }

View File

@ -0,0 +1,5 @@
export * from './video-views-counter'
export * from './video-views-overall-stats'
export * from './video-views-retention-stats'
export * from './video-views-timeserie-stats'
export * from './videos-views-cleaner'

View File

@ -0,0 +1,155 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import 'mocha'
import * as chai from 'chai'
import { FfmpegCommand } from 'fluent-ffmpeg'
import { prepareViewsServers, prepareViewsVideos, processViewsBuffer } from '@server/tests/shared'
import { wait } from '@shared/core-utils'
import { cleanupTests, PeerTubeServer, stopFfmpeg, waitJobs } from '@shared/server-commands'
const expect = chai.expect
describe('Test video views/viewers counters', function () {
let servers: PeerTubeServer[]
async function checkCounter (field: 'views' | 'viewers', id: string, expected: number) {
for (const server of servers) {
const video = await server.videos.get({ id })
const messageSuffix = video.isLive
? 'live video'
: 'vod video'
expect(video[field]).to.equal(expected, `${field} not valid on server ${server.serverNumber} for ${messageSuffix} ${video.uuid}`)
}
}
before(async function () {
this.timeout(120000)
servers = await prepareViewsServers()
})
describe('Test views counter on VOD', function () {
let videoUUID: string
before(async function () {
this.timeout(30000)
const { uuid } = await servers[0].videos.quickUpload({ name: 'video' })
videoUUID = uuid
await waitJobs(servers)
})
it('Should not view a video if watch time is below the threshold', async function () {
await servers[0].views.simulateViewer({ id: videoUUID, currentTimes: [ 1, 2 ] })
await processViewsBuffer(servers)
await checkCounter('views', videoUUID, 0)
})
it('Should view a video if watch time is above the threshold', async function () {
await servers[0].views.simulateViewer({ id: videoUUID, currentTimes: [ 1, 4 ] })
await processViewsBuffer(servers)
await checkCounter('views', videoUUID, 1)
})
it('Should not view again this video with the same IP', async function () {
await servers[0].views.simulateViewer({ id: videoUUID, currentTimes: [ 1, 4 ] })
await processViewsBuffer(servers)
await checkCounter('views', videoUUID, 1)
})
it('Should view the video from server 2 and send the event', async function () {
await servers[1].views.simulateViewer({ id: videoUUID, currentTimes: [ 1, 4 ] })
await waitJobs(servers)
await processViewsBuffer(servers)
await checkCounter('views', videoUUID, 2)
})
})
describe('Test views and viewers counters on live and VOD', function () {
let liveVideoId: string
let vodVideoId: string
let command: FfmpegCommand
before(async function () {
this.timeout(60000);
({ vodVideoId, liveVideoId, ffmpegCommand: command } = await prepareViewsVideos({ servers, live: true, vod: true }))
})
it('Should display no views and viewers', async function () {
await checkCounter('views', liveVideoId, 0)
await checkCounter('viewers', liveVideoId, 0)
await checkCounter('views', vodVideoId, 0)
await checkCounter('viewers', vodVideoId, 0)
})
it('Should view twice and display 1 view/viewer', async function () {
this.timeout(30000)
await servers[0].views.simulateViewer({ id: liveVideoId, currentTimes: [ 0, 35 ] })
await servers[0].views.simulateViewer({ id: liveVideoId, currentTimes: [ 0, 35 ] })
await servers[0].views.simulateViewer({ id: vodVideoId, currentTimes: [ 0, 5 ] })
await servers[0].views.simulateViewer({ id: vodVideoId, currentTimes: [ 0, 5 ] })
await waitJobs(servers)
await checkCounter('viewers', liveVideoId, 1)
await checkCounter('viewers', vodVideoId, 1)
await processViewsBuffer(servers)
await checkCounter('views', liveVideoId, 1)
await checkCounter('views', vodVideoId, 1)
})
it('Should wait and display 0 viewers but still have 1 view', async function () {
this.timeout(30000)
await wait(12000)
await waitJobs(servers)
await checkCounter('views', liveVideoId, 1)
await checkCounter('viewers', liveVideoId, 0)
await checkCounter('views', vodVideoId, 1)
await checkCounter('viewers', vodVideoId, 0)
})
it('Should view on a remote and on local and display 2 viewers and 3 views', async function () {
this.timeout(30000)
await servers[0].views.simulateViewer({ id: vodVideoId, currentTimes: [ 0, 5 ] })
await servers[1].views.simulateViewer({ id: vodVideoId, currentTimes: [ 0, 5 ] })
await servers[1].views.simulateViewer({ id: vodVideoId, currentTimes: [ 0, 5 ] })
await servers[0].views.simulateViewer({ id: liveVideoId, currentTimes: [ 0, 35 ] })
await servers[1].views.simulateViewer({ id: liveVideoId, currentTimes: [ 0, 35 ] })
await servers[1].views.simulateViewer({ id: liveVideoId, currentTimes: [ 0, 35 ] })
await waitJobs(servers)
await checkCounter('viewers', liveVideoId, 2)
await checkCounter('viewers', vodVideoId, 2)
await processViewsBuffer(servers)
await checkCounter('views', liveVideoId, 3)
await checkCounter('views', vodVideoId, 3)
})
after(async function () {
await stopFfmpeg(command)
})
})
after(async function () {
await cleanupTests(servers)
})
})

View File

@ -0,0 +1,291 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import 'mocha'
import * as chai from 'chai'
import { FfmpegCommand } from 'fluent-ffmpeg'
import { prepareViewsServers, prepareViewsVideos, processViewersStats } from '@server/tests/shared'
import { cleanupTests, PeerTubeServer, stopFfmpeg, waitJobs } from '@shared/server-commands'
const expect = chai.expect
describe('Test views overall stats', function () {
let servers: PeerTubeServer[]
before(async function () {
this.timeout(120000)
servers = await prepareViewsServers()
})
describe('Test rates and comments of local videos on VOD', function () {
let vodVideoId: string
before(async function () {
this.timeout(60000);
({ vodVideoId } = await prepareViewsVideos({ servers, live: false, vod: true }))
})
it('Should have the appropriate likes', async function () {
this.timeout(60000)
await servers[0].videos.rate({ id: vodVideoId, rating: 'like' })
await servers[1].videos.rate({ id: vodVideoId, rating: 'like' })
await waitJobs(servers)
const stats = await servers[0].videoStats.getOverallStats({ videoId: vodVideoId })
expect(stats.likes).to.equal(2)
expect(stats.dislikes).to.equal(0)
})
it('Should have the appropriate dislikes', async function () {
this.timeout(60000)
await servers[0].videos.rate({ id: vodVideoId, rating: 'dislike' })
await servers[1].videos.rate({ id: vodVideoId, rating: 'dislike' })
await waitJobs(servers)
const stats = await servers[0].videoStats.getOverallStats({ videoId: vodVideoId })
expect(stats.likes).to.equal(0)
expect(stats.dislikes).to.equal(2)
})
it('Should have the appropriate comments', async function () {
this.timeout(60000)
await servers[0].comments.createThread({ videoId: vodVideoId, text: 'root' })
await servers[0].comments.addReplyToLastThread({ text: 'reply' })
await servers[1].comments.createThread({ videoId: vodVideoId, text: 'root' })
await waitJobs(servers)
const stats = await servers[0].videoStats.getOverallStats({ videoId: vodVideoId })
expect(stats.comments).to.equal(3)
})
})
describe('Test watch time stats of local videos on live and VOD', function () {
let vodVideoId: string
let liveVideoId: string
let command: FfmpegCommand
before(async function () {
this.timeout(60000);
({ vodVideoId, liveVideoId, ffmpegCommand: command } = await prepareViewsVideos({ servers, live: true, vod: true }))
})
it('Should display overall stats of a video with no viewers', async function () {
for (const videoId of [ liveVideoId, vodVideoId ]) {
const stats = await servers[0].videoStats.getOverallStats({ videoId })
expect(stats.views).to.equal(0)
expect(stats.averageWatchTime).to.equal(0)
expect(stats.totalWatchTime).to.equal(0)
}
})
it('Should display overall stats with 1 viewer below the watch time limit', async function () {
this.timeout(60000)
for (const videoId of [ liveVideoId, vodVideoId ]) {
await servers[0].views.simulateViewer({ id: videoId, currentTimes: [ 0, 1 ] })
}
await processViewersStats(servers)
for (const videoId of [ liveVideoId, vodVideoId ]) {
const stats = await servers[0].videoStats.getOverallStats({ videoId })
expect(stats.views).to.equal(0)
expect(stats.averageWatchTime).to.equal(1)
expect(stats.totalWatchTime).to.equal(1)
}
})
it('Should display overall stats with 2 viewers', async function () {
this.timeout(60000)
{
await servers[0].views.simulateViewer({ id: vodVideoId, currentTimes: [ 0, 3 ] })
await servers[0].views.simulateViewer({ id: liveVideoId, currentTimes: [ 0, 35, 40 ] })
await processViewersStats(servers)
{
const stats = await servers[0].videoStats.getOverallStats({ videoId: vodVideoId })
expect(stats.views).to.equal(1)
expect(stats.averageWatchTime).to.equal(2)
expect(stats.totalWatchTime).to.equal(4)
}
{
const stats = await servers[0].videoStats.getOverallStats({ videoId: liveVideoId })
expect(stats.views).to.equal(1)
expect(stats.averageWatchTime).to.equal(21)
expect(stats.totalWatchTime).to.equal(41)
}
}
})
it('Should display overall stats with a remote viewer below the watch time limit', async function () {
this.timeout(60000)
for (const videoId of [ liveVideoId, vodVideoId ]) {
await servers[1].views.simulateViewer({ id: videoId, currentTimes: [ 0, 2 ] })
}
await processViewersStats(servers)
{
const stats = await servers[0].videoStats.getOverallStats({ videoId: vodVideoId })
expect(stats.views).to.equal(1)
expect(stats.averageWatchTime).to.equal(2)
expect(stats.totalWatchTime).to.equal(6)
}
{
const stats = await servers[0].videoStats.getOverallStats({ videoId: liveVideoId })
expect(stats.views).to.equal(1)
expect(stats.averageWatchTime).to.equal(14)
expect(stats.totalWatchTime).to.equal(43)
}
})
it('Should display overall stats with a remote viewer above the watch time limit', async function () {
this.timeout(60000)
await servers[1].views.simulateViewer({ id: vodVideoId, currentTimes: [ 0, 5 ] })
await servers[1].views.simulateViewer({ id: liveVideoId, currentTimes: [ 0, 45 ] })
await processViewersStats(servers)
{
const stats = await servers[0].videoStats.getOverallStats({ videoId: vodVideoId })
expect(stats.views).to.equal(2)
expect(stats.averageWatchTime).to.equal(3)
expect(stats.totalWatchTime).to.equal(11)
}
{
const stats = await servers[0].videoStats.getOverallStats({ videoId: liveVideoId })
expect(stats.views).to.equal(2)
expect(stats.averageWatchTime).to.equal(22)
expect(stats.totalWatchTime).to.equal(88)
}
})
after(async function () {
await stopFfmpeg(command)
})
})
describe('Test watchers peak stats of local videos on VOD', function () {
let videoUUID: string
before(async function () {
this.timeout(60000);
({ vodVideoId: videoUUID } = await prepareViewsVideos({ servers, live: true, vod: true }))
})
it('Should not have watchers peak', async function () {
const stats = await servers[0].videoStats.getOverallStats({ videoId: videoUUID })
expect(stats.viewersPeak).to.equal(0)
expect(stats.viewersPeakDate).to.be.null
})
it('Should have watcher peak with 1 watcher', async function () {
this.timeout(60000)
const before = new Date()
await servers[0].views.simulateViewer({ id: videoUUID, currentTimes: [ 0, 2 ] })
const after = new Date()
await processViewersStats(servers)
const stats = await servers[0].videoStats.getOverallStats({ videoId: videoUUID })
expect(stats.viewersPeak).to.equal(1)
expect(new Date(stats.viewersPeakDate)).to.be.above(before).and.below(after)
})
it('Should have watcher peak with 2 watchers', async function () {
this.timeout(60000)
const before = new Date()
await servers[0].views.view({ id: videoUUID, currentTime: 0 })
await servers[1].views.view({ id: videoUUID, currentTime: 0 })
await servers[0].views.view({ id: videoUUID, currentTime: 2 })
await servers[1].views.view({ id: videoUUID, currentTime: 2 })
const after = new Date()
await processViewersStats(servers)
const stats = await servers[0].videoStats.getOverallStats({ videoId: videoUUID })
expect(stats.viewersPeak).to.equal(2)
expect(new Date(stats.viewersPeakDate)).to.be.above(before).and.below(after)
})
})
describe('Test countries', function () {
it('Should not report countries if geoip is disabled', async function () {
this.timeout(60000)
const { uuid } = await servers[0].videos.quickUpload({ name: 'video' })
await waitJobs(servers)
await servers[1].views.view({ id: uuid, xForwardedFor: '8.8.8.8,127.0.0.1', currentTime: 1 })
await processViewersStats(servers)
const stats = await servers[0].videoStats.getOverallStats({ videoId: uuid })
expect(stats.countries).to.have.lengthOf(0)
})
it('Should report countries if geoip is enabled', async function () {
this.timeout(60000)
const { uuid } = await servers[0].videos.quickUpload({ name: 'video' })
await waitJobs(servers)
await Promise.all([
servers[0].kill(),
servers[1].kill()
])
const config = { geo_ip: { enabled: true } }
await Promise.all([
servers[0].run(config),
servers[1].run(config)
])
await servers[0].views.view({ id: uuid, xForwardedFor: '8.8.8.8,127.0.0.1', currentTime: 1 })
await servers[1].views.view({ id: uuid, xForwardedFor: '8.8.8.4,127.0.0.1', currentTime: 3 })
await servers[1].views.view({ id: uuid, xForwardedFor: '80.67.169.12,127.0.0.1', currentTime: 2 })
await processViewersStats(servers)
const stats = await servers[0].videoStats.getOverallStats({ videoId: uuid })
expect(stats.countries).to.have.lengthOf(2)
expect(stats.countries[0].isoCode).to.equal('US')
expect(stats.countries[0].viewers).to.equal(2)
expect(stats.countries[1].isoCode).to.equal('FR')
expect(stats.countries[1].viewers).to.equal(1)
})
})
after(async function () {
await cleanupTests(servers)
})
})

View File

@ -0,0 +1,56 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import 'mocha'
import * as chai from 'chai'
import { prepareViewsServers, prepareViewsVideos, processViewersStats } from '@server/tests/shared'
import { cleanupTests, PeerTubeServer } from '@shared/server-commands'
const expect = chai.expect
describe('Test views retention stats', function () {
let servers: PeerTubeServer[]
before(async function () {
this.timeout(120000)
servers = await prepareViewsServers()
})
describe('Test retention stats on VOD', function () {
let vodVideoId: string
before(async function () {
this.timeout(60000);
({ vodVideoId } = await prepareViewsVideos({ servers, live: false, vod: true }))
})
it('Should display empty retention', async function () {
const { data } = await servers[0].videoStats.getRetentionStats({ videoId: vodVideoId })
expect(data).to.have.lengthOf(6)
for (let i = 0; i < 6; i++) {
expect(data[i].second).to.equal(i)
expect(data[i].retentionPercent).to.equal(0)
}
})
it('Should display appropriate retention metrics', async function () {
await servers[0].views.simulateViewer({ xForwardedFor: '127.0.0.2,127.0.0.1', id: vodVideoId, currentTimes: [ 0, 1 ] })
await servers[0].views.simulateViewer({ xForwardedFor: '127.0.0.3,127.0.0.1', id: vodVideoId, currentTimes: [ 1, 3 ] })
await servers[1].views.simulateViewer({ xForwardedFor: '127.0.0.2,127.0.0.1', id: vodVideoId, currentTimes: [ 4 ] })
await servers[1].views.simulateViewer({ xForwardedFor: '127.0.0.3,127.0.0.1', id: vodVideoId, currentTimes: [ 0, 1 ] })
await processViewersStats(servers)
const { data } = await servers[0].videoStats.getRetentionStats({ videoId: vodVideoId })
expect(data).to.have.lengthOf(6)
expect(data.map(d => d.retentionPercent)).to.deep.equal([ 50, 75, 25, 25, 25, 0 ])
})
})
after(async function () {
await cleanupTests(servers)
})
})

View File

@ -0,0 +1,109 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import 'mocha'
import * as chai from 'chai'
import { FfmpegCommand } from 'fluent-ffmpeg'
import { prepareViewsServers, prepareViewsVideos, processViewersStats } from '@server/tests/shared'
import { VideoStatsTimeserie, VideoStatsTimeserieMetric } from '@shared/models'
import { cleanupTests, PeerTubeServer, stopFfmpeg } from '@shared/server-commands'
const expect = chai.expect
describe('Test views timeserie stats', function () {
const availableMetrics: VideoStatsTimeserieMetric[] = [ 'viewers' ]
let servers: PeerTubeServer[]
before(async function () {
this.timeout(120000)
servers = await prepareViewsServers()
})
describe('Common metric tests', function () {
let vodVideoId: string
before(async function () {
this.timeout(60000);
({ vodVideoId } = await prepareViewsVideos({ servers, live: false, vod: true }))
})
it('Should display empty metric stats', async function () {
for (const metric of availableMetrics) {
const { data } = await servers[0].videoStats.getTimeserieStats({ videoId: vodVideoId, metric })
expect(data).to.have.lengthOf(30)
for (const d of data) {
expect(d.value).to.equal(0)
}
}
})
})
describe('Test viewer and watch time metrics on live and VOD', function () {
let vodVideoId: string
let liveVideoId: string
let command: FfmpegCommand
function expectTimeserieData (result: VideoStatsTimeserie, lastValue: number) {
const { data } = result
expect(data).to.have.lengthOf(30)
const last = data[data.length - 1]
const today = new Date().getDate()
expect(new Date(last.date).getDate()).to.equal(today)
expect(last.value).to.equal(lastValue)
for (let i = 0; i < data.length - 2; i++) {
expect(data[i].value).to.equal(0)
}
}
before(async function () {
this.timeout(60000);
({ vodVideoId, liveVideoId, ffmpegCommand: command } = await prepareViewsVideos({ servers, live: true, vod: true }))
})
it('Should display appropriate viewers metrics', async function () {
for (const videoId of [ vodVideoId, liveVideoId ]) {
await servers[0].views.simulateViewer({ id: videoId, currentTimes: [ 0, 3 ] })
await servers[1].views.simulateViewer({ id: videoId, currentTimes: [ 0, 5 ] })
}
await processViewersStats(servers)
for (const videoId of [ vodVideoId, liveVideoId ]) {
const result = await servers[0].videoStats.getTimeserieStats({ videoId, metric: 'viewers' })
expectTimeserieData(result, 2)
}
})
it('Should display appropriate watch time metrics', async function () {
for (const videoId of [ vodVideoId, liveVideoId ]) {
const result = await servers[0].videoStats.getTimeserieStats({ videoId, metric: 'aggregateWatchTime' })
expectTimeserieData(result, 8)
await servers[1].views.simulateViewer({ id: videoId, currentTimes: [ 0, 1 ] })
}
await processViewersStats(servers)
for (const videoId of [ vodVideoId, liveVideoId ]) {
const result = await servers[0].videoStats.getTimeserieStats({ videoId, metric: 'aggregateWatchTime' })
expectTimeserieData(result, 9)
}
})
after(async function () {
await stopFfmpeg(command)
})
})
after(async function () {
await cleanupTests(servers)
})
})

View File

@ -34,10 +34,10 @@ describe('Test video views cleaner', function () {
await waitJobs(servers) await waitJobs(servers)
await servers[0].videos.view({ id: videoIdServer1 }) await servers[0].views.simulateView({ id: videoIdServer1 })
await servers[1].videos.view({ id: videoIdServer1 }) await servers[1].views.simulateView({ id: videoIdServer1 })
await servers[0].videos.view({ id: videoIdServer2 }) await servers[0].views.simulateView({ id: videoIdServer2 })
await servers[1].videos.view({ id: videoIdServer2 }) await servers[1].views.simulateView({ id: videoIdServer2 })
await waitJobs(servers) await waitJobs(servers)
}) })

View File

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import 'mocha' import 'mocha'
import { ServerHookName, VideoPlaylistPrivacy, VideoPrivacy } from '@shared/models'
import { import {
cleanupTests, cleanupTests,
createMultipleServers, createMultipleServers,
@ -10,7 +11,6 @@ import {
setAccessTokensToServers, setAccessTokensToServers,
setDefaultVideoChannel setDefaultVideoChannel
} from '@shared/server-commands' } from '@shared/server-commands'
import { ServerHookName, VideoPlaylistPrivacy, VideoPrivacy } from '@shared/models'
describe('Test plugin action hooks', function () { describe('Test plugin action hooks', function () {
let servers: PeerTubeServer[] let servers: PeerTubeServer[]
@ -61,7 +61,7 @@ describe('Test plugin action hooks', function () {
}) })
it('Should run action:api.video.viewed', async function () { it('Should run action:api.video.viewed', async function () {
await servers[0].videos.view({ id: videoUUID }) await servers[0].views.simulateView({ id: videoUUID })
await checkHook('action:api.video.viewed') await checkHook('action:api.video.viewed')
}) })

View File

@ -301,7 +301,7 @@ describe('Test plugin helpers', function () {
// Should not throw -> video exists // Should not throw -> video exists
const video = await servers[0].videos.get({ id: videoUUID }) const video = await servers[0].videos.get({ id: videoUUID })
// Should delete the video // Should delete the video
await servers[0].videos.view({ id: videoUUID }) await servers[0].views.simulateView({ id: videoUUID })
await servers[0].servers.waitUntilLog('Video deleted by plugin four.') await servers[0].servers.waitUntilLog('Video deleted by plugin four.')

View File

@ -13,3 +13,4 @@ export * from './streaming-playlists'
export * from './tests' export * from './tests'
export * from './tracker' export * from './tracker'
export * from './videos' export * from './videos'
export * from './views'

View File

@ -0,0 +1,93 @@
import { FfmpegCommand } from 'fluent-ffmpeg'
import { wait } from '@shared/core-utils'
import { VideoCreateResult, VideoPrivacy } from '@shared/models'
import {
createMultipleServers,
doubleFollow,
PeerTubeServer,
setAccessTokensToServers,
setDefaultVideoChannel,
waitJobs,
waitUntilLivePublishedOnAllServers
} from '@shared/server-commands'
async function processViewersStats (servers: PeerTubeServer[]) {
await wait(6000)
for (const server of servers) {
await server.debug.sendCommand({ body: { command: 'process-video-views-buffer' } })
await server.debug.sendCommand({ body: { command: 'process-video-viewers' } })
}
await waitJobs(servers)
}
async function processViewsBuffer (servers: PeerTubeServer[]) {
for (const server of servers) {
await server.debug.sendCommand({ body: { command: 'process-video-views-buffer' } })
}
await waitJobs(servers)
}
async function prepareViewsServers () {
const servers = await createMultipleServers(2)
await setAccessTokensToServers(servers)
await setDefaultVideoChannel(servers)
await servers[0].config.updateCustomSubConfig({
newConfig: {
live: {
enabled: true,
allowReplay: true,
transcoding: {
enabled: false
}
}
}
})
await doubleFollow(servers[0], servers[1])
return servers
}
async function prepareViewsVideos (options: {
servers: PeerTubeServer[]
live: boolean
vod: boolean
}) {
const { servers } = options
const liveAttributes = {
name: 'live video',
channelId: servers[0].store.channel.id,
privacy: VideoPrivacy.PUBLIC
}
let ffmpegCommand: FfmpegCommand
let live: VideoCreateResult
let vod: VideoCreateResult
if (options.live) {
live = await servers[0].live.create({ fields: liveAttributes })
ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: live.uuid })
await waitUntilLivePublishedOnAllServers(servers, live.uuid)
}
if (options.vod) {
vod = await servers[0].videos.quickUpload({ name: 'video' })
}
await waitJobs(servers)
return { liveVideoId: live?.uuid, vodVideoId: vod?.uuid, ffmpegCommand }
}
export {
processViewersStats,
prepareViewsServers,
processViewsBuffer,
prepareViewsVideos
}

View File

@ -185,6 +185,8 @@ declare module 'express' {
externalAuth?: RegisterServerAuthExternalOptions externalAuth?: RegisterServerAuthExternalOptions
plugin?: MPlugin plugin?: MPlugin
localViewerFull?: MLocalVideoViewerWithWatchSections
} }
} }
} }

View File

@ -1,3 +1,5 @@
export * from './local-video-viewer-watch-section'
export * from './local-video-viewer'
export * from './schedule-video-update' export * from './schedule-video-update'
export * from './tag' export * from './tag'
export * from './thumbnail' export * from './thumbnail'

View File

@ -0,0 +1,5 @@
import { LocalVideoViewerWatchSectionModel } from '@server/models/view/local-video-viewer-watch-section'
// ############################################################################
export type MLocalVideoViewerWatchSection = Omit<LocalVideoViewerWatchSectionModel, 'LocalVideoViewerModel'>

View File

@ -0,0 +1,19 @@
import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer'
import { PickWith } from '@shared/typescript-utils'
import { MLocalVideoViewerWatchSection } from './local-video-viewer-watch-section'
import { MVideo } from './video'
type Use<K extends keyof LocalVideoViewerModel, M> = PickWith<LocalVideoViewerModel, K, M>
// ############################################################################
export type MLocalVideoViewer = Omit<LocalVideoViewerModel, 'Video'>
export type MLocalVideoViewerVideo =
MLocalVideoViewer &
Use<'Video', MVideo>
export type MLocalVideoViewerWithWatchSections =
MLocalVideoViewer &
Use<'Video', MVideo> &
Use<'WatchSections', MLocalVideoViewerWatchSection[]>

View File

@ -1,6 +1,6 @@
import { ActivityPubActor } from './activitypub-actor' import { ActivityPubActor } from './activitypub-actor'
import { ActivityPubSignature } from './activitypub-signature' import { ActivityPubSignature } from './activitypub-signature'
import { ActivityFlagReasonObject, CacheFileObject, VideoObject } from './objects' import { ActivityFlagReasonObject, CacheFileObject, VideoObject, WatchActionObject } from './objects'
import { AbuseObject } from './objects/abuse-object' import { AbuseObject } from './objects/abuse-object'
import { DislikeObject } from './objects/dislike-object' import { DislikeObject } from './objects/dislike-object'
import { APObject } from './objects/object.model' import { APObject } from './objects/object.model'
@ -52,7 +52,7 @@ export interface BaseActivity {
export interface ActivityCreate extends BaseActivity { export interface ActivityCreate extends BaseActivity {
type: 'Create' type: 'Create'
object: VideoObject | AbuseObject | DislikeObject | VideoCommentObject | CacheFileObject | PlaylistObject object: VideoObject | AbuseObject | DislikeObject | VideoCommentObject | CacheFileObject | PlaylistObject | WatchActionObject
} }
export interface ActivityUpdate extends BaseActivity { export interface ActivityUpdate extends BaseActivity {
@ -99,7 +99,9 @@ export interface ActivityView extends BaseActivity {
type: 'View' type: 'View'
actor: string actor: string
object: APObject object: APObject
expires: string
// If sending a "viewer" event
expires?: string
} }
export interface ActivityDislike extends BaseActivity { export interface ActivityDislike extends BaseActivity {

View File

@ -12,4 +12,5 @@ export type ContextType =
'Rate' | 'Rate' |
'Flag' | 'Flag' |
'Actor' | 'Actor' |
'Collection' 'Collection' |
'WatchAction'

View File

@ -8,3 +8,4 @@ export * from './playlist-object'
export * from './video-comment-object' export * from './video-comment-object'
export * from './video-torrent-object' export * from './video-torrent-object'
export * from './view-object' export * from './view-object'
export * from './watch-action-object'

View File

@ -0,0 +1,22 @@
export interface WatchActionObject {
id: string
type: 'WatchAction'
startTime: string
endTime: string
location?: {
addressCountry: string
}
uuid: string
object: string
actionStatus: 'CompletedActionStatus'
duration: string
watchSections: {
startTimestamp: number
endTimestamp: number
}[]
}

View File

@ -4,5 +4,5 @@ export interface Debug {
} }
export interface SendDebugCommand { export interface SendDebugCommand {
command: 'remove-dandling-resumable-uploads' command: 'remove-dandling-resumable-uploads' | 'process-video-views-buffer' | 'process-video-viewers'
} }

View File

@ -12,5 +12,4 @@ export * from './user-scoped-token'
export * from './user-update-me.model' export * from './user-update-me.model'
export * from './user-update.model' export * from './user-update.model'
export * from './user-video-quota.model' export * from './user-video-quota.model'
export * from './user-watching-video.model'
export * from './user.model' export * from './user.model'

View File

@ -1,3 +0,0 @@
export interface UserWatchingVideo {
currentTime: number
}

View File

@ -9,6 +9,7 @@ export * from './file'
export * from './import' export * from './import'
export * from './playlist' export * from './playlist'
export * from './rate' export * from './rate'
export * from './stats'
export * from './transcoding' export * from './transcoding'
export * from './nsfw-policy.type' export * from './nsfw-policy.type'
@ -32,5 +33,6 @@ export * from './video-streaming-playlist.model'
export * from './video-streaming-playlist.type' export * from './video-streaming-playlist.type'
export * from './video-update.model' export * from './video-update.model'
export * from './video-view.model'
export * from './video.model' export * from './video.model'
export * from './video-create-result.model' export * from './video-create-result.model'

View File

@ -0,0 +1,4 @@
export * from './video-stats-overall.model'
export * from './video-stats-retention.model'
export * from './video-stats-timeserie.model'
export * from './video-stats-timeserie-metric.type'

View File

@ -0,0 +1,17 @@
export interface VideoStatsOverall {
averageWatchTime: number
totalWatchTime: number
viewersPeak: number
viewersPeakDate: string
views: number
likes: number
dislikes: number
comments: number
countries: {
isoCode: string
viewers: number
}[]
}

View File

@ -0,0 +1,6 @@
export interface VideoStatsRetention {
data: {
second: number
retentionPercent: number
}[]
}

View File

@ -0,0 +1 @@
export type VideoStatsTimeserieMetric = 'viewers' | 'aggregateWatchTime'

View File

@ -0,0 +1,6 @@
export interface VideoStatsTimeserie {
data: {
date: string
value: number
}[]
}

View File

@ -0,0 +1,6 @@
export type VideoViewEvent = 'seek'
export interface VideoView {
currentTime: number
viewEvent?: VideoViewEvent
}

Some files were not shown because too many files have changed in this diff Show More