diff options
-rw-r--r-- | server/controllers/api/videos/stats.ts | 9 | ||||
-rw-r--r-- | server/middlewares/validators/videos/video-stats.ts | 10 | ||||
-rw-r--r-- | server/models/view/local-video-viewer.ts | 36 | ||||
-rw-r--r-- | server/tests/api/check-params/views.ts | 24 | ||||
-rw-r--r-- | server/tests/api/views/video-views-overall-stats.ts | 21 | ||||
-rw-r--r-- | shared/models/videos/stats/index.ts | 1 | ||||
-rw-r--r-- | shared/models/videos/stats/video-stats-overall-query.model.ts | 4 | ||||
-rw-r--r-- | shared/server-commands/videos/video-stats-command.ts | 4 | ||||
-rw-r--r-- | support/doc/api/openapi.yaml | 24 |
9 files changed, 121 insertions, 12 deletions
diff --git a/server/controllers/api/videos/stats.ts b/server/controllers/api/videos/stats.ts index 71452d9f0..30e2bb06c 100644 --- a/server/controllers/api/videos/stats.ts +++ b/server/controllers/api/videos/stats.ts | |||
@@ -1,6 +1,6 @@ | |||
1 | import express from 'express' | 1 | import express from 'express' |
2 | import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer' | 2 | import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer' |
3 | import { VideoStatsTimeserieMetric, VideoStatsTimeserieQuery } from '@shared/models' | 3 | import { VideoStatsOverallQuery, VideoStatsTimeserieMetric, VideoStatsTimeserieQuery } from '@shared/models' |
4 | import { | 4 | import { |
5 | asyncMiddleware, | 5 | asyncMiddleware, |
6 | authenticate, | 6 | authenticate, |
@@ -39,8 +39,13 @@ export { | |||
39 | 39 | ||
40 | async function getOverallStats (req: express.Request, res: express.Response) { | 40 | async function getOverallStats (req: express.Request, res: express.Response) { |
41 | const video = res.locals.videoAll | 41 | const video = res.locals.videoAll |
42 | const query = req.query as VideoStatsOverallQuery | ||
42 | 43 | ||
43 | const stats = await LocalVideoViewerModel.getOverallStats(video) | 44 | const stats = await LocalVideoViewerModel.getOverallStats({ |
45 | video, | ||
46 | startDate: query.startDate, | ||
47 | endDate: query.endDate | ||
48 | }) | ||
44 | 49 | ||
45 | return res.json(stats) | 50 | return res.json(stats) |
46 | } | 51 | } |
diff --git a/server/middlewares/validators/videos/video-stats.ts b/server/middlewares/validators/videos/video-stats.ts index 12509abde..f17fbcc09 100644 --- a/server/middlewares/validators/videos/video-stats.ts +++ b/server/middlewares/validators/videos/video-stats.ts | |||
@@ -10,6 +10,16 @@ import { areValidationErrors, checkUserCanManageVideo, doesVideoExist, isValidVi | |||
10 | const videoOverallStatsValidator = [ | 10 | const videoOverallStatsValidator = [ |
11 | isValidVideoIdParam('videoId'), | 11 | isValidVideoIdParam('videoId'), |
12 | 12 | ||
13 | query('startDate') | ||
14 | .optional() | ||
15 | .custom(isDateValid) | ||
16 | .withMessage('Should have a valid start date'), | ||
17 | |||
18 | query('endDate') | ||
19 | .optional() | ||
20 | .custom(isDateValid) | ||
21 | .withMessage('Should have a valid end date'), | ||
22 | |||
13 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | 23 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { |
14 | logger.debug('Checking videoOverallStatsValidator parameters', { parameters: req.body }) | 24 | logger.debug('Checking videoOverallStatsValidator parameters', { parameters: req.body }) |
15 | 25 | ||
diff --git a/server/models/view/local-video-viewer.ts b/server/models/view/local-video-viewer.ts index 5928ba5f6..2862f8b96 100644 --- a/server/models/view/local-video-viewer.ts +++ b/server/models/view/local-video-viewer.ts | |||
@@ -100,10 +100,28 @@ export class LocalVideoViewerModel extends Model<Partial<AttributesOnly<LocalVid | |||
100 | }) | 100 | }) |
101 | } | 101 | } |
102 | 102 | ||
103 | static async getOverallStats (video: MVideo): Promise<VideoStatsOverall> { | 103 | static async getOverallStats (options: { |
104 | const options = { | 104 | video: MVideo |
105 | startDate?: string | ||
106 | endDate?: string | ||
107 | }): Promise<VideoStatsOverall> { | ||
108 | const { video, startDate, endDate } = options | ||
109 | |||
110 | const queryOptions = { | ||
105 | type: QueryTypes.SELECT as QueryTypes.SELECT, | 111 | type: QueryTypes.SELECT as QueryTypes.SELECT, |
106 | replacements: { videoId: video.id } | 112 | replacements: { videoId: video.id } as any |
113 | } | ||
114 | |||
115 | let dateWhere = '' | ||
116 | |||
117 | if (startDate) { | ||
118 | dateWhere += ' AND "localVideoViewer"."startDate" >= :startDate' | ||
119 | queryOptions.replacements.startDate = startDate | ||
120 | } | ||
121 | |||
122 | if (endDate) { | ||
123 | dateWhere += ' AND "localVideoViewer"."endDate" <= :endDate' | ||
124 | queryOptions.replacements.endDate = endDate | ||
107 | } | 125 | } |
108 | 126 | ||
109 | const watchTimeQuery = `SELECT ` + | 127 | const watchTimeQuery = `SELECT ` + |
@@ -111,9 +129,9 @@ export class LocalVideoViewerModel extends Model<Partial<AttributesOnly<LocalVid | |||
111 | `AVG("localVideoViewer"."watchTime") AS "averageWatchTime" ` + | 129 | `AVG("localVideoViewer"."watchTime") AS "averageWatchTime" ` + |
112 | `FROM "localVideoViewer" ` + | 130 | `FROM "localVideoViewer" ` + |
113 | `INNER JOIN "video" ON "video"."id" = "localVideoViewer"."videoId" ` + | 131 | `INNER JOIN "video" ON "video"."id" = "localVideoViewer"."videoId" ` + |
114 | `WHERE "videoId" = :videoId` | 132 | `WHERE "videoId" = :videoId ${dateWhere}` |
115 | 133 | ||
116 | const watchTimePromise = LocalVideoViewerModel.sequelize.query<any>(watchTimeQuery, options) | 134 | const watchTimePromise = LocalVideoViewerModel.sequelize.query<any>(watchTimeQuery, queryOptions) |
117 | 135 | ||
118 | const watchPeakQuery = `WITH "watchPeakValues" AS ( | 136 | const watchPeakQuery = `WITH "watchPeakValues" AS ( |
119 | SELECT "startDate" AS "dateBreakpoint", 1 AS "inc" | 137 | SELECT "startDate" AS "dateBreakpoint", 1 AS "inc" |
@@ -122,7 +140,7 @@ export class LocalVideoViewerModel extends Model<Partial<AttributesOnly<LocalVid | |||
122 | UNION ALL | 140 | UNION ALL |
123 | SELECT "endDate" AS "dateBreakpoint", -1 AS "inc" | 141 | SELECT "endDate" AS "dateBreakpoint", -1 AS "inc" |
124 | FROM "localVideoViewer" | 142 | FROM "localVideoViewer" |
125 | WHERE "videoId" = :videoId | 143 | WHERE "videoId" = :videoId ${dateWhere} |
126 | ) | 144 | ) |
127 | SELECT "dateBreakpoint", "concurrent" | 145 | SELECT "dateBreakpoint", "concurrent" |
128 | FROM ( | 146 | FROM ( |
@@ -132,14 +150,14 @@ export class LocalVideoViewerModel extends Model<Partial<AttributesOnly<LocalVid | |||
132 | ) tmp | 150 | ) tmp |
133 | ORDER BY "concurrent" DESC | 151 | ORDER BY "concurrent" DESC |
134 | FETCH FIRST 1 ROW ONLY` | 152 | FETCH FIRST 1 ROW ONLY` |
135 | const watchPeakPromise = LocalVideoViewerModel.sequelize.query<any>(watchPeakQuery, options) | 153 | const watchPeakPromise = LocalVideoViewerModel.sequelize.query<any>(watchPeakQuery, queryOptions) |
136 | 154 | ||
137 | const countriesQuery = `SELECT country, COUNT(country) as viewers ` + | 155 | const countriesQuery = `SELECT country, COUNT(country) as viewers ` + |
138 | `FROM "localVideoViewer" ` + | 156 | `FROM "localVideoViewer" ` + |
139 | `WHERE "videoId" = :videoId AND country IS NOT NULL ` + | 157 | `WHERE "videoId" = :videoId AND country IS NOT NULL ${dateWhere} ` + |
140 | `GROUP BY country ` + | 158 | `GROUP BY country ` + |
141 | `ORDER BY viewers DESC` | 159 | `ORDER BY viewers DESC` |
142 | const countriesPromise = LocalVideoViewerModel.sequelize.query<any>(countriesQuery, options) | 160 | const countriesPromise = LocalVideoViewerModel.sequelize.query<any>(countriesQuery, queryOptions) |
143 | 161 | ||
144 | const [ rowsWatchTime, rowsWatchPeak, rowsCountries ] = await Promise.all([ | 162 | const [ rowsWatchTime, rowsWatchPeak, rowsCountries ] = await Promise.all([ |
145 | watchTimePromise, | 163 | watchTimePromise, |
diff --git a/server/tests/api/check-params/views.ts b/server/tests/api/check-params/views.ts index 3dba2a42e..fe037b145 100644 --- a/server/tests/api/check-params/views.ts +++ b/server/tests/api/check-params/views.ts | |||
@@ -75,8 +75,30 @@ describe('Test videos views', function () { | |||
75 | }) | 75 | }) |
76 | }) | 76 | }) |
77 | 77 | ||
78 | it('Should fail with an invalid start date', async function () { | ||
79 | await servers[0].videoStats.getOverallStats({ | ||
80 | videoId, | ||
81 | startDate: 'fake' as any, | ||
82 | endDate: new Date().toISOString(), | ||
83 | expectedStatus: HttpStatusCode.BAD_REQUEST_400 | ||
84 | }) | ||
85 | }) | ||
86 | |||
87 | it('Should fail with an invalid end date', async function () { | ||
88 | await servers[0].videoStats.getOverallStats({ | ||
89 | videoId, | ||
90 | startDate: new Date().toISOString(), | ||
91 | endDate: 'fake' as any, | ||
92 | expectedStatus: HttpStatusCode.BAD_REQUEST_400 | ||
93 | }) | ||
94 | }) | ||
95 | |||
78 | it('Should succeed with the correct parameters', async function () { | 96 | it('Should succeed with the correct parameters', async function () { |
79 | await servers[0].videoStats.getOverallStats({ videoId }) | 97 | await servers[0].videoStats.getOverallStats({ |
98 | videoId, | ||
99 | startDate: new Date().toISOString(), | ||
100 | endDate: new Date().toISOString() | ||
101 | }) | ||
80 | }) | 102 | }) |
81 | }) | 103 | }) |
82 | 104 | ||
diff --git a/server/tests/api/views/video-views-overall-stats.ts b/server/tests/api/views/video-views-overall-stats.ts index 72b072c96..53b8f0d4b 100644 --- a/server/tests/api/views/video-views-overall-stats.ts +++ b/server/tests/api/views/video-views-overall-stats.ts | |||
@@ -141,6 +141,27 @@ describe('Test views overall stats', function () { | |||
141 | } | 141 | } |
142 | }) | 142 | }) |
143 | 143 | ||
144 | it('Should filter overall stats by date', async function () { | ||
145 | this.timeout(60000) | ||
146 | |||
147 | const beforeView = new Date() | ||
148 | |||
149 | await servers[0].views.simulateViewer({ id: vodVideoId, currentTimes: [ 0, 3 ] }) | ||
150 | await processViewersStats(servers) | ||
151 | |||
152 | { | ||
153 | const stats = await servers[0].videoStats.getOverallStats({ videoId: vodVideoId, startDate: beforeView.toISOString() }) | ||
154 | expect(stats.averageWatchTime).to.equal(3) | ||
155 | expect(stats.totalWatchTime).to.equal(3) | ||
156 | } | ||
157 | |||
158 | { | ||
159 | const stats = await servers[0].videoStats.getOverallStats({ videoId: liveVideoId, endDate: beforeView.toISOString() }) | ||
160 | expect(stats.averageWatchTime).to.equal(22) | ||
161 | expect(stats.totalWatchTime).to.equal(88) | ||
162 | } | ||
163 | }) | ||
164 | |||
144 | after(async function () { | 165 | after(async function () { |
145 | await stopFfmpeg(command) | 166 | await stopFfmpeg(command) |
146 | }) | 167 | }) |
diff --git a/shared/models/videos/stats/index.ts b/shared/models/videos/stats/index.ts index 4a6fdaa71..a9b203f58 100644 --- a/shared/models/videos/stats/index.ts +++ b/shared/models/videos/stats/index.ts | |||
@@ -1,3 +1,4 @@ | |||
1 | export * from './video-stats-overall-query.model' | ||
1 | export * from './video-stats-overall.model' | 2 | export * from './video-stats-overall.model' |
2 | export * from './video-stats-retention.model' | 3 | export * from './video-stats-retention.model' |
3 | export * from './video-stats-timeserie-query.model' | 4 | export * from './video-stats-timeserie-query.model' |
diff --git a/shared/models/videos/stats/video-stats-overall-query.model.ts b/shared/models/videos/stats/video-stats-overall-query.model.ts new file mode 100644 index 000000000..6b4c2164f --- /dev/null +++ b/shared/models/videos/stats/video-stats-overall-query.model.ts | |||
@@ -0,0 +1,4 @@ | |||
1 | export interface VideoStatsOverallQuery { | ||
2 | startDate?: string | ||
3 | endDate?: string | ||
4 | } | ||
diff --git a/shared/server-commands/videos/video-stats-command.ts b/shared/server-commands/videos/video-stats-command.ts index bd4808f63..b9b99bfb5 100644 --- a/shared/server-commands/videos/video-stats-command.ts +++ b/shared/server-commands/videos/video-stats-command.ts | |||
@@ -6,6 +6,8 @@ export class VideoStatsCommand extends AbstractCommand { | |||
6 | 6 | ||
7 | getOverallStats (options: OverrideCommandOptions & { | 7 | getOverallStats (options: OverrideCommandOptions & { |
8 | videoId: number | string | 8 | videoId: number | string |
9 | startDate?: string | ||
10 | endDate?: string | ||
9 | }) { | 11 | }) { |
10 | const path = '/api/v1/videos/' + options.videoId + '/stats/overall' | 12 | const path = '/api/v1/videos/' + options.videoId + '/stats/overall' |
11 | 13 | ||
@@ -13,6 +15,8 @@ export class VideoStatsCommand extends AbstractCommand { | |||
13 | ...options, | 15 | ...options, |
14 | path, | 16 | path, |
15 | 17 | ||
18 | query: pick(options, [ 'startDate', 'endDate' ]), | ||
19 | |||
16 | implicitToken: true, | 20 | implicitToken: true, |
17 | defaultExpectedStatus: HttpStatusCode.OK_200 | 21 | defaultExpectedStatus: HttpStatusCode.OK_200 |
18 | }) | 22 | }) |
diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml index 95670925f..294aa50ab 100644 --- a/support/doc/api/openapi.yaml +++ b/support/doc/api/openapi.yaml | |||
@@ -1952,6 +1952,18 @@ paths: | |||
1952 | - OAuth2: [] | 1952 | - OAuth2: [] |
1953 | parameters: | 1953 | parameters: |
1954 | - $ref: '#/components/parameters/idOrUUID' | 1954 | - $ref: '#/components/parameters/idOrUUID' |
1955 | - name: startDate | ||
1956 | in: query | ||
1957 | description: Filter stats by start date | ||
1958 | schema: | ||
1959 | type: string | ||
1960 | format: date-time | ||
1961 | - name: endDate | ||
1962 | in: query | ||
1963 | description: Filter stats by end date | ||
1964 | schema: | ||
1965 | type: string | ||
1966 | format: date-time | ||
1955 | responses: | 1967 | responses: |
1956 | '200': | 1968 | '200': |
1957 | description: successful operation | 1969 | description: successful operation |
@@ -1996,6 +2008,18 @@ paths: | |||
1996 | enum: | 2008 | enum: |
1997 | - 'viewers' | 2009 | - 'viewers' |
1998 | - 'aggregateWatchTime' | 2010 | - 'aggregateWatchTime' |
2011 | - name: startDate | ||
2012 | in: query | ||
2013 | description: Filter stats by start date | ||
2014 | schema: | ||
2015 | type: string | ||
2016 | format: date-time | ||
2017 | - name: endDate | ||
2018 | in: query | ||
2019 | description: Filter stats by end date | ||
2020 | schema: | ||
2021 | type: string | ||
2022 | format: date-time | ||
1999 | responses: | 2023 | responses: |
2000 | '200': | 2024 | '200': |
2001 | description: successful operation | 2025 | description: successful operation |