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'
17 VideoStatsTimeserieMetric
18 } from '@shared/models/videos'
19 import { VideoStatsService } from './video-stats.service'
21 type ActiveGraphId = VideoStatsTimeserieMetric | 'retention' | 'countries'
23 type CountryData = { name: string, viewers: number }[]
25 type ChartIngestData = VideoStatsTimeserie | VideoStatsRetention | CountryData
26 type ChartBuilderResult = {
28 plugins: Partial<PluginOptionsByType<'line' | 'bar'>>
29 data: ChartData<'line' | 'bar'>
30 displayLegend: boolean
33 type Card = { label: string, value: string | number, moreInfo?: string, help?: string }
36 templateUrl: './video-stats.component.html',
37 styleUrls: [ './video-stats.component.scss' ],
38 providers: [ NumberFormatterPipe ]
40 export class VideoStatsComponent implements OnInit {
41 // Cannot handle date filters
42 globalStatsCards: Card[] = []
43 // Can handle date filters
44 overallStatCards: Card[] = []
46 chartOptions: { [ id in ActiveGraphId ]?: ChartConfiguration<'line' | 'bar'> } = {}
48 chartWidth: string = null
50 availableCharts: { id: string, label: string, zoomEnabled: boolean }[] = []
51 activeGraphId: ActiveGraphId = 'viewers'
55 countries: CountryData = []
57 chartPlugins = [ zoomPlugin ]
59 currentDateFilter = 'all'
60 dateFilters: SelectOptionsItem[] = [
63 label: $localize`Since the video publication`
67 private statsStartDate: Date
68 private statsEndDate: Date
70 private chartIngestData: { [ id in ActiveGraphId ]?: ChartIngestData } = {}
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
83 this.video = this.route.snapshot.data.video
85 this.availableCharts = [
88 label: $localize`Viewers`,
92 id: 'aggregateWatchTime',
93 label: $localize`Watch time`,
98 label: $localize`Countries`,
103 if (!this.video.isLive) {
104 this.availableCharts.push({
106 label: $localize`Retention`,
111 const snapshotQuery = this.route.snapshot.queryParams
112 if (snapshotQuery.startDate || snapshotQuery.endDate) {
113 this.addAndSelectCustomDateFilter()
116 this.route.queryParams.subscribe(params => {
117 this.statsStartDate = params.startDate
118 ? new Date(params.startDate)
121 this.statsEndDate = params.endDate
122 ? new Date(params.endDate)
126 this.loadOverallStats()
129 this.loadDateFilters()
133 return this.countries.length !== 0
136 onChartChange (newActive: ActiveGraphId) {
137 this.activeGraphId = newActive
143 this.peertubeRouter.silentNavigate([], {})
144 this.removeAndResetCustomDateFilter()
148 return !!this.statsStartDate && this.isTimeserieGraph(this.activeGraphId)
151 getViewersStatsTitle () {
152 if (this.statsStartDate && this.statsEndDate) {
153 return $localize`Viewers stats between ${this.toMediumDate(this.statsStartDate)} and ${this.toMediumDate(this.statsEndDate)}`
156 return $localize`Viewers stats`
159 onDateFilterChange () {
160 if (this.currentDateFilter === 'all') {
161 return this.resetZoom()
164 const idParts = this.currentDateFilter.split('|')
165 if (idParts.length === 2) {
166 return this.peertubeRouter.silentNavigate([], { startDate: idParts[0], endDate: idParts[1] })
170 private isTimeserieGraph (graphId: ActiveGraphId) {
171 return graphId === 'aggregateWatchTime' || graphId === 'viewers'
174 private loadOverallStats () {
175 this.statsService.getOverallStats({ videoId: this.video.uuid, startDate: this.statsStartDate, endDate: this.statsEndDate })
178 this.countries = res.countries.map(c => ({
179 name: this.countryCodeToName(c.isoCode),
183 this.buildOverallStatCard(res)
186 error: err => this.notifier.error(err.message)
190 private loadDateFilters () {
191 if (this.video.isLive) return this.loadLiveDateFilters()
193 return this.loadVODDateFilters()
196 private loadLiveDateFilters () {
197 this.liveService.listSessions(this.video.id)
199 next: ({ data }) => {
200 const newFilters = data.map(session => this.buildLiveFilter(session))
202 this.dateFilters = this.dateFilters.concat(newFilters)
205 error: err => this.notifier.error(err.message)
209 private loadVODDateFilters () {
210 this.liveService.findLiveSessionFromVOD(this.video.id)
213 this.dateFilters = this.dateFilters.concat([ this.buildLiveFilter(session) ])
217 if (err.status === HttpStatusCode.NOT_FOUND_404) return
219 this.notifier.error(err.message)
224 private buildLiveFilter (session: LiveVideoSession) {
226 id: session.startDate + '|' + session.endDate,
227 label: $localize`Live as of ${this.toMediumDate(new Date(session.startDate))}`
231 private addAndSelectCustomDateFilter () {
232 const exists = this.dateFilters.some(d => d.id === 'custom')
235 this.dateFilters = this.dateFilters.concat([
238 label: $localize`Custom dates`
243 this.currentDateFilter = 'custom'
246 private removeAndResetCustomDateFilter () {
247 this.dateFilters = this.dateFilters.filter(d => d.id !== 'custom')
249 this.currentDateFilter = 'all'
252 private buildOverallStatCard (overallStats: VideoStatsOverall) {
253 this.globalStatsCards = [
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`
260 label: $localize`Likes`,
261 value: this.numberFormatter.transform(this.video.likes)
265 this.overallStatCards = [
267 label: $localize`Average watch time`,
268 value: secondsToTime(overallStats.averageWatchTime)
271 label: $localize`Total watch time`,
272 value: secondsToTime(overallStats.totalWatchTime)
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))}`
282 label: $localize`Unique viewers`,
283 value: this.numberFormatter.transform(overallStats.totalViewers)
287 if (overallStats.countries.length !== 0) {
288 this.overallStatCards.push({
289 label: $localize`Countries`,
290 value: this.numberFormatter.transform(overallStats.countries.length)
295 private loadChart () {
296 const obsBuilders: { [ id in ActiveGraphId ]: Observable<ChartIngestData> } = {
297 retention: this.statsService.getRetentionStats(this.video.uuid),
299 aggregateWatchTime: this.statsService.getTimeserieStats({
300 videoId: this.video.uuid,
301 startDate: this.statsStartDate,
302 endDate: this.statsEndDate,
303 metric: 'aggregateWatchTime'
305 viewers: this.statsService.getTimeserieStats({
306 videoId: this.video.uuid,
307 startDate: this.statsStartDate,
308 endDate: this.statsEndDate,
312 countries: of(this.countries)
315 obsBuilders[this.activeGraphId].subscribe({
317 this.chartIngestData[this.activeGraphId] = res
319 this.chartOptions[this.activeGraphId] = this.buildChartOptions(this.activeGraphId)
322 error: err => this.notifier.error(err.message)
326 private buildChartOptions (graphId: ActiveGraphId): ChartConfiguration<'line' | 'bar'> {
327 const dataBuilders: {
328 [ id in ActiveGraphId ]: (rawData: ChartIngestData) => ChartBuilderResult
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)
336 const { type, data, displayLegend, plugins } = dataBuilders[graphId](this.chartIngestData[graphId])
350 callback: function (value) {
351 return self.formatXTick({
354 data: self.chartIngestData[graphId] as VideoStatsTimeserie,
364 max: this.activeGraphId === 'retention'
369 callback: value => this.formatYTick({ graphId, value })
376 display: displayLegend
380 title: items => this.formatTooltipTitle({ graphId, items }),
381 label: value => this.formatYTick({ graphId, value: value.raw as number | string })
391 private buildRetentionChartOptions (rawData: VideoStatsRetention): ChartBuilderResult {
392 const labels: string[] = []
393 const data: number[] = []
395 for (const d of rawData.data) {
396 labels.push(secondsToTime(d.second))
397 data.push(d.retentionPercent)
401 type: 'line' as 'line',
403 displayLegend: false,
406 ...this.buildDisabledZoomPlugin()
414 borderColor: this.buildChartColor()
421 private buildTimeserieChartOptions (rawData: VideoStatsTimeserie): ChartBuilderResult {
422 const labels: string[] = []
423 const data: number[] = []
425 for (const d of rawData.data) {
431 type: 'line' as 'line',
433 displayLegend: false,
448 onZoomComplete: ({ chart }) => {
449 const { min, max } = chart.scales.x
451 const startDate = rawData.data[min].date
452 const endDate = this.buildZoomEndDate(rawData.groupInterval, rawData.data[max].date)
454 this.peertubeRouter.silentNavigate([], { startDate, endDate })
455 this.addAndSelectCustomDateFilter()
471 borderColor: this.buildChartColor()
478 private buildCountryChartOptions (rawData: CountryData): ChartBuilderResult {
479 const labels: string[] = []
480 const data: number[] = []
482 for (const d of rawData) {
488 type: 'bar' as 'bar',
493 ...this.buildDisabledZoomPlugin()
500 label: $localize`Viewers`,
501 backgroundColor: this.buildChartColor(),
510 private buildChartColor () {
511 return getComputedStyle(document.body).getPropertyValue('--mainColorLighter')
514 private formatXTick (options: {
515 graphId: ActiveGraphId
516 value: number | string
517 data: VideoStatsTimeserie
520 const { graphId, value, data, scale } = options
522 const label = scale.getLabelForValue(value as number)
524 if (!this.isTimeserieGraph(graphId)) {
528 const date = new Date(label)
530 if (data.groupInterval.match(/ month?$/)) {
531 return date.toLocaleDateString([], { year: '2-digit', month: 'numeric' })
534 if (data.groupInterval.match(/ days?$/)) {
535 return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' })
538 if (data.groupInterval.match(/ hours?$/)) {
539 return date.toLocaleTimeString([], { month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric' })
542 return date.toLocaleTimeString([], { hour: 'numeric', minute: 'numeric' })
545 private formatYTick (options: {
546 graphId: ActiveGraphId
547 value: number | string
549 const { graphId, value } = options
551 if (graphId === 'retention') return value + ' %'
552 if (graphId === 'aggregateWatchTime') return secondsToTime(+value)
554 return value.toLocaleString(this.localeId)
557 private formatTooltipTitle (options: {
558 graphId: ActiveGraphId
559 items: TooltipItem<any>[]
561 const { graphId, items } = options
562 const item = items[0]
564 if (this.isTimeserieGraph(graphId)) {
565 return this.toMediumDate(new Date(item.label))
571 private countryCodeToName (code: string) {
572 const intl: any = Intl
573 if (!intl.DisplayNames) return code
575 const regionNames = new intl.DisplayNames([], { type: 'region' })
577 return regionNames.of(code)
580 private buildDisabledZoomPlugin () {
598 private toMediumDate (d: Date) {
599 return new Date(d).toLocaleString(this.localeId, {
609 private buildZoomEndDate (groupInterval: string, last: string) {
610 const date = new Date(last)
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)
621 return date.toISOString()