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