View stats for channels

This commit is contained in:
Rigel Kent 2020-03-23 10:14:05 +01:00 committed by Chocobozzz
parent 628c155338
commit 8165d00ac6
7 changed files with 212 additions and 51 deletions

View File

@ -6,7 +6,7 @@
</div> </div>
<div class="video-channels"> <div class="video-channels">
<div *ngFor="let videoChannel of videoChannels" class="video-channel"> <div *ngFor="let videoChannel of videoChannels; let i = index" class="video-channel">
<a [routerLink]="[ '/video-channels', videoChannel.nameWithHost ]"> <a [routerLink]="[ '/video-channels', videoChannel.nameWithHost ]">
<img [src]="videoChannel.avatarUrl" alt="Avatar" /> <img [src]="videoChannel.avatarUrl" alt="Avatar" />
</a> </a>
@ -17,13 +17,16 @@
<div class="video-channel-name">{{ videoChannel.nameWithHost }}</div> <div class="video-channel-name">{{ videoChannel.nameWithHost }}</div>
</a> </a>
<div i18n class="video-channel-followers">{{ videoChannel.followersCount }} subscribers</div> <div i18n class="video-channel-followers">{videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</div>
<div *ngIf="!isInSmallView" class="w-100 d-flex justify-content-end">
<p-chart *ngIf="videoChannelsData && videoChannelsData[i]" type="line" [data]="videoChannelsData[i]" [options]="chartOptions" width="40vw" height="100px"></p-chart>
</div>
</div> </div>
<div class="video-channel-buttons"> <div class="video-channel-buttons">
<my-delete-button (click)="deleteVideoChannel(videoChannel)"></my-delete-button>
<my-edit-button [routerLink]="[ 'update', videoChannel.nameWithHost ]"></my-edit-button> <my-edit-button [routerLink]="[ 'update', videoChannel.nameWithHost ]"></my-edit-button>
<my-delete-button (click)="deleteVideoChannel(videoChannel)"></my-delete-button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -6,13 +6,14 @@
} }
::ng-deep .action-button { ::ng-deep .action-button {
&.action-button-delete { &.action-button-edit {
margin-right: 10px; margin-right: 10px;
} }
} }
.video-channel { .video-channel {
@include row-blocks; @include row-blocks;
padding-bottom: 0;
img { img {
@include avatar(80px); @include avatar(80px);
@ -58,6 +59,11 @@
margin: 20px 0 50px; margin: 20px 0 50px;
} }
::ng-deep .chartjs-render-monitor {
position: relative;
top: 1px;
}
@media screen and (max-width: $small-view) { @media screen and (max-width: $small-view) {
.video-channels-header { .video-channels-header {
text-align: center; text-align: center;

View File

@ -4,9 +4,11 @@ import { AuthService } from '../../core/auth'
import { ConfirmService } from '../../core/confirm' import { ConfirmService } from '../../core/confirm'
import { VideoChannel } from '@app/shared/video-channel/video-channel.model' import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
import { ScreenService } from '@app/shared/misc/screen.service'
import { User } from '@app/shared' import { User } from '@app/shared'
import { flatMap } from 'rxjs/operators' import { flatMap } from 'rxjs/operators'
import { I18n } from '@ngx-translate/i18n-polyfill' import { I18n } from '@ngx-translate/i18n-polyfill'
import { minBy, maxBy } from 'lodash-es'
@Component({ @Component({
selector: 'my-account-video-channels', selector: 'my-account-video-channels',
@ -15,6 +17,9 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
}) })
export class MyAccountVideoChannelsComponent implements OnInit { export class MyAccountVideoChannelsComponent implements OnInit {
videoChannels: VideoChannel[] = [] videoChannels: VideoChannel[] = []
videoChannelsData: any[]
videoChannelsMinimumDailyViews = 0
videoChannelsMaximumDailyViews: number
private user: User private user: User
@ -23,6 +28,7 @@ export class MyAccountVideoChannelsComponent implements OnInit {
private notifier: Notifier, private notifier: Notifier,
private confirmService: ConfirmService, private confirmService: ConfirmService,
private videoChannelService: VideoChannelService, private videoChannelService: VideoChannelService,
private screenService: ScreenService,
private i18n: I18n private i18n: I18n
) {} ) {}
@ -32,6 +38,61 @@ export class MyAccountVideoChannelsComponent implements OnInit {
this.loadVideoChannels() this.loadVideoChannels()
} }
get isInSmallView () {
return this.screenService.isInSmallView()
}
get chartOptions () {
return {
legend: {
display: false
},
scales: {
xAxes: [{
display: false
}],
yAxes: [{
display: false,
ticks: {
min: Math.max(0, this.videoChannelsMinimumDailyViews - (3 * this.videoChannelsMaximumDailyViews / 100)),
max: this.videoChannelsMaximumDailyViews
}
}],
},
layout: {
padding: {
left: 15,
right: 15,
top: 10,
bottom: 0
}
},
elements: {
point:{
radius: 0
}
},
tooltips: {
mode: 'index',
intersect: false,
custom: function (tooltip: any) {
if (!tooltip) return;
// disable displaying the color box;
tooltip.displayColors = false;
},
callbacks: {
label: function (tooltip: any, data: any) {
return `${tooltip.value} views`;
}
}
},
hover: {
mode: 'index',
intersect: false
}
}
}
async deleteVideoChannel (videoChannel: VideoChannel) { async deleteVideoChannel (videoChannel: VideoChannel) {
const res = await this.confirmService.confirmWithInput( const res = await this.confirmService.confirmWithInput(
this.i18n( this.i18n(
@ -64,6 +125,21 @@ export class MyAccountVideoChannelsComponent implements OnInit {
private loadVideoChannels () { private loadVideoChannels () {
this.authService.userInformationLoaded this.authService.userInformationLoaded
.pipe(flatMap(() => this.videoChannelService.listAccountVideoChannels(this.user.account))) .pipe(flatMap(() => this.videoChannelService.listAccountVideoChannels(this.user.account)))
.subscribe(res => this.videoChannels = res.data) .subscribe(res => {
this.videoChannels = res.data
this.videoChannelsData = this.videoChannels.map(v => ({
labels: v.viewsPerDay.map(day => day.date.toLocaleDateString()),
datasets: [
{
label: this.i18n('Views for the day'),
data: v.viewsPerDay.map(day => day.views),
fill: false,
borderColor: "#c6c6c6"
}
]
}))
this.videoChannelsMinimumDailyViews = minBy(this.videoChannels.map(v => minBy(v.viewsPerDay, day => day.views)), day => day.views).views
this.videoChannelsMaximumDailyViews = maxBy(this.videoChannels.map(v => maxBy(v.viewsPerDay, day => day.views)), day => day.views).views
})
} }
} }

View File

@ -1,7 +1,8 @@
import { TableModule } from 'primeng/table'
import { NgModule } from '@angular/core' import { NgModule } from '@angular/core'
import { TableModule } from 'primeng/table'
import { AutoCompleteModule } from 'primeng/autocomplete' import { AutoCompleteModule } from 'primeng/autocomplete'
import { InputSwitchModule } from 'primeng/inputswitch' import { InputSwitchModule } from 'primeng/inputswitch'
import { ChartModule } from 'primeng/chart'
import { SharedModule } from '../shared' import { SharedModule } from '../shared'
import { MyAccountRoutingModule } from './my-account-routing.module' import { MyAccountRoutingModule } from './my-account-routing.module'
import { MyAccountChangePasswordComponent } from './my-account-settings/my-account-change-password/my-account-change-password.component' import { MyAccountChangePasswordComponent } from './my-account-settings/my-account-change-password/my-account-change-password.component'
@ -44,7 +45,8 @@ import { MyAccountChangeEmailComponent } from '@app/+my-account/my-account-setti
SharedModule, SharedModule,
TableModule, TableModule,
InputSwitchModule, InputSwitchModule,
DragDropModule DragDropModule,
ChartModule
], ],
declarations: [ declarations: [

View File

@ -1,4 +1,4 @@
import { VideoChannel as ServerVideoChannel } from '../../../../../shared/models/videos' import { VideoChannel as ServerVideoChannel, viewsPerTime } from '../../../../../shared/models/videos'
import { Actor } from '../actor/actor.model' import { Actor } from '../actor/actor.model'
import { Account } from '../../../../../shared/models/actors' import { Account } from '../../../../../shared/models/actors'
@ -12,6 +12,7 @@ export class VideoChannel extends Actor implements ServerVideoChannel {
ownerAccount?: Account ownerAccount?: Account
ownerBy?: string ownerBy?: string
ownerAvatarUrl?: string ownerAvatarUrl?: string
viewsPerDay?: viewsPerTime[]
constructor (hash: ServerVideoChannel) { constructor (hash: ServerVideoChannel) {
super(hash) super(hash)
@ -23,6 +24,10 @@ export class VideoChannel extends Actor implements ServerVideoChannel {
this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host) this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host)
this.nameWithHostForced = Actor.CREATE_BY_STRING(this.name, this.host, true) this.nameWithHostForced = Actor.CREATE_BY_STRING(this.name, this.host, true)
if (hash.viewsPerDay) {
this.viewsPerDay = hash.viewsPerDay.map(v => ({ ...v, date: new Date(v.date)}))
}
if (hash.ownerAccount) { if (hash.ownerAccount) {
this.ownerAccount = hash.ownerAccount this.ownerAccount = hash.ownerAccount
this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host) this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host)

View File

@ -30,7 +30,7 @@ import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttr
import { VideoModel } from './video' import { VideoModel } from './video'
import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants' import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
import { ServerModel } from '../server/server' import { ServerModel } from '../server/server'
import { FindOptions, Op } from 'sequelize' import { FindOptions, Op, literal, ScopeOptions } from 'sequelize'
import { AvatarModel } from '../avatar/avatar' import { AvatarModel } from '../avatar/avatar'
import { VideoPlaylistModel } from './video-playlist' import { VideoPlaylistModel } from './video-playlist'
import * as Bluebird from 'bluebird' import * as Bluebird from 'bluebird'
@ -45,16 +45,21 @@ import {
export enum ScopeNames { export enum ScopeNames {
FOR_API = 'FOR_API', FOR_API = 'FOR_API',
SUMMARY = 'SUMMARY',
WITH_ACCOUNT = 'WITH_ACCOUNT', WITH_ACCOUNT = 'WITH_ACCOUNT',
WITH_ACTOR = 'WITH_ACTOR', WITH_ACTOR = 'WITH_ACTOR',
WITH_VIDEOS = 'WITH_VIDEOS', WITH_VIDEOS = 'WITH_VIDEOS',
SUMMARY = 'SUMMARY' WITH_STATS = 'WITH_STATS'
} }
type AvailableForListOptions = { type AvailableForListOptions = {
actorId: number actorId: number
} }
type AvailableWithStatsOptions = {
daysPrior: number
}
export type SummaryOptions = { export type SummaryOptions = {
withAccount?: boolean // Default: false withAccount?: boolean // Default: false
withAccountBlockerIds?: number[] withAccountBlockerIds?: number[]
@ -69,40 +74,6 @@ export type SummaryOptions = {
] ]
})) }))
@Scopes(() => ({ @Scopes(() => ({
[ScopeNames.SUMMARY]: (options: SummaryOptions = {}) => {
const base: FindOptions = {
attributes: [ 'id', 'name', 'description', 'actorId' ],
include: [
{
attributes: [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
model: ActorModel.unscoped(),
required: true,
include: [
{
attributes: [ 'host' ],
model: ServerModel.unscoped(),
required: false
},
{
model: AvatarModel.unscoped(),
required: false
}
]
}
]
}
if (options.withAccount === true) {
base.include.push({
model: AccountModel.scope({
method: [ AccountModelScopeNames.SUMMARY, { withAccountBlockerIds: options.withAccountBlockerIds } as AccountSummaryOptions ]
}),
required: true
})
}
return base
},
[ScopeNames.FOR_API]: (options: AvailableForListOptions) => { [ScopeNames.FOR_API]: (options: AvailableForListOptions) => {
// Only list local channels OR channels that are on an instance followed by actorId // Only list local channels OR channels that are on an instance followed by actorId
const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId) const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId)
@ -143,6 +114,40 @@ export type SummaryOptions = {
] ]
} }
}, },
[ScopeNames.SUMMARY]: (options: SummaryOptions = {}) => {
const base: FindOptions = {
attributes: [ 'id', 'name', 'description', 'actorId' ],
include: [
{
attributes: [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
model: ActorModel.unscoped(),
required: true,
include: [
{
attributes: [ 'host' ],
model: ServerModel.unscoped(),
required: false
},
{
model: AvatarModel.unscoped(),
required: false
}
]
}
]
}
if (options.withAccount === true) {
base.include.push({
model: AccountModel.scope({
method: [ AccountModelScopeNames.SUMMARY, { withAccountBlockerIds: options.withAccountBlockerIds } as AccountSummaryOptions ]
}),
required: true
})
}
return base
},
[ScopeNames.WITH_ACCOUNT]: { [ScopeNames.WITH_ACCOUNT]: {
include: [ include: [
{ {
@ -151,16 +156,52 @@ export type SummaryOptions = {
} }
] ]
}, },
[ScopeNames.WITH_ACTOR]: {
include: [
ActorModel
]
},
[ScopeNames.WITH_VIDEOS]: { [ScopeNames.WITH_VIDEOS]: {
include: [ include: [
VideoModel VideoModel
] ]
}, },
[ScopeNames.WITH_ACTOR]: { [ScopeNames.WITH_STATS]: (options: AvailableWithStatsOptions = { daysPrior: 30 }) => ({
include: [ attributes: {
ActorModel include: [
] [
} literal(
'(' +
`SELECT string_agg(concat_ws('|', t.day, t.views), ',') ` +
'FROM ( ' +
'WITH ' +
'days AS ( ' +
`SELECT generate_series(date_trunc('day', now()) - '${options.daysPrior} day'::interval, ` +
`date_trunc('day', now()), '1 day'::interval) AS day ` +
'), ' +
'views AS ( ' +
'SELECT * ' +
'FROM "videoView" ' +
'WHERE "videoView"."videoId" IN ( ' +
'SELECT "video"."id" ' +
'FROM "video" ' +
'WHERE "video"."channelId" = "VideoChannelModel"."id" ' +
') ' +
') ' +
'SELECT days.day AS day, ' +
'COALESCE(SUM(views.views), 0) AS views ' +
'FROM days ' +
`LEFT JOIN views ON date_trunc('day', "views"."createdAt") = days.day ` +
'GROUP BY 1 ' +
'ORDER BY day ' +
') t' +
')'
),
'viewsPerDay'
]
]
}
})
})) }))
@Table({ @Table({
tableName: 'videoChannel', tableName: 'videoChannel',
@ -352,6 +393,7 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
start: number start: number
count: number count: number
sort: string sort: string
withStats?: boolean
}) { }) {
const query = { const query = {
offset: options.start, offset: options.start,
@ -368,7 +410,17 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
] ]
} }
const scopes: string | ScopeOptions | (string | ScopeOptions)[] = [ ScopeNames.WITH_ACTOR ]
options.withStats = true // TODO: remove beyond after initial tests
if (options.withStats) {
scopes.push({
method: [ ScopeNames.WITH_STATS, { daysPrior: 30 } as AvailableWithStatsOptions ]
})
}
return VideoChannelModel return VideoChannelModel
.scope(scopes)
.findAndCountAll(query) .findAndCountAll(query)
.then(({ rows, count }) => { .then(({ rows, count }) => {
return { total: count, data: rows } return { total: count, data: rows }
@ -496,6 +548,8 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
} }
toFormattedJSON (this: MChannelFormattable): VideoChannel { toFormattedJSON (this: MChannelFormattable): VideoChannel {
const viewsPerDay = this.get('viewsPerDay') as string
const actor = this.Actor.toFormattedJSON() const actor = this.Actor.toFormattedJSON()
const videoChannel = { const videoChannel = {
id: this.id, id: this.id,
@ -505,7 +559,16 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
isLocal: this.Actor.isOwned(), isLocal: this.Actor.isOwned(),
createdAt: this.createdAt, createdAt: this.createdAt,
updatedAt: this.updatedAt, updatedAt: this.updatedAt,
ownerAccount: undefined ownerAccount: undefined,
viewsPerDay: viewsPerDay !== undefined
? viewsPerDay.split(',').map(v => {
const o = v.split('|')
return {
date: new Date(o[0]),
views: +o[1]
}
})
: undefined
} }
if (this.Account) videoChannel.ownerAccount = this.Account.toFormattedJSON() if (this.Account) videoChannel.ownerAccount = this.Account.toFormattedJSON()

View File

@ -2,12 +2,18 @@ import { Actor } from '../../actors/actor.model'
import { Account } from '../../actors/index' import { Account } from '../../actors/index'
import { Avatar } from '../../avatars' import { Avatar } from '../../avatars'
export type viewsPerTime = {
date: Date
views: number
}
export interface VideoChannel extends Actor { export interface VideoChannel extends Actor {
displayName: string displayName: string
description: string description: string
support: string support: string
isLocal: boolean isLocal: boolean
ownerAccount?: Account ownerAccount?: Account
viewsPerDay?: viewsPerTime[] // chronologically ordered
} }
export interface VideoChannelSummary { export interface VideoChannelSummary {