]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - client/src/app/+stats/video/video-stats.component.ts
Fix angular build, again
[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, Inject, LOCALE_ID, 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 @Inject(LOCALE_ID) private localeId: string,
74 private route: ActivatedRoute,
75 private notifier: Notifier,
76 private statsService: VideoStatsService,
77 private peertubeRouter: PeerTubeRouterService,
78 private numberFormatter: NumberFormatterPipe,
79 private liveService: LiveVideoService
80 ) {}
81
82 ngOnInit () {
83 this.video = this.route.snapshot.data.video
84
85 this.availableCharts = [
86 {
87 id: 'viewers',
88 label: $localize`Viewers`,
89 zoomEnabled: true
90 },
91 {
92 id: 'aggregateWatchTime',
93 label: $localize`Watch time`,
94 zoomEnabled: true
95 },
96 {
97 id: 'countries',
98 label: $localize`Countries`,
99 zoomEnabled: false
100 }
101 ]
102
103 if (!this.video.isLive) {
104 this.availableCharts.push({
105 id: 'retention',
106 label: $localize`Retention`,
107 zoomEnabled: false
108 })
109 }
110
111 const snapshotQuery = this.route.snapshot.queryParams
112 if (snapshotQuery.startDate || snapshotQuery.endDate) {
113 this.addAndSelectCustomDateFilter()
114 }
115
116 this.route.queryParams.subscribe(params => {
117 this.statsStartDate = params.startDate
118 ? new Date(params.startDate)
119 : undefined
120
121 this.statsEndDate = params.endDate
122 ? new Date(params.endDate)
123 : undefined
124
125 this.loadChart()
126 this.loadOverallStats()
127 })
128
129 this.loadDateFilters()
130 }
131
132 hasCountries () {
133 return this.countries.length !== 0
134 }
135
136 onChartChange (newActive: ActiveGraphId) {
137 this.activeGraphId = newActive
138
139 this.loadChart()
140 }
141
142 resetZoom () {
143 this.peertubeRouter.silentNavigate([], {})
144 this.removeAndResetCustomDateFilter()
145 }
146
147 hasZoom () {
148 return !!this.statsStartDate && this.isTimeserieGraph(this.activeGraphId)
149 }
150
151 getViewersStatsTitle () {
152 if (this.statsStartDate && this.statsEndDate) {
153 return $localize`Viewers stats between ${this.toMediumDate(this.statsStartDate)} and ${this.toMediumDate(this.statsEndDate)}`
154 }
155
156 return $localize`Viewers stats`
157 }
158
159 onDateFilterChange () {
160 if (this.currentDateFilter === 'all') {
161 return this.resetZoom()
162 }
163
164 const idParts = this.currentDateFilter.split('|')
165 if (idParts.length === 2) {
166 return this.peertubeRouter.silentNavigate([], { startDate: idParts[0], endDate: idParts[1] })
167 }
168 }
169
170 private isTimeserieGraph (graphId: ActiveGraphId) {
171 return graphId === 'aggregateWatchTime' || graphId === 'viewers'
172 }
173
174 private loadOverallStats () {
175 this.statsService.getOverallStats({ videoId: this.video.uuid, startDate: this.statsStartDate, endDate: this.statsEndDate })
176 .subscribe({
177 next: res => {
178 this.countries = res.countries.map(c => ({
179 name: this.countryCodeToName(c.isoCode),
180 viewers: c.viewers
181 }))
182
183 this.buildOverallStatCard(res)
184 },
185
186 error: err => this.notifier.error(err.message)
187 })
188 }
189
190 private loadDateFilters () {
191 if (this.video.isLive) return this.loadLiveDateFilters()
192
193 return this.loadVODDateFilters()
194 }
195
196 private loadLiveDateFilters () {
197 this.liveService.listSessions(this.video.id)
198 .subscribe({
199 next: ({ data }) => {
200 const newFilters = data.map(session => this.buildLiveFilter(session))
201
202 this.dateFilters = this.dateFilters.concat(newFilters)
203 },
204
205 error: err => this.notifier.error(err.message)
206 })
207 }
208
209 private loadVODDateFilters () {
210 this.liveService.findLiveSessionFromVOD(this.video.id)
211 .subscribe({
212 next: session => {
213 this.dateFilters = this.dateFilters.concat([ this.buildLiveFilter(session) ])
214 },
215
216 error: err => {
217 if (err.status === HttpStatusCode.NOT_FOUND_404) return
218
219 this.notifier.error(err.message)
220 }
221 })
222 }
223
224 private buildLiveFilter (session: LiveVideoSession) {
225 return {
226 id: session.startDate + '|' + session.endDate,
227 label: $localize`Live as of ${this.toMediumDate(new Date(session.startDate))}`
228 }
229 }
230
231 private addAndSelectCustomDateFilter () {
232 const exists = this.dateFilters.some(d => d.id === 'custom')
233
234 if (!exists) {
235 this.dateFilters = this.dateFilters.concat([
236 {
237 id: 'custom',
238 label: $localize`Custom dates`
239 }
240 ])
241 }
242
243 this.currentDateFilter = 'custom'
244 }
245
246 private removeAndResetCustomDateFilter () {
247 this.dateFilters = this.dateFilters.filter(d => d.id !== 'custom')
248
249 this.currentDateFilter = 'all'
250 }
251
252 private buildOverallStatCard (overallStats: VideoStatsOverall) {
253 this.globalStatsCards = [
254 {
255 label: $localize`Views`,
256 value: this.numberFormatter.transform(this.video.views),
257 help: $localize`A view means that someone watched the video for at least 30 seconds`
258 },
259 {
260 label: $localize`Likes`,
261 value: this.numberFormatter.transform(this.video.likes)
262 }
263 ]
264
265 this.overallStatCards = [
266 {
267 label: $localize`Average watch time`,
268 value: secondsToTime(overallStats.averageWatchTime)
269 },
270 {
271 label: $localize`Total watch time`,
272 value: secondsToTime(overallStats.totalWatchTime)
273 },
274 {
275 label: $localize`Peak viewers`,
276 value: this.numberFormatter.transform(overallStats.viewersPeak),
277 moreInfo: overallStats.viewersPeak !== 0
278 ? $localize`at ${this.toMediumDate(new Date(overallStats.viewersPeakDate))}`
279 : undefined
280 },
281 {
282 label: $localize`Unique viewers`,
283 value: this.numberFormatter.transform(overallStats.totalViewers)
284 }
285 ]
286
287 if (overallStats.countries.length !== 0) {
288 this.overallStatCards.push({
289 label: $localize`Countries`,
290 value: this.numberFormatter.transform(overallStats.countries.length)
291 })
292 }
293 }
294
295 private loadChart () {
296 const obsBuilders: { [ id in ActiveGraphId ]: Observable<ChartIngestData> } = {
297 retention: this.statsService.getRetentionStats(this.video.uuid),
298
299 aggregateWatchTime: this.statsService.getTimeserieStats({
300 videoId: this.video.uuid,
301 startDate: this.statsStartDate,
302 endDate: this.statsEndDate,
303 metric: 'aggregateWatchTime'
304 }),
305 viewers: this.statsService.getTimeserieStats({
306 videoId: this.video.uuid,
307 startDate: this.statsStartDate,
308 endDate: this.statsEndDate,
309 metric: 'viewers'
310 }),
311
312 countries: of(this.countries)
313 }
314
315 obsBuilders[this.activeGraphId].subscribe({
316 next: res => {
317 this.chartIngestData[this.activeGraphId] = res
318
319 this.chartOptions[this.activeGraphId] = this.buildChartOptions(this.activeGraphId)
320 },
321
322 error: err => this.notifier.error(err.message)
323 })
324 }
325
326 private buildChartOptions (graphId: ActiveGraphId): ChartConfiguration<'line' | 'bar'> {
327 const dataBuilders: {
328 [ id in ActiveGraphId ]: (rawData: ChartIngestData) => ChartBuilderResult
329 } = {
330 retention: (rawData: VideoStatsRetention) => this.buildRetentionChartOptions(rawData),
331 aggregateWatchTime: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData),
332 viewers: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData),
333 countries: (rawData: CountryData) => this.buildCountryChartOptions(rawData)
334 }
335
336 const { type, data, displayLegend, plugins } = dataBuilders[graphId](this.chartIngestData[graphId])
337
338 const self = this
339
340 return {
341 type,
342 data,
343
344 options: {
345 responsive: true,
346
347 scales: {
348 x: {
349 ticks: {
350 callback: function (value) {
351 return self.formatXTick({
352 graphId,
353 value,
354 data: self.chartIngestData[graphId] as VideoStatsTimeserie,
355 scale: this
356 })
357 }
358 }
359 },
360
361 y: {
362 beginAtZero: true,
363
364 max: this.activeGraphId === 'retention'
365 ? 100
366 : undefined,
367
368 ticks: {
369 callback: value => this.formatYTick({ graphId, value })
370 }
371 }
372 },
373
374 plugins: {
375 legend: {
376 display: displayLegend
377 },
378 tooltip: {
379 callbacks: {
380 title: items => this.formatTooltipTitle({ graphId, items }),
381 label: value => this.formatYTick({ graphId, value: value.raw as number | string })
382 }
383 },
384
385 ...plugins
386 }
387 }
388 }
389 }
390
391 private buildRetentionChartOptions (rawData: VideoStatsRetention): ChartBuilderResult {
392 const labels: string[] = []
393 const data: number[] = []
394
395 for (const d of rawData.data) {
396 labels.push(secondsToTime(d.second))
397 data.push(d.retentionPercent)
398 }
399
400 return {
401 type: 'line' as 'line',
402
403 displayLegend: false,
404
405 plugins: {
406 ...this.buildDisabledZoomPlugin()
407 },
408
409 data: {
410 labels,
411 datasets: [
412 {
413 data,
414 borderColor: this.buildChartColor()
415 }
416 ]
417 }
418 }
419 }
420
421 private buildTimeserieChartOptions (rawData: VideoStatsTimeserie): ChartBuilderResult {
422 const labels: string[] = []
423 const data: number[] = []
424
425 for (const d of rawData.data) {
426 labels.push(d.date)
427 data.push(d.value)
428 }
429
430 return {
431 type: 'line' as 'line',
432
433 displayLegend: false,
434
435 plugins: {
436 zoom: {
437 zoom: {
438 wheel: {
439 enabled: false
440 },
441 drag: {
442 enabled: true
443 },
444 pinch: {
445 enabled: true
446 },
447 mode: 'x',
448 onZoomComplete: ({ chart }) => {
449 const { min, max } = chart.scales.x
450
451 const startDate = rawData.data[min].date
452 const endDate = this.buildZoomEndDate(rawData.groupInterval, rawData.data[max].date)
453
454 this.peertubeRouter.silentNavigate([], { startDate, endDate })
455 this.addAndSelectCustomDateFilter()
456 }
457 },
458 limits: {
459 x: {
460 minRange: 2
461 }
462 }
463 }
464 },
465
466 data: {
467 labels,
468 datasets: [
469 {
470 data,
471 borderColor: this.buildChartColor()
472 }
473 ]
474 }
475 }
476 }
477
478 private buildCountryChartOptions (rawData: CountryData): ChartBuilderResult {
479 const labels: string[] = []
480 const data: number[] = []
481
482 for (const d of rawData) {
483 labels.push(d.name)
484 data.push(d.viewers)
485 }
486
487 return {
488 type: 'bar' as 'bar',
489
490 displayLegend: true,
491
492 plugins: {
493 ...this.buildDisabledZoomPlugin()
494 },
495
496 data: {
497 labels,
498 datasets: [
499 {
500 label: $localize`Viewers`,
501 backgroundColor: this.buildChartColor(),
502 maxBarThickness: 20,
503 data
504 }
505 ]
506 }
507 }
508 }
509
510 private buildChartColor () {
511 return getComputedStyle(document.body).getPropertyValue('--mainColorLighter')
512 }
513
514 private formatXTick (options: {
515 graphId: ActiveGraphId
516 value: number | string
517 data: VideoStatsTimeserie
518 scale: Scale
519 }) {
520 const { graphId, value, data, scale } = options
521
522 const label = scale.getLabelForValue(value as number)
523
524 if (!this.isTimeserieGraph(graphId)) {
525 return label
526 }
527
528 const date = new Date(label)
529
530 if (data.groupInterval.match(/ month?$/)) {
531 return date.toLocaleDateString([], { year: '2-digit', month: 'numeric' })
532 }
533
534 if (data.groupInterval.match(/ days?$/)) {
535 return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' })
536 }
537
538 if (data.groupInterval.match(/ hours?$/)) {
539 return date.toLocaleTimeString([], { month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric' })
540 }
541
542 return date.toLocaleTimeString([], { hour: 'numeric', minute: 'numeric' })
543 }
544
545 private formatYTick (options: {
546 graphId: ActiveGraphId
547 value: number | string
548 }) {
549 const { graphId, value } = options
550
551 if (graphId === 'retention') return value + ' %'
552 if (graphId === 'aggregateWatchTime') return secondsToTime(+value)
553
554 return value.toLocaleString(this.localeId)
555 }
556
557 private formatTooltipTitle (options: {
558 graphId: ActiveGraphId
559 items: TooltipItem<any>[]
560 }) {
561 const { graphId, items } = options
562 const item = items[0]
563
564 if (this.isTimeserieGraph(graphId)) {
565 return this.toMediumDate(new Date(item.label))
566 }
567
568 return item.label
569 }
570
571 private countryCodeToName (code: string) {
572 const intl: any = Intl
573 if (!intl.DisplayNames) return code
574
575 const regionNames = new intl.DisplayNames([], { type: 'region' })
576
577 return regionNames.of(code)
578 }
579
580 private buildDisabledZoomPlugin () {
581 return {
582 zoom: {
583 zoom: {
584 wheel: {
585 enabled: false
586 },
587 drag: {
588 enabled: false
589 },
590 pinch: {
591 enabled: false
592 }
593 }
594 }
595 }
596 }
597
598 private toMediumDate (d: Date) {
599 return new Date(d).toLocaleString(this.localeId, {
600 day: 'numeric',
601 month: 'short',
602 year: 'numeric',
603 hour: 'numeric',
604 minute: 'numeric',
605 second: 'numeric'
606 })
607 }
608
609 private buildZoomEndDate (groupInterval: string, last: string) {
610 const date = new Date(last)
611
612 // Remove parts of the date we don't need
613 if (groupInterval.endsWith(' day') || groupInterval.endsWith(' days')) {
614 date.setHours(23, 59, 59)
615 } else if (groupInterval.endsWith(' hour') || groupInterval.endsWith(' hours')) {
616 date.setMinutes(59, 59)
617 } else {
618 date.setSeconds(59)
619 }
620
621 return date.toISOString()
622 }
623 }