]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Add ability to filter overall video stats by date
authorChocobozzz <me@florianbigard.com>
Fri, 6 May 2022 12:23:02 +0000 (14:23 +0200)
committerChocobozzz <me@florianbigard.com>
Fri, 6 May 2022 12:23:02 +0000 (14:23 +0200)
15 files changed:
client/src/app/+stats/stats-routing.module.ts
client/src/app/+stats/stats.module.ts
client/src/app/+stats/video/video-stats.component.html
client/src/app/+stats/video/video-stats.component.scss
client/src/app/+stats/video/video-stats.component.ts
client/src/app/+stats/video/video-stats.service.ts
client/src/app/+video-studio/video-studio-routing.module.ts
client/src/app/shared/shared-video-live/live-video.service.ts
server/controllers/api/videos/stats.ts
server/initializers/constants.ts
server/lib/timeserie.ts
server/models/view/local-video-viewer.ts
server/tests/api/check-params/views.ts
server/tests/api/views/video-views-overall-stats.ts
server/tests/api/views/video-views-timeserie-stats.ts

index 59519a703816b3bbc4e8b1965bd9318a10912e33..b6225cafd3436ee5d1dddcb7f2b495ec298149fc 100644 (file)
@@ -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: {
index 0497576e746b712078ba22c5e5d30dc1eaad39dc..e813782208a9feee81134c3d53085295ff04fcf0 100644 (file)
@@ -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
   ],
index 400c049ebe9c00948e241cbd5666659c1362e83f..e5412f1b818cac308443491b1dbce43e2ad3a7ac 100644 (file)
@@ -1,9 +1,9 @@
 <div class="margin-content">
-  <h1 class="title-page title-page-single" i18n>Stats for {{ video.name }}</h1>
+  <h1 class="title-page title-page-single" i18n>{{ video.name }}</h1>
 
-  <div class="overall-stats-embed">
-    <div class="overall-stats">
-      <div *ngFor="let card of overallStatCards" class="card overall-stats-card">
+  <div class="stats-embed">
+    <div class="global-stats">
+      <div *ngFor="let card of globalStatsCards" class="card stats-card">
         <div class="label">{{ card.label }}</div>
         <div class="value">{{ card.value }}</div>
         <div *ngIf="card.moreInfo" class="more-info">{{ card.moreInfo }}</div>
     <my-embed [video]="video"></my-embed>
   </div>
 
-  <div class="timeserie">
-    <div ngbNav #nav="ngbNav" [activeId]="activeGraphId" (activeIdChange)="onChartChange($event)" class="nav-tabs">
-
-      <ng-container *ngFor="let availableChart of availableCharts" [ngbNavItem]="availableChart.id">
-        <a ngbNavLink i18n>
-          <span>{{ availableChart.label }}</span>
-        </a>
-
-        <ng-template ngbNavContent>
-          <div class="chart-container" [ngStyle]="{ 'min-height': chartHeight }">
-            <p-chart
-              *ngIf="chartOptions[availableChart.id]"
-              [height]="chartHeight" [width]="chartWidth"
-              [type]="chartOptions[availableChart.id].type" [options]="chartOptions[availableChart.id].options" [data]="chartOptions[availableChart.id].data"
-              [plugins]="chartPlugins"
-            ></p-chart>
-          </div>
-
-          <div class="zoom-container">
-            <span *ngIf="!hasZoom() && availableChart.zoomEnabled" i18n class="description">You can select a part of the graph to zoom in</span>
-
-            <my-button i18n *ngIf="hasZoom()" (click)="resetZoom()">Reset zoom</my-button>
-          </div>
-        </ng-template>
-      </ng-container>
+  <div class="stats-with-date">
+    <div class="overall-stats">
+      <div class="date-filter-wrapper">
+        <h2>{{ getViewersStatsTitle() }}</h2>
+
+        <my-select-options [(ngModel)]="currentDateFilter" (ngModelChange)="onDateFilterChange()" [items]="dateFilters"></my-select-options>
+      </div>
+
+      <div class="cards">
+        <div *ngFor="let card of overallStatCards" class="card stats-card">
+          <div class="label">{{ card.label }}</div>
+          <div class="value">{{ card.value }}</div>
+          <div *ngIf="card.moreInfo" class="more-info">{{ card.moreInfo }}</div>
+        </div>
+      </div>
     </div>
 
-    <div [ngbNavOutlet]="nav"></div>
+    <div class="timeserie">
+      <div ngbNav #nav="ngbNav" [activeId]="activeGraphId" (activeIdChange)="onChartChange($event)" class="nav-tabs">
+
+        <ng-container *ngFor="let availableChart of availableCharts" [ngbNavItem]="availableChart.id">
+          <a ngbNavLink i18n>
+            <span>{{ availableChart.label }}</span>
+          </a>
+
+          <ng-template ngbNavContent>
+            <div class="chart-container" [ngStyle]="{ 'min-height': chartHeight }">
+              <p-chart
+                *ngIf="chartOptions[availableChart.id]"
+                [height]="chartHeight" [width]="chartWidth"
+                [type]="chartOptions[availableChart.id].type" [options]="chartOptions[availableChart.id].options" [data]="chartOptions[availableChart.id].data"
+                [plugins]="chartPlugins"
+              ></p-chart>
+            </div>
+
+            <div class="zoom-container">
+              <span *ngIf="!hasZoom() && availableChart.zoomEnabled" i18n class="description">You can select a part of the graph to zoom in</span>
+
+              <my-button i18n *ngIf="hasZoom()" (click)="resetZoom()">Reset zoom</my-button>
+            </div>
+          </ng-template>
+        </ng-container>
+      </div>
+
+      <div [ngbNavOutlet]="nav"></div>
+    </div>
   </div>
 </div>
index e2a74152f00dafdde37cfa62fdcc658fce5f000d..e4c2d899f8dfd3255c6d3f817230193ecb2365ed 100644 (file)
@@ -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 {
+  justify-content: space-between;
+
+  .cards {
+    display: flex;
+  }
 }
 
-.overall-stats-card {
+.stats-card {
   display: flex;
   justify-content: center;
   align-items: center;
     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 {
index a5435fe23a2785c86f5df26815fa38a6b098f076..f433259ef93ceba1803df0d89f66d76ce0fe59ba 100644 (file)
@@ -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' })
     }
index 712d03971c35ba212173efd517ec9242cd0d92bd..e019c87f71e485e20a3ae2c33f1c7f3647648d3a 100644 (file)
@@ -17,8 +17,18 @@ export class VideoStatsService {
     private restExtractor: RestExtractor
   ) { }
 
-  getOverallStats (videoId: string) {
-    return this.authHttp.get<VideoStatsOverall>(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<VideoStatsOverall>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/overall', { params })
       .pipe(catchError(err => this.restExtractor.handleError(err)))
   }
 
index 4c08631a1f4309980a78b84731e794cedaa8e376..9d276be7c813833bc39b74575db56c72c536fff8 100644 (file)
@@ -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',
index 11b9dd739e761f790088d691e6ae6d6f1c8c1a40..89bfd84a0130bf79a805222330af4b8ed1f70a80 100644 (file)
@@ -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<LiveVideoSession>(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)
index 30e2bb06c347f7295a0aeb221c04863b7c49a9e0..e79f01888b99bafa00c82d35ee86e0c2635bb4af 100644 (file)
@@ -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
-}
index fa0fbc19d9c1462bf5780813bdf8077d369d5d34..dca792b1ba71103cc1134d37fbd243edda007b59 100644 (file)
@@ -813,7 +813,7 @@ const SEARCH_INDEX = {
 // ---------------------------------------------------------------------------
 
 const STATS_TIMESERIE = {
-  MAX_DAYS: 30
+  MAX_DAYS: 365 * 10 // Around 10 years
 }
 
 // ---------------------------------------------------------------------------
index bd3d1c1caed105a1f4a2270df5a4dbb4ecd078c6..08b12129afa45045027228db9683e1dad8b49938 100644 (file)
@@ -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'
index 2862f8b96454651821b8f9fb1621d1eaf7d06357..2305c72624434f2b0607d0c6e7ff69a0358cb2f1 100644 (file)
@@ -136,7 +136,7 @@ export class LocalVideoViewerModel extends Model<Partial<AttributesOnly<LocalVid
     const watchPeakQuery = `WITH "watchPeakValues" AS (
         SELECT "startDate" AS "dateBreakpoint", 1 AS "inc"
         FROM "localVideoViewer"
-        WHERE "videoId" = :videoId
+        WHERE "videoId" = :videoId ${dateWhere}
         UNION ALL
         SELECT "endDate" AS "dateBreakpoint", -1 AS "inc"
         FROM "localVideoViewer"
@@ -165,6 +165,10 @@ export class LocalVideoViewerModel extends Model<Partial<AttributesOnly<LocalVid
       countriesPromise
     ])
 
+    const viewersPeak = rowsWatchPeak.length !== 0
+      ? parseInt(rowsWatchPeak[0].concurrent) || 0
+      : 0
+
     return {
       totalWatchTime: rowsWatchTime.length !== 0
         ? Math.round(rowsWatchTime[0].totalWatchTime) || 0
@@ -173,10 +177,8 @@ export class LocalVideoViewerModel extends Model<Partial<AttributesOnly<LocalVid
         ? Math.round(rowsWatchTime[0].averageWatchTime) || 0
         : 0,
 
-      viewersPeak: rowsWatchPeak.length !== 0
-        ? parseInt(rowsWatchPeak[0].concurrent) || 0
-        : 0,
-      viewersPeakDate: rowsWatchPeak.length !== 0
+      viewersPeak,
+      viewersPeakDate: rowsWatchPeak.length !== 0 && viewersPeak !== 0
         ? rowsWatchPeak[0].dateBreakpoint || null
         : null,
 
index fe037b1455ba709cda296e229c61dbcaf1c798e3..8f1fa796bc1487db334fbce5c0e6e20c33a7320d 100644 (file)
@@ -176,7 +176,7 @@ describe('Test videos views', function () {
       await servers[0].videoStats.getTimeserieStats({
         videoId,
         metric: 'viewers',
-        startDate: new Date('2021-04-07T08:31:57.126Z'),
+        startDate: new Date('2000-04-07T08:31:57.126Z'),
         endDate: new Date(),
         expectedStatus: HttpStatusCode.BAD_REQUEST_400
       })
index 53b8f0d4b97e91e570ae6819e6ac6a1ac0e9e5c6..02012388df959cd41680c119c7152a390b4aadbc 100644 (file)
@@ -169,6 +169,7 @@ describe('Test views overall stats', function () {
 
   describe('Test watchers peak stats of local videos on VOD', function () {
     let videoUUID: string
+    let before2Watchers: Date
 
     before(async function () {
       this.timeout(120000);
@@ -201,7 +202,7 @@ describe('Test views overall stats', function () {
     it('Should have watcher peak with 2 watchers', async function () {
       this.timeout(60000)
 
-      const before = new Date()
+      before2Watchers = new Date()
       await servers[0].views.view({ id: videoUUID, currentTime: 0 })
       await servers[1].views.view({ id: videoUUID, currentTime: 0 })
       await servers[0].views.view({ id: videoUUID, currentTime: 2 })
@@ -213,11 +214,26 @@ describe('Test views overall stats', function () {
       const stats = await servers[0].videoStats.getOverallStats({ videoId: videoUUID })
 
       expect(stats.viewersPeak).to.equal(2)
-      expect(new Date(stats.viewersPeakDate)).to.be.above(before).and.below(after)
+      expect(new Date(stats.viewersPeakDate)).to.be.above(before2Watchers).and.below(after)
+    })
+
+    it('Should filter peak viewers stats by date', async function () {
+      {
+        const stats = await servers[0].videoStats.getOverallStats({ videoId: videoUUID, startDate: new Date().toISOString() })
+        expect(stats.viewersPeak).to.equal(0)
+        expect(stats.viewersPeakDate).to.not.exist
+      }
+
+      {
+        const stats = await servers[0].videoStats.getOverallStats({ videoId: videoUUID, endDate: before2Watchers.toISOString() })
+        expect(stats.viewersPeak).to.equal(1)
+        expect(new Date(stats.viewersPeakDate)).to.be.below(before2Watchers)
+      }
     })
   })
 
   describe('Test countries', function () {
+    let videoUUID: string
 
     it('Should not report countries if geoip is disabled', async function () {
       this.timeout(120000)
@@ -237,6 +253,7 @@ describe('Test views overall stats', function () {
       this.timeout(240000)
 
       const { uuid } = await servers[0].videos.quickUpload({ name: 'video' })
+      videoUUID = uuid
       await waitJobs(servers)
 
       await Promise.all([
@@ -265,6 +282,11 @@ describe('Test views overall stats', function () {
       expect(stats.countries[1].isoCode).to.equal('FR')
       expect(stats.countries[1].viewers).to.equal(1)
     })
+
+    it('Should filter countries stats by date', async function () {
+      const stats = await servers[0].videoStats.getOverallStats({ videoId: videoUUID, startDate: new Date().toISOString() })
+      expect(stats.countries).to.have.lengthOf(0)
+    })
   })
 
   after(async function () {
index fd3aba188a33e8f74da18e760056be451c377898..6f03b0e82b52bbd55c8bcc0baeedec7f26f8c45d 100644 (file)
@@ -9,6 +9,15 @@ import { cleanupTests, PeerTubeServer, stopFfmpeg } from '@shared/server-command
 
 const expect = chai.expect
 
+function buildOneMonthAgo () {
+  const monthAgo = new Date()
+  monthAgo.setHours(0, 0, 0, 0)
+
+  monthAgo.setDate(monthAgo.getDate() - 29)
+
+  return monthAgo
+}
+
 describe('Test views timeserie stats', function () {
   const availableMetrics: VideoStatsTimeserieMetric[] = [ 'viewers' ]
 
@@ -33,7 +42,7 @@ describe('Test views timeserie stats', function () {
       for (const metric of availableMetrics) {
         const { data } = await servers[0].videoStats.getTimeserieStats({ videoId: vodVideoId, metric })
 
-        expect(data).to.have.lengthOf(30)
+        expect(data).to.have.length.at.least(1)
 
         for (const d of data) {
           expect(d.value).to.equal(0)
@@ -47,17 +56,19 @@ describe('Test views timeserie stats', function () {
     let liveVideoId: string
     let command: FfmpegCommand
 
-    function expectTodayLastValue (result: VideoStatsTimeserie, lastValue: number) {
+    function expectTodayLastValue (result: VideoStatsTimeserie, lastValue?: number) {
       const { data } = result
 
       const last = data[data.length - 1]
       const today = new Date().getDate()
       expect(new Date(last.date).getDate()).to.equal(today)
+
+      if (lastValue) expect(last.value).to.equal(lastValue)
     }
 
     function expectTimeserieData (result: VideoStatsTimeserie, lastValue: number) {
       const { data } = result
-      expect(data).to.have.lengthOf(30)
+      expect(data).to.have.length.at.least(25)
 
       expectTodayLastValue(result, lastValue)
 
@@ -87,14 +98,24 @@ describe('Test views timeserie stats', function () {
       await processViewersStats(servers)
 
       for (const videoId of [ vodVideoId, liveVideoId ]) {
-        const result = await servers[0].videoStats.getTimeserieStats({ videoId, metric: 'viewers' })
+        const result = await servers[0].videoStats.getTimeserieStats({
+          videoId,
+          startDate: buildOneMonthAgo(),
+          endDate: new Date(),
+          metric: 'viewers'
+        })
         expectTimeserieData(result, 2)
       }
     })
 
     it('Should display appropriate watch time metrics', async function () {
       for (const videoId of [ vodVideoId, liveVideoId ]) {
-        const result = await servers[0].videoStats.getTimeserieStats({ videoId, metric: 'aggregateWatchTime' })
+        const result = await servers[0].videoStats.getTimeserieStats({
+          videoId,
+          startDate: buildOneMonthAgo(),
+          endDate: new Date(),
+          metric: 'aggregateWatchTime'
+        })
         expectTimeserieData(result, 8)
 
         await servers[1].views.simulateViewer({ id: videoId, currentTimes: [ 0, 1 ] })
@@ -103,7 +124,12 @@ describe('Test views timeserie stats', function () {
       await processViewersStats(servers)
 
       for (const videoId of [ vodVideoId, liveVideoId ]) {
-        const result = await servers[0].videoStats.getTimeserieStats({ videoId, metric: 'aggregateWatchTime' })
+        const result = await servers[0].videoStats.getTimeserieStats({
+          videoId,
+          startDate: buildOneMonthAgo(),
+          endDate: new Date(),
+          metric: 'aggregateWatchTime'
+        })
         expectTimeserieData(result, 9)
       }
     })
@@ -130,6 +156,38 @@ describe('Test views timeserie stats', function () {
       expectTodayLastValue(result, 9)
     })
 
+    it('Should automatically group by months', async function () {
+      const now = new Date()
+      const heightYearsAgo = new Date()
+      heightYearsAgo.setFullYear(heightYearsAgo.getFullYear() - 7)
+
+      const result = await servers[0].videoStats.getTimeserieStats({
+        videoId: vodVideoId,
+        metric: 'aggregateWatchTime',
+        startDate: heightYearsAgo,
+        endDate: now
+      })
+
+      expect(result.groupInterval).to.equal('6 months')
+      expect(result.data).to.have.length.above(10).and.below(200)
+    })
+
+    it('Should automatically group by days', async function () {
+      const now = new Date()
+      const threeMonthsAgo = new Date()
+      threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3)
+
+      const result = await servers[0].videoStats.getTimeserieStats({
+        videoId: vodVideoId,
+        metric: 'aggregateWatchTime',
+        startDate: threeMonthsAgo,
+        endDate: now
+      })
+
+      expect(result.groupInterval).to.equal('2 days')
+      expect(result.data).to.have.length.above(10).and.below(200)
+    })
+
     it('Should automatically group by hours', async function () {
       const now = new Date()
       const twoDaysAgo = new Date()
@@ -165,7 +223,7 @@ describe('Test views timeserie stats', function () {
       expect(result.data).to.have.length.above(20).and.below(30)
 
       expectInterval(result, 60 * 10 * 1000)
-      expectTodayLastValue(result, 9)
+      expectTodayLastValue(result)
     })
 
     it('Should automatically group by one minute', async function () {
@@ -184,7 +242,7 @@ describe('Test views timeserie stats', function () {
       expect(result.data).to.have.length.above(20).and.below(40)
 
       expectInterval(result, 60 * 1000)
-      expectTodayLastValue(result, 9)
+      expectTodayLastValue(result)
     })
 
     after(async function () {