aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.html11
-rw-r--r--client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.scss8
-rw-r--r--client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.ts78
-rw-r--r--client/src/app/+my-account/my-account.module.ts6
-rw-r--r--client/src/app/shared/video-channel/video-channel.model.ts7
-rw-r--r--server/models/video/video-channel.ts147
-rw-r--r--shared/models/videos/channel/video-channel.model.ts6
7 files changed, 212 insertions, 51 deletions
diff --git a/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.html b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.html
index 2461aa3f5..94e74938b 100644
--- a/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.html
+++ b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.html
@@ -6,7 +6,7 @@
6</div> 6</div>
7 7
8<div class="video-channels"> 8<div class="video-channels">
9 <div *ngFor="let videoChannel of videoChannels" class="video-channel"> 9 <div *ngFor="let videoChannel of videoChannels; let i = index" class="video-channel">
10 <a [routerLink]="[ '/video-channels', videoChannel.nameWithHost ]"> 10 <a [routerLink]="[ '/video-channels', videoChannel.nameWithHost ]">
11 <img [src]="videoChannel.avatarUrl" alt="Avatar" /> 11 <img [src]="videoChannel.avatarUrl" alt="Avatar" />
12 </a> 12 </a>
@@ -17,13 +17,16 @@
17 <div class="video-channel-name">{{ videoChannel.nameWithHost }}</div> 17 <div class="video-channel-name">{{ videoChannel.nameWithHost }}</div>
18 </a> 18 </a>
19 19
20 <div i18n class="video-channel-followers">{{ videoChannel.followersCount }} subscribers</div> 20 <div i18n class="video-channel-followers">{videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</div>
21
22 <div *ngIf="!isInSmallView" class="w-100 d-flex justify-content-end">
23 <p-chart *ngIf="videoChannelsData && videoChannelsData[i]" type="line" [data]="videoChannelsData[i]" [options]="chartOptions" width="40vw" height="100px"></p-chart>
24 </div>
21 </div> 25 </div>
22 26
23 <div class="video-channel-buttons"> 27 <div class="video-channel-buttons">
24 <my-delete-button (click)="deleteVideoChannel(videoChannel)"></my-delete-button>
25
26 <my-edit-button [routerLink]="[ 'update', videoChannel.nameWithHost ]"></my-edit-button> 28 <my-edit-button [routerLink]="[ 'update', videoChannel.nameWithHost ]"></my-edit-button>
29 <my-delete-button (click)="deleteVideoChannel(videoChannel)"></my-delete-button>
27 </div> 30 </div>
28 </div> 31 </div>
29</div> 32</div>
diff --git a/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.scss b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.scss
index db0c7f94f..c0dc41f12 100644
--- a/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.scss
+++ b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.scss
@@ -6,13 +6,14 @@
6} 6}
7 7
8::ng-deep .action-button { 8::ng-deep .action-button {
9 &.action-button-delete { 9 &.action-button-edit {
10 margin-right: 10px; 10 margin-right: 10px;
11 } 11 }
12} 12}
13 13
14.video-channel { 14.video-channel {
15 @include row-blocks; 15 @include row-blocks;
16 padding-bottom: 0;
16 17
17 img { 18 img {
18 @include avatar(80px); 19 @include avatar(80px);
@@ -58,6 +59,11 @@
58 margin: 20px 0 50px; 59 margin: 20px 0 50px;
59} 60}
60 61
62::ng-deep .chartjs-render-monitor {
63 position: relative;
64 top: 1px;
65}
66
61@media screen and (max-width: $small-view) { 67@media screen and (max-width: $small-view) {
62 .video-channels-header { 68 .video-channels-header {
63 text-align: center; 69 text-align: center;
diff --git a/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.ts b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.ts
index 3b01b6c9f..eeab3a8dd 100644
--- a/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.ts
+++ b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.ts
@@ -4,9 +4,11 @@ import { AuthService } from '../../core/auth'
4import { ConfirmService } from '../../core/confirm' 4import { ConfirmService } from '../../core/confirm'
5import { VideoChannel } from '@app/shared/video-channel/video-channel.model' 5import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
6import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' 6import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
7import { ScreenService } from '@app/shared/misc/screen.service'
7import { User } from '@app/shared' 8import { User } from '@app/shared'
8import { flatMap } from 'rxjs/operators' 9import { flatMap } from 'rxjs/operators'
9import { I18n } from '@ngx-translate/i18n-polyfill' 10import { I18n } from '@ngx-translate/i18n-polyfill'
11import { minBy, maxBy } from 'lodash-es'
10 12
11@Component({ 13@Component({
12 selector: 'my-account-video-channels', 14 selector: 'my-account-video-channels',
@@ -15,6 +17,9 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
15}) 17})
16export class MyAccountVideoChannelsComponent implements OnInit { 18export class MyAccountVideoChannelsComponent implements OnInit {
17 videoChannels: VideoChannel[] = [] 19 videoChannels: VideoChannel[] = []
20 videoChannelsData: any[]
21 videoChannelsMinimumDailyViews = 0
22 videoChannelsMaximumDailyViews: number
18 23
19 private user: User 24 private user: User
20 25
@@ -23,6 +28,7 @@ export class MyAccountVideoChannelsComponent implements OnInit {
23 private notifier: Notifier, 28 private notifier: Notifier,
24 private confirmService: ConfirmService, 29 private confirmService: ConfirmService,
25 private videoChannelService: VideoChannelService, 30 private videoChannelService: VideoChannelService,
31 private screenService: ScreenService,
26 private i18n: I18n 32 private i18n: I18n
27 ) {} 33 ) {}
28 34
@@ -32,6 +38,61 @@ export class MyAccountVideoChannelsComponent implements OnInit {
32 this.loadVideoChannels() 38 this.loadVideoChannels()
33 } 39 }
34 40
41 get isInSmallView () {
42 return this.screenService.isInSmallView()
43 }
44
45 get chartOptions () {
46 return {
47 legend: {
48 display: false
49 },
50 scales: {
51 xAxes: [{
52 display: false
53 }],
54 yAxes: [{
55 display: false,
56 ticks: {
57 min: Math.max(0, this.videoChannelsMinimumDailyViews - (3 * this.videoChannelsMaximumDailyViews / 100)),
58 max: this.videoChannelsMaximumDailyViews
59 }
60 }],
61 },
62 layout: {
63 padding: {
64 left: 15,
65 right: 15,
66 top: 10,
67 bottom: 0
68 }
69 },
70 elements: {
71 point:{
72 radius: 0
73 }
74 },
75 tooltips: {
76 mode: 'index',
77 intersect: false,
78 custom: function (tooltip: any) {
79 if (!tooltip) return;
80 // disable displaying the color box;
81 tooltip.displayColors = false;
82 },
83 callbacks: {
84 label: function (tooltip: any, data: any) {
85 return `${tooltip.value} views`;
86 }
87 }
88 },
89 hover: {
90 mode: 'index',
91 intersect: false
92 }
93 }
94 }
95
35 async deleteVideoChannel (videoChannel: VideoChannel) { 96 async deleteVideoChannel (videoChannel: VideoChannel) {
36 const res = await this.confirmService.confirmWithInput( 97 const res = await this.confirmService.confirmWithInput(
37 this.i18n( 98 this.i18n(
@@ -64,6 +125,21 @@ export class MyAccountVideoChannelsComponent implements OnInit {
64 private loadVideoChannels () { 125 private loadVideoChannels () {
65 this.authService.userInformationLoaded 126 this.authService.userInformationLoaded
66 .pipe(flatMap(() => this.videoChannelService.listAccountVideoChannels(this.user.account))) 127 .pipe(flatMap(() => this.videoChannelService.listAccountVideoChannels(this.user.account)))
67 .subscribe(res => this.videoChannels = res.data) 128 .subscribe(res => {
129 this.videoChannels = res.data
130 this.videoChannelsData = this.videoChannels.map(v => ({
131 labels: v.viewsPerDay.map(day => day.date.toLocaleDateString()),
132 datasets: [
133 {
134 label: this.i18n('Views for the day'),
135 data: v.viewsPerDay.map(day => day.views),
136 fill: false,
137 borderColor: "#c6c6c6"
138 }
139 ]
140 }))
141 this.videoChannelsMinimumDailyViews = minBy(this.videoChannels.map(v => minBy(v.viewsPerDay, day => day.views)), day => day.views).views
142 this.videoChannelsMaximumDailyViews = maxBy(this.videoChannels.map(v => maxBy(v.viewsPerDay, day => day.views)), day => day.views).views
143 })
68 } 144 }
69} 145}
diff --git a/client/src/app/+my-account/my-account.module.ts b/client/src/app/+my-account/my-account.module.ts
index f8c04cb4d..42b61bba6 100644
--- a/client/src/app/+my-account/my-account.module.ts
+++ b/client/src/app/+my-account/my-account.module.ts
@@ -1,7 +1,8 @@
1import { TableModule } from 'primeng/table'
2import { NgModule } from '@angular/core' 1import { NgModule } from '@angular/core'
2import { TableModule } from 'primeng/table'
3import { AutoCompleteModule } from 'primeng/autocomplete' 3import { AutoCompleteModule } from 'primeng/autocomplete'
4import { InputSwitchModule } from 'primeng/inputswitch' 4import { InputSwitchModule } from 'primeng/inputswitch'
5import { ChartModule } from 'primeng/chart'
5import { SharedModule } from '../shared' 6import { SharedModule } from '../shared'
6import { MyAccountRoutingModule } from './my-account-routing.module' 7import { MyAccountRoutingModule } from './my-account-routing.module'
7import { MyAccountChangePasswordComponent } from './my-account-settings/my-account-change-password/my-account-change-password.component' 8import { 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
44 SharedModule, 45 SharedModule,
45 TableModule, 46 TableModule,
46 InputSwitchModule, 47 InputSwitchModule,
47 DragDropModule 48 DragDropModule,
49 ChartModule
48 ], 50 ],
49 51
50 declarations: [ 52 declarations: [
diff --git a/client/src/app/shared/video-channel/video-channel.model.ts b/client/src/app/shared/video-channel/video-channel.model.ts
index fec050cde..ee3288d7a 100644
--- a/client/src/app/shared/video-channel/video-channel.model.ts
+++ b/client/src/app/shared/video-channel/video-channel.model.ts
@@ -1,4 +1,4 @@
1import { VideoChannel as ServerVideoChannel } from '../../../../../shared/models/videos' 1import { VideoChannel as ServerVideoChannel, viewsPerTime } from '../../../../../shared/models/videos'
2import { Actor } from '../actor/actor.model' 2import { Actor } from '../actor/actor.model'
3import { Account } from '../../../../../shared/models/actors' 3import { Account } from '../../../../../shared/models/actors'
4 4
@@ -12,6 +12,7 @@ export class VideoChannel extends Actor implements ServerVideoChannel {
12 ownerAccount?: Account 12 ownerAccount?: Account
13 ownerBy?: string 13 ownerBy?: string
14 ownerAvatarUrl?: string 14 ownerAvatarUrl?: string
15 viewsPerDay?: viewsPerTime[]
15 16
16 constructor (hash: ServerVideoChannel) { 17 constructor (hash: ServerVideoChannel) {
17 super(hash) 18 super(hash)
@@ -23,6 +24,10 @@ export class VideoChannel extends Actor implements ServerVideoChannel {
23 this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host) 24 this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host)
24 this.nameWithHostForced = Actor.CREATE_BY_STRING(this.name, this.host, true) 25 this.nameWithHostForced = Actor.CREATE_BY_STRING(this.name, this.host, true)
25 26
27 if (hash.viewsPerDay) {
28 this.viewsPerDay = hash.viewsPerDay.map(v => ({ ...v, date: new Date(v.date)}))
29 }
30
26 if (hash.ownerAccount) { 31 if (hash.ownerAccount) {
27 this.ownerAccount = hash.ownerAccount 32 this.ownerAccount = hash.ownerAccount
28 this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host) 33 this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host)
diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts
index 835216671..128915af3 100644
--- a/server/models/video/video-channel.ts
+++ b/server/models/video/video-channel.ts
@@ -30,7 +30,7 @@ import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttr
30import { VideoModel } from './video' 30import { VideoModel } from './video'
31import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants' 31import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
32import { ServerModel } from '../server/server' 32import { ServerModel } from '../server/server'
33import { FindOptions, Op } from 'sequelize' 33import { FindOptions, Op, literal, ScopeOptions } from 'sequelize'
34import { AvatarModel } from '../avatar/avatar' 34import { AvatarModel } from '../avatar/avatar'
35import { VideoPlaylistModel } from './video-playlist' 35import { VideoPlaylistModel } from './video-playlist'
36import * as Bluebird from 'bluebird' 36import * as Bluebird from 'bluebird'
@@ -45,16 +45,21 @@ import {
45 45
46export enum ScopeNames { 46export enum ScopeNames {
47 FOR_API = 'FOR_API', 47 FOR_API = 'FOR_API',
48 SUMMARY = 'SUMMARY',
48 WITH_ACCOUNT = 'WITH_ACCOUNT', 49 WITH_ACCOUNT = 'WITH_ACCOUNT',
49 WITH_ACTOR = 'WITH_ACTOR', 50 WITH_ACTOR = 'WITH_ACTOR',
50 WITH_VIDEOS = 'WITH_VIDEOS', 51 WITH_VIDEOS = 'WITH_VIDEOS',
51 SUMMARY = 'SUMMARY' 52 WITH_STATS = 'WITH_STATS'
52} 53}
53 54
54type AvailableForListOptions = { 55type AvailableForListOptions = {
55 actorId: number 56 actorId: number
56} 57}
57 58
59type AvailableWithStatsOptions = {
60 daysPrior: number
61}
62
58export type SummaryOptions = { 63export type SummaryOptions = {
59 withAccount?: boolean // Default: false 64 withAccount?: boolean // Default: false
60 withAccountBlockerIds?: number[] 65 withAccountBlockerIds?: number[]
@@ -69,40 +74,6 @@ export type SummaryOptions = {
69 ] 74 ]
70})) 75}))
71@Scopes(() => ({ 76@Scopes(() => ({
72 [ScopeNames.SUMMARY]: (options: SummaryOptions = {}) => {
73 const base: FindOptions = {
74 attributes: [ 'id', 'name', 'description', 'actorId' ],
75 include: [
76 {
77 attributes: [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
78 model: ActorModel.unscoped(),
79 required: true,
80 include: [
81 {
82 attributes: [ 'host' ],
83 model: ServerModel.unscoped(),
84 required: false
85 },
86 {
87 model: AvatarModel.unscoped(),
88 required: false
89 }
90 ]
91 }
92 ]
93 }
94
95 if (options.withAccount === true) {
96 base.include.push({
97 model: AccountModel.scope({
98 method: [ AccountModelScopeNames.SUMMARY, { withAccountBlockerIds: options.withAccountBlockerIds } as AccountSummaryOptions ]
99 }),
100 required: true
101 })
102 }
103
104 return base
105 },
106 [ScopeNames.FOR_API]: (options: AvailableForListOptions) => { 77 [ScopeNames.FOR_API]: (options: AvailableForListOptions) => {
107 // Only list local channels OR channels that are on an instance followed by actorId 78 // Only list local channels OR channels that are on an instance followed by actorId
108 const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId) 79 const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId)
@@ -143,6 +114,40 @@ export type SummaryOptions = {
143 ] 114 ]
144 } 115 }
145 }, 116 },
117 [ScopeNames.SUMMARY]: (options: SummaryOptions = {}) => {
118 const base: FindOptions = {
119 attributes: [ 'id', 'name', 'description', 'actorId' ],
120 include: [
121 {
122 attributes: [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
123 model: ActorModel.unscoped(),
124 required: true,
125 include: [
126 {
127 attributes: [ 'host' ],
128 model: ServerModel.unscoped(),
129 required: false
130 },
131 {
132 model: AvatarModel.unscoped(),
133 required: false
134 }
135 ]
136 }
137 ]
138 }
139
140 if (options.withAccount === true) {
141 base.include.push({
142 model: AccountModel.scope({
143 method: [ AccountModelScopeNames.SUMMARY, { withAccountBlockerIds: options.withAccountBlockerIds } as AccountSummaryOptions ]
144 }),
145 required: true
146 })
147 }
148
149 return base
150 },
146 [ScopeNames.WITH_ACCOUNT]: { 151 [ScopeNames.WITH_ACCOUNT]: {
147 include: [ 152 include: [
148 { 153 {
@@ -151,16 +156,52 @@ export type SummaryOptions = {
151 } 156 }
152 ] 157 ]
153 }, 158 },
154 [ScopeNames.WITH_VIDEOS]: { 159 [ScopeNames.WITH_ACTOR]: {
155 include: [ 160 include: [
156 VideoModel 161 ActorModel
157 ] 162 ]
158 }, 163 },
159 [ScopeNames.WITH_ACTOR]: { 164 [ScopeNames.WITH_VIDEOS]: {
160 include: [ 165 include: [
161 ActorModel 166 VideoModel
162 ] 167 ]
163 } 168 },
169 [ScopeNames.WITH_STATS]: (options: AvailableWithStatsOptions = { daysPrior: 30 }) => ({
170 attributes: {
171 include: [
172 [
173 literal(
174 '(' +
175 `SELECT string_agg(concat_ws('|', t.day, t.views), ',') ` +
176 'FROM ( ' +
177 'WITH ' +
178 'days AS ( ' +
179 `SELECT generate_series(date_trunc('day', now()) - '${options.daysPrior} day'::interval, ` +
180 `date_trunc('day', now()), '1 day'::interval) AS day ` +
181 '), ' +
182 'views AS ( ' +
183 'SELECT * ' +
184 'FROM "videoView" ' +
185 'WHERE "videoView"."videoId" IN ( ' +
186 'SELECT "video"."id" ' +
187 'FROM "video" ' +
188 'WHERE "video"."channelId" = "VideoChannelModel"."id" ' +
189 ') ' +
190 ') ' +
191 'SELECT days.day AS day, ' +
192 'COALESCE(SUM(views.views), 0) AS views ' +
193 'FROM days ' +
194 `LEFT JOIN views ON date_trunc('day', "views"."createdAt") = days.day ` +
195 'GROUP BY 1 ' +
196 'ORDER BY day ' +
197 ') t' +
198 ')'
199 ),
200 'viewsPerDay'
201 ]
202 ]
203 }
204 })
164})) 205}))
165@Table({ 206@Table({
166 tableName: 'videoChannel', 207 tableName: 'videoChannel',
@@ -352,6 +393,7 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
352 start: number 393 start: number
353 count: number 394 count: number
354 sort: string 395 sort: string
396 withStats?: boolean
355 }) { 397 }) {
356 const query = { 398 const query = {
357 offset: options.start, 399 offset: options.start,
@@ -368,7 +410,17 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
368 ] 410 ]
369 } 411 }
370 412
413 const scopes: string | ScopeOptions | (string | ScopeOptions)[] = [ ScopeNames.WITH_ACTOR ]
414
415 options.withStats = true // TODO: remove beyond after initial tests
416 if (options.withStats) {
417 scopes.push({
418 method: [ ScopeNames.WITH_STATS, { daysPrior: 30 } as AvailableWithStatsOptions ]
419 })
420 }
421
371 return VideoChannelModel 422 return VideoChannelModel
423 .scope(scopes)
372 .findAndCountAll(query) 424 .findAndCountAll(query)
373 .then(({ rows, count }) => { 425 .then(({ rows, count }) => {
374 return { total: count, data: rows } 426 return { total: count, data: rows }
@@ -496,6 +548,8 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
496 } 548 }
497 549
498 toFormattedJSON (this: MChannelFormattable): VideoChannel { 550 toFormattedJSON (this: MChannelFormattable): VideoChannel {
551 const viewsPerDay = this.get('viewsPerDay') as string
552
499 const actor = this.Actor.toFormattedJSON() 553 const actor = this.Actor.toFormattedJSON()
500 const videoChannel = { 554 const videoChannel = {
501 id: this.id, 555 id: this.id,
@@ -505,7 +559,16 @@ export class VideoChannelModel extends Model<VideoChannelModel> {
505 isLocal: this.Actor.isOwned(), 559 isLocal: this.Actor.isOwned(),
506 createdAt: this.createdAt, 560 createdAt: this.createdAt,
507 updatedAt: this.updatedAt, 561 updatedAt: this.updatedAt,
508 ownerAccount: undefined 562 ownerAccount: undefined,
563 viewsPerDay: viewsPerDay !== undefined
564 ? viewsPerDay.split(',').map(v => {
565 const o = v.split('|')
566 return {
567 date: new Date(o[0]),
568 views: +o[1]
569 }
570 })
571 : undefined
509 } 572 }
510 573
511 if (this.Account) videoChannel.ownerAccount = this.Account.toFormattedJSON() 574 if (this.Account) videoChannel.ownerAccount = this.Account.toFormattedJSON()
diff --git a/shared/models/videos/channel/video-channel.model.ts b/shared/models/videos/channel/video-channel.model.ts
index de4c26b3d..5fe6609d9 100644
--- a/shared/models/videos/channel/video-channel.model.ts
+++ b/shared/models/videos/channel/video-channel.model.ts
@@ -2,12 +2,18 @@ import { Actor } from '../../actors/actor.model'
2import { Account } from '../../actors/index' 2import { Account } from '../../actors/index'
3import { Avatar } from '../../avatars' 3import { Avatar } from '../../avatars'
4 4
5export type viewsPerTime = {
6 date: Date
7 views: number
8}
9
5export interface VideoChannel extends Actor { 10export interface VideoChannel extends Actor {
6 displayName: string 11 displayName: string
7 description: string 12 description: string
8 support: string 13 support: string
9 isLocal: boolean 14 isLocal: boolean
10 ownerAccount?: Account 15 ownerAccount?: Account
16 viewsPerDay?: viewsPerTime[] // chronologically ordered
11} 17}
12 18
13export interface VideoChannelSummary { 19export interface VideoChannelSummary {