aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2022-05-06 14:23:02 +0200
committerChocobozzz <me@florianbigard.com>2022-05-06 14:23:02 +0200
commitf40712abbbb74e51f06037ef02757c42736bccf8 (patch)
tree4b130c6387f9687d52d570907eb1bbac6bc04b61
parent49f0468d44468528c2fb2c8b0efd19cdaeeec43d (diff)
downloadPeerTube-f40712abbbb74e51f06037ef02757c42736bccf8.tar.gz
PeerTube-f40712abbbb74e51f06037ef02757c42736bccf8.tar.zst
PeerTube-f40712abbbb74e51f06037ef02757c42736bccf8.zip
Add ability to filter overall video stats by date
-rw-r--r--client/src/app/+stats/stats-routing.module.ts2
-rw-r--r--client/src/app/+stats/stats.module.ts4
-rw-r--r--client/src/app/+stats/video/video-stats.component.html78
-rw-r--r--client/src/app/+stats/video/video-stats.component.scss34
-rw-r--r--client/src/app/+stats/video/video-stats.component.ts212
-rw-r--r--client/src/app/+stats/video/video-stats.service.ts14
-rw-r--r--client/src/app/+video-studio/video-studio-routing.module.ts2
-rw-r--r--client/src/app/shared/shared-video-live/live-video.service.ts7
-rw-r--r--server/controllers/api/videos/stats.ts11
-rw-r--r--server/initializers/constants.ts2
-rw-r--r--server/lib/timeserie.ts14
-rw-r--r--server/models/view/local-video-viewer.ts12
-rw-r--r--server/tests/api/check-params/views.ts2
-rw-r--r--server/tests/api/views/video-views-overall-stats.ts26
-rw-r--r--server/tests/api/views/video-views-timeserie-stats.ts74
15 files changed, 385 insertions, 109 deletions
diff --git a/client/src/app/+stats/stats-routing.module.ts b/client/src/app/+stats/stats-routing.module.ts
index 59519a703..b6225cafd 100644
--- a/client/src/app/+stats/stats-routing.module.ts
+++ b/client/src/app/+stats/stats-routing.module.ts
@@ -1,11 +1,13 @@
1import { NgModule } from '@angular/core' 1import { NgModule } from '@angular/core'
2import { RouterModule, Routes } from '@angular/router' 2import { RouterModule, Routes } from '@angular/router'
3import { LoginGuard } from '@app/core'
3import { VideoResolver } from '@app/shared/shared-main' 4import { VideoResolver } from '@app/shared/shared-main'
4import { VideoStatsComponent } from './video' 5import { VideoStatsComponent } from './video'
5 6
6const statsRoutes: Routes = [ 7const statsRoutes: Routes = [
7 { 8 {
8 path: 'videos/:videoId', 9 path: 'videos/:videoId',
10 canActivate: [ LoginGuard ],
9 component: VideoStatsComponent, 11 component: VideoStatsComponent,
10 data: { 12 data: {
11 meta: { 13 meta: {
diff --git a/client/src/app/+stats/stats.module.ts b/client/src/app/+stats/stats.module.ts
index 0497576e7..e81378220 100644
--- a/client/src/app/+stats/stats.module.ts
+++ b/client/src/app/+stats/stats.module.ts
@@ -1,7 +1,9 @@
1import { ChartModule } from 'primeng/chart' 1import { ChartModule } from 'primeng/chart'
2import { NgModule } from '@angular/core' 2import { NgModule } from '@angular/core'
3import { SharedFormModule } from '@app/shared/shared-forms'
3import { SharedGlobalIconModule } from '@app/shared/shared-icons' 4import { SharedGlobalIconModule } from '@app/shared/shared-icons'
4import { SharedMainModule } from '@app/shared/shared-main' 5import { SharedMainModule } from '@app/shared/shared-main'
6import { SharedVideoLiveModule } from '@app/shared/shared-video-live'
5import { StatsRoutingModule } from './stats-routing.module' 7import { StatsRoutingModule } from './stats-routing.module'
6import { VideoStatsComponent, VideoStatsService } from './video' 8import { VideoStatsComponent, VideoStatsService } from './video'
7 9
@@ -10,7 +12,9 @@ import { VideoStatsComponent, VideoStatsService } from './video'
10 StatsRoutingModule, 12 StatsRoutingModule,
11 13
12 SharedMainModule, 14 SharedMainModule,
15 SharedFormModule,
13 SharedGlobalIconModule, 16 SharedGlobalIconModule,
17 SharedVideoLiveModule,
14 18
15 ChartModule 19 ChartModule
16 ], 20 ],
diff --git a/client/src/app/+stats/video/video-stats.component.html b/client/src/app/+stats/video/video-stats.component.html
index 400c049eb..e5412f1b8 100644
--- a/client/src/app/+stats/video/video-stats.component.html
+++ b/client/src/app/+stats/video/video-stats.component.html
@@ -1,9 +1,9 @@
1<div class="margin-content"> 1<div class="margin-content">
2 <h1 class="title-page title-page-single" i18n>Stats for {{ video.name }}</h1> 2 <h1 class="title-page title-page-single" i18n>{{ video.name }}</h1>
3 3
4 <div class="overall-stats-embed"> 4 <div class="stats-embed">
5 <div class="overall-stats"> 5 <div class="global-stats">
6 <div *ngFor="let card of overallStatCards" class="card overall-stats-card"> 6 <div *ngFor="let card of globalStatsCards" class="card stats-card">
7 <div class="label">{{ card.label }}</div> 7 <div class="label">{{ card.label }}</div>
8 <div class="value">{{ card.value }}</div> 8 <div class="value">{{ card.value }}</div>
9 <div *ngIf="card.moreInfo" class="more-info">{{ card.moreInfo }}</div> 9 <div *ngIf="card.moreInfo" class="more-info">{{ card.moreInfo }}</div>
@@ -13,33 +13,51 @@
13 <my-embed [video]="video"></my-embed> 13 <my-embed [video]="video"></my-embed>
14 </div> 14 </div>
15 15
16 <div class="timeserie"> 16 <div class="stats-with-date">
17 <div ngbNav #nav="ngbNav" [activeId]="activeGraphId" (activeIdChange)="onChartChange($event)" class="nav-tabs"> 17 <div class="overall-stats">
18 18 <div class="date-filter-wrapper">
19 <ng-container *ngFor="let availableChart of availableCharts" [ngbNavItem]="availableChart.id"> 19 <h2>{{ getViewersStatsTitle() }}</h2>
20 <a ngbNavLink i18n> 20
21 <span>{{ availableChart.label }}</span> 21 <my-select-options [(ngModel)]="currentDateFilter" (ngModelChange)="onDateFilterChange()" [items]="dateFilters"></my-select-options>
22 </a> 22 </div>
23 23
24 <ng-template ngbNavContent> 24 <div class="cards">
25 <div class="chart-container" [ngStyle]="{ 'min-height': chartHeight }"> 25 <div *ngFor="let card of overallStatCards" class="card stats-card">
26 <p-chart 26 <div class="label">{{ card.label }}</div>
27 *ngIf="chartOptions[availableChart.id]" 27 <div class="value">{{ card.value }}</div>
28 [height]="chartHeight" [width]="chartWidth" 28 <div *ngIf="card.moreInfo" class="more-info">{{ card.moreInfo }}</div>
29 [type]="chartOptions[availableChart.id].type" [options]="chartOptions[availableChart.id].options" [data]="chartOptions[availableChart.id].data" 29 </div>
30 [plugins]="chartPlugins" 30 </div>
31 ></p-chart>
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>
39 </ng-template>
40 </ng-container>
41 </div> 31 </div>
42 32
43 <div [ngbNavOutlet]="nav"></div> 33 <div class="timeserie">
34 <div ngbNav #nav="ngbNav" [activeId]="activeGraphId" (activeIdChange)="onChartChange($event)" class="nav-tabs">
35
36 <ng-container *ngFor="let availableChart of availableCharts" [ngbNavItem]="availableChart.id">
37 <a ngbNavLink i18n>
38 <span>{{ availableChart.label }}</span>
39 </a>
40
41 <ng-template ngbNavContent>
42 <div class="chart-container" [ngStyle]="{ 'min-height': chartHeight }">
43 <p-chart
44 *ngIf="chartOptions[availableChart.id]"
45 [height]="chartHeight" [width]="chartWidth"
46 [type]="chartOptions[availableChart.id].type" [options]="chartOptions[availableChart.id].options" [data]="chartOptions[availableChart.id].data"
47 [plugins]="chartPlugins"
48 ></p-chart>
49 </div>
50
51 <div class="zoom-container">
52 <span *ngIf="!hasZoom() && availableChart.zoomEnabled" i18n class="description">You can select a part of the graph to zoom in</span>
53
54 <my-button i18n *ngIf="hasZoom()" (click)="resetZoom()">Reset zoom</my-button>
55 </div>
56 </ng-template>
57 </ng-container>
58 </div>
59
60 <div [ngbNavOutlet]="nav"></div>
61 </div>
44 </div> 62 </div>
45</div> 63</div>
diff --git a/client/src/app/+stats/video/video-stats.component.scss b/client/src/app/+stats/video/video-stats.component.scss
index e2a74152f..e4c2d899f 100644
--- a/client/src/app/+stats/video/video-stats.component.scss
+++ b/client/src/app/+stats/video/video-stats.component.scss
@@ -2,17 +2,31 @@
2@use '_mixins' as *; 2@use '_mixins' as *;
3@use '_nav' as *; 3@use '_nav' as *;
4 4
5.overall-stats-embed { 5.stats-embed {
6 display: flex; 6 display: flex;
7 justify-content: space-between; 7 justify-content: space-between;
8} 8}
9 9
10.overall-stats { 10.overall-stats,
11.global-stats {
11 display: flex; 12 display: flex;
12 flex-wrap: wrap; 13 flex-wrap: wrap;
14
15 h2 {
16 font-size: 16px;
17 width: 100%;
18 }
19}
20
21.overall-stats {
22 justify-content: space-between;
23
24 .cards {
25 display: flex;
26 }
13} 27}
14 28
15.overall-stats-card { 29.stats-card {
16 display: flex; 30 display: flex;
17 justify-content: center; 31 justify-content: center;
18 align-items: center; 32 align-items: center;
@@ -28,12 +42,6 @@
28 font-size: 14px; 42 font-size: 14px;
29 } 43 }
30 44
31 .label {
32 color: pvar(--greyForegroundColor);
33 font-weight: $font-semibold;
34 opacity: 0.8;
35 }
36
37 .value { 45 .value {
38 font-size: 24px; 46 font-size: 24px;
39 font-weight: $font-semibold; 47 font-weight: $font-semibold;
@@ -52,6 +60,12 @@ my-embed {
52 width: 100%; 60 width: 100%;
53} 61}
54 62
63.stats-with-date {
64 margin-top: 30px;
65 padding-top: 30px;
66 border-top: 1px solid $separator-border-color;
67}
68
55@include on-small-main-col { 69@include on-small-main-col {
56 my-embed { 70 my-embed {
57 display: none; 71 display: none;
@@ -59,7 +73,7 @@ my-embed {
59} 73}
60 74
61.tab-content { 75.tab-content {
62 margin-top: 15px; 76 margin-top: 5px;
63} 77}
64 78
65.nav-tabs { 79.nav-tabs {
diff --git a/client/src/app/+stats/video/video-stats.component.ts b/client/src/app/+stats/video/video-stats.component.ts
index a5435fe23..f433259ef 100644
--- a/client/src/app/+stats/video/video-stats.component.ts
+++ b/client/src/app/+stats/video/video-stats.component.ts
@@ -1,12 +1,21 @@
1import { ChartConfiguration, ChartData, PluginOptionsByType, Scale, TooltipItem } from 'chart.js' 1import { ChartConfiguration, ChartData, PluginOptionsByType, Scale, TooltipItem } from 'chart.js'
2import zoomPlugin from 'chartjs-plugin-zoom' 2import zoomPlugin from 'chartjs-plugin-zoom'
3import { Observable, of } from 'rxjs' 3import { Observable, of } from 'rxjs'
4import { SelectOptionsItem } from 'src/types'
4import { Component, OnInit } from '@angular/core' 5import { Component, OnInit } from '@angular/core'
5import { ActivatedRoute } from '@angular/router' 6import { ActivatedRoute } from '@angular/router'
6import { Notifier, PeerTubeRouterService } from '@app/core' 7import { Notifier, PeerTubeRouterService } from '@app/core'
7import { NumberFormatterPipe, VideoDetails } from '@app/shared/shared-main' 8import { NumberFormatterPipe, VideoDetails } from '@app/shared/shared-main'
9import { LiveVideoService } from '@app/shared/shared-video-live'
8import { secondsToTime } from '@shared/core-utils' 10import { secondsToTime } from '@shared/core-utils'
9import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric } from '@shared/models/videos' 11import { HttpStatusCode } from '@shared/models/http'
12import {
13 LiveVideoSession,
14 VideoStatsOverall,
15 VideoStatsRetention,
16 VideoStatsTimeserie,
17 VideoStatsTimeserieMetric
18} from '@shared/models/videos'
10import { VideoStatsService } from './video-stats.service' 19import { VideoStatsService } from './video-stats.service'
11 20
12type ActiveGraphId = VideoStatsTimeserieMetric | 'retention' | 'countries' 21type ActiveGraphId = VideoStatsTimeserieMetric | 'retention' | 'countries'
@@ -21,41 +30,24 @@ type ChartBuilderResult = {
21 displayLegend: boolean 30 displayLegend: boolean
22} 31}
23 32
33type Card = { label: string, value: string | number, moreInfo?: string }
34
24@Component({ 35@Component({
25 templateUrl: './video-stats.component.html', 36 templateUrl: './video-stats.component.html',
26 styleUrls: [ './video-stats.component.scss' ], 37 styleUrls: [ './video-stats.component.scss' ],
27 providers: [ NumberFormatterPipe ] 38 providers: [ NumberFormatterPipe ]
28}) 39})
29export class VideoStatsComponent implements OnInit { 40export class VideoStatsComponent implements OnInit {
30 overallStatCards: { label: string, value: string | number, moreInfo?: string }[] = [] 41 // Cannot handle date filters
42 globalStatsCards: Card[] = []
43 // Can handle date filters
44 overallStatCards: Card[] = []
31 45
32 chartOptions: { [ id in ActiveGraphId ]?: ChartConfiguration<'line' | 'bar'> } = {} 46 chartOptions: { [ id in ActiveGraphId ]?: ChartConfiguration<'line' | 'bar'> } = {}
33 chartHeight = '300px' 47 chartHeight = '300px'
34 chartWidth: string = null 48 chartWidth: string = null
35 49
36 availableCharts = [ 50 availableCharts: { id: string, label: string, zoomEnabled: boolean }[] = []
37 {
38 id: 'viewers',
39 label: $localize`Viewers`,
40 zoomEnabled: true
41 },
42 {
43 id: 'aggregateWatchTime',
44 label: $localize`Watch time`,
45 zoomEnabled: true
46 },
47 {
48 id: 'retention',
49 label: $localize`Retention`,
50 zoomEnabled: false
51 },
52 {
53 id: 'countries',
54 label: $localize`Countries`,
55 zoomEnabled: false
56 }
57 ]
58
59 activeGraphId: ActiveGraphId = 'viewers' 51 activeGraphId: ActiveGraphId = 'viewers'
60 52
61 video: VideoDetails 53 video: VideoDetails
@@ -64,8 +56,16 @@ export class VideoStatsComponent implements OnInit {
64 56
65 chartPlugins = [ zoomPlugin ] 57 chartPlugins = [ zoomPlugin ]
66 58
67 private timeseriesStartDate: Date 59 currentDateFilter = 'all'
68 private timeseriesEndDate: Date 60 dateFilters: SelectOptionsItem[] = [
61 {
62 id: 'all',
63 label: $localize`Since the video publication`
64 }
65 ]
66
67 private statsStartDate: Date
68 private statsEndDate: Date
69 69
70 private chartIngestData: { [ id in ActiveGraphId ]?: ChartIngestData } = {} 70 private chartIngestData: { [ id in ActiveGraphId ]?: ChartIngestData } = {}
71 71
@@ -74,25 +74,58 @@ export class VideoStatsComponent implements OnInit {
74 private notifier: Notifier, 74 private notifier: Notifier,
75 private statsService: VideoStatsService, 75 private statsService: VideoStatsService,
76 private peertubeRouter: PeerTubeRouterService, 76 private peertubeRouter: PeerTubeRouterService,
77 private numberFormatter: NumberFormatterPipe 77 private numberFormatter: NumberFormatterPipe,
78 private liveService: LiveVideoService
78 ) {} 79 ) {}
79 80
80 ngOnInit () { 81 ngOnInit () {
81 this.video = this.route.snapshot.data.video 82 this.video = this.route.snapshot.data.video
82 83
84 this.availableCharts = [
85 {
86 id: 'viewers',
87 label: $localize`Viewers`,
88 zoomEnabled: true
89 },
90 {
91 id: 'aggregateWatchTime',
92 label: $localize`Watch time`,
93 zoomEnabled: true
94 },
95 {
96 id: 'countries',
97 label: $localize`Countries`,
98 zoomEnabled: false
99 }
100 ]
101
102 if (!this.video.isLive) {
103 this.availableCharts.push({
104 id: 'retention',
105 label: $localize`Retention`,
106 zoomEnabled: false
107 })
108 }
109
110 const snapshotQuery = this.route.snapshot.queryParams
111 if (snapshotQuery.startDate || snapshotQuery.endDate) {
112 this.addAndSelectCustomDateFilter()
113 }
114
83 this.route.queryParams.subscribe(params => { 115 this.route.queryParams.subscribe(params => {
84 this.timeseriesStartDate = params.startDate 116 this.statsStartDate = params.startDate
85 ? new Date(params.startDate) 117 ? new Date(params.startDate)
86 : undefined 118 : undefined
87 119
88 this.timeseriesEndDate = params.endDate 120 this.statsEndDate = params.endDate
89 ? new Date(params.endDate) 121 ? new Date(params.endDate)
90 : undefined 122 : undefined
91 123
92 this.loadChart() 124 this.loadChart()
125 this.loadOverallStats()
93 }) 126 })
94 127
95 this.loadOverallStats() 128 this.loadDateFilters()
96 } 129 }
97 130
98 hasCountries () { 131 hasCountries () {
@@ -107,10 +140,30 @@ export class VideoStatsComponent implements OnInit {
107 140
108 resetZoom () { 141 resetZoom () {
109 this.peertubeRouter.silentNavigate([], {}) 142 this.peertubeRouter.silentNavigate([], {})
143 this.removeAndResetCustomDateFilter()
110 } 144 }
111 145
112 hasZoom () { 146 hasZoom () {
113 return !!this.timeseriesStartDate && this.isTimeserieGraph(this.activeGraphId) 147 return !!this.statsStartDate && this.isTimeserieGraph(this.activeGraphId)
148 }
149
150 getViewersStatsTitle () {
151 if (this.statsStartDate && this.statsEndDate) {
152 return $localize`Viewers stats between ${this.statsStartDate.toLocaleString()} and ${this.statsEndDate.toLocaleString()}`
153 }
154
155 return $localize`Viewers stats`
156 }
157
158 onDateFilterChange () {
159 if (this.currentDateFilter === 'all') {
160 return this.resetZoom()
161 }
162
163 const idParts = this.currentDateFilter.split('|')
164 if (idParts.length === 2) {
165 return this.peertubeRouter.silentNavigate([], { startDate: idParts[0], endDate: idParts[1] })
166 }
114 } 167 }
115 168
116 private isTimeserieGraph (graphId: ActiveGraphId) { 169 private isTimeserieGraph (graphId: ActiveGraphId) {
@@ -118,7 +171,7 @@ export class VideoStatsComponent implements OnInit {
118 } 171 }
119 172
120 private loadOverallStats () { 173 private loadOverallStats () {
121 this.statsService.getOverallStats(this.video.uuid) 174 this.statsService.getOverallStats({ videoId: this.video.uuid, startDate: this.statsStartDate, endDate: this.statsEndDate })
122 .subscribe({ 175 .subscribe({
123 next: res => { 176 next: res => {
124 this.countries = res.countries.slice(0, 10).map(c => ({ 177 this.countries = res.countries.slice(0, 10).map(c => ({
@@ -133,8 +186,70 @@ export class VideoStatsComponent implements OnInit {
133 }) 186 })
134 } 187 }
135 188
189 private loadDateFilters () {
190 if (this.video.isLive) return this.loadLiveDateFilters()
191
192 return this.loadVODDateFilters()
193 }
194
195 private loadLiveDateFilters () {
196 this.liveService.listSessions(this.video.id)
197 .subscribe({
198 next: ({ data }) => {
199 const newFilters = data.map(session => this.buildLiveFilter(session))
200
201 this.dateFilters = this.dateFilters.concat(newFilters)
202 },
203
204 error: err => this.notifier.error(err.message)
205 })
206 }
207
208 private loadVODDateFilters () {
209 this.liveService.findLiveSessionFromVOD(this.video.id)
210 .subscribe({
211 next: session => {
212 this.dateFilters = this.dateFilters.concat([ this.buildLiveFilter(session) ])
213 },
214
215 error: err => {
216 if (err.status === HttpStatusCode.NOT_FOUND_404) return
217
218 this.notifier.error(err.message)
219 }
220 })
221 }
222
223 private buildLiveFilter (session: LiveVideoSession) {
224 return {
225 id: session.startDate + '|' + session.endDate,
226 label: $localize`Of live of ${new Date(session.startDate).toLocaleString()}`
227 }
228 }
229
230 private addAndSelectCustomDateFilter () {
231 const exists = this.dateFilters.some(d => d.id === 'custom')
232
233 if (!exists) {
234 this.dateFilters = this.dateFilters.concat([
235 {
236 id: 'custom',
237 label: $localize`Custom dates`
238 }
239 ])
240 }
241
242 this.currentDateFilter = 'custom'
243 }
244
245 private removeAndResetCustomDateFilter () {
246 this.dateFilters = this.dateFilters.filter(d => d.id !== 'custom')
247
248 this.currentDateFilter = 'all'
249 }
250
136 private buildOverallStatCard (overallStats: VideoStatsOverall) { 251 private buildOverallStatCard (overallStats: VideoStatsOverall) {
137 this.overallStatCards = [ 252 this.globalStatsCards = [
138 { 253 {
139 label: $localize`Views`, 254 label: $localize`Views`,
140 value: this.numberFormatter.transform(this.video.views) 255 value: this.numberFormatter.transform(this.video.views)
@@ -142,12 +257,19 @@ export class VideoStatsComponent implements OnInit {
142 { 257 {
143 label: $localize`Likes`, 258 label: $localize`Likes`,
144 value: this.numberFormatter.transform(this.video.likes) 259 value: this.numberFormatter.transform(this.video.likes)
145 }, 260 }
261 ]
262
263 this.overallStatCards = [
146 { 264 {
147 label: $localize`Average watch time`, 265 label: $localize`Average watch time`,
148 value: secondsToTime(overallStats.averageWatchTime) 266 value: secondsToTime(overallStats.averageWatchTime)
149 }, 267 },
150 { 268 {
269 label: $localize`Total watch time`,
270 value: secondsToTime(overallStats.totalWatchTime)
271 },
272 {
151 label: $localize`Peak viewers`, 273 label: $localize`Peak viewers`,
152 value: this.numberFormatter.transform(overallStats.viewersPeak), 274 value: this.numberFormatter.transform(overallStats.viewersPeak),
153 moreInfo: overallStats.viewersPeak !== 0 275 moreInfo: overallStats.viewersPeak !== 0
@@ -155,6 +277,13 @@ export class VideoStatsComponent implements OnInit {
155 : undefined 277 : undefined
156 } 278 }
157 ] 279 ]
280
281 if (overallStats.countries.length !== 0) {
282 this.overallStatCards.push({
283 label: $localize`Countries`,
284 value: this.numberFormatter.transform(overallStats.countries.length)
285 })
286 }
158 } 287 }
159 288
160 private loadChart () { 289 private loadChart () {
@@ -163,14 +292,14 @@ export class VideoStatsComponent implements OnInit {
163 292
164 aggregateWatchTime: this.statsService.getTimeserieStats({ 293 aggregateWatchTime: this.statsService.getTimeserieStats({
165 videoId: this.video.uuid, 294 videoId: this.video.uuid,
166 startDate: this.timeseriesStartDate, 295 startDate: this.statsStartDate,
167 endDate: this.timeseriesEndDate, 296 endDate: this.statsEndDate,
168 metric: 'aggregateWatchTime' 297 metric: 'aggregateWatchTime'
169 }), 298 }),
170 viewers: this.statsService.getTimeserieStats({ 299 viewers: this.statsService.getTimeserieStats({
171 videoId: this.video.uuid, 300 videoId: this.video.uuid,
172 startDate: this.timeseriesStartDate, 301 startDate: this.statsStartDate,
173 endDate: this.timeseriesEndDate, 302 endDate: this.statsEndDate,
174 metric: 'viewers' 303 metric: 'viewers'
175 }), 304 }),
176 305
@@ -317,6 +446,7 @@ export class VideoStatsComponent implements OnInit {
317 const endDate = this.buildZoomEndDate(rawData.groupInterval, rawData.data[max].date) 446 const endDate = this.buildZoomEndDate(rawData.groupInterval, rawData.data[max].date)
318 447
319 this.peertubeRouter.silentNavigate([], { startDate, endDate }) 448 this.peertubeRouter.silentNavigate([], { startDate, endDate })
449 this.addAndSelectCustomDateFilter()
320 } 450 }
321 } 451 }
322 } 452 }
@@ -386,6 +516,10 @@ export class VideoStatsComponent implements OnInit {
386 516
387 const date = new Date(label) 517 const date = new Date(label)
388 518
519 if (data.groupInterval.match(/ month?$/)) {
520 return date.toLocaleDateString([], { month: 'numeric' })
521 }
522
389 if (data.groupInterval.match(/ days?$/)) { 523 if (data.groupInterval.match(/ days?$/)) {
390 return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' }) 524 return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' })
391 } 525 }
diff --git a/client/src/app/+stats/video/video-stats.service.ts b/client/src/app/+stats/video/video-stats.service.ts
index 712d03971..e019c87f7 100644
--- a/client/src/app/+stats/video/video-stats.service.ts
+++ b/client/src/app/+stats/video/video-stats.service.ts
@@ -17,8 +17,18 @@ export class VideoStatsService {
17 private restExtractor: RestExtractor 17 private restExtractor: RestExtractor
18 ) { } 18 ) { }
19 19
20 getOverallStats (videoId: string) { 20 getOverallStats (options: {
21 return this.authHttp.get<VideoStatsOverall>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/overall') 21 videoId: string
22 startDate?: Date
23 endDate?: Date
24 }) {
25 const { videoId, startDate, endDate } = options
26
27 let params = new HttpParams()
28 if (startDate) params = params.append('startDate', startDate.toISOString())
29 if (endDate) params = params.append('endDate', endDate.toISOString())
30
31 return this.authHttp.get<VideoStatsOverall>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/overall', { params })
22 .pipe(catchError(err => this.restExtractor.handleError(err))) 32 .pipe(catchError(err => this.restExtractor.handleError(err)))
23 } 33 }
24 34
diff --git a/client/src/app/+video-studio/video-studio-routing.module.ts b/client/src/app/+video-studio/video-studio-routing.module.ts
index 4c08631a1..9d276be7c 100644
--- a/client/src/app/+video-studio/video-studio-routing.module.ts
+++ b/client/src/app/+video-studio/video-studio-routing.module.ts
@@ -1,11 +1,13 @@
1import { NgModule } from '@angular/core' 1import { NgModule } from '@angular/core'
2import { RouterModule, Routes } from '@angular/router' 2import { RouterModule, Routes } from '@angular/router'
3import { LoginGuard } from '@app/core'
3import { VideoResolver } from '@app/shared/shared-main' 4import { VideoResolver } from '@app/shared/shared-main'
4import { VideoStudioEditComponent } from './edit' 5import { VideoStudioEditComponent } from './edit'
5 6
6const videoStudioRoutes: Routes = [ 7const videoStudioRoutes: Routes = [
7 { 8 {
8 path: '', 9 path: '',
10 canActivateChild: [ LoginGuard ],
9 children: [ 11 children: [
10 { 12 {
11 path: 'edit/:videoId', 13 path: 'edit/:videoId',
diff --git a/client/src/app/shared/shared-video-live/live-video.service.ts b/client/src/app/shared/shared-video-live/live-video.service.ts
index 11b9dd739..89bfd84a0 100644
--- a/client/src/app/shared/shared-video-live/live-video.service.ts
+++ b/client/src/app/shared/shared-video-live/live-video.service.ts
@@ -4,6 +4,7 @@ import { Injectable } from '@angular/core'
4import { RestExtractor } from '@app/core' 4import { RestExtractor } from '@app/core'
5import { LiveVideo, LiveVideoCreate, LiveVideoSession, LiveVideoUpdate, ResultList, VideoCreateResult } from '@shared/models' 5import { LiveVideo, LiveVideoCreate, LiveVideoSession, LiveVideoUpdate, ResultList, VideoCreateResult } from '@shared/models'
6import { environment } from '../../../environments/environment' 6import { environment } from '../../../environments/environment'
7import { VideoService } from '../shared-main'
7 8
8@Injectable() 9@Injectable()
9export class LiveVideoService { 10export class LiveVideoService {
@@ -32,6 +33,12 @@ export class LiveVideoService {
32 .pipe(catchError(err => this.restExtractor.handleError(err))) 33 .pipe(catchError(err => this.restExtractor.handleError(err)))
33 } 34 }
34 35
36 findLiveSessionFromVOD (videoId: number | string) {
37 return this.authHttp
38 .get<LiveVideoSession>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/live-session')
39 .pipe(catchError(err => this.restExtractor.handleError(err)))
40 }
41
35 updateLive (videoId: number | string, liveUpdate: LiveVideoUpdate) { 42 updateLive (videoId: number | string, liveUpdate: LiveVideoUpdate) {
36 return this.authHttp 43 return this.authHttp
37 .put(LiveVideoService.BASE_VIDEO_LIVE_URL + videoId, liveUpdate) 44 .put(LiveVideoService.BASE_VIDEO_LIVE_URL + videoId, liveUpdate)
diff --git a/server/controllers/api/videos/stats.ts b/server/controllers/api/videos/stats.ts
index 30e2bb06c..e79f01888 100644
--- a/server/controllers/api/videos/stats.ts
+++ b/server/controllers/api/videos/stats.ts
@@ -67,18 +67,9 @@ async function getTimeserieStats (req: express.Request, res: express.Response) {
67 const stats = await LocalVideoViewerModel.getTimeserieStats({ 67 const stats = await LocalVideoViewerModel.getTimeserieStats({
68 video, 68 video,
69 metric, 69 metric,
70 startDate: query.startDate ?? buildOneMonthAgo().toISOString(), 70 startDate: query.startDate ?? video.createdAt.toISOString(),
71 endDate: query.endDate ?? new Date().toISOString() 71 endDate: query.endDate ?? new Date().toISOString()
72 }) 72 })
73 73
74 return res.json(stats) 74 return res.json(stats)
75} 75}
76
77function buildOneMonthAgo () {
78 const monthAgo = new Date()
79 monthAgo.setHours(0, 0, 0, 0)
80
81 monthAgo.setDate(monthAgo.getDate() - 29)
82
83 return monthAgo
84}
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index fa0fbc19d..dca792b1b 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -813,7 +813,7 @@ const SEARCH_INDEX = {
813// --------------------------------------------------------------------------- 813// ---------------------------------------------------------------------------
814 814
815const STATS_TIMESERIE = { 815const STATS_TIMESERIE = {
816 MAX_DAYS: 30 816 MAX_DAYS: 365 * 10 // Around 10 years
817} 817}
818 818
819// --------------------------------------------------------------------------- 819// ---------------------------------------------------------------------------
diff --git a/server/lib/timeserie.ts b/server/lib/timeserie.ts
index bd3d1c1ca..08b12129a 100644
--- a/server/lib/timeserie.ts
+++ b/server/lib/timeserie.ts
@@ -9,7 +9,10 @@ function buildGroupByAndBoundaries (startDateString: string, endDateString: stri
9 logger.debug('Found "%s" group interval.', groupInterval, { startDate, endDate }) 9 logger.debug('Found "%s" group interval.', groupInterval, { startDate, endDate })
10 10
11 // Remove parts of the date we don't need 11 // Remove parts of the date we don't need
12 if (groupInterval.endsWith(' day') || groupInterval.endsWith(' days')) { 12 if (groupInterval.endsWith(' month') || groupInterval.endsWith(' months')) {
13 startDate.setDate(1)
14 startDate.setHours(0, 0, 0, 0)
15 } else if (groupInterval.endsWith(' day') || groupInterval.endsWith(' days')) {
13 startDate.setHours(0, 0, 0, 0) 16 startDate.setHours(0, 0, 0, 0)
14 } else if (groupInterval.endsWith(' hour') || groupInterval.endsWith(' hours')) { 17 } else if (groupInterval.endsWith(' hour') || groupInterval.endsWith(' hours')) {
15 startDate.setMinutes(0, 0, 0) 18 startDate.setMinutes(0, 0, 0)
@@ -33,16 +36,25 @@ export {
33// --------------------------------------------------------------------------- 36// ---------------------------------------------------------------------------
34 37
35function buildGroupInterval (startDate: Date, endDate: Date): string { 38function buildGroupInterval (startDate: Date, endDate: Date): string {
39 const aYear = 31536000
40 const aMonth = 2678400
36 const aDay = 86400 41 const aDay = 86400
37 const anHour = 3600 42 const anHour = 3600
38 const aMinute = 60 43 const aMinute = 60
39 44
40 const diffSeconds = (endDate.getTime() - startDate.getTime()) / 1000 45 const diffSeconds = (endDate.getTime() - startDate.getTime()) / 1000
41 46
47 if (diffSeconds >= 6 * aYear) return '6 months'
48 if (diffSeconds >= 2 * aYear) return '1 month'
49 if (diffSeconds >= 6 * aMonth) return '7 days'
50 if (diffSeconds >= 2 * aMonth) return '2 days'
51
42 if (diffSeconds >= 15 * aDay) return '1 day' 52 if (diffSeconds >= 15 * aDay) return '1 day'
43 if (diffSeconds >= 8 * aDay) return '12 hours' 53 if (diffSeconds >= 8 * aDay) return '12 hours'
44 if (diffSeconds >= 4 * aDay) return '6 hours' 54 if (diffSeconds >= 4 * aDay) return '6 hours'
55
45 if (diffSeconds >= 15 * anHour) return '1 hour' 56 if (diffSeconds >= 15 * anHour) return '1 hour'
57
46 if (diffSeconds >= 180 * aMinute) return '10 minutes' 58 if (diffSeconds >= 180 * aMinute) return '10 minutes'
47 59
48 return '1 minute' 60 return '1 minute'
diff --git a/server/models/view/local-video-viewer.ts b/server/models/view/local-video-viewer.ts
index 2862f8b96..2305c7262 100644
--- a/server/models/view/local-video-viewer.ts
+++ b/server/models/view/local-video-viewer.ts
@@ -136,7 +136,7 @@ export class LocalVideoViewerModel extends Model<Partial<AttributesOnly<LocalVid
136 const watchPeakQuery = `WITH "watchPeakValues" AS ( 136 const watchPeakQuery = `WITH "watchPeakValues" AS (
137 SELECT "startDate" AS "dateBreakpoint", 1 AS "inc" 137 SELECT "startDate" AS "dateBreakpoint", 1 AS "inc"
138 FROM "localVideoViewer" 138 FROM "localVideoViewer"
139 WHERE "videoId" = :videoId 139 WHERE "videoId" = :videoId ${dateWhere}
140 UNION ALL 140 UNION ALL
141 SELECT "endDate" AS "dateBreakpoint", -1 AS "inc" 141 SELECT "endDate" AS "dateBreakpoint", -1 AS "inc"
142 FROM "localVideoViewer" 142 FROM "localVideoViewer"
@@ -165,6 +165,10 @@ export class LocalVideoViewerModel extends Model<Partial<AttributesOnly<LocalVid
165 countriesPromise 165 countriesPromise
166 ]) 166 ])
167 167
168 const viewersPeak = rowsWatchPeak.length !== 0
169 ? parseInt(rowsWatchPeak[0].concurrent) || 0
170 : 0
171
168 return { 172 return {
169 totalWatchTime: rowsWatchTime.length !== 0 173 totalWatchTime: rowsWatchTime.length !== 0
170 ? Math.round(rowsWatchTime[0].totalWatchTime) || 0 174 ? Math.round(rowsWatchTime[0].totalWatchTime) || 0
@@ -173,10 +177,8 @@ export class LocalVideoViewerModel extends Model<Partial<AttributesOnly<LocalVid
173 ? Math.round(rowsWatchTime[0].averageWatchTime) || 0 177 ? Math.round(rowsWatchTime[0].averageWatchTime) || 0
174 : 0, 178 : 0,
175 179
176 viewersPeak: rowsWatchPeak.length !== 0 180 viewersPeak,
177 ? parseInt(rowsWatchPeak[0].concurrent) || 0 181 viewersPeakDate: rowsWatchPeak.length !== 0 && viewersPeak !== 0
178 : 0,
179 viewersPeakDate: rowsWatchPeak.length !== 0
180 ? rowsWatchPeak[0].dateBreakpoint || null 182 ? rowsWatchPeak[0].dateBreakpoint || null
181 : null, 183 : null,
182 184
diff --git a/server/tests/api/check-params/views.ts b/server/tests/api/check-params/views.ts
index fe037b145..8f1fa796b 100644
--- a/server/tests/api/check-params/views.ts
+++ b/server/tests/api/check-params/views.ts
@@ -176,7 +176,7 @@ describe('Test videos views', function () {
176 await servers[0].videoStats.getTimeserieStats({ 176 await servers[0].videoStats.getTimeserieStats({
177 videoId, 177 videoId,
178 metric: 'viewers', 178 metric: 'viewers',
179 startDate: new Date('2021-04-07T08:31:57.126Z'), 179 startDate: new Date('2000-04-07T08:31:57.126Z'),
180 endDate: new Date(), 180 endDate: new Date(),
181 expectedStatus: HttpStatusCode.BAD_REQUEST_400 181 expectedStatus: HttpStatusCode.BAD_REQUEST_400
182 }) 182 })
diff --git a/server/tests/api/views/video-views-overall-stats.ts b/server/tests/api/views/video-views-overall-stats.ts
index 53b8f0d4b..02012388d 100644
--- a/server/tests/api/views/video-views-overall-stats.ts
+++ b/server/tests/api/views/video-views-overall-stats.ts
@@ -169,6 +169,7 @@ describe('Test views overall stats', function () {
169 169
170 describe('Test watchers peak stats of local videos on VOD', function () { 170 describe('Test watchers peak stats of local videos on VOD', function () {
171 let videoUUID: string 171 let videoUUID: string
172 let before2Watchers: Date
172 173
173 before(async function () { 174 before(async function () {
174 this.timeout(120000); 175 this.timeout(120000);
@@ -201,7 +202,7 @@ describe('Test views overall stats', function () {
201 it('Should have watcher peak with 2 watchers', async function () { 202 it('Should have watcher peak with 2 watchers', async function () {
202 this.timeout(60000) 203 this.timeout(60000)
203 204
204 const before = new Date() 205 before2Watchers = new Date()
205 await servers[0].views.view({ id: videoUUID, currentTime: 0 }) 206 await servers[0].views.view({ id: videoUUID, currentTime: 0 })
206 await servers[1].views.view({ id: videoUUID, currentTime: 0 }) 207 await servers[1].views.view({ id: videoUUID, currentTime: 0 })
207 await servers[0].views.view({ id: videoUUID, currentTime: 2 }) 208 await servers[0].views.view({ id: videoUUID, currentTime: 2 })
@@ -213,11 +214,26 @@ describe('Test views overall stats', function () {
213 const stats = await servers[0].videoStats.getOverallStats({ videoId: videoUUID }) 214 const stats = await servers[0].videoStats.getOverallStats({ videoId: videoUUID })
214 215
215 expect(stats.viewersPeak).to.equal(2) 216 expect(stats.viewersPeak).to.equal(2)
216 expect(new Date(stats.viewersPeakDate)).to.be.above(before).and.below(after) 217 expect(new Date(stats.viewersPeakDate)).to.be.above(before2Watchers).and.below(after)
218 })
219
220 it('Should filter peak viewers stats by date', async function () {
221 {
222 const stats = await servers[0].videoStats.getOverallStats({ videoId: videoUUID, startDate: new Date().toISOString() })
223 expect(stats.viewersPeak).to.equal(0)
224 expect(stats.viewersPeakDate).to.not.exist
225 }
226
227 {
228 const stats = await servers[0].videoStats.getOverallStats({ videoId: videoUUID, endDate: before2Watchers.toISOString() })
229 expect(stats.viewersPeak).to.equal(1)
230 expect(new Date(stats.viewersPeakDate)).to.be.below(before2Watchers)
231 }
217 }) 232 })
218 }) 233 })
219 234
220 describe('Test countries', function () { 235 describe('Test countries', function () {
236 let videoUUID: string
221 237
222 it('Should not report countries if geoip is disabled', async function () { 238 it('Should not report countries if geoip is disabled', async function () {
223 this.timeout(120000) 239 this.timeout(120000)
@@ -237,6 +253,7 @@ describe('Test views overall stats', function () {
237 this.timeout(240000) 253 this.timeout(240000)
238 254
239 const { uuid } = await servers[0].videos.quickUpload({ name: 'video' }) 255 const { uuid } = await servers[0].videos.quickUpload({ name: 'video' })
256 videoUUID = uuid
240 await waitJobs(servers) 257 await waitJobs(servers)
241 258
242 await Promise.all([ 259 await Promise.all([
@@ -265,6 +282,11 @@ describe('Test views overall stats', function () {
265 expect(stats.countries[1].isoCode).to.equal('FR') 282 expect(stats.countries[1].isoCode).to.equal('FR')
266 expect(stats.countries[1].viewers).to.equal(1) 283 expect(stats.countries[1].viewers).to.equal(1)
267 }) 284 })
285
286 it('Should filter countries stats by date', async function () {
287 const stats = await servers[0].videoStats.getOverallStats({ videoId: videoUUID, startDate: new Date().toISOString() })
288 expect(stats.countries).to.have.lengthOf(0)
289 })
268 }) 290 })
269 291
270 after(async function () { 292 after(async function () {
diff --git a/server/tests/api/views/video-views-timeserie-stats.ts b/server/tests/api/views/video-views-timeserie-stats.ts
index fd3aba188..6f03b0e82 100644
--- a/server/tests/api/views/video-views-timeserie-stats.ts
+++ b/server/tests/api/views/video-views-timeserie-stats.ts
@@ -9,6 +9,15 @@ import { cleanupTests, PeerTubeServer, stopFfmpeg } from '@shared/server-command
9 9
10const expect = chai.expect 10const expect = chai.expect
11 11
12function buildOneMonthAgo () {
13 const monthAgo = new Date()
14 monthAgo.setHours(0, 0, 0, 0)
15
16 monthAgo.setDate(monthAgo.getDate() - 29)
17
18 return monthAgo
19}
20
12describe('Test views timeserie stats', function () { 21describe('Test views timeserie stats', function () {
13 const availableMetrics: VideoStatsTimeserieMetric[] = [ 'viewers' ] 22 const availableMetrics: VideoStatsTimeserieMetric[] = [ 'viewers' ]
14 23
@@ -33,7 +42,7 @@ describe('Test views timeserie stats', function () {
33 for (const metric of availableMetrics) { 42 for (const metric of availableMetrics) {
34 const { data } = await servers[0].videoStats.getTimeserieStats({ videoId: vodVideoId, metric }) 43 const { data } = await servers[0].videoStats.getTimeserieStats({ videoId: vodVideoId, metric })
35 44
36 expect(data).to.have.lengthOf(30) 45 expect(data).to.have.length.at.least(1)
37 46
38 for (const d of data) { 47 for (const d of data) {
39 expect(d.value).to.equal(0) 48 expect(d.value).to.equal(0)
@@ -47,17 +56,19 @@ describe('Test views timeserie stats', function () {
47 let liveVideoId: string 56 let liveVideoId: string
48 let command: FfmpegCommand 57 let command: FfmpegCommand
49 58
50 function expectTodayLastValue (result: VideoStatsTimeserie, lastValue: number) { 59 function expectTodayLastValue (result: VideoStatsTimeserie, lastValue?: number) {
51 const { data } = result 60 const { data } = result
52 61
53 const last = data[data.length - 1] 62 const last = data[data.length - 1]
54 const today = new Date().getDate() 63 const today = new Date().getDate()
55 expect(new Date(last.date).getDate()).to.equal(today) 64 expect(new Date(last.date).getDate()).to.equal(today)
65
66 if (lastValue) expect(last.value).to.equal(lastValue)
56 } 67 }
57 68
58 function expectTimeserieData (result: VideoStatsTimeserie, lastValue: number) { 69 function expectTimeserieData (result: VideoStatsTimeserie, lastValue: number) {
59 const { data } = result 70 const { data } = result
60 expect(data).to.have.lengthOf(30) 71 expect(data).to.have.length.at.least(25)
61 72
62 expectTodayLastValue(result, lastValue) 73 expectTodayLastValue(result, lastValue)
63 74
@@ -87,14 +98,24 @@ describe('Test views timeserie stats', function () {
87 await processViewersStats(servers) 98 await processViewersStats(servers)
88 99
89 for (const videoId of [ vodVideoId, liveVideoId ]) { 100 for (const videoId of [ vodVideoId, liveVideoId ]) {
90 const result = await servers[0].videoStats.getTimeserieStats({ videoId, metric: 'viewers' }) 101 const result = await servers[0].videoStats.getTimeserieStats({
102 videoId,
103 startDate: buildOneMonthAgo(),
104 endDate: new Date(),
105 metric: 'viewers'
106 })
91 expectTimeserieData(result, 2) 107 expectTimeserieData(result, 2)
92 } 108 }
93 }) 109 })
94 110
95 it('Should display appropriate watch time metrics', async function () { 111 it('Should display appropriate watch time metrics', async function () {
96 for (const videoId of [ vodVideoId, liveVideoId ]) { 112 for (const videoId of [ vodVideoId, liveVideoId ]) {
97 const result = await servers[0].videoStats.getTimeserieStats({ videoId, metric: 'aggregateWatchTime' }) 113 const result = await servers[0].videoStats.getTimeserieStats({
114 videoId,
115 startDate: buildOneMonthAgo(),
116 endDate: new Date(),
117 metric: 'aggregateWatchTime'
118 })
98 expectTimeserieData(result, 8) 119 expectTimeserieData(result, 8)
99 120
100 await servers[1].views.simulateViewer({ id: videoId, currentTimes: [ 0, 1 ] }) 121 await servers[1].views.simulateViewer({ id: videoId, currentTimes: [ 0, 1 ] })
@@ -103,7 +124,12 @@ describe('Test views timeserie stats', function () {
103 await processViewersStats(servers) 124 await processViewersStats(servers)
104 125
105 for (const videoId of [ vodVideoId, liveVideoId ]) { 126 for (const videoId of [ vodVideoId, liveVideoId ]) {
106 const result = await servers[0].videoStats.getTimeserieStats({ videoId, metric: 'aggregateWatchTime' }) 127 const result = await servers[0].videoStats.getTimeserieStats({
128 videoId,
129 startDate: buildOneMonthAgo(),
130 endDate: new Date(),
131 metric: 'aggregateWatchTime'
132 })
107 expectTimeserieData(result, 9) 133 expectTimeserieData(result, 9)
108 } 134 }
109 }) 135 })
@@ -130,6 +156,38 @@ describe('Test views timeserie stats', function () {
130 expectTodayLastValue(result, 9) 156 expectTodayLastValue(result, 9)
131 }) 157 })
132 158
159 it('Should automatically group by months', async function () {
160 const now = new Date()
161 const heightYearsAgo = new Date()
162 heightYearsAgo.setFullYear(heightYearsAgo.getFullYear() - 7)
163
164 const result = await servers[0].videoStats.getTimeserieStats({
165 videoId: vodVideoId,
166 metric: 'aggregateWatchTime',
167 startDate: heightYearsAgo,
168 endDate: now
169 })
170
171 expect(result.groupInterval).to.equal('6 months')
172 expect(result.data).to.have.length.above(10).and.below(200)
173 })
174
175 it('Should automatically group by days', async function () {
176 const now = new Date()
177 const threeMonthsAgo = new Date()
178 threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3)
179
180 const result = await servers[0].videoStats.getTimeserieStats({
181 videoId: vodVideoId,
182 metric: 'aggregateWatchTime',
183 startDate: threeMonthsAgo,
184 endDate: now
185 })
186
187 expect(result.groupInterval).to.equal('2 days')
188 expect(result.data).to.have.length.above(10).and.below(200)
189 })
190
133 it('Should automatically group by hours', async function () { 191 it('Should automatically group by hours', async function () {
134 const now = new Date() 192 const now = new Date()
135 const twoDaysAgo = new Date() 193 const twoDaysAgo = new Date()
@@ -165,7 +223,7 @@ describe('Test views timeserie stats', function () {
165 expect(result.data).to.have.length.above(20).and.below(30) 223 expect(result.data).to.have.length.above(20).and.below(30)
166 224
167 expectInterval(result, 60 * 10 * 1000) 225 expectInterval(result, 60 * 10 * 1000)
168 expectTodayLastValue(result, 9) 226 expectTodayLastValue(result)
169 }) 227 })
170 228
171 it('Should automatically group by one minute', async function () { 229 it('Should automatically group by one minute', async function () {
@@ -184,7 +242,7 @@ describe('Test views timeserie stats', function () {
184 expect(result.data).to.have.length.above(20).and.below(40) 242 expect(result.data).to.have.length.above(20).and.below(40)
185 243
186 expectInterval(result, 60 * 1000) 244 expectInterval(result, 60 * 1000)
187 expectTodayLastValue(result, 9) 245 expectTodayLastValue(result)
188 }) 246 })
189 247
190 after(async function () { 248 after(async function () {