]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - client/src/app/+stats/video/video-stats.component.ts
Add total viewers overall stat
[github/Chocobozzz/PeerTube.git] / client / src / app / +stats / video / video-stats.component.ts
1 import { ChartConfiguration, ChartData, PluginOptionsByType, Scale, TooltipItem } from 'chart.js'
2 import zoomPlugin from 'chartjs-plugin-zoom'
3 import { Observable, of } from 'rxjs'
4 import { SelectOptionsItem } from 'src/types'
5 import { Component, OnInit } from '@angular/core'
6 import { ActivatedRoute } from '@angular/router'
7 import { Notifier, PeerTubeRouterService } from '@app/core'
8 import { NumberFormatterPipe, VideoDetails } from '@app/shared/shared-main'
9 import { LiveVideoService } from '@app/shared/shared-video-live'
10 import { secondsToTime } from '@shared/core-utils'
11 import { HttpStatusCode } from '@shared/models/http'
12 import {
13 LiveVideoSession,
14 VideoStatsOverall,
15 VideoStatsRetention,
16 VideoStatsTimeserie,
17 VideoStatsTimeserieMetric
18 } from '@shared/models/videos'
19 import { VideoStatsService } from './video-stats.service'
20
21 type ActiveGraphId = VideoStatsTimeserieMetric | 'retention' | 'countries'
22
23 type CountryData = { name: string, viewers: number }[]
24
25 type ChartIngestData = VideoStatsTimeserie | VideoStatsRetention | CountryData
26 type ChartBuilderResult = {
27 type: 'line' | 'bar'
28 plugins: Partial<PluginOptionsByType<'line' | 'bar'>>
29 data: ChartData<'line' | 'bar'>
30 displayLegend: boolean
31 }
32
33 type Card = { label: string, value: string | number, moreInfo?: string, help?: string }
34
35 @Component({
36 templateUrl: './video-stats.component.html',
37 styleUrls: [ './video-stats.component.scss' ],
38 providers: [ NumberFormatterPipe ]
39 })
40 export class VideoStatsComponent implements OnInit {
41 // Cannot handle date filters
42 globalStatsCards: Card[] = []
43 // Can handle date filters
44 overallStatCards: Card[] = []
45
46 chartOptions: { [ id in ActiveGraphId ]?: ChartConfiguration<'line' | 'bar'> } = {}
47 chartHeight = '300px'
48 chartWidth: string = null
49
50 availableCharts: { id: string, label: string, zoomEnabled: boolean }[] = []
51 activeGraphId: ActiveGraphId = 'viewers'
52
53 video: VideoDetails
54
55 countries: CountryData = []
56
57 chartPlugins = [ zoomPlugin ]
58
59 currentDateFilter = 'all'
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
70 private chartIngestData: { [ id in ActiveGraphId ]?: ChartIngestData } = {}
71
72 constructor (
73 private route: ActivatedRoute,
74 private notifier: Notifier,
75 private statsService: VideoStatsService,
76 private peertubeRouter: PeerTubeRouterService,
77 private numberFormatter: NumberFormatterPipe,
78 private liveService: LiveVideoService
79 ) {}
80
81 ngOnInit () {
82 this.video = this.route.snapshot.data.video
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
115 this.route.queryParams.subscribe(params => {
116 this.statsStartDate = params.startDate
117 ? new Date(params.startDate)
118 : undefined
119
120 this.statsEndDate = params.endDate
121 ? new Date(params.endDate)
122 : undefined
123
124 this.loadChart()
125 this.loadOverallStats()
126 })
127
128 this.loadDateFilters()
129 }
130
131 hasCountries () {
132 return this.countries.length !== 0
133 }
134
135 onChartChange (newActive: ActiveGraphId) {
136 this.activeGraphId = newActive
137
138 this.loadChart()
139 }
140
141 resetZoom () {
142 this.peertubeRouter.silentNavigate([], {})
143 this.removeAndResetCustomDateFilter()
144 }
145
146 hasZoom () {
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 }
167 }
168
169 private isTimeserieGraph (graphId: ActiveGraphId) {
170 return graphId === 'aggregateWatchTime' || graphId === 'viewers'
171 }
172
173 private loadOverallStats () {
174 this.statsService.getOverallStats({ videoId: this.video.uuid, startDate: this.statsStartDate, endDate: this.statsEndDate })
175 .subscribe({
176 next: res => {
177 this.countries = res.countries.slice(0, 10).map(c => ({
178 name: this.countryCodeToName(c.isoCode),
179 viewers: c.viewers
180 }))
181
182 this.buildOverallStatCard(res)
183 },
184
185 error: err => this.notifier.error(err.message)
186 })
187 }
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`Live as 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
251 private buildOverallStatCard (overallStats: VideoStatsOverall) {
252 this.globalStatsCards = [
253 {
254 label: $localize`Views`,
255 value: this.numberFormatter.transform(this.video.views),
256 help: $localize`A view means that someone watched the video for at least 30 seconds`
257 },
258 {
259 label: $localize`Likes`,
260 value: this.numberFormatter.transform(this.video.likes)
261 }
262 ]
263
264 this.overallStatCards = [
265 {
266 label: $localize`Average watch time`,
267 value: secondsToTime(overallStats.averageWatchTime)
268 },
269 {
270 label: $localize`Total watch time`,
271 value: secondsToTime(overallStats.totalWatchTime)
272 },
273 {
274 label: $localize`Peak viewers`,
275 value: this.numberFormatter.transform(overallStats.viewersPeak),
276 moreInfo: overallStats.viewersPeak !== 0
277 ? $localize`at ${new Date(overallStats.viewersPeakDate).toLocaleString()}`
278 : undefined
279 },
280 {
281 label: $localize`Unique viewers`,
282 value: this.numberFormatter.transform(overallStats.totalViewers)
283 }
284 ]
285
286 if (overallStats.countries.length !== 0) {
287 this.overallStatCards.push({
288 label: $localize`Countries`,
289 value: this.numberFormatter.transform(overallStats.countries.length)
290 })
291 }
292 }
293
294 private loadChart () {
295 const obsBuilders: { [ id in ActiveGraphId ]: Observable<ChartIngestData> } = {
296 retention: this.statsService.getRetentionStats(this.video.uuid),
297
298 aggregateWatchTime: this.statsService.getTimeserieStats({
299 videoId: this.video.uuid,
300 startDate: this.statsStartDate,
301 endDate: this.statsEndDate,
302 metric: 'aggregateWatchTime'
303 }),
304 viewers: this.statsService.getTimeserieStats({
305 videoId: this.video.uuid,
306 startDate: this.statsStartDate,
307 endDate: this.statsEndDate,
308 metric: 'viewers'
309 }),
310
311 countries: of(this.countries)
312 }
313
314 obsBuilders[this.activeGraphId].subscribe({
315 next: res => {
316 this.chartIngestData[this.activeGraphId] = res
317
318 this.chartOptions[this.activeGraphId] = this.buildChartOptions(this.activeGraphId)
319 },
320
321 error: err => this.notifier.error(err.message)
322 })
323 }
324
325 private buildChartOptions (graphId: ActiveGraphId): ChartConfiguration<'line' | 'bar'> {
326 const dataBuilders: {
327 [ id in ActiveGraphId ]: (rawData: ChartIngestData) => ChartBuilderResult
328 } = {
329 retention: (rawData: VideoStatsRetention) => this.buildRetentionChartOptions(rawData),
330 aggregateWatchTime: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData),
331 viewers: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData),
332 countries: (rawData: CountryData) => this.buildCountryChartOptions(rawData)
333 }
334
335 const { type, data, displayLegend, plugins } = dataBuilders[graphId](this.chartIngestData[graphId])
336
337 const self = this
338
339 return {
340 type,
341 data,
342
343 options: {
344 responsive: true,
345
346 scales: {
347 x: {
348 ticks: {
349 callback: function (value) {
350 return self.formatXTick({
351 graphId,
352 value,
353 data: self.chartIngestData[graphId] as VideoStatsTimeserie,
354 scale: this
355 })
356 }
357 }
358 },
359
360 y: {
361 beginAtZero: true,
362
363 max: this.activeGraphId === 'retention'
364 ? 100
365 : undefined,
366
367 ticks: {
368 callback: value => this.formatYTick({ graphId, value })
369 }
370 }
371 },
372
373 plugins: {
374 legend: {
375 display: displayLegend
376 },
377 tooltip: {
378 callbacks: {
379 title: items => this.formatTooltipTitle({ graphId, items }),
380 label: value => this.formatYTick({ graphId, value: value.raw as number | string })
381 }
382 },
383
384 ...plugins
385 }
386 }
387 }
388 }
389
390 private buildRetentionChartOptions (rawData: VideoStatsRetention): ChartBuilderResult {
391 const labels: string[] = []
392 const data: number[] = []
393
394 for (const d of rawData.data) {
395 labels.push(secondsToTime(d.second))
396 data.push(d.retentionPercent)
397 }
398
399 return {
400 type: 'line' as 'line',
401
402 displayLegend: false,
403
404 plugins: {
405 ...this.buildDisabledZoomPlugin()
406 },
407
408 data: {
409 labels,
410 datasets: [
411 {
412 data,
413 borderColor: this.buildChartColor()
414 }
415 ]
416 }
417 }
418 }
419
420 private buildTimeserieChartOptions (rawData: VideoStatsTimeserie): ChartBuilderResult {
421 const labels: string[] = []
422 const data: number[] = []
423
424 for (const d of rawData.data) {
425 labels.push(d.date)
426 data.push(d.value)
427 }
428
429 return {
430 type: 'line' as 'line',
431
432 displayLegend: false,
433
434 plugins: {
435 zoom: {
436 zoom: {
437 wheel: {
438 enabled: false
439 },
440 drag: {
441 enabled: true
442 },
443 pinch: {
444 enabled: true
445 },
446 mode: 'x',
447 onZoomComplete: ({ chart }) => {
448 const { min, max } = chart.scales.x
449
450 const startDate = rawData.data[min].date
451 const endDate = this.buildZoomEndDate(rawData.groupInterval, rawData.data[max].date)
452
453 this.peertubeRouter.silentNavigate([], { startDate, endDate })
454 this.addAndSelectCustomDateFilter()
455 }
456 },
457 limits: {
458 x: {
459 minRange: 2
460 }
461 }
462 }
463 },
464
465 data: {
466 labels,
467 datasets: [
468 {
469 data,
470 borderColor: this.buildChartColor()
471 }
472 ]
473 }
474 }
475 }
476
477 private buildCountryChartOptions (rawData: CountryData): ChartBuilderResult {
478 const labels: string[] = []
479 const data: number[] = []
480
481 for (const d of rawData) {
482 labels.push(d.name)
483 data.push(d.viewers)
484 }
485
486 return {
487 type: 'bar' as 'bar',
488
489 displayLegend: true,
490
491 plugins: {
492 ...this.buildDisabledZoomPlugin()
493 },
494
495 data: {
496 labels,
497 datasets: [
498 {
499 label: $localize`Viewers`,
500 backgroundColor: this.buildChartColor(),
501 maxBarThickness: 20,
502 data
503 }
504 ]
505 }
506 }
507 }
508
509 private buildChartColor () {
510 return getComputedStyle(document.body).getPropertyValue('--mainColorLighter')
511 }
512
513 private formatXTick (options: {
514 graphId: ActiveGraphId
515 value: number | string
516 data: VideoStatsTimeserie
517 scale: Scale
518 }) {
519 const { graphId, value, data, scale } = options
520
521 const label = scale.getLabelForValue(value as number)
522
523 if (!this.isTimeserieGraph(graphId)) {
524 return label
525 }
526
527 const date = new Date(label)
528
529 if (data.groupInterval.match(/ month?$/)) {
530 return date.toLocaleDateString([], { month: 'numeric' })
531 }
532
533 if (data.groupInterval.match(/ days?$/)) {
534 return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' })
535 }
536
537 if (data.groupInterval.match(/ hours?$/)) {
538 return date.toLocaleTimeString([], { month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric' })
539 }
540
541 return date.toLocaleTimeString([], { hour: 'numeric', minute: 'numeric' })
542 }
543
544 private formatYTick (options: {
545 graphId: ActiveGraphId
546 value: number | string
547 }) {
548 const { graphId, value } = options
549
550 if (graphId === 'retention') return value + ' %'
551 if (graphId === 'aggregateWatchTime') return secondsToTime(+value)
552
553 return value.toLocaleString()
554 }
555
556 private formatTooltipTitle (options: {
557 graphId: ActiveGraphId
558 items: TooltipItem<any>[]
559 }) {
560 const { graphId, items } = options
561 const item = items[0]
562
563 if (this.isTimeserieGraph(graphId)) return new Date(item.label).toLocaleString()
564
565 return item.label
566 }
567
568 private countryCodeToName (code: string) {
569 const intl: any = Intl
570 if (!intl.DisplayNames) return code
571
572 const regionNames = new intl.DisplayNames([], { type: 'region' })
573
574 return regionNames.of(code)
575 }
576
577 private buildDisabledZoomPlugin () {
578 return {
579 zoom: {
580 zoom: {
581 wheel: {
582 enabled: false
583 },
584 drag: {
585 enabled: false
586 },
587 pinch: {
588 enabled: false
589 }
590 }
591 }
592 }
593 }
594
595 private buildZoomEndDate (groupInterval: string, last: string) {
596 const date = new Date(last)
597
598 // Remove parts of the date we don't need
599 if (groupInterval.endsWith(' day') || groupInterval.endsWith(' days')) {
600 date.setHours(23, 59, 59)
601 } else if (groupInterval.endsWith(' hour') || groupInterval.endsWith(' hours')) {
602 date.setMinutes(59, 59)
603 } else {
604 date.setSeconds(59)
605 }
606
607 return date.toISOString()
608 }
609 }