diff options
author | Chocobozzz <me@florianbigard.com> | 2022-05-06 14:23:02 +0200 |
---|---|---|
committer | Chocobozzz <me@florianbigard.com> | 2022-05-06 14:23:02 +0200 |
commit | f40712abbbb74e51f06037ef02757c42736bccf8 (patch) | |
tree | 4b130c6387f9687d52d570907eb1bbac6bc04b61 | |
parent | 49f0468d44468528c2fb2c8b0efd19cdaeeec43d (diff) | |
download | PeerTube-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.ts | 2 | ||||
-rw-r--r-- | client/src/app/+stats/stats.module.ts | 4 | ||||
-rw-r--r-- | client/src/app/+stats/video/video-stats.component.html | 78 | ||||
-rw-r--r-- | client/src/app/+stats/video/video-stats.component.scss | 34 | ||||
-rw-r--r-- | client/src/app/+stats/video/video-stats.component.ts | 212 | ||||
-rw-r--r-- | client/src/app/+stats/video/video-stats.service.ts | 14 | ||||
-rw-r--r-- | client/src/app/+video-studio/video-studio-routing.module.ts | 2 | ||||
-rw-r--r-- | client/src/app/shared/shared-video-live/live-video.service.ts | 7 | ||||
-rw-r--r-- | server/controllers/api/videos/stats.ts | 11 | ||||
-rw-r--r-- | server/initializers/constants.ts | 2 | ||||
-rw-r--r-- | server/lib/timeserie.ts | 14 | ||||
-rw-r--r-- | server/models/view/local-video-viewer.ts | 12 | ||||
-rw-r--r-- | server/tests/api/check-params/views.ts | 2 | ||||
-rw-r--r-- | server/tests/api/views/video-views-overall-stats.ts | 26 | ||||
-rw-r--r-- | server/tests/api/views/video-views-timeserie-stats.ts | 74 |
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 @@ | |||
1 | import { NgModule } from '@angular/core' | 1 | import { NgModule } from '@angular/core' |
2 | import { RouterModule, Routes } from '@angular/router' | 2 | import { RouterModule, Routes } from '@angular/router' |
3 | import { LoginGuard } from '@app/core' | ||
3 | import { VideoResolver } from '@app/shared/shared-main' | 4 | import { VideoResolver } from '@app/shared/shared-main' |
4 | import { VideoStatsComponent } from './video' | 5 | import { VideoStatsComponent } from './video' |
5 | 6 | ||
6 | const statsRoutes: Routes = [ | 7 | const 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 @@ | |||
1 | import { ChartModule } from 'primeng/chart' | 1 | import { ChartModule } from 'primeng/chart' |
2 | import { NgModule } from '@angular/core' | 2 | import { NgModule } from '@angular/core' |
3 | import { SharedFormModule } from '@app/shared/shared-forms' | ||
3 | import { SharedGlobalIconModule } from '@app/shared/shared-icons' | 4 | import { SharedGlobalIconModule } from '@app/shared/shared-icons' |
4 | import { SharedMainModule } from '@app/shared/shared-main' | 5 | import { SharedMainModule } from '@app/shared/shared-main' |
6 | import { SharedVideoLiveModule } from '@app/shared/shared-video-live' | ||
5 | import { StatsRoutingModule } from './stats-routing.module' | 7 | import { StatsRoutingModule } from './stats-routing.module' |
6 | import { VideoStatsComponent, VideoStatsService } from './video' | 8 | import { 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 @@ | |||
1 | import { ChartConfiguration, ChartData, PluginOptionsByType, Scale, TooltipItem } from 'chart.js' | 1 | import { ChartConfiguration, ChartData, PluginOptionsByType, Scale, TooltipItem } from 'chart.js' |
2 | import zoomPlugin from 'chartjs-plugin-zoom' | 2 | import zoomPlugin from 'chartjs-plugin-zoom' |
3 | import { Observable, of } from 'rxjs' | 3 | import { Observable, of } from 'rxjs' |
4 | import { SelectOptionsItem } from 'src/types' | ||
4 | import { Component, OnInit } from '@angular/core' | 5 | import { Component, OnInit } from '@angular/core' |
5 | import { ActivatedRoute } from '@angular/router' | 6 | import { ActivatedRoute } from '@angular/router' |
6 | import { Notifier, PeerTubeRouterService } from '@app/core' | 7 | import { Notifier, PeerTubeRouterService } from '@app/core' |
7 | import { NumberFormatterPipe, VideoDetails } from '@app/shared/shared-main' | 8 | import { NumberFormatterPipe, VideoDetails } from '@app/shared/shared-main' |
9 | import { LiveVideoService } from '@app/shared/shared-video-live' | ||
8 | import { secondsToTime } from '@shared/core-utils' | 10 | import { secondsToTime } from '@shared/core-utils' |
9 | import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric } from '@shared/models/videos' | 11 | import { HttpStatusCode } from '@shared/models/http' |
12 | import { | ||
13 | LiveVideoSession, | ||
14 | VideoStatsOverall, | ||
15 | VideoStatsRetention, | ||
16 | VideoStatsTimeserie, | ||
17 | VideoStatsTimeserieMetric | ||
18 | } from '@shared/models/videos' | ||
10 | import { VideoStatsService } from './video-stats.service' | 19 | import { VideoStatsService } from './video-stats.service' |
11 | 20 | ||
12 | type ActiveGraphId = VideoStatsTimeserieMetric | 'retention' | 'countries' | 21 | type ActiveGraphId = VideoStatsTimeserieMetric | 'retention' | 'countries' |
@@ -21,41 +30,24 @@ type ChartBuilderResult = { | |||
21 | displayLegend: boolean | 30 | displayLegend: boolean |
22 | } | 31 | } |
23 | 32 | ||
33 | type 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 | }) |
29 | export class VideoStatsComponent implements OnInit { | 40 | export 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 @@ | |||
1 | import { NgModule } from '@angular/core' | 1 | import { NgModule } from '@angular/core' |
2 | import { RouterModule, Routes } from '@angular/router' | 2 | import { RouterModule, Routes } from '@angular/router' |
3 | import { LoginGuard } from '@app/core' | ||
3 | import { VideoResolver } from '@app/shared/shared-main' | 4 | import { VideoResolver } from '@app/shared/shared-main' |
4 | import { VideoStudioEditComponent } from './edit' | 5 | import { VideoStudioEditComponent } from './edit' |
5 | 6 | ||
6 | const videoStudioRoutes: Routes = [ | 7 | const 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' | |||
4 | import { RestExtractor } from '@app/core' | 4 | import { RestExtractor } from '@app/core' |
5 | import { LiveVideo, LiveVideoCreate, LiveVideoSession, LiveVideoUpdate, ResultList, VideoCreateResult } from '@shared/models' | 5 | import { LiveVideo, LiveVideoCreate, LiveVideoSession, LiveVideoUpdate, ResultList, VideoCreateResult } from '@shared/models' |
6 | import { environment } from '../../../environments/environment' | 6 | import { environment } from '../../../environments/environment' |
7 | import { VideoService } from '../shared-main' | ||
7 | 8 | ||
8 | @Injectable() | 9 | @Injectable() |
9 | export class LiveVideoService { | 10 | export 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 | |||
77 | function 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 | ||
815 | const STATS_TIMESERIE = { | 815 | const 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 | ||
35 | function buildGroupInterval (startDate: Date, endDate: Date): string { | 38 | function 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 | ||
10 | const expect = chai.expect | 10 | const expect = chai.expect |
11 | 11 | ||
12 | function 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 | |||
12 | describe('Test views timeserie stats', function () { | 21 | describe('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 () { |