diff options
-rw-r--r-- | client/package.json | 1 | ||||
-rw-r--r-- | client/src/app/+stats/video/video-stats.component.html | 9 | ||||
-rw-r--r-- | client/src/app/+stats/video/video-stats.component.scss | 26 | ||||
-rw-r--r-- | client/src/app/+stats/video/video-stats.component.ts | 207 | ||||
-rw-r--r-- | client/src/app/+stats/video/video-stats.service.ts | 17 | ||||
-rw-r--r-- | client/yarn.lock | 12 | ||||
-rw-r--r-- | server/lib/timeserie.ts | 24 | ||||
-rw-r--r-- | server/models/view/local-video-viewer.ts | 8 | ||||
-rw-r--r-- | server/tests/api/views/video-views-timeserie-stats.ts | 22 | ||||
-rw-r--r-- | shared/models/videos/stats/index.ts | 1 | ||||
-rw-r--r-- | shared/models/videos/stats/video-stats-timeserie-group-interval.type.ts | 1 | ||||
-rw-r--r-- | shared/models/videos/stats/video-stats-timeserie.model.ts | 4 |
12 files changed, 268 insertions, 64 deletions
diff --git a/client/package.json b/client/package.json index 7c0732b44..7da61df66 100644 --- a/client/package.json +++ b/client/package.json | |||
@@ -83,6 +83,7 @@ | |||
83 | "buffer": "^6.0.3", | 83 | "buffer": "^6.0.3", |
84 | "cache-chunk-store": "^3.0.0", | 84 | "cache-chunk-store": "^3.0.0", |
85 | "chart.js": "^3.5.1", | 85 | "chart.js": "^3.5.1", |
86 | "chartjs-plugin-zoom": "^1.2.1", | ||
86 | "chromedriver": "^99.0.0", | 87 | "chromedriver": "^99.0.0", |
87 | "core-js": "^3.1.4", | 88 | "core-js": "^3.1.4", |
88 | "css-loader": "^6.2.0", | 89 | "css-loader": "^6.2.0", |
diff --git a/client/src/app/+stats/video/video-stats.component.html b/client/src/app/+stats/video/video-stats.component.html index ef43c9fba..400c049eb 100644 --- a/client/src/app/+stats/video/video-stats.component.html +++ b/client/src/app/+stats/video/video-stats.component.html | |||
@@ -22,13 +22,20 @@ | |||
22 | </a> | 22 | </a> |
23 | 23 | ||
24 | <ng-template ngbNavContent> | 24 | <ng-template ngbNavContent> |
25 | <div [ngStyle]="{ 'min-height': chartHeight }"> | 25 | <div class="chart-container" [ngStyle]="{ 'min-height': chartHeight }"> |
26 | <p-chart | 26 | <p-chart |
27 | *ngIf="chartOptions[availableChart.id]" | 27 | *ngIf="chartOptions[availableChart.id]" |
28 | [height]="chartHeight" [width]="chartWidth" | 28 | [height]="chartHeight" [width]="chartWidth" |
29 | [type]="chartOptions[availableChart.id].type" [options]="chartOptions[availableChart.id].options" [data]="chartOptions[availableChart.id].data" | 29 | [type]="chartOptions[availableChart.id].type" [options]="chartOptions[availableChart.id].options" [data]="chartOptions[availableChart.id].data" |
30 | [plugins]="chartPlugins" | ||
30 | ></p-chart> | 31 | ></p-chart> |
31 | </div> | 32 | </div> |
33 | |||
34 | <div class="zoom-container"> | ||
35 | <span *ngIf="!hasZoom() && availableChart.zoomEnabled" i18n class="description">You can select a part of the graph to zoom in</span> | ||
36 | |||
37 | <my-button i18n *ngIf="hasZoom()" (click)="resetZoom()">Reset zoom</my-button> | ||
38 | </div> | ||
32 | </ng-template> | 39 | </ng-template> |
33 | </ng-container> | 40 | </ng-container> |
34 | </div> | 41 | </div> |
diff --git a/client/src/app/+stats/video/video-stats.component.scss b/client/src/app/+stats/video/video-stats.component.scss index 190499b5c..e2a74152f 100644 --- a/client/src/app/+stats/video/video-stats.component.scss +++ b/client/src/app/+stats/video/video-stats.component.scss | |||
@@ -21,6 +21,7 @@ | |||
21 | min-width: 200px; | 21 | min-width: 200px; |
22 | margin-right: 15px; | 22 | margin-right: 15px; |
23 | background-color: pvar(--submenuBackgroundColor); | 23 | background-color: pvar(--submenuBackgroundColor); |
24 | margin-bottom: 15px; | ||
24 | 25 | ||
25 | .label, | 26 | .label, |
26 | .more-info { | 27 | .more-info { |
@@ -37,6 +38,12 @@ | |||
37 | font-size: 24px; | 38 | font-size: 24px; |
38 | font-weight: $font-semibold; | 39 | font-weight: $font-semibold; |
39 | } | 40 | } |
41 | |||
42 | @media screen and (max-width: $mobile-view) { | ||
43 | min-height: fit-content; | ||
44 | min-width: fit-content; | ||
45 | padding: 15px; | ||
46 | } | ||
40 | } | 47 | } |
41 | 48 | ||
42 | my-embed { | 49 | my-embed { |
@@ -45,6 +52,12 @@ my-embed { | |||
45 | width: 100%; | 52 | width: 100%; |
46 | } | 53 | } |
47 | 54 | ||
55 | @include on-small-main-col { | ||
56 | my-embed { | ||
57 | display: none; | ||
58 | } | ||
59 | } | ||
60 | |||
48 | .tab-content { | 61 | .tab-content { |
49 | margin-top: 15px; | 62 | margin-top: 15px; |
50 | } | 63 | } |
@@ -52,3 +65,16 @@ my-embed { | |||
52 | .nav-tabs { | 65 | .nav-tabs { |
53 | @include peertube-nav-tabs($border-width: 2px); | 66 | @include peertube-nav-tabs($border-width: 2px); |
54 | } | 67 | } |
68 | |||
69 | .chart-container { | ||
70 | margin-bottom: 10px; | ||
71 | } | ||
72 | |||
73 | .zoom-container { | ||
74 | display: flex; | ||
75 | justify-content: center; | ||
76 | |||
77 | .description { | ||
78 | font-style: italic; | ||
79 | } | ||
80 | } | ||
diff --git a/client/src/app/+stats/video/video-stats.component.ts b/client/src/app/+stats/video/video-stats.component.ts index 05319539b..14db31ecf 100644 --- a/client/src/app/+stats/video/video-stats.component.ts +++ b/client/src/app/+stats/video/video-stats.component.ts | |||
@@ -1,8 +1,9 @@ | |||
1 | import { ChartConfiguration, ChartData } from 'chart.js' | 1 | import { ChartConfiguration, ChartData, PluginOptionsByType, Scale, TooltipItem } from 'chart.js' |
2 | import zoomPlugin from 'chartjs-plugin-zoom' | ||
2 | import { Observable, of } from 'rxjs' | 3 | import { Observable, of } from 'rxjs' |
3 | import { Component, OnInit } from '@angular/core' | 4 | import { Component, OnInit } from '@angular/core' |
4 | import { ActivatedRoute } from '@angular/router' | 5 | import { ActivatedRoute } from '@angular/router' |
5 | import { Notifier } from '@app/core' | 6 | import { Notifier, PeerTubeRouterService } from '@app/core' |
6 | import { NumberFormatterPipe, VideoDetails } from '@app/shared/shared-main' | 7 | import { NumberFormatterPipe, VideoDetails } from '@app/shared/shared-main' |
7 | import { secondsToTime } from '@shared/core-utils' | 8 | import { secondsToTime } from '@shared/core-utils' |
8 | import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric } from '@shared/models/videos' | 9 | import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric } from '@shared/models/videos' |
@@ -15,6 +16,7 @@ type CountryData = { name: string, viewers: number }[] | |||
15 | type ChartIngestData = VideoStatsTimeserie | VideoStatsRetention | CountryData | 16 | type ChartIngestData = VideoStatsTimeserie | VideoStatsRetention | CountryData |
16 | type ChartBuilderResult = { | 17 | type ChartBuilderResult = { |
17 | type: 'line' | 'bar' | 18 | type: 'line' | 'bar' |
19 | plugins: Partial<PluginOptionsByType<'line' | 'bar'>> | ||
18 | data: ChartData<'line' | 'bar'> | 20 | data: ChartData<'line' | 'bar'> |
19 | displayLegend: boolean | 21 | displayLegend: boolean |
20 | } | 22 | } |
@@ -34,19 +36,23 @@ export class VideoStatsComponent implements OnInit { | |||
34 | availableCharts = [ | 36 | availableCharts = [ |
35 | { | 37 | { |
36 | id: 'viewers', | 38 | id: 'viewers', |
37 | label: $localize`Viewers` | 39 | label: $localize`Viewers`, |
40 | zoomEnabled: true | ||
38 | }, | 41 | }, |
39 | { | 42 | { |
40 | id: 'aggregateWatchTime', | 43 | id: 'aggregateWatchTime', |
41 | label: $localize`Watch time` | 44 | label: $localize`Watch time`, |
45 | zoomEnabled: true | ||
42 | }, | 46 | }, |
43 | { | 47 | { |
44 | id: 'retention', | 48 | id: 'retention', |
45 | label: $localize`Retention` | 49 | label: $localize`Retention`, |
50 | zoomEnabled: false | ||
46 | }, | 51 | }, |
47 | { | 52 | { |
48 | id: 'countries', | 53 | id: 'countries', |
49 | label: $localize`Countries` | 54 | label: $localize`Countries`, |
55 | zoomEnabled: false | ||
50 | } | 56 | } |
51 | ] | 57 | ] |
52 | 58 | ||
@@ -56,18 +62,37 @@ export class VideoStatsComponent implements OnInit { | |||
56 | 62 | ||
57 | countries: CountryData = [] | 63 | countries: CountryData = [] |
58 | 64 | ||
65 | chartPlugins = [ zoomPlugin ] | ||
66 | |||
67 | private timeseriesStartDate: Date | ||
68 | private timeseriesEndDate: Date | ||
69 | |||
70 | private chartIngestData: { [ id in ActiveGraphId ]?: ChartIngestData } = {} | ||
71 | |||
59 | constructor ( | 72 | constructor ( |
60 | private route: ActivatedRoute, | 73 | private route: ActivatedRoute, |
61 | private notifier: Notifier, | 74 | private notifier: Notifier, |
62 | private statsService: VideoStatsService, | 75 | private statsService: VideoStatsService, |
76 | private peertubeRouter: PeerTubeRouterService, | ||
63 | private numberFormatter: NumberFormatterPipe | 77 | private numberFormatter: NumberFormatterPipe |
64 | ) {} | 78 | ) {} |
65 | 79 | ||
66 | ngOnInit () { | 80 | ngOnInit () { |
67 | this.video = this.route.snapshot.data.video | 81 | this.video = this.route.snapshot.data.video |
68 | 82 | ||
83 | this.route.queryParams.subscribe(params => { | ||
84 | this.timeseriesStartDate = params.startDate | ||
85 | ? new Date(params.startDate) | ||
86 | : undefined | ||
87 | |||
88 | this.timeseriesEndDate = params.endDate | ||
89 | ? new Date(params.endDate) | ||
90 | : undefined | ||
91 | |||
92 | this.loadChart() | ||
93 | }) | ||
94 | |||
69 | this.loadOverallStats() | 95 | this.loadOverallStats() |
70 | this.loadChart() | ||
71 | } | 96 | } |
72 | 97 | ||
73 | hasCountries () { | 98 | hasCountries () { |
@@ -80,6 +105,18 @@ export class VideoStatsComponent implements OnInit { | |||
80 | this.loadChart() | 105 | this.loadChart() |
81 | } | 106 | } |
82 | 107 | ||
108 | resetZoom () { | ||
109 | this.peertubeRouter.silentNavigate([], {}) | ||
110 | } | ||
111 | |||
112 | hasZoom () { | ||
113 | return !!this.timeseriesStartDate && this.isTimeserieGraph(this.activeGraphId) | ||
114 | } | ||
115 | |||
116 | private isTimeserieGraph (graphId: ActiveGraphId) { | ||
117 | return graphId === 'aggregateWatchTime' || graphId === 'viewers' | ||
118 | } | ||
119 | |||
83 | private loadOverallStats () { | 120 | private loadOverallStats () { |
84 | this.statsService.getOverallStats(this.video.uuid) | 121 | this.statsService.getOverallStats(this.video.uuid) |
85 | .subscribe({ | 122 | .subscribe({ |
@@ -125,24 +162,35 @@ export class VideoStatsComponent implements OnInit { | |||
125 | private loadChart () { | 162 | private loadChart () { |
126 | const obsBuilders: { [ id in ActiveGraphId ]: Observable<ChartIngestData> } = { | 163 | const obsBuilders: { [ id in ActiveGraphId ]: Observable<ChartIngestData> } = { |
127 | retention: this.statsService.getRetentionStats(this.video.uuid), | 164 | retention: this.statsService.getRetentionStats(this.video.uuid), |
128 | aggregateWatchTime: this.statsService.getTimeserieStats(this.video.uuid, 'aggregateWatchTime'), | 165 | |
129 | viewers: this.statsService.getTimeserieStats(this.video.uuid, 'viewers'), | 166 | aggregateWatchTime: this.statsService.getTimeserieStats({ |
167 | videoId: this.video.uuid, | ||
168 | startDate: this.timeseriesStartDate, | ||
169 | endDate: this.timeseriesEndDate, | ||
170 | metric: 'aggregateWatchTime' | ||
171 | }), | ||
172 | viewers: this.statsService.getTimeserieStats({ | ||
173 | videoId: this.video.uuid, | ||
174 | startDate: this.timeseriesStartDate, | ||
175 | endDate: this.timeseriesEndDate, | ||
176 | metric: 'viewers' | ||
177 | }), | ||
178 | |||
130 | countries: of(this.countries) | 179 | countries: of(this.countries) |
131 | } | 180 | } |
132 | 181 | ||
133 | obsBuilders[this.activeGraphId].subscribe({ | 182 | obsBuilders[this.activeGraphId].subscribe({ |
134 | next: res => { | 183 | next: res => { |
135 | this.chartOptions[this.activeGraphId] = this.buildChartOptions(this.activeGraphId, res) | 184 | this.chartIngestData[this.activeGraphId] = res |
185 | |||
186 | this.chartOptions[this.activeGraphId] = this.buildChartOptions(this.activeGraphId) | ||
136 | }, | 187 | }, |
137 | 188 | ||
138 | error: err => this.notifier.error(err.message) | 189 | error: err => this.notifier.error(err.message) |
139 | }) | 190 | }) |
140 | } | 191 | } |
141 | 192 | ||
142 | private buildChartOptions ( | 193 | private buildChartOptions (graphId: ActiveGraphId): ChartConfiguration<'line' | 'bar'> { |
143 | graphId: ActiveGraphId, | ||
144 | rawData: ChartIngestData | ||
145 | ): ChartConfiguration<'line' | 'bar'> { | ||
146 | const dataBuilders: { | 194 | const dataBuilders: { |
147 | [ id in ActiveGraphId ]: (rawData: ChartIngestData) => ChartBuilderResult | 195 | [ id in ActiveGraphId ]: (rawData: ChartIngestData) => ChartBuilderResult |
148 | } = { | 196 | } = { |
@@ -152,7 +200,9 @@ export class VideoStatsComponent implements OnInit { | |||
152 | countries: (rawData: CountryData) => this.buildCountryChartOptions(rawData) | 200 | countries: (rawData: CountryData) => this.buildCountryChartOptions(rawData) |
153 | } | 201 | } |
154 | 202 | ||
155 | const { type, data, displayLegend } = dataBuilders[graphId](rawData) | 203 | const { type, data, displayLegend, plugins } = dataBuilders[graphId](this.chartIngestData[graphId]) |
204 | |||
205 | const self = this | ||
156 | 206 | ||
157 | return { | 207 | return { |
158 | type, | 208 | type, |
@@ -162,6 +212,19 @@ export class VideoStatsComponent implements OnInit { | |||
162 | responsive: true, | 212 | responsive: true, |
163 | 213 | ||
164 | scales: { | 214 | scales: { |
215 | x: { | ||
216 | ticks: { | ||
217 | callback: function (value) { | ||
218 | return self.formatXTick({ | ||
219 | graphId, | ||
220 | value, | ||
221 | data: self.chartIngestData[graphId] as VideoStatsTimeserie, | ||
222 | scale: this | ||
223 | }) | ||
224 | } | ||
225 | } | ||
226 | }, | ||
227 | |||
165 | y: { | 228 | y: { |
166 | beginAtZero: true, | 229 | beginAtZero: true, |
167 | 230 | ||
@@ -170,7 +233,7 @@ export class VideoStatsComponent implements OnInit { | |||
170 | : undefined, | 233 | : undefined, |
171 | 234 | ||
172 | ticks: { | 235 | ticks: { |
173 | callback: value => this.formatTick(graphId, value) | 236 | callback: value => this.formatYTick({ graphId, value }) |
174 | } | 237 | } |
175 | } | 238 | } |
176 | }, | 239 | }, |
@@ -181,15 +244,18 @@ export class VideoStatsComponent implements OnInit { | |||
181 | }, | 244 | }, |
182 | tooltip: { | 245 | tooltip: { |
183 | callbacks: { | 246 | callbacks: { |
184 | label: value => this.formatTick(graphId, value.raw as number | string) | 247 | title: items => this.formatTooltipTitle({ graphId, items }), |
248 | label: value => this.formatYTick({ graphId, value: value.raw as number | string }) | ||
185 | } | 249 | } |
186 | } | 250 | }, |
251 | |||
252 | ...plugins | ||
187 | } | 253 | } |
188 | } | 254 | } |
189 | } | 255 | } |
190 | } | 256 | } |
191 | 257 | ||
192 | private buildRetentionChartOptions (rawData: VideoStatsRetention) { | 258 | private buildRetentionChartOptions (rawData: VideoStatsRetention): ChartBuilderResult { |
193 | const labels: string[] = [] | 259 | const labels: string[] = [] |
194 | const data: number[] = [] | 260 | const data: number[] = [] |
195 | 261 | ||
@@ -203,6 +269,10 @@ export class VideoStatsComponent implements OnInit { | |||
203 | 269 | ||
204 | displayLegend: false, | 270 | displayLegend: false, |
205 | 271 | ||
272 | plugins: { | ||
273 | ...this.buildDisabledZoomPlugin() | ||
274 | }, | ||
275 | |||
206 | data: { | 276 | data: { |
207 | labels, | 277 | labels, |
208 | datasets: [ | 278 | datasets: [ |
@@ -215,12 +285,12 @@ export class VideoStatsComponent implements OnInit { | |||
215 | } | 285 | } |
216 | } | 286 | } |
217 | 287 | ||
218 | private buildTimeserieChartOptions (rawData: VideoStatsTimeserie) { | 288 | private buildTimeserieChartOptions (rawData: VideoStatsTimeserie): ChartBuilderResult { |
219 | const labels: string[] = [] | 289 | const labels: string[] = [] |
220 | const data: number[] = [] | 290 | const data: number[] = [] |
221 | 291 | ||
222 | for (const d of rawData.data) { | 292 | for (const d of rawData.data) { |
223 | labels.push(new Date(d.date).toLocaleDateString()) | 293 | labels.push(d.date) |
224 | data.push(d.value) | 294 | data.push(d.value) |
225 | } | 295 | } |
226 | 296 | ||
@@ -229,6 +299,31 @@ export class VideoStatsComponent implements OnInit { | |||
229 | 299 | ||
230 | displayLegend: false, | 300 | displayLegend: false, |
231 | 301 | ||
302 | plugins: { | ||
303 | zoom: { | ||
304 | zoom: { | ||
305 | wheel: { | ||
306 | enabled: false | ||
307 | }, | ||
308 | drag: { | ||
309 | enabled: true | ||
310 | }, | ||
311 | pinch: { | ||
312 | enabled: true | ||
313 | }, | ||
314 | mode: 'x', | ||
315 | onZoomComplete: ({ chart }) => { | ||
316 | const { min, max } = chart.scales.x | ||
317 | |||
318 | const startDate = rawData.data[min].date | ||
319 | const endDate = rawData.data[max].date | ||
320 | |||
321 | this.peertubeRouter.silentNavigate([], { startDate, endDate }) | ||
322 | } | ||
323 | } | ||
324 | } | ||
325 | }, | ||
326 | |||
232 | data: { | 327 | data: { |
233 | labels, | 328 | labels, |
234 | datasets: [ | 329 | datasets: [ |
@@ -241,7 +336,7 @@ export class VideoStatsComponent implements OnInit { | |||
241 | } | 336 | } |
242 | } | 337 | } |
243 | 338 | ||
244 | private buildCountryChartOptions (rawData: CountryData) { | 339 | private buildCountryChartOptions (rawData: CountryData): ChartBuilderResult { |
245 | const labels: string[] = [] | 340 | const labels: string[] = [] |
246 | const data: number[] = [] | 341 | const data: number[] = [] |
247 | 342 | ||
@@ -255,8 +350,8 @@ export class VideoStatsComponent implements OnInit { | |||
255 | 350 | ||
256 | displayLegend: true, | 351 | displayLegend: true, |
257 | 352 | ||
258 | options: { | 353 | plugins: { |
259 | indexAxis: 'y' | 354 | ...this.buildDisabledZoomPlugin() |
260 | }, | 355 | }, |
261 | 356 | ||
262 | data: { | 357 | data: { |
@@ -277,13 +372,57 @@ export class VideoStatsComponent implements OnInit { | |||
277 | return getComputedStyle(document.body).getPropertyValue('--mainColorLighter') | 372 | return getComputedStyle(document.body).getPropertyValue('--mainColorLighter') |
278 | } | 373 | } |
279 | 374 | ||
280 | private formatTick (graphId: ActiveGraphId, value: number | string) { | 375 | private formatXTick (options: { |
376 | graphId: ActiveGraphId | ||
377 | value: number | string | ||
378 | data: VideoStatsTimeserie | ||
379 | scale: Scale | ||
380 | }) { | ||
381 | const { graphId, value, data, scale } = options | ||
382 | |||
383 | const label = scale.getLabelForValue(value as number) | ||
384 | |||
385 | if (!this.isTimeserieGraph(graphId)) { | ||
386 | return label | ||
387 | } | ||
388 | |||
389 | const date = new Date(label) | ||
390 | |||
391 | if (data.groupInterval.match(/ days?$/)) { | ||
392 | return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' }) | ||
393 | } | ||
394 | |||
395 | if (data.groupInterval.match(/ hours?$/)) { | ||
396 | return date.toLocaleTimeString([], { month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric' }) | ||
397 | } | ||
398 | |||
399 | return date.toLocaleTimeString([], { hour: 'numeric', minute: 'numeric' }) | ||
400 | } | ||
401 | |||
402 | private formatYTick (options: { | ||
403 | graphId: ActiveGraphId | ||
404 | value: number | string | ||
405 | }) { | ||
406 | const { graphId, value } = options | ||
407 | |||
281 | if (graphId === 'retention') return value + ' %' | 408 | if (graphId === 'retention') return value + ' %' |
282 | if (graphId === 'aggregateWatchTime') return secondsToTime(+value) | 409 | if (graphId === 'aggregateWatchTime') return secondsToTime(+value) |
283 | 410 | ||
284 | return value.toLocaleString() | 411 | return value.toLocaleString() |
285 | } | 412 | } |
286 | 413 | ||
414 | private formatTooltipTitle (options: { | ||
415 | graphId: ActiveGraphId | ||
416 | items: TooltipItem<any>[] | ||
417 | }) { | ||
418 | const { graphId, items } = options | ||
419 | const item = items[0] | ||
420 | |||
421 | if (this.isTimeserieGraph(graphId)) return new Date(item.label).toLocaleString() | ||
422 | |||
423 | return item.label | ||
424 | } | ||
425 | |||
287 | private countryCodeToName (code: string) { | 426 | private countryCodeToName (code: string) { |
288 | const intl: any = Intl | 427 | const intl: any = Intl |
289 | if (!intl.DisplayNames) return code | 428 | if (!intl.DisplayNames) return code |
@@ -292,4 +431,22 @@ export class VideoStatsComponent implements OnInit { | |||
292 | 431 | ||
293 | return regionNames.of(code) | 432 | return regionNames.of(code) |
294 | } | 433 | } |
434 | |||
435 | private buildDisabledZoomPlugin () { | ||
436 | return { | ||
437 | zoom: { | ||
438 | zoom: { | ||
439 | wheel: { | ||
440 | enabled: false | ||
441 | }, | ||
442 | drag: { | ||
443 | enabled: false | ||
444 | }, | ||
445 | pinch: { | ||
446 | enabled: false | ||
447 | } | ||
448 | } | ||
449 | } | ||
450 | } | ||
451 | } | ||
295 | } | 452 | } |
diff --git a/client/src/app/+stats/video/video-stats.service.ts b/client/src/app/+stats/video/video-stats.service.ts index 8f9d48f60..712d03971 100644 --- a/client/src/app/+stats/video/video-stats.service.ts +++ b/client/src/app/+stats/video/video-stats.service.ts | |||
@@ -1,6 +1,6 @@ | |||
1 | import { catchError } from 'rxjs' | 1 | import { catchError } from 'rxjs' |
2 | import { environment } from 'src/environments/environment' | 2 | import { environment } from 'src/environments/environment' |
3 | import { HttpClient } from '@angular/common/http' | 3 | import { HttpClient, HttpParams } from '@angular/common/http' |
4 | import { Injectable } from '@angular/core' | 4 | import { Injectable } from '@angular/core' |
5 | import { RestExtractor } from '@app/core' | 5 | import { RestExtractor } from '@app/core' |
6 | import { VideoService } from '@app/shared/shared-main' | 6 | import { VideoService } from '@app/shared/shared-main' |
@@ -22,8 +22,19 @@ export class VideoStatsService { | |||
22 | .pipe(catchError(err => this.restExtractor.handleError(err))) | 22 | .pipe(catchError(err => this.restExtractor.handleError(err))) |
23 | } | 23 | } |
24 | 24 | ||
25 | getTimeserieStats (videoId: string, metric: VideoStatsTimeserieMetric) { | 25 | getTimeserieStats (options: { |
26 | return this.authHttp.get<VideoStatsTimeserie>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/timeseries/' + metric) | 26 | videoId: string |
27 | metric: VideoStatsTimeserieMetric | ||
28 | startDate?: Date | ||
29 | endDate?: Date | ||
30 | }) { | ||
31 | const { videoId, metric, startDate, endDate } = options | ||
32 | |||
33 | let params = new HttpParams() | ||
34 | if (startDate) params = params.append('startDate', startDate.toISOString()) | ||
35 | if (endDate) params = params.append('endDate', endDate.toISOString()) | ||
36 | |||
37 | return this.authHttp.get<VideoStatsTimeserie>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/timeseries/' + metric, { params }) | ||
27 | .pipe(catchError(err => this.restExtractor.handleError(err))) | 38 | .pipe(catchError(err => this.restExtractor.handleError(err))) |
28 | } | 39 | } |
29 | 40 | ||
diff --git a/client/yarn.lock b/client/yarn.lock index 5c6e9f8b9..800c226c2 100644 --- a/client/yarn.lock +++ b/client/yarn.lock | |||
@@ -3792,6 +3792,13 @@ chart.js@^3.5.1: | |||
3792 | resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-3.7.1.tgz#0516f690c6a8680c6c707e31a4c1807a6f400ada" | 3792 | resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-3.7.1.tgz#0516f690c6a8680c6c707e31a4c1807a6f400ada" |
3793 | integrity sha512-8knRegQLFnPQAheZV8MjxIXc5gQEfDFD897BJgv/klO/vtIyFFmgMXrNfgrXpbTr/XbTturxRgxIXx/Y+ASJBA== | 3793 | integrity sha512-8knRegQLFnPQAheZV8MjxIXc5gQEfDFD897BJgv/klO/vtIyFFmgMXrNfgrXpbTr/XbTturxRgxIXx/Y+ASJBA== |
3794 | 3794 | ||
3795 | chartjs-plugin-zoom@^1.2.1: | ||
3796 | version "1.2.1" | ||
3797 | resolved "https://registry.yarnpkg.com/chartjs-plugin-zoom/-/chartjs-plugin-zoom-1.2.1.tgz#7e350ba20d907f397d0c055239dcc67d326df705" | ||
3798 | integrity sha512-2zbWvw2pljrtMLMXkKw1uxYzAne5PtjJiOZftcut4Lo3Ee8qUt95RpMKDWrZ+pBZxZKQKOD/etdU4pN2jxZUmg== | ||
3799 | dependencies: | ||
3800 | hammerjs "^2.0.8" | ||
3801 | |||
3795 | chokidar@3.5.3, "chokidar@>=3.0.0 <4.0.0", chokidar@^3.0.0, chokidar@^3.5.2: | 3802 | chokidar@3.5.3, "chokidar@>=3.0.0 <4.0.0", chokidar@^3.0.0, chokidar@^3.5.2: |
3796 | version "3.5.3" | 3803 | version "3.5.3" |
3797 | resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" | 3804 | resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" |
@@ -5961,6 +5968,11 @@ gzip-size@^6.0.0: | |||
5961 | dependencies: | 5968 | dependencies: |
5962 | duplexer "^0.1.2" | 5969 | duplexer "^0.1.2" |
5963 | 5970 | ||
5971 | hammerjs@^2.0.8: | ||
5972 | version "2.0.8" | ||
5973 | resolved "https://registry.yarnpkg.com/hammerjs/-/hammerjs-2.0.8.tgz#04ef77862cff2bb79d30f7692095930222bf60f1" | ||
5974 | integrity sha1-BO93hiz/K7edMPdpIJWTAiK/YPE= | ||
5975 | |||
5964 | handle-thing@^2.0.0: | 5976 | handle-thing@^2.0.0: |
5965 | version "2.0.1" | 5977 | version "2.0.1" |
5966 | resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e" | 5978 | resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e" |
diff --git a/server/lib/timeserie.ts b/server/lib/timeserie.ts index d8f700a2f..bd3d1c1ca 100644 --- a/server/lib/timeserie.ts +++ b/server/lib/timeserie.ts | |||
@@ -1,24 +1,17 @@ | |||
1 | import { logger } from '@server/helpers/logger' | 1 | import { logger } from '@server/helpers/logger' |
2 | import { VideoStatsTimeserieGroupInterval } from '@shared/models' | ||
3 | 2 | ||
4 | function buildGroupByAndBoundaries (startDateString: string, endDateString: string) { | 3 | function buildGroupByAndBoundaries (startDateString: string, endDateString: string) { |
5 | const startDate = new Date(startDateString) | 4 | const startDate = new Date(startDateString) |
6 | const endDate = new Date(endDateString) | 5 | const endDate = new Date(endDateString) |
7 | 6 | ||
8 | const groupByMatrix: { [ id in VideoStatsTimeserieGroupInterval ]: string } = { | ||
9 | one_day: '1 day', | ||
10 | one_hour: '1 hour', | ||
11 | ten_minutes: '10 minutes', | ||
12 | one_minute: '1 minute' | ||
13 | } | ||
14 | const groupInterval = buildGroupInterval(startDate, endDate) | 7 | const groupInterval = buildGroupInterval(startDate, endDate) |
15 | 8 | ||
16 | logger.debug('Found "%s" group interval.', groupInterval, { startDate, endDate }) | 9 | logger.debug('Found "%s" group interval.', groupInterval, { startDate, endDate }) |
17 | 10 | ||
18 | // Remove parts of the date we don't need | 11 | // Remove parts of the date we don't need |
19 | if (groupInterval === 'one_day') { | 12 | if (groupInterval.endsWith(' day') || groupInterval.endsWith(' days')) { |
20 | startDate.setHours(0, 0, 0, 0) | 13 | startDate.setHours(0, 0, 0, 0) |
21 | } else if (groupInterval === 'one_hour') { | 14 | } else if (groupInterval.endsWith(' hour') || groupInterval.endsWith(' hours')) { |
22 | startDate.setMinutes(0, 0, 0) | 15 | startDate.setMinutes(0, 0, 0) |
23 | } else { | 16 | } else { |
24 | startDate.setSeconds(0, 0) | 17 | startDate.setSeconds(0, 0) |
@@ -26,7 +19,6 @@ function buildGroupByAndBoundaries (startDateString: string, endDateString: stri | |||
26 | 19 | ||
27 | return { | 20 | return { |
28 | groupInterval, | 21 | groupInterval, |
29 | sqlInterval: groupByMatrix[groupInterval], | ||
30 | startDate, | 22 | startDate, |
31 | endDate | 23 | endDate |
32 | } | 24 | } |
@@ -40,16 +32,18 @@ export { | |||
40 | 32 | ||
41 | // --------------------------------------------------------------------------- | 33 | // --------------------------------------------------------------------------- |
42 | 34 | ||
43 | function buildGroupInterval (startDate: Date, endDate: Date): VideoStatsTimeserieGroupInterval { | 35 | function buildGroupInterval (startDate: Date, endDate: Date): string { |
44 | const aDay = 86400 | 36 | const aDay = 86400 |
45 | const anHour = 3600 | 37 | const anHour = 3600 |
46 | const aMinute = 60 | 38 | const aMinute = 60 |
47 | 39 | ||
48 | const diffSeconds = (endDate.getTime() - startDate.getTime()) / 1000 | 40 | const diffSeconds = (endDate.getTime() - startDate.getTime()) / 1000 |
49 | 41 | ||
50 | if (diffSeconds >= 6 * aDay) return 'one_day' | 42 | if (diffSeconds >= 15 * aDay) return '1 day' |
51 | if (diffSeconds >= 6 * anHour) return 'one_hour' | 43 | if (diffSeconds >= 8 * aDay) return '12 hours' |
52 | if (diffSeconds >= 60 * aMinute) return 'ten_minutes' | 44 | if (diffSeconds >= 4 * aDay) return '6 hours' |
45 | if (diffSeconds >= 15 * anHour) return '1 hour' | ||
46 | if (diffSeconds >= 180 * aMinute) return '10 minutes' | ||
53 | 47 | ||
54 | return 'one_minute' | 48 | return '1 minute' |
55 | } | 49 | } |
diff --git a/server/models/view/local-video-viewer.ts b/server/models/view/local-video-viewer.ts index ad2ad35ca..b6ddcbb57 100644 --- a/server/models/view/local-video-viewer.ts +++ b/server/models/view/local-video-viewer.ts | |||
@@ -221,7 +221,7 @@ export class LocalVideoViewerModel extends Model<Partial<AttributesOnly<LocalVid | |||
221 | }): Promise<VideoStatsTimeserie> { | 221 | }): Promise<VideoStatsTimeserie> { |
222 | const { video, metric } = options | 222 | const { video, metric } = options |
223 | 223 | ||
224 | const { groupInterval, sqlInterval, startDate, endDate } = buildGroupByAndBoundaries(options.startDate, options.endDate) | 224 | const { groupInterval, startDate, endDate } = buildGroupByAndBoundaries(options.startDate, options.endDate) |
225 | 225 | ||
226 | const selectMetrics: { [ id in VideoStatsTimeserieMetric ]: string } = { | 226 | const selectMetrics: { [ id in VideoStatsTimeserieMetric ]: string } = { |
227 | viewers: 'COUNT("localVideoViewer"."id")', | 227 | viewers: 'COUNT("localVideoViewer"."id")', |
@@ -230,9 +230,9 @@ export class LocalVideoViewerModel extends Model<Partial<AttributesOnly<LocalVid | |||
230 | 230 | ||
231 | const query = `WITH "intervals" AS ( | 231 | const query = `WITH "intervals" AS ( |
232 | SELECT | 232 | SELECT |
233 | "time" AS "startDate", "time" + :sqlInterval::interval as "endDate" | 233 | "time" AS "startDate", "time" + :groupInterval::interval as "endDate" |
234 | FROM | 234 | FROM |
235 | generate_series(:startDate::timestamptz, :endDate::timestamptz, :sqlInterval::interval) serie("time") | 235 | generate_series(:startDate::timestamptz, :endDate::timestamptz, :groupInterval::interval) serie("time") |
236 | ) | 236 | ) |
237 | SELECT "intervals"."startDate" as "date", COALESCE(${selectMetrics[metric]}, 0) AS value | 237 | SELECT "intervals"."startDate" as "date", COALESCE(${selectMetrics[metric]}, 0) AS value |
238 | FROM | 238 | FROM |
@@ -249,7 +249,7 @@ export class LocalVideoViewerModel extends Model<Partial<AttributesOnly<LocalVid | |||
249 | replacements: { | 249 | replacements: { |
250 | startDate, | 250 | startDate, |
251 | endDate, | 251 | endDate, |
252 | sqlInterval, | 252 | groupInterval, |
253 | videoId: video.id | 253 | videoId: video.id |
254 | } | 254 | } |
255 | } | 255 | } |
diff --git a/server/tests/api/views/video-views-timeserie-stats.ts b/server/tests/api/views/video-views-timeserie-stats.ts index 4db76fe89..fd3aba188 100644 --- a/server/tests/api/views/video-views-timeserie-stats.ts +++ b/server/tests/api/views/video-views-timeserie-stats.ts | |||
@@ -110,21 +110,21 @@ describe('Test views timeserie stats', function () { | |||
110 | 110 | ||
111 | it('Should use a custom start/end date', async function () { | 111 | it('Should use a custom start/end date', async function () { |
112 | const now = new Date() | 112 | const now = new Date() |
113 | const tenDaysAgo = new Date() | 113 | const twentyDaysAgo = new Date() |
114 | tenDaysAgo.setDate(tenDaysAgo.getDate() - 9) | 114 | twentyDaysAgo.setDate(twentyDaysAgo.getDate() - 19) |
115 | 115 | ||
116 | const result = await servers[0].videoStats.getTimeserieStats({ | 116 | const result = await servers[0].videoStats.getTimeserieStats({ |
117 | videoId: vodVideoId, | 117 | videoId: vodVideoId, |
118 | metric: 'aggregateWatchTime', | 118 | metric: 'aggregateWatchTime', |
119 | startDate: tenDaysAgo, | 119 | startDate: twentyDaysAgo, |
120 | endDate: now | 120 | endDate: now |
121 | }) | 121 | }) |
122 | 122 | ||
123 | expect(result.groupInterval).to.equal('one_day') | 123 | expect(result.groupInterval).to.equal('1 day') |
124 | expect(result.data).to.have.lengthOf(10) | 124 | expect(result.data).to.have.lengthOf(20) |
125 | 125 | ||
126 | const first = result.data[0] | 126 | const first = result.data[0] |
127 | expect(new Date(first.date).toLocaleDateString()).to.equal(tenDaysAgo.toLocaleDateString()) | 127 | expect(new Date(first.date).toLocaleDateString()).to.equal(twentyDaysAgo.toLocaleDateString()) |
128 | 128 | ||
129 | expectInterval(result, 24 * 3600 * 1000) | 129 | expectInterval(result, 24 * 3600 * 1000) |
130 | expectTodayLastValue(result, 9) | 130 | expectTodayLastValue(result, 9) |
@@ -142,7 +142,7 @@ describe('Test views timeserie stats', function () { | |||
142 | endDate: now | 142 | endDate: now |
143 | }) | 143 | }) |
144 | 144 | ||
145 | expect(result.groupInterval).to.equal('one_hour') | 145 | expect(result.groupInterval).to.equal('1 hour') |
146 | expect(result.data).to.have.length.above(24).and.below(50) | 146 | expect(result.data).to.have.length.above(24).and.below(50) |
147 | 147 | ||
148 | expectInterval(result, 3600 * 1000) | 148 | expectInterval(result, 3600 * 1000) |
@@ -152,7 +152,7 @@ describe('Test views timeserie stats', function () { | |||
152 | it('Should automatically group by ten minutes', async function () { | 152 | it('Should automatically group by ten minutes', async function () { |
153 | const now = new Date() | 153 | const now = new Date() |
154 | const twoHoursAgo = new Date() | 154 | const twoHoursAgo = new Date() |
155 | twoHoursAgo.setHours(twoHoursAgo.getHours() - 1) | 155 | twoHoursAgo.setHours(twoHoursAgo.getHours() - 4) |
156 | 156 | ||
157 | const result = await servers[0].videoStats.getTimeserieStats({ | 157 | const result = await servers[0].videoStats.getTimeserieStats({ |
158 | videoId: vodVideoId, | 158 | videoId: vodVideoId, |
@@ -161,8 +161,8 @@ describe('Test views timeserie stats', function () { | |||
161 | endDate: now | 161 | endDate: now |
162 | }) | 162 | }) |
163 | 163 | ||
164 | expect(result.groupInterval).to.equal('ten_minutes') | 164 | expect(result.groupInterval).to.equal('10 minutes') |
165 | expect(result.data).to.have.length.above(6).and.below(18) | 165 | expect(result.data).to.have.length.above(20).and.below(30) |
166 | 166 | ||
167 | expectInterval(result, 60 * 10 * 1000) | 167 | expectInterval(result, 60 * 10 * 1000) |
168 | expectTodayLastValue(result, 9) | 168 | expectTodayLastValue(result, 9) |
@@ -180,7 +180,7 @@ describe('Test views timeserie stats', function () { | |||
180 | endDate: now | 180 | endDate: now |
181 | }) | 181 | }) |
182 | 182 | ||
183 | expect(result.groupInterval).to.equal('one_minute') | 183 | expect(result.groupInterval).to.equal('1 minute') |
184 | expect(result.data).to.have.length.above(20).and.below(40) | 184 | expect(result.data).to.have.length.above(20).and.below(40) |
185 | 185 | ||
186 | expectInterval(result, 60 * 1000) | 186 | expectInterval(result, 60 * 1000) |
diff --git a/shared/models/videos/stats/index.ts b/shared/models/videos/stats/index.ts index 5c4c9df2a..4a6fdaa71 100644 --- a/shared/models/videos/stats/index.ts +++ b/shared/models/videos/stats/index.ts | |||
@@ -1,6 +1,5 @@ | |||
1 | export * from './video-stats-overall.model' | 1 | export * from './video-stats-overall.model' |
2 | export * from './video-stats-retention.model' | 2 | export * from './video-stats-retention.model' |
3 | export * from './video-stats-timeserie-group-interval.type' | ||
4 | export * from './video-stats-timeserie-query.model' | 3 | export * from './video-stats-timeserie-query.model' |
5 | export * from './video-stats-timeserie-metric.type' | 4 | export * from './video-stats-timeserie-metric.type' |
6 | export * from './video-stats-timeserie.model' | 5 | export * from './video-stats-timeserie.model' |
diff --git a/shared/models/videos/stats/video-stats-timeserie-group-interval.type.ts b/shared/models/videos/stats/video-stats-timeserie-group-interval.type.ts deleted file mode 100644 index 9609ecb72..000000000 --- a/shared/models/videos/stats/video-stats-timeserie-group-interval.type.ts +++ /dev/null | |||
@@ -1 +0,0 @@ | |||
1 | export type VideoStatsTimeserieGroupInterval = 'one_day' | 'one_hour' | 'ten_minutes' | 'one_minute' | ||
diff --git a/shared/models/videos/stats/video-stats-timeserie.model.ts b/shared/models/videos/stats/video-stats-timeserie.model.ts index 99bbbe2e3..4a0e208df 100644 --- a/shared/models/videos/stats/video-stats-timeserie.model.ts +++ b/shared/models/videos/stats/video-stats-timeserie.model.ts | |||
@@ -1,7 +1,5 @@ | |||
1 | import { VideoStatsTimeserieGroupInterval } from './video-stats-timeserie-group-interval.type' | ||
2 | |||
3 | export interface VideoStatsTimeserie { | 1 | export interface VideoStatsTimeserie { |
4 | groupInterval: VideoStatsTimeserieGroupInterval | 2 | groupInterval: string |
5 | 3 | ||
6 | data: { | 4 | data: { |
7 | date: string | 5 | date: string |