aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2022-04-08 10:22:56 +0200
committerChocobozzz <chocobozzz@cpy.re>2022-04-15 09:49:35 +0200
commit3eda9b775ae700ac544e8c5588514627796b83cd (patch)
tree97ec1fdfce274e83d976352f5b4154c315ee33d7
parent901bcf5c188ea79350fecd499ad76460b866617b (diff)
downloadPeerTube-3eda9b775ae700ac544e8c5588514627796b83cd.tar.gz
PeerTube-3eda9b775ae700ac544e8c5588514627796b83cd.tar.zst
PeerTube-3eda9b775ae700ac544e8c5588514627796b83cd.zip
Support interactive video stats graph
-rw-r--r--client/package.json1
-rw-r--r--client/src/app/+stats/video/video-stats.component.html9
-rw-r--r--client/src/app/+stats/video/video-stats.component.scss26
-rw-r--r--client/src/app/+stats/video/video-stats.component.ts207
-rw-r--r--client/src/app/+stats/video/video-stats.service.ts17
-rw-r--r--client/yarn.lock12
-rw-r--r--server/lib/timeserie.ts24
-rw-r--r--server/models/view/local-video-viewer.ts8
-rw-r--r--server/tests/api/views/video-views-timeserie-stats.ts22
-rw-r--r--shared/models/videos/stats/index.ts1
-rw-r--r--shared/models/videos/stats/video-stats-timeserie-group-interval.type.ts1
-rw-r--r--shared/models/videos/stats/video-stats-timeserie.model.ts4
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
42my-embed { 49my-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 @@
1import { ChartConfiguration, ChartData } from 'chart.js' 1import { ChartConfiguration, ChartData, PluginOptionsByType, Scale, TooltipItem } from 'chart.js'
2import zoomPlugin from 'chartjs-plugin-zoom'
2import { Observable, of } from 'rxjs' 3import { Observable, of } from 'rxjs'
3import { Component, OnInit } from '@angular/core' 4import { Component, OnInit } from '@angular/core'
4import { ActivatedRoute } from '@angular/router' 5import { ActivatedRoute } from '@angular/router'
5import { Notifier } from '@app/core' 6import { Notifier, PeerTubeRouterService } from '@app/core'
6import { NumberFormatterPipe, VideoDetails } from '@app/shared/shared-main' 7import { NumberFormatterPipe, VideoDetails } from '@app/shared/shared-main'
7import { secondsToTime } from '@shared/core-utils' 8import { secondsToTime } from '@shared/core-utils'
8import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric } from '@shared/models/videos' 9import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric } from '@shared/models/videos'
@@ -15,6 +16,7 @@ type CountryData = { name: string, viewers: number }[]
15type ChartIngestData = VideoStatsTimeserie | VideoStatsRetention | CountryData 16type ChartIngestData = VideoStatsTimeserie | VideoStatsRetention | CountryData
16type ChartBuilderResult = { 17type 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 @@
1import { catchError } from 'rxjs' 1import { catchError } from 'rxjs'
2import { environment } from 'src/environments/environment' 2import { environment } from 'src/environments/environment'
3import { HttpClient } from '@angular/common/http' 3import { HttpClient, HttpParams } from '@angular/common/http'
4import { Injectable } from '@angular/core' 4import { Injectable } from '@angular/core'
5import { RestExtractor } from '@app/core' 5import { RestExtractor } from '@app/core'
6import { VideoService } from '@app/shared/shared-main' 6import { 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
3795chartjs-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
3795chokidar@3.5.3, "chokidar@>=3.0.0 <4.0.0", chokidar@^3.0.0, chokidar@^3.5.2: 3802chokidar@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
5971hammerjs@^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
5964handle-thing@^2.0.0: 5976handle-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 @@
1import { logger } from '@server/helpers/logger' 1import { logger } from '@server/helpers/logger'
2import { VideoStatsTimeserieGroupInterval } from '@shared/models'
3 2
4function buildGroupByAndBoundaries (startDateString: string, endDateString: string) { 3function 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
43function buildGroupInterval (startDate: Date, endDate: Date): VideoStatsTimeserieGroupInterval { 35function 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 @@
1export * from './video-stats-overall.model' 1export * from './video-stats-overall.model'
2export * from './video-stats-retention.model' 2export * from './video-stats-retention.model'
3export * from './video-stats-timeserie-group-interval.type'
4export * from './video-stats-timeserie-query.model' 3export * from './video-stats-timeserie-query.model'
5export * from './video-stats-timeserie-metric.type' 4export * from './video-stats-timeserie-metric.type'
6export * from './video-stats-timeserie.model' 5export * 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 @@
1export 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 @@
1import { VideoStatsTimeserieGroupInterval } from './video-stats-timeserie-group-interval.type'
2
3export interface VideoStatsTimeserie { 1export interface VideoStatsTimeserie {
4 groupInterval: VideoStatsTimeserieGroupInterval 2 groupInterval: string
5 3
6 data: { 4 data: {
7 date: string 5 date: string