aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/controllers
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2022-03-24 13:36:47 +0100
committerChocobozzz <chocobozzz@cpy.re>2022-04-15 09:49:35 +0200
commitb211106695bb82f6c32e53306081b5262c3d109d (patch)
treefa187de1c33b0956665f5362e29af6b0f6d8bb57 /server/controllers
parent69d48ee30c9d47cddf0c3c047dc99a99dcb6e894 (diff)
downloadPeerTube-b211106695bb82f6c32e53306081b5262c3d109d.tar.gz
PeerTube-b211106695bb82f6c32e53306081b5262c3d109d.tar.zst
PeerTube-b211106695bb82f6c32e53306081b5262c3d109d.zip
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
Diffstat (limited to 'server/controllers')
-rw-r--r--server/controllers/activitypub/client.ts14
-rw-r--r--server/controllers/api/server/debug.ts10
-rw-r--r--server/controllers/api/videos/index.ts31
-rw-r--r--server/controllers/api/videos/stats.ts66
-rw-r--r--server/controllers/api/videos/view.ts68
-rw-r--r--server/controllers/api/videos/watching.ts44
6 files changed, 160 insertions, 73 deletions
diff --git a/server/controllers/activitypub/client.ts b/server/controllers/activitypub/client.ts
index d0f761009..8e064fb5b 100644
--- a/server/controllers/activitypub/client.ts
+++ b/server/controllers/activitypub/client.ts
@@ -27,7 +27,7 @@ import {
27 videosShareValidator 27 videosShareValidator
28} from '../../middlewares' 28} from '../../middlewares'
29import { cacheRoute } from '../../middlewares/cache/cache' 29import { cacheRoute } from '../../middlewares/cache/cache'
30import { getAccountVideoRateValidatorFactory, videoCommentGetValidator } from '../../middlewares/validators' 30import { getAccountVideoRateValidatorFactory, getVideoLocalViewerValidator, videoCommentGetValidator } from '../../middlewares/validators'
31import { videoFileRedundancyGetValidator, videoPlaylistRedundancyGetValidator } from '../../middlewares/validators/redundancy' 31import { videoFileRedundancyGetValidator, videoPlaylistRedundancyGetValidator } from '../../middlewares/validators/redundancy'
32import { videoPlaylistElementAPGetValidator, videoPlaylistsGetValidator } from '../../middlewares/validators/videos/video-playlists' 32import { videoPlaylistElementAPGetValidator, videoPlaylistsGetValidator } from '../../middlewares/validators/videos/video-playlists'
33import { AccountModel } from '../../models/account/account' 33import { AccountModel } from '../../models/account/account'
@@ -175,6 +175,12 @@ activityPubClientRouter.get('/video-playlists/:playlistId/videos/:playlistElemen
175 videoPlaylistElementController 175 videoPlaylistElementController
176) 176)
177 177
178activityPubClientRouter.get('/videos/local-viewer/:localViewerId',
179 executeIfActivityPub,
180 asyncMiddleware(getVideoLocalViewerValidator),
181 getVideoLocalViewerController
182)
183
178// --------------------------------------------------------------------------- 184// ---------------------------------------------------------------------------
179 185
180export { 186export {
@@ -399,6 +405,12 @@ function videoPlaylistElementController (req: express.Request, res: express.Resp
399 return activityPubResponse(activityPubContextify(json, 'Playlist'), res) 405 return activityPubResponse(activityPubContextify(json, 'Playlist'), res)
400} 406}
401 407
408function getVideoLocalViewerController (req: express.Request, res: express.Response) {
409 const localViewer = res.locals.localViewerFull
410
411 return activityPubResponse(activityPubContextify(localViewer.toActivityPubObject(), 'WatchAction'), res)
412}
413
402// --------------------------------------------------------------------------- 414// ---------------------------------------------------------------------------
403 415
404function actorFollowing (req: express.Request, actor: MActorId) { 416function actorFollowing (req: express.Request, actor: MActorId) {
diff --git a/server/controllers/api/server/debug.ts b/server/controllers/api/server/debug.ts
index 093e6a03c..6b6ff027c 100644
--- a/server/controllers/api/server/debug.ts
+++ b/server/controllers/api/server/debug.ts
@@ -1,6 +1,8 @@
1import express from 'express' 1import express from 'express'
2import { InboxManager } from '@server/lib/activitypub/inbox-manager' 2import { InboxManager } from '@server/lib/activitypub/inbox-manager'
3import { RemoveDanglingResumableUploadsScheduler } from '@server/lib/schedulers/remove-dangling-resumable-uploads-scheduler' 3import { RemoveDanglingResumableUploadsScheduler } from '@server/lib/schedulers/remove-dangling-resumable-uploads-scheduler'
4import { VideoViewsBufferScheduler } from '@server/lib/schedulers/video-views-buffer-scheduler'
5import { VideoViewsManager } from '@server/lib/views/video-views-manager'
4import { Debug, SendDebugCommand } from '@shared/models' 6import { Debug, SendDebugCommand } from '@shared/models'
5import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' 7import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
6import { UserRight } from '../../../../shared/models/users' 8import { UserRight } from '../../../../shared/models/users'
@@ -38,9 +40,13 @@ function getDebug (req: express.Request, res: express.Response) {
38async function runCommand (req: express.Request, res: express.Response) { 40async function runCommand (req: express.Request, res: express.Response) {
39 const body: SendDebugCommand = req.body 41 const body: SendDebugCommand = req.body
40 42
41 if (body.command === 'remove-dandling-resumable-uploads') { 43 const processors: { [id in SendDebugCommand['command']]: () => Promise<any> } = {
42 await RemoveDanglingResumableUploadsScheduler.Instance.execute() 44 'remove-dandling-resumable-uploads': () => RemoveDanglingResumableUploadsScheduler.Instance.execute(),
45 'process-video-views-buffer': () => VideoViewsBufferScheduler.Instance.execute(),
46 'process-video-viewers': () => VideoViewsManager.Instance.processViewers()
43 } 47 }
44 48
49 await processors[body.command]()
50
45 return res.status(HttpStatusCode.NO_CONTENT_204).end() 51 return res.status(HttpStatusCode.NO_CONTENT_204).end()
46} 52}
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts
index c7617093c..be233722c 100644
--- a/server/controllers/api/videos/index.ts
+++ b/server/controllers/api/videos/index.ts
@@ -1,7 +1,6 @@
1import express from 'express' 1import express from 'express'
2import { pickCommonVideoQuery } from '@server/helpers/query' 2import { pickCommonVideoQuery } from '@server/helpers/query'
3import { doJSONRequest } from '@server/helpers/requests' 3import { doJSONRequest } from '@server/helpers/requests'
4import { VideoViews } from '@server/lib/video-views'
5import { openapiOperationDoc } from '@server/middlewares/doc' 4import { openapiOperationDoc } from '@server/middlewares/doc'
6import { getServerActor } from '@server/models/application/application' 5import { getServerActor } from '@server/models/application/application'
7import { guessAdditionalAttributesFromQuery } from '@server/models/video/formatter/video-format-utils' 6import { guessAdditionalAttributesFromQuery } from '@server/models/video/formatter/video-format-utils'
@@ -13,7 +12,6 @@ import { logger } from '../../../helpers/logger'
13import { getFormattedObjects } from '../../../helpers/utils' 12import { getFormattedObjects } from '../../../helpers/utils'
14import { REMOTE_SCHEME, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES } from '../../../initializers/constants' 13import { REMOTE_SCHEME, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES } from '../../../initializers/constants'
15import { sequelizeTypescript } from '../../../initializers/database' 14import { sequelizeTypescript } from '../../../initializers/database'
16import { sendView } from '../../../lib/activitypub/send/send-view'
17import { JobQueue } from '../../../lib/job-queue' 15import { JobQueue } from '../../../lib/job-queue'
18import { Hooks } from '../../../lib/plugins/hooks' 16import { Hooks } from '../../../lib/plugins/hooks'
19import { 17import {
@@ -35,28 +33,30 @@ import { VideoModel } from '../../../models/video/video'
35import { blacklistRouter } from './blacklist' 33import { blacklistRouter } from './blacklist'
36import { videoCaptionsRouter } from './captions' 34import { videoCaptionsRouter } from './captions'
37import { videoCommentRouter } from './comment' 35import { videoCommentRouter } from './comment'
38import { studioRouter } from './studio'
39import { filesRouter } from './files' 36import { filesRouter } from './files'
40import { videoImportsRouter } from './import' 37import { videoImportsRouter } from './import'
41import { liveRouter } from './live' 38import { liveRouter } from './live'
42import { ownershipVideoRouter } from './ownership' 39import { ownershipVideoRouter } from './ownership'
43import { rateVideoRouter } from './rate' 40import { rateVideoRouter } from './rate'
41import { statsRouter } from './stats'
42import { studioRouter } from './studio'
44import { transcodingRouter } from './transcoding' 43import { transcodingRouter } from './transcoding'
45import { updateRouter } from './update' 44import { updateRouter } from './update'
46import { uploadRouter } from './upload' 45import { uploadRouter } from './upload'
47import { watchingRouter } from './watching' 46import { viewRouter } from './view'
48 47
49const auditLogger = auditLoggerFactory('videos') 48const auditLogger = auditLoggerFactory('videos')
50const videosRouter = express.Router() 49const videosRouter = express.Router()
51 50
52videosRouter.use('/', blacklistRouter) 51videosRouter.use('/', blacklistRouter)
52videosRouter.use('/', statsRouter)
53videosRouter.use('/', rateVideoRouter) 53videosRouter.use('/', rateVideoRouter)
54videosRouter.use('/', videoCommentRouter) 54videosRouter.use('/', videoCommentRouter)
55videosRouter.use('/', studioRouter) 55videosRouter.use('/', studioRouter)
56videosRouter.use('/', videoCaptionsRouter) 56videosRouter.use('/', videoCaptionsRouter)
57videosRouter.use('/', videoImportsRouter) 57videosRouter.use('/', videoImportsRouter)
58videosRouter.use('/', ownershipVideoRouter) 58videosRouter.use('/', ownershipVideoRouter)
59videosRouter.use('/', watchingRouter) 59videosRouter.use('/', viewRouter)
60videosRouter.use('/', liveRouter) 60videosRouter.use('/', liveRouter)
61videosRouter.use('/', uploadRouter) 61videosRouter.use('/', uploadRouter)
62videosRouter.use('/', updateRouter) 62videosRouter.use('/', updateRouter)
@@ -103,11 +103,6 @@ videosRouter.get('/:id',
103 asyncMiddleware(checkVideoFollowConstraints), 103 asyncMiddleware(checkVideoFollowConstraints),
104 getVideo 104 getVideo
105) 105)
106videosRouter.post('/:id/views',
107 openapiOperationDoc({ operationId: 'addView' }),
108 asyncMiddleware(videosCustomGetValidator('only-video')),
109 asyncMiddleware(viewVideo)
110)
111 106
112videosRouter.delete('/:id', 107videosRouter.delete('/:id',
113 openapiOperationDoc({ operationId: 'delVideo' }), 108 openapiOperationDoc({ operationId: 'delVideo' }),
@@ -150,22 +145,6 @@ function getVideo (_req: express.Request, res: express.Response) {
150 return res.json(video.toFormattedDetailsJSON()) 145 return res.json(video.toFormattedDetailsJSON())
151} 146}
152 147
153async function viewVideo (req: express.Request, res: express.Response) {
154 const video = res.locals.onlyVideo
155
156 const ip = req.ip
157 const success = await VideoViews.Instance.processView({ video, ip })
158
159 if (success) {
160 const serverActor = await getServerActor()
161 await sendView(serverActor, video, undefined)
162
163 Hooks.runAction('action:api.video.viewed', { video: video, ip, req, res })
164 }
165
166 return res.status(HttpStatusCode.NO_CONTENT_204).end()
167}
168
169async function getVideoDescription (req: express.Request, res: express.Response) { 148async function getVideoDescription (req: express.Request, res: express.Response) {
170 const videoInstance = res.locals.videoAll 149 const videoInstance = res.locals.videoAll
171 150
diff --git a/server/controllers/api/videos/stats.ts b/server/controllers/api/videos/stats.ts
new file mode 100644
index 000000000..5f8513e9e
--- /dev/null
+++ b/server/controllers/api/videos/stats.ts
@@ -0,0 +1,66 @@
1import express from 'express'
2import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer'
3import { VideoStatsTimeserieMetric } from '@shared/models'
4import {
5 asyncMiddleware,
6 authenticate,
7 videoOverallStatsValidator,
8 videoRetentionStatsValidator,
9 videoTimeserieStatsValidator
10} from '../../../middlewares'
11
12const statsRouter = express.Router()
13
14statsRouter.get('/:videoId/stats/overall',
15 authenticate,
16 asyncMiddleware(videoOverallStatsValidator),
17 asyncMiddleware(getOverallStats)
18)
19
20statsRouter.get('/:videoId/stats/timeseries/:metric',
21 authenticate,
22 asyncMiddleware(videoTimeserieStatsValidator),
23 asyncMiddleware(getTimeserieStats)
24)
25
26statsRouter.get('/:videoId/stats/retention',
27 authenticate,
28 asyncMiddleware(videoRetentionStatsValidator),
29 asyncMiddleware(getRetentionStats)
30)
31
32// ---------------------------------------------------------------------------
33
34export {
35 statsRouter
36}
37
38// ---------------------------------------------------------------------------
39
40async function getOverallStats (req: express.Request, res: express.Response) {
41 const video = res.locals.videoAll
42
43 const stats = await LocalVideoViewerModel.getOverallStats(video)
44
45 return res.json(stats)
46}
47
48async function getRetentionStats (req: express.Request, res: express.Response) {
49 const video = res.locals.videoAll
50
51 const stats = await LocalVideoViewerModel.getRetentionStats(video)
52
53 return res.json(stats)
54}
55
56async function getTimeserieStats (req: express.Request, res: express.Response) {
57 const video = res.locals.videoAll
58 const metric = req.params.metric as VideoStatsTimeserieMetric
59
60 const stats = await LocalVideoViewerModel.getTimeserieStats({
61 video,
62 metric
63 })
64
65 return res.json(stats)
66}
diff --git a/server/controllers/api/videos/view.ts b/server/controllers/api/videos/view.ts
new file mode 100644
index 000000000..e28cf371a
--- /dev/null
+++ b/server/controllers/api/videos/view.ts
@@ -0,0 +1,68 @@
1import express from 'express'
2import { sendView } from '@server/lib/activitypub/send/send-view'
3import { Hooks } from '@server/lib/plugins/hooks'
4import { VideoViewsManager } from '@server/lib/views/video-views-manager'
5import { getServerActor } from '@server/models/application/application'
6import { MVideoId } from '@server/types/models'
7import { HttpStatusCode, VideoView } from '@shared/models'
8import { asyncMiddleware, methodsValidator, openapiOperationDoc, optionalAuthenticate, videoViewValidator } from '../../../middlewares'
9import { UserVideoHistoryModel } from '../../../models/user/user-video-history'
10
11const viewRouter = express.Router()
12
13viewRouter.all(
14 [ '/:videoId/views', '/:videoId/watching' ],
15 openapiOperationDoc({ operationId: 'addView' }),
16 methodsValidator([ 'PUT', 'POST' ]),
17 optionalAuthenticate,
18 asyncMiddleware(videoViewValidator),
19 asyncMiddleware(viewVideo)
20)
21
22// ---------------------------------------------------------------------------
23
24export {
25 viewRouter
26}
27
28// ---------------------------------------------------------------------------
29
30async function viewVideo (req: express.Request, res: express.Response) {
31 const video = res.locals.onlyVideo
32
33 const body = req.body as VideoView
34
35 const ip = req.ip
36 const { successView, successViewer } = await VideoViewsManager.Instance.processLocalView({
37 video,
38 ip,
39 currentTime: body.currentTime,
40 viewEvent: body.viewEvent
41 })
42
43 if (successView) {
44 await sendView({ byActor: await getServerActor(), video, type: 'view' })
45
46 Hooks.runAction('action:api.video.viewed', { video: video, ip, req, res })
47 }
48
49 if (successViewer) {
50 await sendView({ byActor: await getServerActor(), video, type: 'viewer' })
51 }
52
53 await updateUserHistoryIfNeeded(body, video, res)
54
55 return res.status(HttpStatusCode.NO_CONTENT_204).end()
56}
57
58async function updateUserHistoryIfNeeded (body: VideoView, video: MVideoId, res: express.Response) {
59 const user = res.locals.oauth?.token.User
60 if (!user) return
61 if (user.videosHistoryEnabled !== true) return
62
63 await UserVideoHistoryModel.upsert({
64 videoId: video.id,
65 userId: user.id,
66 currentTime: body.currentTime
67 })
68}
diff --git a/server/controllers/api/videos/watching.ts b/server/controllers/api/videos/watching.ts
deleted file mode 100644
index 3fd22caac..000000000
--- a/server/controllers/api/videos/watching.ts
+++ /dev/null
@@ -1,44 +0,0 @@
1import express from 'express'
2import { HttpStatusCode, UserWatchingVideo } from '@shared/models'
3import {
4 asyncMiddleware,
5 asyncRetryTransactionMiddleware,
6 authenticate,
7 openapiOperationDoc,
8 videoWatchingValidator
9} from '../../../middlewares'
10import { UserVideoHistoryModel } from '../../../models/user/user-video-history'
11
12const watchingRouter = express.Router()
13
14watchingRouter.put('/:videoId/watching',
15 openapiOperationDoc({ operationId: 'setProgress' }),
16 authenticate,
17 asyncMiddleware(videoWatchingValidator),
18 asyncRetryTransactionMiddleware(userWatchVideo)
19)
20
21// ---------------------------------------------------------------------------
22
23export {
24 watchingRouter
25}
26
27// ---------------------------------------------------------------------------
28
29async function userWatchVideo (req: express.Request, res: express.Response) {
30 const user = res.locals.oauth.token.User
31
32 const body: UserWatchingVideo = req.body
33 const { id: videoId } = res.locals.videoId
34
35 await UserVideoHistoryModel.upsert({
36 videoId,
37 userId: user.id,
38 currentTime: body.currentTime
39 })
40
41 return res.type('json')
42 .status(HttpStatusCode.NO_CONTENT_204)
43 .end()
44}