From f40712abbbb74e51f06037ef02757c42736bccf8 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 6 May 2022 14:23:02 +0200 Subject: [PATCH] Add ability to filter overall video stats by date --- client/src/app/+stats/stats-routing.module.ts | 2 + client/src/app/+stats/stats.module.ts | 4 + .../+stats/video/video-stats.component.html | 74 +++--- .../+stats/video/video-stats.component.scss | 34 ++- .../app/+stats/video/video-stats.component.ts | 212 ++++++++++++++---- .../app/+stats/video/video-stats.service.ts | 14 +- .../video-studio-routing.module.ts | 2 + .../shared-video-live/live-video.service.ts | 7 + server/controllers/api/videos/stats.ts | 11 +- server/initializers/constants.ts | 2 +- server/lib/timeserie.ts | 14 +- server/models/view/local-video-viewer.ts | 12 +- server/tests/api/check-params/views.ts | 2 +- .../api/views/video-views-overall-stats.ts | 26 ++- .../api/views/video-views-timeserie-stats.ts | 74 +++++- 15 files changed, 383 insertions(+), 107 deletions(-) diff --git a/client/src/app/+stats/stats-routing.module.ts b/client/src/app/+stats/stats-routing.module.ts index 59519a703..b6225cafd 100644 --- a/client/src/app/+stats/stats-routing.module.ts +++ b/client/src/app/+stats/stats-routing.module.ts @@ -1,11 +1,13 @@ import { NgModule } from '@angular/core' import { RouterModule, Routes } from '@angular/router' +import { LoginGuard } from '@app/core' import { VideoResolver } from '@app/shared/shared-main' import { VideoStatsComponent } from './video' const statsRoutes: Routes = [ { path: 'videos/:videoId', + canActivate: [ LoginGuard ], component: VideoStatsComponent, data: { meta: { diff --git a/client/src/app/+stats/stats.module.ts b/client/src/app/+stats/stats.module.ts index 0497576e7..e81378220 100644 --- a/client/src/app/+stats/stats.module.ts +++ b/client/src/app/+stats/stats.module.ts @@ -1,7 +1,9 @@ import { ChartModule } from 'primeng/chart' import { NgModule } from '@angular/core' +import { SharedFormModule } from '@app/shared/shared-forms' import { SharedGlobalIconModule } from '@app/shared/shared-icons' import { SharedMainModule } from '@app/shared/shared-main' +import { SharedVideoLiveModule } from '@app/shared/shared-video-live' import { StatsRoutingModule } from './stats-routing.module' import { VideoStatsComponent, VideoStatsService } from './video' @@ -10,7 +12,9 @@ import { VideoStatsComponent, VideoStatsService } from './video' StatsRoutingModule, SharedMainModule, + SharedFormModule, SharedGlobalIconModule, + SharedVideoLiveModule, ChartModule ], diff --git a/client/src/app/+stats/video/video-stats.component.html b/client/src/app/+stats/video/video-stats.component.html index 400c049eb..e5412f1b8 100644 --- a/client/src/app/+stats/video/video-stats.component.html +++ b/client/src/app/+stats/video/video-stats.component.html @@ -1,9 +1,9 @@
-

Stats for {{ video.name }}

+

{{ video.name }}

-
-
-
+
+
+
{{ card.label }}
{{ card.value }}
{{ card.moreInfo }}
@@ -13,33 +13,51 @@
-
- diff --git a/client/src/app/+stats/video/video-stats.component.scss b/client/src/app/+stats/video/video-stats.component.scss index e2a74152f..e4c2d899f 100644 --- a/client/src/app/+stats/video/video-stats.component.scss +++ b/client/src/app/+stats/video/video-stats.component.scss @@ -2,17 +2,31 @@ @use '_mixins' as *; @use '_nav' as *; -.overall-stats-embed { +.stats-embed { display: flex; justify-content: space-between; } -.overall-stats { +.overall-stats, +.global-stats { display: flex; flex-wrap: wrap; + + h2 { + font-size: 16px; + width: 100%; + } } -.overall-stats-card { +.overall-stats { + justify-content: space-between; + + .cards { + display: flex; + } +} + +.stats-card { display: flex; justify-content: center; align-items: center; @@ -28,12 +42,6 @@ font-size: 14px; } - .label { - color: pvar(--greyForegroundColor); - font-weight: $font-semibold; - opacity: 0.8; - } - .value { font-size: 24px; font-weight: $font-semibold; @@ -52,6 +60,12 @@ my-embed { width: 100%; } +.stats-with-date { + margin-top: 30px; + padding-top: 30px; + border-top: 1px solid $separator-border-color; +} + @include on-small-main-col { my-embed { display: none; @@ -59,7 +73,7 @@ my-embed { } .tab-content { - margin-top: 15px; + margin-top: 5px; } .nav-tabs { diff --git a/client/src/app/+stats/video/video-stats.component.ts b/client/src/app/+stats/video/video-stats.component.ts index a5435fe23..f433259ef 100644 --- a/client/src/app/+stats/video/video-stats.component.ts +++ b/client/src/app/+stats/video/video-stats.component.ts @@ -1,12 +1,21 @@ import { ChartConfiguration, ChartData, PluginOptionsByType, Scale, TooltipItem } from 'chart.js' import zoomPlugin from 'chartjs-plugin-zoom' import { Observable, of } from 'rxjs' +import { SelectOptionsItem } from 'src/types' import { Component, OnInit } from '@angular/core' import { ActivatedRoute } from '@angular/router' import { Notifier, PeerTubeRouterService } from '@app/core' import { NumberFormatterPipe, VideoDetails } from '@app/shared/shared-main' +import { LiveVideoService } from '@app/shared/shared-video-live' import { secondsToTime } from '@shared/core-utils' -import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric } from '@shared/models/videos' +import { HttpStatusCode } from '@shared/models/http' +import { + LiveVideoSession, + VideoStatsOverall, + VideoStatsRetention, + VideoStatsTimeserie, + VideoStatsTimeserieMetric +} from '@shared/models/videos' import { VideoStatsService } from './video-stats.service' type ActiveGraphId = VideoStatsTimeserieMetric | 'retention' | 'countries' @@ -21,41 +30,24 @@ type ChartBuilderResult = { displayLegend: boolean } +type Card = { label: string, value: string | number, moreInfo?: string } + @Component({ templateUrl: './video-stats.component.html', styleUrls: [ './video-stats.component.scss' ], providers: [ NumberFormatterPipe ] }) export class VideoStatsComponent implements OnInit { - overallStatCards: { label: string, value: string | number, moreInfo?: string }[] = [] + // Cannot handle date filters + globalStatsCards: Card[] = [] + // Can handle date filters + overallStatCards: Card[] = [] chartOptions: { [ id in ActiveGraphId ]?: ChartConfiguration<'line' | 'bar'> } = {} chartHeight = '300px' chartWidth: string = null - availableCharts = [ - { - id: 'viewers', - label: $localize`Viewers`, - zoomEnabled: true - }, - { - id: 'aggregateWatchTime', - label: $localize`Watch time`, - zoomEnabled: true - }, - { - id: 'retention', - label: $localize`Retention`, - zoomEnabled: false - }, - { - id: 'countries', - label: $localize`Countries`, - zoomEnabled: false - } - ] - + availableCharts: { id: string, label: string, zoomEnabled: boolean }[] = [] activeGraphId: ActiveGraphId = 'viewers' video: VideoDetails @@ -64,8 +56,16 @@ export class VideoStatsComponent implements OnInit { chartPlugins = [ zoomPlugin ] - private timeseriesStartDate: Date - private timeseriesEndDate: Date + currentDateFilter = 'all' + dateFilters: SelectOptionsItem[] = [ + { + id: 'all', + label: $localize`Since the video publication` + } + ] + + private statsStartDate: Date + private statsEndDate: Date private chartIngestData: { [ id in ActiveGraphId ]?: ChartIngestData } = {} @@ -74,25 +74,58 @@ export class VideoStatsComponent implements OnInit { private notifier: Notifier, private statsService: VideoStatsService, private peertubeRouter: PeerTubeRouterService, - private numberFormatter: NumberFormatterPipe + private numberFormatter: NumberFormatterPipe, + private liveService: LiveVideoService ) {} ngOnInit () { this.video = this.route.snapshot.data.video + this.availableCharts = [ + { + id: 'viewers', + label: $localize`Viewers`, + zoomEnabled: true + }, + { + id: 'aggregateWatchTime', + label: $localize`Watch time`, + zoomEnabled: true + }, + { + id: 'countries', + label: $localize`Countries`, + zoomEnabled: false + } + ] + + if (!this.video.isLive) { + this.availableCharts.push({ + id: 'retention', + label: $localize`Retention`, + zoomEnabled: false + }) + } + + const snapshotQuery = this.route.snapshot.queryParams + if (snapshotQuery.startDate || snapshotQuery.endDate) { + this.addAndSelectCustomDateFilter() + } + this.route.queryParams.subscribe(params => { - this.timeseriesStartDate = params.startDate + this.statsStartDate = params.startDate ? new Date(params.startDate) : undefined - this.timeseriesEndDate = params.endDate + this.statsEndDate = params.endDate ? new Date(params.endDate) : undefined this.loadChart() + this.loadOverallStats() }) - this.loadOverallStats() + this.loadDateFilters() } hasCountries () { @@ -107,10 +140,30 @@ export class VideoStatsComponent implements OnInit { resetZoom () { this.peertubeRouter.silentNavigate([], {}) + this.removeAndResetCustomDateFilter() } hasZoom () { - return !!this.timeseriesStartDate && this.isTimeserieGraph(this.activeGraphId) + return !!this.statsStartDate && this.isTimeserieGraph(this.activeGraphId) + } + + getViewersStatsTitle () { + if (this.statsStartDate && this.statsEndDate) { + return $localize`Viewers stats between ${this.statsStartDate.toLocaleString()} and ${this.statsEndDate.toLocaleString()}` + } + + return $localize`Viewers stats` + } + + onDateFilterChange () { + if (this.currentDateFilter === 'all') { + return this.resetZoom() + } + + const idParts = this.currentDateFilter.split('|') + if (idParts.length === 2) { + return this.peertubeRouter.silentNavigate([], { startDate: idParts[0], endDate: idParts[1] }) + } } private isTimeserieGraph (graphId: ActiveGraphId) { @@ -118,7 +171,7 @@ export class VideoStatsComponent implements OnInit { } private loadOverallStats () { - this.statsService.getOverallStats(this.video.uuid) + this.statsService.getOverallStats({ videoId: this.video.uuid, startDate: this.statsStartDate, endDate: this.statsEndDate }) .subscribe({ next: res => { this.countries = res.countries.slice(0, 10).map(c => ({ @@ -133,8 +186,70 @@ export class VideoStatsComponent implements OnInit { }) } + private loadDateFilters () { + if (this.video.isLive) return this.loadLiveDateFilters() + + return this.loadVODDateFilters() + } + + private loadLiveDateFilters () { + this.liveService.listSessions(this.video.id) + .subscribe({ + next: ({ data }) => { + const newFilters = data.map(session => this.buildLiveFilter(session)) + + this.dateFilters = this.dateFilters.concat(newFilters) + }, + + error: err => this.notifier.error(err.message) + }) + } + + private loadVODDateFilters () { + this.liveService.findLiveSessionFromVOD(this.video.id) + .subscribe({ + next: session => { + this.dateFilters = this.dateFilters.concat([ this.buildLiveFilter(session) ]) + }, + + error: err => { + if (err.status === HttpStatusCode.NOT_FOUND_404) return + + this.notifier.error(err.message) + } + }) + } + + private buildLiveFilter (session: LiveVideoSession) { + return { + id: session.startDate + '|' + session.endDate, + label: $localize`Of live of ${new Date(session.startDate).toLocaleString()}` + } + } + + private addAndSelectCustomDateFilter () { + const exists = this.dateFilters.some(d => d.id === 'custom') + + if (!exists) { + this.dateFilters = this.dateFilters.concat([ + { + id: 'custom', + label: $localize`Custom dates` + } + ]) + } + + this.currentDateFilter = 'custom' + } + + private removeAndResetCustomDateFilter () { + this.dateFilters = this.dateFilters.filter(d => d.id !== 'custom') + + this.currentDateFilter = 'all' + } + private buildOverallStatCard (overallStats: VideoStatsOverall) { - this.overallStatCards = [ + this.globalStatsCards = [ { label: $localize`Views`, value: this.numberFormatter.transform(this.video.views) @@ -142,11 +257,18 @@ export class VideoStatsComponent implements OnInit { { label: $localize`Likes`, value: this.numberFormatter.transform(this.video.likes) - }, + } + ] + + this.overallStatCards = [ { label: $localize`Average watch time`, value: secondsToTime(overallStats.averageWatchTime) }, + { + label: $localize`Total watch time`, + value: secondsToTime(overallStats.totalWatchTime) + }, { label: $localize`Peak viewers`, value: this.numberFormatter.transform(overallStats.viewersPeak), @@ -155,6 +277,13 @@ export class VideoStatsComponent implements OnInit { : undefined } ] + + if (overallStats.countries.length !== 0) { + this.overallStatCards.push({ + label: $localize`Countries`, + value: this.numberFormatter.transform(overallStats.countries.length) + }) + } } private loadChart () { @@ -163,14 +292,14 @@ export class VideoStatsComponent implements OnInit { aggregateWatchTime: this.statsService.getTimeserieStats({ videoId: this.video.uuid, - startDate: this.timeseriesStartDate, - endDate: this.timeseriesEndDate, + startDate: this.statsStartDate, + endDate: this.statsEndDate, metric: 'aggregateWatchTime' }), viewers: this.statsService.getTimeserieStats({ videoId: this.video.uuid, - startDate: this.timeseriesStartDate, - endDate: this.timeseriesEndDate, + startDate: this.statsStartDate, + endDate: this.statsEndDate, metric: 'viewers' }), @@ -317,6 +446,7 @@ export class VideoStatsComponent implements OnInit { const endDate = this.buildZoomEndDate(rawData.groupInterval, rawData.data[max].date) this.peertubeRouter.silentNavigate([], { startDate, endDate }) + this.addAndSelectCustomDateFilter() } } } @@ -386,6 +516,10 @@ export class VideoStatsComponent implements OnInit { const date = new Date(label) + if (data.groupInterval.match(/ month?$/)) { + return date.toLocaleDateString([], { month: 'numeric' }) + } + if (data.groupInterval.match(/ days?$/)) { return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' }) } diff --git a/client/src/app/+stats/video/video-stats.service.ts b/client/src/app/+stats/video/video-stats.service.ts index 712d03971..e019c87f7 100644 --- a/client/src/app/+stats/video/video-stats.service.ts +++ b/client/src/app/+stats/video/video-stats.service.ts @@ -17,8 +17,18 @@ export class VideoStatsService { private restExtractor: RestExtractor ) { } - getOverallStats (videoId: string) { - return this.authHttp.get(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/overall') + getOverallStats (options: { + videoId: string + startDate?: Date + endDate?: Date + }) { + const { videoId, startDate, endDate } = options + + let params = new HttpParams() + if (startDate) params = params.append('startDate', startDate.toISOString()) + if (endDate) params = params.append('endDate', endDate.toISOString()) + + return this.authHttp.get(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/overall', { params }) .pipe(catchError(err => this.restExtractor.handleError(err))) } diff --git a/client/src/app/+video-studio/video-studio-routing.module.ts b/client/src/app/+video-studio/video-studio-routing.module.ts index 4c08631a1..9d276be7c 100644 --- a/client/src/app/+video-studio/video-studio-routing.module.ts +++ b/client/src/app/+video-studio/video-studio-routing.module.ts @@ -1,11 +1,13 @@ import { NgModule } from '@angular/core' import { RouterModule, Routes } from '@angular/router' +import { LoginGuard } from '@app/core' import { VideoResolver } from '@app/shared/shared-main' import { VideoStudioEditComponent } from './edit' const videoStudioRoutes: Routes = [ { path: '', + canActivateChild: [ LoginGuard ], children: [ { path: 'edit/:videoId', diff --git a/client/src/app/shared/shared-video-live/live-video.service.ts b/client/src/app/shared/shared-video-live/live-video.service.ts index 11b9dd739..89bfd84a0 100644 --- a/client/src/app/shared/shared-video-live/live-video.service.ts +++ b/client/src/app/shared/shared-video-live/live-video.service.ts @@ -4,6 +4,7 @@ import { Injectable } from '@angular/core' import { RestExtractor } from '@app/core' import { LiveVideo, LiveVideoCreate, LiveVideoSession, LiveVideoUpdate, ResultList, VideoCreateResult } from '@shared/models' import { environment } from '../../../environments/environment' +import { VideoService } from '../shared-main' @Injectable() export class LiveVideoService { @@ -32,6 +33,12 @@ export class LiveVideoService { .pipe(catchError(err => this.restExtractor.handleError(err))) } + findLiveSessionFromVOD (videoId: number | string) { + return this.authHttp + .get(VideoService.BASE_VIDEO_URL + '/' + videoId + '/live-session') + .pipe(catchError(err => this.restExtractor.handleError(err))) + } + updateLive (videoId: number | string, liveUpdate: LiveVideoUpdate) { return this.authHttp .put(LiveVideoService.BASE_VIDEO_LIVE_URL + videoId, liveUpdate) diff --git a/server/controllers/api/videos/stats.ts b/server/controllers/api/videos/stats.ts index 30e2bb06c..e79f01888 100644 --- a/server/controllers/api/videos/stats.ts +++ b/server/controllers/api/videos/stats.ts @@ -67,18 +67,9 @@ async function getTimeserieStats (req: express.Request, res: express.Response) { const stats = await LocalVideoViewerModel.getTimeserieStats({ video, metric, - startDate: query.startDate ?? buildOneMonthAgo().toISOString(), + startDate: query.startDate ?? video.createdAt.toISOString(), endDate: query.endDate ?? new Date().toISOString() }) return res.json(stats) } - -function buildOneMonthAgo () { - const monthAgo = new Date() - monthAgo.setHours(0, 0, 0, 0) - - monthAgo.setDate(monthAgo.getDate() - 29) - - return monthAgo -} diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index fa0fbc19d..dca792b1b 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -813,7 +813,7 @@ const SEARCH_INDEX = { // --------------------------------------------------------------------------- const STATS_TIMESERIE = { - MAX_DAYS: 30 + MAX_DAYS: 365 * 10 // Around 10 years } // --------------------------------------------------------------------------- diff --git a/server/lib/timeserie.ts b/server/lib/timeserie.ts index bd3d1c1ca..08b12129a 100644 --- a/server/lib/timeserie.ts +++ b/server/lib/timeserie.ts @@ -9,7 +9,10 @@ function buildGroupByAndBoundaries (startDateString: string, endDateString: stri logger.debug('Found "%s" group interval.', groupInterval, { startDate, endDate }) // Remove parts of the date we don't need - if (groupInterval.endsWith(' day') || groupInterval.endsWith(' days')) { + if (groupInterval.endsWith(' month') || groupInterval.endsWith(' months')) { + startDate.setDate(1) + startDate.setHours(0, 0, 0, 0) + } else if (groupInterval.endsWith(' day') || groupInterval.endsWith(' days')) { startDate.setHours(0, 0, 0, 0) } else if (groupInterval.endsWith(' hour') || groupInterval.endsWith(' hours')) { startDate.setMinutes(0, 0, 0) @@ -33,16 +36,25 @@ export { // --------------------------------------------------------------------------- function buildGroupInterval (startDate: Date, endDate: Date): string { + const aYear = 31536000 + const aMonth = 2678400 const aDay = 86400 const anHour = 3600 const aMinute = 60 const diffSeconds = (endDate.getTime() - startDate.getTime()) / 1000 + if (diffSeconds >= 6 * aYear) return '6 months' + if (diffSeconds >= 2 * aYear) return '1 month' + if (diffSeconds >= 6 * aMonth) return '7 days' + if (diffSeconds >= 2 * aMonth) return '2 days' + if (diffSeconds >= 15 * aDay) return '1 day' if (diffSeconds >= 8 * aDay) return '12 hours' if (diffSeconds >= 4 * aDay) return '6 hours' + if (diffSeconds >= 15 * anHour) return '1 hour' + if (diffSeconds >= 180 * aMinute) return '10 minutes' return '1 minute' diff --git a/server/models/view/local-video-viewer.ts b/server/models/view/local-video-viewer.ts index 2862f8b96..2305c7262 100644 --- a/server/models/view/local-video-viewer.ts +++ b/server/models/view/local-video-viewer.ts @@ -136,7 +136,7 @@ export class LocalVideoViewerModel extends Model