aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/app/+stats/video/video-stats.component.ts
diff options
context:
space:
mode:
Diffstat (limited to 'client/src/app/+stats/video/video-stats.component.ts')
-rw-r--r--client/src/app/+stats/video/video-stats.component.ts212
1 files changed, 173 insertions, 39 deletions
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 }