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'
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 private route: ActivatedRoute,
74 private notifier: Notifier,
75 private statsService: VideoStatsService,
76 private peertubeRouter: PeerTubeRouterService,
77 private numberFormatter: NumberFormatterPipe,
78 private liveService: LiveVideoService
82 this.video = this.route.snapshot.data.video
84 this.availableCharts = [
87 label: $localize`Viewers`,
91 id: 'aggregateWatchTime',
92 label: $localize`Watch time`,
97 label: $localize`Countries`,
102 if (!this.video.isLive) {
103 this.availableCharts.push({
105 label: $localize`Retention`,
110 const snapshotQuery = this.route.snapshot.queryParams
111 if (snapshotQuery.startDate || snapshotQuery.endDate) {
112 this.addAndSelectCustomDateFilter()
115 this.route.queryParams.subscribe(params => {
116 this.statsStartDate = params.startDate
117 ? new Date(params.startDate)
120 this.statsEndDate = params.endDate
121 ? new Date(params.endDate)
125 this.loadOverallStats()
128 this.loadDateFilters()
132 return this.countries.length !== 0
135 onChartChange (newActive: ActiveGraphId) {
136 this.activeGraphId = newActive
142 this.peertubeRouter.silentNavigate([], {})
143 this.removeAndResetCustomDateFilter()
147 return !!this.statsStartDate && this.isTimeserieGraph(this.activeGraphId)
150 getViewersStatsTitle () {
151 if (this.statsStartDate && this.statsEndDate) {
152 return $localize`Viewers stats between ${this.statsStartDate.toLocaleString()} and ${this.statsEndDate.toLocaleString()}`
155 return $localize`Viewers stats`
158 onDateFilterChange () {
159 if (this.currentDateFilter === 'all') {
160 return this.resetZoom()
163 const idParts = this.currentDateFilter.split('|')
164 if (idParts.length === 2) {
165 return this.peertubeRouter.silentNavigate([], { startDate: idParts[0], endDate: idParts[1] })
169 private isTimeserieGraph (graphId: ActiveGraphId) {
170 return graphId === 'aggregateWatchTime' || graphId === 'viewers'
173 private loadOverallStats () {
174 this.statsService.getOverallStats({ videoId: this.video.uuid, startDate: this.statsStartDate, endDate: this.statsEndDate })
177 this.countries = res.countries.slice(0, 10).map(c => ({
178 name: this.countryCodeToName(c.isoCode),
182 this.buildOverallStatCard(res)
185 error: err => this.notifier.error(err.message)
189 private loadDateFilters () {
190 if (this.video.isLive) return this.loadLiveDateFilters()
192 return this.loadVODDateFilters()
195 private loadLiveDateFilters () {
196 this.liveService.listSessions(this.video.id)
198 next: ({ data }) => {
199 const newFilters = data.map(session => this.buildLiveFilter(session))
201 this.dateFilters = this.dateFilters.concat(newFilters)
204 error: err => this.notifier.error(err.message)
208 private loadVODDateFilters () {
209 this.liveService.findLiveSessionFromVOD(this.video.id)
212 this.dateFilters = this.dateFilters.concat([ this.buildLiveFilter(session) ])
216 if (err.status === HttpStatusCode.NOT_FOUND_404) return
218 this.notifier.error(err.message)
223 private buildLiveFilter (session: LiveVideoSession) {
225 id: session.startDate + '|' + session.endDate,
226 label: $localize`Of live of ${new Date(session.startDate).toLocaleString()}`
230 private addAndSelectCustomDateFilter () {
231 const exists = this.dateFilters.some(d => d.id === 'custom')
234 this.dateFilters = this.dateFilters.concat([
237 label: $localize`Custom dates`
242 this.currentDateFilter = 'custom'
245 private removeAndResetCustomDateFilter () {
246 this.dateFilters = this.dateFilters.filter(d => d.id !== 'custom')
248 this.currentDateFilter = 'all'
251 private buildOverallStatCard (overallStats: VideoStatsOverall) {
252 this.globalStatsCards = [
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`
259 label: $localize`Likes`,
260 value: this.numberFormatter.transform(this.video.likes)
264 this.overallStatCards = [
266 label: $localize`Average watch time`,
267 value: secondsToTime(overallStats.averageWatchTime)
270 label: $localize`Total watch time`,
271 value: secondsToTime(overallStats.totalWatchTime)
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()}`
282 if (overallStats.countries.length !== 0) {
283 this.overallStatCards.push({
284 label: $localize`Countries`,
285 value: this.numberFormatter.transform(overallStats.countries.length)
290 private loadChart () {
291 const obsBuilders: { [ id in ActiveGraphId ]: Observable<ChartIngestData> } = {
292 retention: this.statsService.getRetentionStats(this.video.uuid),
294 aggregateWatchTime: this.statsService.getTimeserieStats({
295 videoId: this.video.uuid,
296 startDate: this.statsStartDate,
297 endDate: this.statsEndDate,
298 metric: 'aggregateWatchTime'
300 viewers: this.statsService.getTimeserieStats({
301 videoId: this.video.uuid,
302 startDate: this.statsStartDate,
303 endDate: this.statsEndDate,
307 countries: of(this.countries)
310 obsBuilders[this.activeGraphId].subscribe({
312 this.chartIngestData[this.activeGraphId] = res
314 this.chartOptions[this.activeGraphId] = this.buildChartOptions(this.activeGraphId)
317 error: err => this.notifier.error(err.message)
321 private buildChartOptions (graphId: ActiveGraphId): ChartConfiguration<'line' | 'bar'> {
322 const dataBuilders: {
323 [ id in ActiveGraphId ]: (rawData: ChartIngestData) => ChartBuilderResult
325 retention: (rawData: VideoStatsRetention) => this.buildRetentionChartOptions(rawData),
326 aggregateWatchTime: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData),
327 viewers: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData),
328 countries: (rawData: CountryData) => this.buildCountryChartOptions(rawData)
331 const { type, data, displayLegend, plugins } = dataBuilders[graphId](this.chartIngestData[graphId])
345 callback: function (value) {
346 return self.formatXTick({
349 data: self.chartIngestData[graphId] as VideoStatsTimeserie,
359 max: this.activeGraphId === 'retention'
364 callback: value => this.formatYTick({ graphId, value })
371 display: displayLegend
375 title: items => this.formatTooltipTitle({ graphId, items }),
376 label: value => this.formatYTick({ graphId, value: value.raw as number | string })
386 private buildRetentionChartOptions (rawData: VideoStatsRetention): ChartBuilderResult {
387 const labels: string[] = []
388 const data: number[] = []
390 for (const d of rawData.data) {
391 labels.push(secondsToTime(d.second))
392 data.push(d.retentionPercent)
396 type: 'line' as 'line',
398 displayLegend: false,
401 ...this.buildDisabledZoomPlugin()
409 borderColor: this.buildChartColor()
416 private buildTimeserieChartOptions (rawData: VideoStatsTimeserie): ChartBuilderResult {
417 const labels: string[] = []
418 const data: number[] = []
420 for (const d of rawData.data) {
426 type: 'line' as 'line',
428 displayLegend: false,
443 onZoomComplete: ({ chart }) => {
444 const { min, max } = chart.scales.x
446 const startDate = rawData.data[min].date
447 const endDate = this.buildZoomEndDate(rawData.groupInterval, rawData.data[max].date)
449 this.peertubeRouter.silentNavigate([], { startDate, endDate })
450 this.addAndSelectCustomDateFilter()
466 borderColor: this.buildChartColor()
473 private buildCountryChartOptions (rawData: CountryData): ChartBuilderResult {
474 const labels: string[] = []
475 const data: number[] = []
477 for (const d of rawData) {
483 type: 'bar' as 'bar',
488 ...this.buildDisabledZoomPlugin()
495 label: $localize`Viewers`,
496 backgroundColor: this.buildChartColor(),
505 private buildChartColor () {
506 return getComputedStyle(document.body).getPropertyValue('--mainColorLighter')
509 private formatXTick (options: {
510 graphId: ActiveGraphId
511 value: number | string
512 data: VideoStatsTimeserie
515 const { graphId, value, data, scale } = options
517 const label = scale.getLabelForValue(value as number)
519 if (!this.isTimeserieGraph(graphId)) {
523 const date = new Date(label)
525 if (data.groupInterval.match(/ month?$/)) {
526 return date.toLocaleDateString([], { month: 'numeric' })
529 if (data.groupInterval.match(/ days?$/)) {
530 return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' })
533 if (data.groupInterval.match(/ hours?$/)) {
534 return date.toLocaleTimeString([], { month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric' })
537 return date.toLocaleTimeString([], { hour: 'numeric', minute: 'numeric' })
540 private formatYTick (options: {
541 graphId: ActiveGraphId
542 value: number | string
544 const { graphId, value } = options
546 if (graphId === 'retention') return value + ' %'
547 if (graphId === 'aggregateWatchTime') return secondsToTime(+value)
549 return value.toLocaleString()
552 private formatTooltipTitle (options: {
553 graphId: ActiveGraphId
554 items: TooltipItem<any>[]
556 const { graphId, items } = options
557 const item = items[0]
559 if (this.isTimeserieGraph(graphId)) return new Date(item.label).toLocaleString()
564 private countryCodeToName (code: string) {
565 const intl: any = Intl
566 if (!intl.DisplayNames) return code
568 const regionNames = new intl.DisplayNames([], { type: 'region' })
570 return regionNames.of(code)
573 private buildDisabledZoomPlugin () {
591 private buildZoomEndDate (groupInterval: string, last: string) {
592 const date = new Date(last)
594 // Remove parts of the date we don't need
595 if (groupInterval.endsWith(' day') || groupInterval.endsWith(' days')) {
596 date.setHours(23, 59, 59)
597 } else if (groupInterval.endsWith(' hour') || groupInterval.endsWith(' hours')) {
598 date.setMinutes(59, 59)
603 return date.toISOString()