View stats for channels
authorRigel Kent <sendmemail@rigelk.eu>
Mon, 23 Mar 2020 09:14:05 +0000 (10:14 +0100)
committerChocobozzz <chocobozzz@cpy.re>
Tue, 31 Mar 2020 08:29:24 +0000 (10:29 +0200)
client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.html
client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.scss
client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.ts
client/src/app/+my-account/my-account.module.ts
client/src/app/shared/video-channel/video-channel.model.ts
server/models/video/video-channel.ts
shared/models/videos/channel/video-channel.model.ts

index 2461aa3f5bedac8bdae68c990824eeaca8505b72..94e74938b84215cf6b42b97c7568e5fb43009a44 100644 (file)
@@ -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>
         <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>
index db0c7f94faf0580f63d2257bbb10d8085ea636c9..c0dc41f127229eac35cd52582aaa9a0ae425c4a7 100644 (file)
@@ -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);
   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;
index 3b01b6c9f3d111cd6daf578be500121b701a2f1f..eeab3a8dd60c9b84ebfac61674c1d0c784b83039 100644 (file)
@@ -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
+        })
   }
 }
index f8c04cb4d23299604b618943ad3caa17a8799265..42b61bba67c1079769194b7db754c7dd3341c0da 100644 (file)
@@ -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: [
index fec050cdeccc4b2b691c53c47ddb6ebc18a684aa..ee3288d7a55d5873df3072a008c5596f554f6685 100644 (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 { 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)
index 835216671730eeaa1955182318d61c3caf4d6262..128915af31a9d7d0b727c55946f9e7d7105313b6 100644 (file)
@@ -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_VIDEOS]: {
+  [ScopeNames.WITH_ACTOR]: {
     include: [
-      VideoModel
+      ActorModel
     ]
   },
-  [ScopeNames.WITH_ACTOR]: {
+  [ScopeNames.WITH_VIDEOS]: {
     include: [
-      ActorModel
+      VideoModel
     ]
-  }
+  },
+  [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()
index de4c26b3dd5236cdcd9e116c2bfc30a53d316f95..5fe6609d907275543c09268d6ca8f66ed66120d7 100644 (file)
@@ -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 {