View stats for channels
This commit is contained in:
parent
628c155338
commit
8165d00ac6
7 changed files with 212 additions and 51 deletions
|
@ -6,7 +6,7 @@
|
|||
</div>
|
||||
|
||||
<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 ]">
|
||||
<img [src]="videoChannel.avatarUrl" alt="Avatar" />
|
||||
</a>
|
||||
|
@ -17,13 +17,16 @@
|
|||
<div class="video-channel-name">{{ videoChannel.nameWithHost }}</div>
|
||||
</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 class="video-channel-buttons">
|
||||
<my-delete-button (click)="deleteVideoChannel(videoChannel)"></my-delete-button>
|
||||
|
||||
<my-edit-button [routerLink]="[ 'update', videoChannel.nameWithHost ]"></my-edit-button>
|
||||
<my-delete-button (click)="deleteVideoChannel(videoChannel)"></my-delete-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -6,13 +6,14 @@
|
|||
}
|
||||
|
||||
::ng-deep .action-button {
|
||||
&.action-button-delete {
|
||||
&.action-button-edit {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.video-channel {
|
||||
@include row-blocks;
|
||||
padding-bottom: 0;
|
||||
|
||||
img {
|
||||
@include avatar(80px);
|
||||
|
@ -58,6 +59,11 @@
|
|||
margin: 20px 0 50px;
|
||||
}
|
||||
|
||||
::ng-deep .chartjs-render-monitor {
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $small-view) {
|
||||
.video-channels-header {
|
||||
text-align: center;
|
||||
|
|
|
@ -4,9 +4,11 @@ import { AuthService } from '../../core/auth'
|
|||
import { ConfirmService } from '../../core/confirm'
|
||||
import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
|
||||
import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
|
||||
import { ScreenService } from '@app/shared/misc/screen.service'
|
||||
import { User } from '@app/shared'
|
||||
import { flatMap } from 'rxjs/operators'
|
||||
import { I18n } from '@ngx-translate/i18n-polyfill'
|
||||
import { minBy, maxBy } from 'lodash-es'
|
||||
|
||||
@Component({
|
||||
selector: 'my-account-video-channels',
|
||||
|
@ -15,6 +17,9 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
|
|||
})
|
||||
export class MyAccountVideoChannelsComponent implements OnInit {
|
||||
videoChannels: VideoChannel[] = []
|
||||
videoChannelsData: any[]
|
||||
videoChannelsMinimumDailyViews = 0
|
||||
videoChannelsMaximumDailyViews: number
|
||||
|
||||
private user: User
|
||||
|
||||
|
@ -23,6 +28,7 @@ export class MyAccountVideoChannelsComponent implements OnInit {
|
|||
private notifier: Notifier,
|
||||
private confirmService: ConfirmService,
|
||||
private videoChannelService: VideoChannelService,
|
||||
private screenService: ScreenService,
|
||||
private i18n: I18n
|
||||
) {}
|
||||
|
||||
|
@ -32,6 +38,61 @@ export class MyAccountVideoChannelsComponent implements OnInit {
|
|||
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) {
|
||||
const res = await this.confirmService.confirmWithInput(
|
||||
this.i18n(
|
||||
|
@ -64,6 +125,21 @@ export class MyAccountVideoChannelsComponent implements OnInit {
|
|||
private loadVideoChannels () {
|
||||
this.authService.userInformationLoaded
|
||||
.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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import { TableModule } from 'primeng/table'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { TableModule } from 'primeng/table'
|
||||
import { AutoCompleteModule } from 'primeng/autocomplete'
|
||||
import { InputSwitchModule } from 'primeng/inputswitch'
|
||||
import { ChartModule } from 'primeng/chart'
|
||||
import { SharedModule } from '../shared'
|
||||
import { MyAccountRoutingModule } from './my-account-routing.module'
|
||||
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,
|
||||
TableModule,
|
||||
InputSwitchModule,
|
||||
DragDropModule
|
||||
DragDropModule,
|
||||
ChartModule
|
||||
],
|
||||
|
||||
declarations: [
|
||||
|
|
|
@ -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 { Account } from '../../../../../shared/models/actors'
|
||||
|
||||
|
@ -12,6 +12,7 @@ export class VideoChannel extends Actor implements ServerVideoChannel {
|
|||
ownerAccount?: Account
|
||||
ownerBy?: string
|
||||
ownerAvatarUrl?: string
|
||||
viewsPerDay?: viewsPerTime[]
|
||||
|
||||
constructor (hash: ServerVideoChannel) {
|
||||
super(hash)
|
||||
|
@ -23,6 +24,10 @@ export class VideoChannel extends Actor implements ServerVideoChannel {
|
|||
this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host)
|
||||
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) {
|
||||
this.ownerAccount = hash.ownerAccount
|
||||
this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host)
|
||||
|
|
|
@ -30,7 +30,7 @@ import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttr
|
|||
import { VideoModel } from './video'
|
||||
import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
|
||||
import { ServerModel } from '../server/server'
|
||||
import { FindOptions, Op } from 'sequelize'
|
||||
import { FindOptions, Op, literal, ScopeOptions } from 'sequelize'
|
||||
import { AvatarModel } from '../avatar/avatar'
|
||||
import { VideoPlaylistModel } from './video-playlist'
|
||||
import * as Bluebird from 'bluebird'
|
||||
|
@ -45,16 +45,21 @@ import {
|
|||
|
||||
export enum ScopeNames {
|
||||
FOR_API = 'FOR_API',
|
||||
SUMMARY = 'SUMMARY',
|
||||
WITH_ACCOUNT = 'WITH_ACCOUNT',
|
||||
WITH_ACTOR = 'WITH_ACTOR',
|
||||
WITH_VIDEOS = 'WITH_VIDEOS',
|
||||
SUMMARY = 'SUMMARY'
|
||||
WITH_STATS = 'WITH_STATS'
|
||||
}
|
||||
|
||||
type AvailableForListOptions = {
|
||||
actorId: number
|
||||
}
|
||||
|
||||
type AvailableWithStatsOptions = {
|
||||
daysPrior: number
|
||||
}
|
||||
|
||||
export type SummaryOptions = {
|
||||
withAccount?: boolean // Default: false
|
||||
withAccountBlockerIds?: number[]
|
||||
|
@ -69,40 +74,6 @@ export type SummaryOptions = {
|
|||
]
|
||||
}))
|
||||
@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) => {
|
||||
// Only list local channels OR channels that are on an instance followed by 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]: {
|
||||
include: [
|
||||
{
|
||||
|
@ -151,16 +156,52 @@ export type SummaryOptions = {
|
|||
}
|
||||
]
|
||||
},
|
||||
[ScopeNames.WITH_ACTOR]: {
|
||||
include: [
|
||||
ActorModel
|
||||
]
|
||||
},
|
||||
[ScopeNames.WITH_VIDEOS]: {
|
||||
include: [
|
||||
VideoModel
|
||||
]
|
||||
},
|
||||
[ScopeNames.WITH_ACTOR]: {
|
||||
include: [
|
||||
ActorModel
|
||||
]
|
||||
}
|
||||
[ScopeNames.WITH_STATS]: (options: AvailableWithStatsOptions = { daysPrior: 30 }) => ({
|
||||
attributes: {
|
||||
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({
|
||||
tableName: 'videoChannel',
|
||||
|
@ -352,6 +393,7 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
|
|||
start: number
|
||||
count: number
|
||||
sort: string
|
||||
withStats?: boolean
|
||||
}) {
|
||||
const query = {
|
||||
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
|
||||
.scope(scopes)
|
||||
.findAndCountAll(query)
|
||||
.then(({ rows, count }) => {
|
||||
return { total: count, data: rows }
|
||||
|
@ -496,6 +548,8 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
|
|||
}
|
||||
|
||||
toFormattedJSON (this: MChannelFormattable): VideoChannel {
|
||||
const viewsPerDay = this.get('viewsPerDay') as string
|
||||
|
||||
const actor = this.Actor.toFormattedJSON()
|
||||
const videoChannel = {
|
||||
id: this.id,
|
||||
|
@ -505,7 +559,16 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
|
|||
isLocal: this.Actor.isOwned(),
|
||||
createdAt: this.createdAt,
|
||||
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()
|
||||
|
|
|
@ -2,12 +2,18 @@ import { Actor } from '../../actors/actor.model'
|
|||
import { Account } from '../../actors/index'
|
||||
import { Avatar } from '../../avatars'
|
||||
|
||||
export type viewsPerTime = {
|
||||
date: Date
|
||||
views: number
|
||||
}
|
||||
|
||||
export interface VideoChannel extends Actor {
|
||||
displayName: string
|
||||
description: string
|
||||
support: string
|
||||
isLocal: boolean
|
||||
ownerAccount?: Account
|
||||
viewsPerDay?: viewsPerTime[] // chronologically ordered
|
||||
}
|
||||
|
||||
export interface VideoChannelSummary {
|
||||
|
|
Loading…
Reference in a new issue