aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html5
-rw-r--r--client/src/sass/class-helpers.scss21
-rw-r--r--client/src/sass/include/_fonts.scss4
-rw-r--r--client/src/sass/include/_icons.scss24
-rw-r--r--client/src/sass/primeng-custom.scss12
-rw-r--r--server/models/view/local-video-viewer.ts120
-rw-r--r--server/tests/api/views/video-views-overall-stats.ts66
7 files changed, 186 insertions, 66 deletions
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html
index 779d42e0c..7b6bd993c 100644
--- a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html
+++ b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.html
@@ -66,7 +66,10 @@
66 <span *ngIf="videoUploadPercents !== 100 || videoUploaded">{{ videoUploadPercents }}%</span> 66 <span *ngIf="videoUploadPercents !== 100 || videoUploaded">{{ videoUploadPercents }}%</span>
67 </div> 67 </div>
68 </div> 68 </div>
69 <input *ngIf="videoUploaded === false" type="button" i18n-value="Cancel ongoing upload of a video" value="Cancel" (click)="cancelUpload()" /> 69 <input
70 *ngIf="videoUploaded === false"
71 type="button" class="peertube-button grey-button ms-1" i18n-value="Cancel ongoing upload of a video" value="Cancel" (click)="cancelUpload()"
72 />
70</div> 73</div>
71 74
72<div *ngIf="error && enableRetryAfterError" class="upload-progress-retry"> 75<div *ngIf="error && enableRetryAfterError" class="upload-progress-retry">
diff --git a/client/src/sass/class-helpers.scss b/client/src/sass/class-helpers.scss
index 72381d1a8..bc965331a 100644
--- a/client/src/sass/class-helpers.scss
+++ b/client/src/sass/class-helpers.scss
@@ -2,6 +2,7 @@
2@use '_mixins' as *; 2@use '_mixins' as *;
3@use '_badges' as *; 3@use '_badges' as *;
4@use '_icons' as *; 4@use '_icons' as *;
5@use '_fonts' as *;
5 6
6.link-orange { 7.link-orange {
7 color: pvar(--mainForegroundColor); 8 color: pvar(--mainForegroundColor);
@@ -62,7 +63,7 @@
62// --------------------------------------------------------------------------- 63// ---------------------------------------------------------------------------
63 64
64.muted { 65.muted {
65 color: pvar(--greyForegroundColor) !important; 66 @include muted;
66} 67}
67 68
68// --------------------------------------------------------------------------- 69// ---------------------------------------------------------------------------
@@ -111,7 +112,7 @@
111} 112}
112 113
113.form-group-description { 114.form-group-description {
114 @extend .muted !optional; 115 @include muted;
115 116
116 font-size: 14px; 117 font-size: 14px;
117 margin-top: 10px; 118 margin-top: 10px;
@@ -219,27 +220,19 @@ label + .form-group-description {
219// --------------------------------------------------------------------------- 220// ---------------------------------------------------------------------------
220 221
221.chevron-down { 222.chevron-down {
222 @include chevron-down(0.55rem, 0.15rem); 223 @include chevron-down-default;
223
224 margin: 0 8px;
225} 224}
226 225
227.chevron-up { 226.chevron-up {
228 @include chevron-up(0.55rem, 0.15rem); 227 @include chevron-up-default;
229
230 margin: 0 8px;
231} 228}
232 229
233.chevron-right { 230.chevron-right {
234 @include chevron-right(0.55rem, 0.15rem); 231 @include chevron-right-default;
235
236 margin: 0 8px;
237} 232}
238 233
239.chevron-left { 234.chevron-left {
240 @include chevron-left(0.55rem, 0.15rem); 235 @include chevron-left-default;
241
242 margin: 0 8px;
243} 236}
244 237
245// --------------------------------------------------------------------------- 238// ---------------------------------------------------------------------------
diff --git a/client/src/sass/include/_fonts.scss b/client/src/sass/include/_fonts.scss
index 514261d01..e5a40af34 100644
--- a/client/src/sass/include/_fonts.scss
+++ b/client/src/sass/include/_fonts.scss
@@ -15,3 +15,7 @@
15 font-display: swap; 15 font-display: swap;
16 src: url('../fonts/source-sans/WOFF2/VAR/SourceSans3VF-Italic.ttf.woff2') format('woff2'); 16 src: url('../fonts/source-sans/WOFF2/VAR/SourceSans3VF-Italic.ttf.woff2') format('woff2');
17} 17}
18
19@mixin muted {
20 color: pvar(--greyForegroundColor) !important;
21}
diff --git a/client/src/sass/include/_icons.scss b/client/src/sass/include/_icons.scss
index 5d8a312db..08a0c02e3 100644
--- a/client/src/sass/include/_icons.scss
+++ b/client/src/sass/include/_icons.scss
@@ -18,6 +18,12 @@
18 transform: rotate(45deg); 18 transform: rotate(45deg);
19} 19}
20 20
21@mixin chevron-right-default {
22 @include chevron-right(0.55rem, 0.15rem);
23
24 margin: 0 8px;
25}
26
21@mixin chevron-down ($size, $border-width) { 27@mixin chevron-down ($size, $border-width) {
22 @include chevron($size, $border-width); 28 @include chevron($size, $border-width);
23 29
@@ -25,6 +31,12 @@
25 transform: rotate(135deg); 31 transform: rotate(135deg);
26} 32}
27 33
34@mixin chevron-down-default {
35 @include chevron-down(0.55rem, 0.15rem);
36
37 margin: 0 8px;
38}
39
28@mixin chevron-up ($size, $border-width) { 40@mixin chevron-up ($size, $border-width) {
29 @include chevron($size, $border-width); 41 @include chevron($size, $border-width);
30 42
@@ -32,6 +44,12 @@
32 transform: rotate(-45deg); 44 transform: rotate(-45deg);
33} 45}
34 46
47@mixin chevron-up-default {
48 @include chevron-up(0.55rem, 0.15rem);
49
50 margin: 0 8px;
51}
52
35@mixin chevron-left ($size, $border-width) { 53@mixin chevron-left ($size, $border-width) {
36 @include chevron($size, $border-width); 54 @include chevron($size, $border-width);
37 55
@@ -39,6 +57,12 @@
39 transform: rotate(-135deg); 57 transform: rotate(-135deg);
40} 58}
41 59
60@mixin chevron-left-default {
61 @include chevron-left(0.55rem, 0.15rem);
62
63 margin: 0 8px;
64}
65
42// --------------------------------------------------------------------------- 66// ---------------------------------------------------------------------------
43 67
44@mixin arrow-up ($size) { 68@mixin arrow-up ($size) {
diff --git a/client/src/sass/primeng-custom.scss b/client/src/sass/primeng-custom.scss
index a82cdbbb9..fb1d3f7bd 100644
--- a/client/src/sass/primeng-custom.scss
+++ b/client/src/sass/primeng-custom.scss
@@ -667,7 +667,7 @@ p-table {
667 @include margin-right(10px); 667 @include margin-right(10px);
668 668
669 .p-paginator-icon { 669 .p-paginator-icon {
670 @extend .chevron-left !optional; 670 @include chevron-left-default;
671 } 671 }
672 } 672 }
673 673
@@ -675,7 +675,7 @@ p-table {
675 @include margin-left(10px); 675 @include margin-left(10px);
676 676
677 .p-paginator-icon { 677 .p-paginator-icon {
678 @extend .chevron-right !optional; 678 @include chevron-right-default;
679 } 679 }
680 } 680 }
681 681
@@ -769,7 +769,7 @@ p-calendar .p-datepicker {
769 } 769 }
770 770
771 .p-datepicker-next { 771 .p-datepicker-next {
772 @extend .chevron-right !optional; 772 @include chevron-right-default;
773 773
774 color: #000 !important; 774 color: #000 !important;
775 text-align: end; 775 text-align: end;
@@ -780,7 +780,7 @@ p-calendar .p-datepicker {
780 } 780 }
781 781
782 .p-datepicker-prev { 782 .p-datepicker-prev {
783 @extend .chevron-left !optional; 783 @include chevron-left-default;
784 784
785 color: #000 !important; 785 color: #000 !important;
786 text-align: start; 786 text-align: start;
@@ -794,13 +794,13 @@ p-calendar .p-datepicker {
794 .p-timepicker { 794 .p-timepicker {
795 795
796 .pi.pi-chevron-up { 796 .pi.pi-chevron-up {
797 @extend .chevron-up !optional; 797 @include chevron-up-default;
798 798
799 color: #000 !important; 799 color: #000 !important;
800 } 800 }
801 801
802 .pi.pi-chevron-down { 802 .pi.pi-chevron-down {
803 @extend .chevron-down !optional; 803 @include chevron-down-default;
804 804
805 color: #000 !important; 805 color: #000 !important;
806 } 806 }
diff --git a/server/models/view/local-video-viewer.ts b/server/models/view/local-video-viewer.ts
index 12350861b..9d0d89a59 100644
--- a/server/models/view/local-video-viewer.ts
+++ b/server/models/view/local-video-viewer.ts
@@ -112,58 +112,88 @@ export class LocalVideoViewerModel extends Model<Partial<AttributesOnly<LocalVid
112 replacements: { videoId: video.id } as any 112 replacements: { videoId: video.id } as any
113 } 113 }
114 114
115 let dateWhere = '' 115 if (startDate) queryOptions.replacements.startDate = startDate
116 if (endDate) queryOptions.replacements.endDate = endDate
116 117
117 if (startDate) { 118 const buildWatchTimePromise = () => {
118 dateWhere += ' AND "localVideoViewer"."startDate" >= :startDate' 119 let watchTimeDateWhere = ''
119 queryOptions.replacements.startDate = startDate 120
121 if (startDate) watchTimeDateWhere += ' AND "localVideoViewer"."startDate" >= :startDate'
122 if (endDate) watchTimeDateWhere += ' AND "localVideoViewer"."endDate" <= :endDate'
123
124 const watchTimeQuery = `SELECT ` +
125 `COUNT("localVideoViewer"."id") AS "totalViewers", ` +
126 `SUM("localVideoViewer"."watchTime") AS "totalWatchTime", ` +
127 `AVG("localVideoViewer"."watchTime") AS "averageWatchTime" ` +
128 `FROM "localVideoViewer" ` +
129 `INNER JOIN "video" ON "video"."id" = "localVideoViewer"."videoId" ` +
130 `WHERE "videoId" = :videoId ${watchTimeDateWhere}`
131
132 return LocalVideoViewerModel.sequelize.query<any>(watchTimeQuery, queryOptions)
120 } 133 }
121 134
122 if (endDate) { 135 const buildWatchPeakPromise = () => {
123 dateWhere += ' AND "localVideoViewer"."endDate" <= :endDate' 136 let watchPeakDateWhereStart = ''
124 queryOptions.replacements.endDate = endDate 137 let watchPeakDateWhereEnd = ''
138
139 if (startDate) {
140 watchPeakDateWhereStart += ' AND "localVideoViewer"."startDate" >= :startDate'
141 watchPeakDateWhereEnd += ' AND "localVideoViewer"."endDate" >= :startDate'
142 }
143
144 if (endDate) {
145 watchPeakDateWhereStart += ' AND "localVideoViewer"."startDate" <= :endDate'
146 watchPeakDateWhereEnd += ' AND "localVideoViewer"."endDate" <= :endDate'
147 }
148
149 // Add viewers that were already here, before our start date
150 const beforeWatchersQuery = startDate
151 // eslint-disable-next-line max-len
152 ? `SELECT COUNT(*) AS "total" FROM "localVideoViewer" WHERE "localVideoViewer"."startDate" < :startDate AND "localVideoViewer"."endDate" >= :startDate`
153 : `SELECT 0 AS "total"`
154
155 const watchPeakQuery = `WITH
156 "beforeWatchers" AS (${beforeWatchersQuery}),
157 "watchPeakValues" AS (
158 SELECT "startDate" AS "dateBreakpoint", 1 AS "inc"
159 FROM "localVideoViewer"
160 WHERE "videoId" = :videoId ${watchPeakDateWhereStart}
161 UNION ALL
162 SELECT "endDate" AS "dateBreakpoint", -1 AS "inc"
163 FROM "localVideoViewer"
164 WHERE "videoId" = :videoId ${watchPeakDateWhereEnd}
165 )
166 SELECT "dateBreakpoint", "concurrent"
167 FROM (
168 SELECT "dateBreakpoint", SUM(SUM("inc")) OVER (ORDER BY "dateBreakpoint") + (SELECT "total" FROM "beforeWatchers") AS "concurrent"
169 FROM "watchPeakValues"
170 GROUP BY "dateBreakpoint"
171 ) tmp
172 ORDER BY "concurrent" DESC
173 FETCH FIRST 1 ROW ONLY`
174
175 return LocalVideoViewerModel.sequelize.query<any>(watchPeakQuery, queryOptions)
125 } 176 }
126 177
127 const watchTimeQuery = `SELECT ` + 178 const buildCountriesPromise = () => {
128 `COUNT("localVideoViewer"."id") AS "totalViewers", ` + 179 let countryDateWhere = ''
129 `SUM("localVideoViewer"."watchTime") AS "totalWatchTime", ` + 180
130 `AVG("localVideoViewer"."watchTime") AS "averageWatchTime" ` + 181 if (startDate) countryDateWhere += ' AND "localVideoViewer"."endDate" >= :startDate'
131 `FROM "localVideoViewer" ` + 182 if (endDate) countryDateWhere += ' AND "localVideoViewer"."startDate" <= :endDate'
132 `INNER JOIN "video" ON "video"."id" = "localVideoViewer"."videoId" ` + 183
133 `WHERE "videoId" = :videoId ${dateWhere}` 184 const countriesQuery = `SELECT country, COUNT(country) as viewers ` +
134 185 `FROM "localVideoViewer" ` +
135 const watchTimePromise = LocalVideoViewerModel.sequelize.query<any>(watchTimeQuery, queryOptions) 186 `WHERE "videoId" = :videoId AND country IS NOT NULL ${countryDateWhere} ` +
136 187 `GROUP BY country ` +
137 const watchPeakQuery = `WITH "watchPeakValues" AS ( 188 `ORDER BY viewers DESC`
138 SELECT "startDate" AS "dateBreakpoint", 1 AS "inc" 189
139 FROM "localVideoViewer" 190 return LocalVideoViewerModel.sequelize.query<any>(countriesQuery, queryOptions)
140 WHERE "videoId" = :videoId ${dateWhere} 191 }
141 UNION ALL
142 SELECT "endDate" AS "dateBreakpoint", -1 AS "inc"
143 FROM "localVideoViewer"
144 WHERE "videoId" = :videoId ${dateWhere}
145 )
146 SELECT "dateBreakpoint", "concurrent"
147 FROM (
148 SELECT "dateBreakpoint", SUM(SUM("inc")) OVER (ORDER BY "dateBreakpoint") AS "concurrent"
149 FROM "watchPeakValues"
150 GROUP BY "dateBreakpoint"
151 ) tmp
152 ORDER BY "concurrent" DESC
153 FETCH FIRST 1 ROW ONLY`
154 const watchPeakPromise = LocalVideoViewerModel.sequelize.query<any>(watchPeakQuery, queryOptions)
155
156 const countriesQuery = `SELECT country, COUNT(country) as viewers ` +
157 `FROM "localVideoViewer" ` +
158 `WHERE "videoId" = :videoId AND country IS NOT NULL ${dateWhere} ` +
159 `GROUP BY country ` +
160 `ORDER BY viewers DESC`
161 const countriesPromise = LocalVideoViewerModel.sequelize.query<any>(countriesQuery, queryOptions)
162 192
163 const [ rowsWatchTime, rowsWatchPeak, rowsCountries ] = await Promise.all([ 193 const [ rowsWatchTime, rowsWatchPeak, rowsCountries ] = await Promise.all([
164 watchTimePromise, 194 buildWatchTimePromise(),
165 watchPeakPromise, 195 buildWatchPeakPromise(),
166 countriesPromise 196 buildCountriesPromise()
167 ]) 197 ])
168 198
169 const viewersPeak = rowsWatchPeak.length !== 0 199 const viewersPeak = rowsWatchPeak.length !== 0
diff --git a/server/tests/api/views/video-views-overall-stats.ts b/server/tests/api/views/video-views-overall-stats.ts
index 3aadc9689..ac636961e 100644
--- a/server/tests/api/views/video-views-overall-stats.ts
+++ b/server/tests/api/views/video-views-overall-stats.ts
@@ -4,6 +4,56 @@ import { expect } from 'chai'
4import { FfmpegCommand } from 'fluent-ffmpeg' 4import { FfmpegCommand } from 'fluent-ffmpeg'
5import { prepareViewsServers, prepareViewsVideos, processViewersStats } from '@server/tests/shared' 5import { prepareViewsServers, prepareViewsVideos, processViewersStats } from '@server/tests/shared'
6import { cleanupTests, PeerTubeServer, stopFfmpeg, waitJobs } from '@shared/server-commands' 6import { cleanupTests, PeerTubeServer, stopFfmpeg, waitJobs } from '@shared/server-commands'
7import { wait } from '@shared/core-utils'
8import { VideoStatsOverall } from '@shared/models'
9
10/**
11 *
12 * Simulate 5 sections of viewers
13 * * user0 started and ended before start date
14 * * user1 started before start date and ended in the interval
15 * * user2 started started in the interval and ended after end date
16 * * user3 started and ended in the interval
17 * * user4 started and ended after end date
18 */
19async function simulateComplexViewers (servers: PeerTubeServer[], videoUUID: string) {
20 const user0 = '8.8.8.8,127.0.0.1'
21 const user1 = '8.8.8.8,127.0.0.1'
22 const user2 = '8.8.8.9,127.0.0.1'
23 const user3 = '8.8.8.10,127.0.0.1'
24 const user4 = '8.8.8.11,127.0.0.1'
25
26 await servers[0].views.view({ id: videoUUID, currentTime: 0, xForwardedFor: user0 }) // User 0 starts
27 await wait(500)
28
29 await servers[0].views.view({ id: videoUUID, currentTime: 0, xForwardedFor: user1 }) // User 1 starts
30 await servers[0].views.view({ id: videoUUID, currentTime: 2, xForwardedFor: user0 }) // User 0 ends
31 await wait(500)
32
33 const startDate = new Date().toISOString()
34 await servers[0].views.view({ id: videoUUID, currentTime: 0, xForwardedFor: user2 }) // User 2 starts
35 await wait(500)
36
37 await servers[0].views.view({ id: videoUUID, currentTime: 0, xForwardedFor: user3 }) // User 3 starts
38 await wait(500)
39
40 await servers[0].views.view({ id: videoUUID, currentTime: 4, xForwardedFor: user1 }) // User 1 ends
41 await wait(500)
42
43 await servers[0].views.view({ id: videoUUID, currentTime: 3, xForwardedFor: user3 }) // User 3 ends
44 await wait(500)
45
46 const endDate = new Date().toISOString()
47 await servers[0].views.view({ id: videoUUID, currentTime: 0, xForwardedFor: user4 }) // User 4 starts
48 await servers[0].views.view({ id: videoUUID, currentTime: 5, xForwardedFor: user2 }) // User 2 ends
49 await wait(500)
50
51 await servers[0].views.view({ id: videoUUID, currentTime: 1, xForwardedFor: user4 }) // User 4 ends
52
53 await processViewersStats(servers)
54
55 return { startDate, endDate }
56}
7 57
8describe('Test views overall stats', function () { 58describe('Test views overall stats', function () {
9 let servers: PeerTubeServer[] 59 let servers: PeerTubeServer[]
@@ -237,6 +287,22 @@ describe('Test views overall stats', function () {
237 expect(new Date(stats.viewersPeakDate)).to.be.below(before2Watchers) 287 expect(new Date(stats.viewersPeakDate)).to.be.below(before2Watchers)
238 } 288 }
239 }) 289 })
290
291 it('Should complex filter peak viewers by date', async function () {
292 this.timeout(60000)
293
294 const { startDate, endDate } = await simulateComplexViewers(servers, videoUUID)
295
296 const expectCorrect = (stats: VideoStatsOverall) => {
297 expect(stats.viewersPeak).to.equal(3)
298 expect(new Date(stats.viewersPeakDate)).to.be.above(new Date(startDate)).and.below(new Date(endDate))
299 }
300
301 expectCorrect(await servers[0].videoStats.getOverallStats({ videoId: videoUUID, startDate, endDate }))
302 expectCorrect(await servers[0].videoStats.getOverallStats({ videoId: videoUUID, startDate }))
303 expectCorrect(await servers[0].videoStats.getOverallStats({ videoId: videoUUID, endDate }))
304 expectCorrect(await servers[0].videoStats.getOverallStats({ videoId: videoUUID }))
305 })
240 }) 306 })
241 307
242 describe('Test countries', function () { 308 describe('Test countries', function () {