Add ability to filter overall video stats by date
This commit is contained in:
parent
49f0468d44
commit
f40712abbb
15 changed files with 383 additions and 107 deletions
|
@ -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: {
|
||||
|
|
|
@ -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
|
||||
],
|
||||
|
|
|
@ -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>
|
||||
|
@ -13,33 +13,51 @@
|
|||
<my-embed [video]="video"></my-embed>
|
||||
</div>
|
||||
|
||||
<div class="timeserie">
|
||||
<div ngbNav #nav="ngbNav" [activeId]="activeGraphId" (activeIdChange)="onChartChange($event)" class="nav-tabs">
|
||||
<div class="stats-with-date">
|
||||
<div class="overall-stats">
|
||||
<div class="date-filter-wrapper">
|
||||
<h2>{{ getViewersStatsTitle() }}</h2>
|
||||
|
||||
<ng-container *ngFor="let availableChart of availableCharts" [ngbNavItem]="availableChart.id">
|
||||
<a ngbNavLink i18n>
|
||||
<span>{{ availableChart.label }}</span>
|
||||
</a>
|
||||
<my-select-options [(ngModel)]="currentDateFilter" (ngModelChange)="onDateFilterChange()" [items]="dateFilters"></my-select-options>
|
||||
</div>
|
||||
|
||||
<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="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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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' })
|
||||
}
|
||||
|
|
|
@ -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)))
|
||||
}
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -813,7 +813,7 @@ const SEARCH_INDEX = {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STATS_TIMESERIE = {
|
||||
MAX_DAYS: 30
|
||||
MAX_DAYS: 365 * 10 // Around 10 years
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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,
|
||||
|
||||
|
|
|
@ -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
|
||||
})
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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 () {
|
||||
|
|
Loading…
Reference in a new issue