aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2020-09-25 16:19:35 +0200
committerChocobozzz <chocobozzz@cpy.re>2020-11-09 15:33:04 +0100
commitfb7194043d0486ce0a6a40b2ffbdf32878c33a6f (patch)
tree6ed304a5d730a75da0a4460b3009df88684fa598
parenta5cf76afa378aae81af2a9b0ce548e5d2582f832 (diff)
downloadPeerTube-fb7194043d0486ce0a6a40b2ffbdf32878c33a6f.tar.gz
PeerTube-fb7194043d0486ce0a6a40b2ffbdf32878c33a6f.tar.zst
PeerTube-fb7194043d0486ce0a6a40b2ffbdf32878c33a6f.zip
Check live duration and size
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html102
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts30
-rw-r--r--client/src/app/core/server/server.service.ts2
-rw-r--r--config/test.yaml2
-rw-r--r--server/controllers/api/config.ts5
-rw-r--r--server/controllers/api/users/me.ts6
-rw-r--r--server/helpers/core-utils.ts1
-rw-r--r--server/helpers/custom-validators/misc.ts5
-rw-r--r--server/helpers/ffmpeg-utils.ts22
-rw-r--r--server/initializers/checker-after-init.ts7
-rw-r--r--server/initializers/checker-before-init.ts7
-rw-r--r--server/initializers/config.ts3
-rw-r--r--server/initializers/constants.ts7
-rw-r--r--server/initializers/migrations/0535-video-live.ts2
-rw-r--r--server/lib/job-queue/handlers/video-import.ts3
-rw-r--r--server/lib/live-manager.ts89
-rw-r--r--server/lib/user.ts72
-rw-r--r--server/middlewares/validators/config.ts40
-rw-r--r--server/middlewares/validators/users.ts2
-rw-r--r--server/middlewares/validators/videos/videos.ts5
-rw-r--r--server/models/account/user.ts159
-rw-r--r--server/models/video/video-live.ts10
-rw-r--r--server/tests/api/check-params/config.ts3
-rw-r--r--server/tests/api/server/config.ts6
-rw-r--r--shared/extra-utils/server/config.ts2
-rw-r--r--shared/models/server/custom-config.model.ts3
-rw-r--r--shared/models/server/server-config.model.ts3
27 files changed, 433 insertions, 165 deletions
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
index 8000f471f..2f3202e06 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
@@ -699,7 +699,7 @@
699 </ng-template> 699 </ng-template>
700 </ng-container> 700 </ng-container>
701 701
702 <ng-container ngbNavItem="live"> 702 <div ngbNavItem="live">
703 <a ngbNavLink i18n>Live streaming</a> 703 <a ngbNavLink i18n>Live streaming</a>
704 704
705 <ng-template ngbNavContent> 705 <ng-template ngbNavContent>
@@ -722,54 +722,78 @@
722 <ng-container i18n>Allow live streaming</ng-container> 722 <ng-container i18n>Allow live streaming</ng-container>
723 </ng-template> 723 </ng-template>
724 724
725 <ng-template ptTemplate="help"> 725 <ng-container ngProjectAs="description" i18n>
726 <ng-container i18n>Enabling live streaming requires trust in your users and extra moderation work</ng-container> 726 ⚠️ Enabling live streaming requires trust in your users and extra moderation work
727 </ng-template> 727 </ng-container>
728 728
729 <ng-container ngProjectAs="extra" formGroupName="transcoding"> 729 <ng-container ngProjectAs="extra">
730 730
731 <div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isLiveEnabled() }"> 731 <div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isLiveEnabled() }">
732 <my-peertube-checkbox 732 <my-peertube-checkbox
733 inputName="liveTranscodingEnabled" formControlName="enabled" 733 inputName="liveAllowReplay" formControlName="allowReplay"
734 i18n-labelText labelText="Enable live transcoding" 734 i18n-labelText labelText="Allow your users to automatically publish a replay of their live"
735 > 735 >
736 <ng-container ngProjectAs="description"> 736 <ng-container ngProjectAs="description" i18n>
737 Requires a lot of CPU! 737 If the user quota is reached, PeerTube will automatically terminate the live streaming
738 </ng-container> 738 </ng-container>
739 </my-peertube-checkbox> 739 </my-peertube-checkbox>
740 </div> 740 </div>
741 741
742 <div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isLiveEnabled() || !isLiveTranscodingEnabled() }"> 742 <div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isLiveEnabled() }">
743 <label i18n for="liveTranscodingThreads">Live transcoding threads</label> 743 <label i18n for="liveMaxDuration">Max live duration</label>
744 <div class="peertube-select-container"> 744 <div class="peertube-select-container">
745 <select id="liveTranscodingThreads" formControlName="threads" class="form-control"> 745 <select id="liveMaxDuration" formControlName="maxDuration" class="form-control">
746 <option *ngFor="let transcodingThreadOption of transcodingThreadOptions" [value]="transcodingThreadOption.value"> 746 <option *ngFor="let liveMaxDurationOption of liveMaxDurationOptions" [value]="liveMaxDurationOption.value">
747 {{ transcodingThreadOption.label }} 747 {{ liveMaxDurationOption.label }}
748 </option> 748 </option>
749 </select> 749 </select>
750 </div> 750 </div>
751 <div *ngIf="formErrors.live.transcoding.threads" class="form-error">{{ formErrors.live.transcoding.threads }}</div>
752 </div> 751 </div>
753 752
754 <div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isLiveEnabled() || !isLiveTranscodingEnabled() }"> 753 <ng-container formGroupName="transcoding">
755 754
756 <label i18n for="liveTranscodingThreads">Live resolutions to generate</label> 755 <div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isLiveEnabled() }">
757 756 <my-peertube-checkbox
758 <div class="ml-2 mt-2 d-flex flex-column"> 757 inputName="liveTranscodingEnabled" formControlName="enabled"
759 <ng-container formGroupName="resolutions"> 758 i18n-labelText labelText="Enable live transcoding"
760 <div class="form-group" *ngFor="let resolution of liveResolutions"> 759 >
761 <my-peertube-checkbox 760 <ng-container ngProjectAs="description" i18n>
762 [inputName]="getResolutionKey(resolution.id)" [formControlName]="resolution.id" 761 Requires a lot of CPU!
763 labelText="{{resolution.label}}" 762 </ng-container>
764 > 763 </my-peertube-checkbox>
765 <ng-template *ngIf="resolution.description" ptTemplate="help">
766 <div [innerHTML]="resolution.description"></div>
767 </ng-template>
768 </my-peertube-checkbox>
769 </div>
770 </ng-container>
771 </div> 764 </div>
772 </div> 765
766 <div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isLiveEnabled() || !isLiveTranscodingEnabled() }">
767 <label i18n for="liveTranscodingThreads">Live transcoding threads</label>
768 <div class="peertube-select-container">
769 <select id="liveTranscodingThreads" formControlName="threads" class="form-control">
770 <option *ngFor="let transcodingThreadOption of transcodingThreadOptions" [value]="transcodingThreadOption.value">
771 {{ transcodingThreadOption.label }}
772 </option>
773 </select>
774 </div>
775 <div *ngIf="formErrors.live.transcoding.threads" class="form-error">{{ formErrors.live.transcoding.threads }}</div>
776 </div>
777
778 <div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isLiveEnabled() || !isLiveTranscodingEnabled() }">
779 <label i18n for="liveTranscodingThreads">Live resolutions to generate</label>
780
781 <div class="ml-2 mt-2 d-flex flex-column">
782 <ng-container formGroupName="resolutions">
783 <div class="form-group" *ngFor="let resolution of liveResolutions">
784 <my-peertube-checkbox
785 [inputName]="getResolutionKey(resolution.id)" [formControlName]="resolution.id"
786 labelText="{{resolution.label}}"
787 >
788 <ng-template *ngIf="resolution.description" ptTemplate="help">
789 <div [innerHTML]="resolution.description"></div>
790 </ng-template>
791 </my-peertube-checkbox>
792 </div>
793 </ng-container>
794 </div>
795 </div>
796 </ng-container>
773 </ng-container> 797 </ng-container>
774 </my-peertube-checkbox> 798 </my-peertube-checkbox>
775 </div> 799 </div>
@@ -778,7 +802,7 @@
778 </div> 802 </div>
779 803
780 </ng-template> 804 </ng-template>
781 </ng-container> 805 </div>
782 806
783 <ng-container ngbNavItem="advanced-configuration"> 807 <ng-container ngbNavItem="advanced-configuration">
784 <a ngbNavLink i18n>Advanced configuration</a> 808 <a ngbNavLink i18n>Advanced configuration</a>
@@ -1026,9 +1050,15 @@
1026 <div class="form-row mt-4"> <!-- submit placement block --> 1050 <div class="form-row mt-4"> <!-- submit placement block -->
1027 <div class="col-md-7 col-xl-5"></div> 1051 <div class="col-md-7 col-xl-5"></div>
1028 <div class="col-md-5 col-xl-5"> 1052 <div class="col-md-5 col-xl-5">
1029 <span class="form-error submit-error" i18n *ngIf="!form.valid">It seems like the configuration is invalid. Please search for potential errors in the different tabs.</span> 1053 <span class="form-error submit-error" i18n *ngIf="!form.valid">
1054 It seems like the configuration is invalid. Please search for potential errors in the different tabs.
1055 </span>
1056
1057 <span class="form-error submit-error" i18n *ngIf="!hasLiveAllowReplayConsistentOptions()">
1058 You cannot allow live replay if you don't enable transcoding.
1059 </span>
1030 1060
1031 <input (click)="formValidated()" type="submit" i18n-value value="Update configuration" [disabled]="!form.valid"> 1061 <input (click)="formValidated()" type="submit" i18n-value value="Update configuration" [disabled]="!form.valid || !hasConsistentOptions()">
1032 </div> 1062 </div>
1033 </div> 1063 </div>
1034</form> 1064</form>
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
index de800c87e..745238647 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
@@ -36,6 +36,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
36 resolutions: { id: string, label: string, description?: string }[] = [] 36 resolutions: { id: string, label: string, description?: string }[] = []
37 liveResolutions: { id: string, label: string, description?: string }[] = [] 37 liveResolutions: { id: string, label: string, description?: string }[] = []
38 transcodingThreadOptions: { label: string, value: number }[] = [] 38 transcodingThreadOptions: { label: string, value: number }[] = []
39 liveMaxDurationOptions: { label: string, value: number }[] = []
39 40
40 languageItems: SelectOptionsItem[] = [] 41 languageItems: SelectOptionsItem[] = []
41 categoryItems: SelectOptionsItem[] = [] 42 categoryItems: SelectOptionsItem[] = []
@@ -92,6 +93,14 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
92 { value: 4, label: '4' }, 93 { value: 4, label: '4' },
93 { value: 8, label: '8' } 94 { value: 8, label: '8' }
94 ] 95 ]
96
97 this.liveMaxDurationOptions = [
98 { value: 0, label: $localize`No limit` },
99 { value: 1000 * 3600, label: $localize`1 hour` },
100 { value: 1000 * 3600 * 3, label: $localize`3 hours` },
101 { value: 1000 * 3600 * 5, label: $localize`5 hours` },
102 { value: 1000 * 3600 * 10, label: $localize`10 hours` }
103 ]
95 } 104 }
96 105
97 get videoQuotaOptions () { 106 get videoQuotaOptions () {
@@ -114,7 +123,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
114 ngOnInit () { 123 ngOnInit () {
115 this.serverConfig = this.serverService.getTmpConfig() 124 this.serverConfig = this.serverService.getTmpConfig()
116 this.serverService.getConfig() 125 this.serverService.getConfig()
117 .subscribe(config => this.serverConfig = config) 126 .subscribe(config => {
127 this.serverConfig = config
128 })
118 129
119 const formGroupData: { [key in keyof CustomConfig ]: any } = { 130 const formGroupData: { [key in keyof CustomConfig ]: any } = {
120 instance: { 131 instance: {
@@ -204,6 +215,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
204 live: { 215 live: {
205 enabled: null, 216 enabled: null,
206 217
218 maxDuration: null,
219 allowReplay: null,
220
207 transcoding: { 221 transcoding: {
208 enabled: null, 222 enabled: null,
209 threads: TRANSCODING_THREADS_VALIDATOR, 223 threads: TRANSCODING_THREADS_VALIDATOR,
@@ -341,6 +355,20 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
341 } 355 }
342 } 356 }
343 357
358 hasConsistentOptions () {
359 if (this.hasLiveAllowReplayConsistentOptions()) return true
360
361 return false
362 }
363
364 hasLiveAllowReplayConsistentOptions () {
365 if (this.isTranscodingEnabled() === false && this.isLiveEnabled() && this.form.value['live']['allowReplay'] === true) {
366 return false
367 }
368
369 return true
370 }
371
344 private updateForm () { 372 private updateForm () {
345 this.form.patchValue(this.customConfig) 373 this.form.patchValue(this.customConfig)
346 } 374 }
diff --git a/client/src/app/core/server/server.service.ts b/client/src/app/core/server/server.service.ts
index bc76bacfc..c19c3c12e 100644
--- a/client/src/app/core/server/server.service.ts
+++ b/client/src/app/core/server/server.service.ts
@@ -76,6 +76,8 @@ export class ServerService {
76 }, 76 },
77 live: { 77 live: {
78 enabled: false, 78 enabled: false,
79 allowReplay: true,
80 maxDuration: null,
79 transcoding: { 81 transcoding: {
80 enabled: false, 82 enabled: false,
81 enabledResolutions: [] 83 enabledResolutions: []
diff --git a/config/test.yaml b/config/test.yaml
index 865ed5400..b9279b5e6 100644
--- a/config/test.yaml
+++ b/config/test.yaml
@@ -89,7 +89,7 @@ live:
89 port: 1935 89 port: 1935
90 90
91 transcoding: 91 transcoding:
92 enabled: true 92 enabled: false
93 threads: 2 93 threads: 2
94 94
95 resolutions: 95 resolutions:
diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts
index bd100ef9c..99aabba62 100644
--- a/server/controllers/api/config.ts
+++ b/server/controllers/api/config.ts
@@ -118,6 +118,9 @@ async function getConfig (req: express.Request, res: express.Response) {
118 live: { 118 live: {
119 enabled: CONFIG.LIVE.ENABLED, 119 enabled: CONFIG.LIVE.ENABLED,
120 120
121 allowReplay: CONFIG.LIVE.ALLOW_REPLAY,
122 maxDuration: CONFIG.LIVE.MAX_DURATION,
123
121 transcoding: { 124 transcoding: {
122 enabled: CONFIG.LIVE.TRANSCODING.ENABLED, 125 enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
123 enabledResolutions: getEnabledResolutions('live') 126 enabledResolutions: getEnabledResolutions('live')
@@ -425,6 +428,8 @@ function customConfig (): CustomConfig {
425 }, 428 },
426 live: { 429 live: {
427 enabled: CONFIG.LIVE.ENABLED, 430 enabled: CONFIG.LIVE.ENABLED,
431 allowReplay: CONFIG.LIVE.ALLOW_REPLAY,
432 maxDuration: CONFIG.LIVE.MAX_DURATION,
428 transcoding: { 433 transcoding: {
429 enabled: CONFIG.LIVE.TRANSCODING.ENABLED, 434 enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
430 threads: CONFIG.LIVE.TRANSCODING.THREADS, 435 threads: CONFIG.LIVE.TRANSCODING.THREADS,
diff --git a/server/controllers/api/users/me.ts b/server/controllers/api/users/me.ts
index ba60a3d2a..b490518fc 100644
--- a/server/controllers/api/users/me.ts
+++ b/server/controllers/api/users/me.ts
@@ -9,7 +9,7 @@ import { MIMETYPES } from '../../../initializers/constants'
9import { sequelizeTypescript } from '../../../initializers/database' 9import { sequelizeTypescript } from '../../../initializers/database'
10import { sendUpdateActor } from '../../../lib/activitypub/send' 10import { sendUpdateActor } from '../../../lib/activitypub/send'
11import { updateActorAvatarFile } from '../../../lib/avatar' 11import { updateActorAvatarFile } from '../../../lib/avatar'
12import { sendVerifyUserEmail } from '../../../lib/user' 12import { getOriginalVideoFileTotalDailyFromUser, getOriginalVideoFileTotalFromUser, sendVerifyUserEmail } from '../../../lib/user'
13import { 13import {
14 asyncMiddleware, 14 asyncMiddleware,
15 asyncRetryTransactionMiddleware, 15 asyncRetryTransactionMiddleware,
@@ -133,8 +133,8 @@ async function getUserInformation (req: express.Request, res: express.Response)
133 133
134async function getUserVideoQuotaUsed (req: express.Request, res: express.Response) { 134async function getUserVideoQuotaUsed (req: express.Request, res: express.Response) {
135 const user = res.locals.oauth.token.user 135 const user = res.locals.oauth.token.user
136 const videoQuotaUsed = await UserModel.getOriginalVideoFileTotalFromUser(user) 136 const videoQuotaUsed = await getOriginalVideoFileTotalFromUser(user)
137 const videoQuotaUsedDaily = await UserModel.getOriginalVideoFileTotalDailyFromUser(user) 137 const videoQuotaUsedDaily = await getOriginalVideoFileTotalDailyFromUser(user)
138 138
139 const data: UserVideoQuota = { 139 const data: UserVideoQuota = {
140 videoQuotaUsed, 140 videoQuotaUsed,
diff --git a/server/helpers/core-utils.ts b/server/helpers/core-utils.ts
index 49eee7c59..e1c15a6eb 100644
--- a/server/helpers/core-utils.ts
+++ b/server/helpers/core-utils.ts
@@ -41,6 +41,7 @@ const timeTable = {
41} 41}
42 42
43export function parseDurationToMs (duration: number | string): number { 43export function parseDurationToMs (duration: number | string): number {
44 if (duration === null) return null
44 if (typeof duration === 'number') return duration 45 if (typeof duration === 'number') return duration
45 46
46 if (typeof duration === 'string') { 47 if (typeof duration === 'string') {
diff --git a/server/helpers/custom-validators/misc.ts b/server/helpers/custom-validators/misc.ts
index cf32201c4..61c03f0c9 100644
--- a/server/helpers/custom-validators/misc.ts
+++ b/server/helpers/custom-validators/misc.ts
@@ -45,6 +45,10 @@ function isBooleanValid (value: any) {
45 return typeof value === 'boolean' || (typeof value === 'string' && validator.isBoolean(value)) 45 return typeof value === 'boolean' || (typeof value === 'string' && validator.isBoolean(value))
46} 46}
47 47
48function isIntOrNull (value: any) {
49 return value === null || validator.isInt('' + value)
50}
51
48function toIntOrNull (value: string) { 52function toIntOrNull (value: string) {
49 const v = toValueOrNull(value) 53 const v = toValueOrNull(value)
50 54
@@ -116,6 +120,7 @@ export {
116 isArrayOf, 120 isArrayOf,
117 isNotEmptyIntArray, 121 isNotEmptyIntArray,
118 isArray, 122 isArray,
123 isIntOrNull,
119 isIdValid, 124 isIdValid,
120 isSafePath, 125 isSafePath,
121 isUUIDValid, 126 isUUIDValid,
diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts
index fac2595f1..b25dcaa90 100644
--- a/server/helpers/ffmpeg-utils.ts
+++ b/server/helpers/ffmpeg-utils.ts
@@ -5,7 +5,7 @@ import { VideoFileMetadata } from '@shared/models/videos/video-file-metadata'
5import { getMaxBitrate, getTargetBitrate, VideoResolution } from '../../shared/models/videos' 5import { getMaxBitrate, getTargetBitrate, VideoResolution } from '../../shared/models/videos'
6import { checkFFmpegEncoders } from '../initializers/checker-before-init' 6import { checkFFmpegEncoders } from '../initializers/checker-before-init'
7import { CONFIG } from '../initializers/config' 7import { CONFIG } from '../initializers/config'
8import { FFMPEG_NICE, VIDEO_TRANSCODING_FPS } from '../initializers/constants' 8import { FFMPEG_NICE, VIDEO_LIVE, VIDEO_TRANSCODING_FPS } from '../initializers/constants'
9import { processImage } from './image-utils' 9import { processImage } from './image-utils'
10import { logger } from './logger' 10import { logger } from './logger'
11 11
@@ -353,7 +353,7 @@ function convertWebPToJPG (path: string, destination: string): Promise<void> {
353 }) 353 })
354} 354}
355 355
356function runLiveTranscoding (rtmpUrl: string, outPath: string, resolutions: number[]) { 356function runLiveTranscoding (rtmpUrl: string, outPath: string, resolutions: number[], deleteSegments: boolean) {
357 const command = getFFmpeg(rtmpUrl) 357 const command = getFFmpeg(rtmpUrl)
358 command.inputOption('-fflags nobuffer') 358 command.inputOption('-fflags nobuffer')
359 359
@@ -399,7 +399,7 @@ function runLiveTranscoding (rtmpUrl: string, outPath: string, resolutions: numb
399 varStreamMap.push(`v:${i},a:${i}`) 399 varStreamMap.push(`v:${i},a:${i}`)
400 } 400 }
401 401
402 addDefaultLiveHLSParams(command, outPath) 402 addDefaultLiveHLSParams(command, outPath, deleteSegments)
403 403
404 command.outputOption('-var_stream_map', varStreamMap.join(' ')) 404 command.outputOption('-var_stream_map', varStreamMap.join(' '))
405 405
@@ -408,7 +408,7 @@ function runLiveTranscoding (rtmpUrl: string, outPath: string, resolutions: numb
408 return command 408 return command
409} 409}
410 410
411function runLiveMuxing (rtmpUrl: string, outPath: string) { 411function runLiveMuxing (rtmpUrl: string, outPath: string, deleteSegments: boolean) {
412 const command = getFFmpeg(rtmpUrl) 412 const command = getFFmpeg(rtmpUrl)
413 command.inputOption('-fflags nobuffer') 413 command.inputOption('-fflags nobuffer')
414 414
@@ -417,7 +417,7 @@ function runLiveMuxing (rtmpUrl: string, outPath: string) {
417 command.outputOption('-map 0:a?') 417 command.outputOption('-map 0:a?')
418 command.outputOption('-map 0:v?') 418 command.outputOption('-map 0:v?')
419 419
420 addDefaultLiveHLSParams(command, outPath) 420 addDefaultLiveHLSParams(command, outPath, deleteSegments)
421 421
422 command.run() 422 command.run()
423 423
@@ -457,10 +457,14 @@ function addDefaultX264Params (command: ffmpeg.FfmpegCommand) {
457 .outputOption('-map_metadata -1') // strip all metadata 457 .outputOption('-map_metadata -1') // strip all metadata
458} 458}
459 459
460function addDefaultLiveHLSParams (command: ffmpeg.FfmpegCommand, outPath: string) { 460function addDefaultLiveHLSParams (command: ffmpeg.FfmpegCommand, outPath: string, deleteSegments: boolean) {
461 command.outputOption('-hls_time 4') 461 command.outputOption('-hls_time ' + VIDEO_LIVE.SEGMENT_TIME)
462 command.outputOption('-hls_list_size 15') 462 command.outputOption('-hls_list_size ' + VIDEO_LIVE.SEGMENTS_LIST_SIZE)
463 command.outputOption('-hls_flags delete_segments') 463
464 if (deleteSegments === true) {
465 command.outputOption('-hls_flags delete_segments')
466 }
467
464 command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%d.ts')}`) 468 command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%d.ts')}`)
465 command.outputOption('-master_pl_name master.m3u8') 469 command.outputOption('-master_pl_name master.m3u8')
466 command.outputOption(`-f hls`) 470 command.outputOption(`-f hls`)
diff --git a/server/initializers/checker-after-init.ts b/server/initializers/checker-after-init.ts
index b49ab6bca..979c97a8b 100644
--- a/server/initializers/checker-after-init.ts
+++ b/server/initializers/checker-after-init.ts
@@ -135,6 +135,13 @@ function checkConfig () {
135 } 135 }
136 } 136 }
137 137
138 // Live
139 if (CONFIG.LIVE.ENABLED === true) {
140 if (CONFIG.LIVE.ALLOW_REPLAY === true && CONFIG.TRANSCODING.ENABLED === false) {
141 return 'Live allow replay cannot be enabled if transcoding is not enabled.'
142 }
143 }
144
138 return null 145 return null
139} 146}
140 147
diff --git a/server/initializers/checker-before-init.ts b/server/initializers/checker-before-init.ts
index e0819c4aa..d4140e3fa 100644
--- a/server/initializers/checker-before-init.ts
+++ b/server/initializers/checker-before-init.ts
@@ -37,8 +37,13 @@ function checkMissedConfig () {
37 'remote_redundancy.videos.accept_from', 37 'remote_redundancy.videos.accept_from',
38 'federation.videos.federate_unlisted', 38 'federation.videos.federate_unlisted',
39 'search.remote_uri.users', 'search.remote_uri.anonymous', 'search.search_index.enabled', 'search.search_index.url', 39 'search.remote_uri.users', 'search.remote_uri.anonymous', 'search.search_index.enabled', 'search.search_index.url',
40 'search.search_index.disable_local_search', 'search.search_index.is_default_search' 40 'search.search_index.disable_local_search', 'search.search_index.is_default_search',
41 'live.enabled', 'live.allow_replay', 'live.max_duration',
42 'live.transcoding.enabled', 'live.transcoding.threads',
43 'live.transcoding.resolutions.240p', 'live.transcoding.resolutions.360p', 'live.transcoding.resolutions.480p',
44 'live.transcoding.resolutions.720p', 'live.transcoding.resolutions.1080p', 'live.transcoding.resolutions.2160p'
41 ] 45 ]
46
42 const requiredAlternatives = [ 47 const requiredAlternatives = [
43 [ // set 48 [ // set
44 [ 'redis.hostname', 'redis.port' ], // alternative 49 [ 'redis.hostname', 'redis.port' ], // alternative
diff --git a/server/initializers/config.ts b/server/initializers/config.ts
index 7a8200ed9..9e8927350 100644
--- a/server/initializers/config.ts
+++ b/server/initializers/config.ts
@@ -201,6 +201,9 @@ const CONFIG = {
201 LIVE: { 201 LIVE: {
202 get ENABLED () { return config.get<boolean>('live.enabled') }, 202 get ENABLED () { return config.get<boolean>('live.enabled') },
203 203
204 get MAX_DURATION () { return parseDurationToMs(config.get<string>('live.max_duration')) },
205 get ALLOW_REPLAY () { return config.get<boolean>('live.allow_replay') },
206
204 RTMP: { 207 RTMP: {
205 get PORT () { return config.get<number>('live.rtmp.port') } 208 get PORT () { return config.get<number>('live.rtmp.port') }
206 }, 209 },
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 82d04a94e..065012b32 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -608,7 +608,9 @@ const HLS_REDUNDANCY_DIRECTORY = join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls')
608 608
609const VIDEO_LIVE = { 609const VIDEO_LIVE = {
610 EXTENSION: '.ts', 610 EXTENSION: '.ts',
611 CLEANUP_DELAY: 1000 * 60 * 5, // 5 mintues 611 CLEANUP_DELAY: 1000 * 60 * 5, // 5 minutes
612 SEGMENT_TIME: 4, // 4 seconds
613 SEGMENTS_LIST_SIZE: 15, // 15 maximum segments in live playlist
612 RTMP: { 614 RTMP: {
613 CHUNK_SIZE: 60000, 615 CHUNK_SIZE: 60000,
614 GOP_CACHE: true, 616 GOP_CACHE: true,
@@ -620,7 +622,8 @@ const VIDEO_LIVE = {
620 622
621const MEMOIZE_TTL = { 623const MEMOIZE_TTL = {
622 OVERVIEWS_SAMPLE: 1000 * 3600 * 4, // 4 hours 624 OVERVIEWS_SAMPLE: 1000 * 3600 * 4, // 4 hours
623 INFO_HASH_EXISTS: 1000 * 3600 * 12 // 12 hours 625 INFO_HASH_EXISTS: 1000 * 3600 * 12, // 12 hours
626 LIVE_ABLE_TO_UPLOAD: 1000 * 60 // 1 minute
624} 627}
625 628
626const MEMOIZE_LENGTH = { 629const MEMOIZE_LENGTH = {
diff --git a/server/initializers/migrations/0535-video-live.ts b/server/initializers/migrations/0535-video-live.ts
index 35523efc4..7501e080b 100644
--- a/server/initializers/migrations/0535-video-live.ts
+++ b/server/initializers/migrations/0535-video-live.ts
@@ -9,7 +9,7 @@ async function up (utils: {
9 const query = ` 9 const query = `
10 CREATE TABLE IF NOT EXISTS "videoLive" ( 10 CREATE TABLE IF NOT EXISTS "videoLive" (
11 "id" SERIAL , 11 "id" SERIAL ,
12 "streamKey" VARCHAR(255) NOT NULL, 12 "streamKey" VARCHAR(255),
13 "videoId" INTEGER NOT NULL REFERENCES "video" ("id") ON DELETE CASCADE ON UPDATE CASCADE, 13 "videoId" INTEGER NOT NULL REFERENCES "video" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
14 "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, 14 "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL,
15 "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, 15 "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
diff --git a/server/lib/job-queue/handlers/video-import.ts b/server/lib/job-queue/handlers/video-import.ts
index 9b5f2bb2b..9210aec54 100644
--- a/server/lib/job-queue/handlers/video-import.ts
+++ b/server/lib/job-queue/handlers/video-import.ts
@@ -4,6 +4,7 @@ import { extname } from 'path'
4import { addOptimizeOrMergeAudioJob } from '@server/helpers/video' 4import { addOptimizeOrMergeAudioJob } from '@server/helpers/video'
5import { isPostImportVideoAccepted } from '@server/lib/moderation' 5import { isPostImportVideoAccepted } from '@server/lib/moderation'
6import { Hooks } from '@server/lib/plugins/hooks' 6import { Hooks } from '@server/lib/plugins/hooks'
7import { isAbleToUploadVideo } from '@server/lib/user'
7import { getVideoFilePath } from '@server/lib/video-paths' 8import { getVideoFilePath } from '@server/lib/video-paths'
8import { MVideoImportDefault, MVideoImportDefaultFiles, MVideoImportVideo } from '@server/types/models/video/video-import' 9import { MVideoImportDefault, MVideoImportDefaultFiles, MVideoImportVideo } from '@server/types/models/video/video-import'
9import { 10import {
@@ -108,7 +109,7 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid
108 109
109 // Get information about this video 110 // Get information about this video
110 const stats = await stat(tempVideoPath) 111 const stats = await stat(tempVideoPath)
111 const isAble = await videoImport.User.isAbleToUploadVideo({ size: stats.size }) 112 const isAble = await isAbleToUploadVideo(videoImport.User.id, stats.size)
112 if (isAble === false) { 113 if (isAble === false) {
113 throw new Error('The user video quota is exceeded with this video to import.') 114 throw new Error('The user video quota is exceeded with this video to import.')
114 } 115 }
diff --git a/server/lib/live-manager.ts b/server/lib/live-manager.ts
index 41176d197..3ff2434ff 100644
--- a/server/lib/live-manager.ts
+++ b/server/lib/live-manager.ts
@@ -2,24 +2,27 @@
2import { AsyncQueue, queue } from 'async' 2import { AsyncQueue, queue } from 'async'
3import * as chokidar from 'chokidar' 3import * as chokidar from 'chokidar'
4import { FfmpegCommand } from 'fluent-ffmpeg' 4import { FfmpegCommand } from 'fluent-ffmpeg'
5import { ensureDir } from 'fs-extra' 5import { ensureDir, stat } from 'fs-extra'
6import { basename } from 'path' 6import { basename } from 'path'
7import { computeResolutionsToTranscode, runLiveMuxing, runLiveTranscoding } from '@server/helpers/ffmpeg-utils' 7import { computeResolutionsToTranscode, runLiveMuxing, runLiveTranscoding } from '@server/helpers/ffmpeg-utils'
8import { logger } from '@server/helpers/logger' 8import { logger } from '@server/helpers/logger'
9import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config' 9import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config'
10import { P2P_MEDIA_LOADER_PEER_VERSION, VIDEO_LIVE, WEBSERVER } from '@server/initializers/constants' 10import { MEMOIZE_TTL, P2P_MEDIA_LOADER_PEER_VERSION, VIDEO_LIVE, WEBSERVER } from '@server/initializers/constants'
11import { UserModel } from '@server/models/account/user'
11import { VideoModel } from '@server/models/video/video' 12import { VideoModel } from '@server/models/video/video'
12import { VideoFileModel } from '@server/models/video/video-file' 13import { VideoFileModel } from '@server/models/video/video-file'
13import { VideoLiveModel } from '@server/models/video/video-live' 14import { VideoLiveModel } from '@server/models/video/video-live'
14import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist' 15import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
15import { MStreamingPlaylist, MVideoLiveVideo } from '@server/types/models' 16import { MStreamingPlaylist, MUser, MUserId, MVideoLive, MVideoLiveVideo } from '@server/types/models'
16import { VideoState, VideoStreamingPlaylistType } from '@shared/models' 17import { VideoState, VideoStreamingPlaylistType } from '@shared/models'
17import { federateVideoIfNeeded } from './activitypub/videos' 18import { federateVideoIfNeeded } from './activitypub/videos'
18import { buildSha256Segment } from './hls' 19import { buildSha256Segment } from './hls'
19import { JobQueue } from './job-queue' 20import { JobQueue } from './job-queue'
20import { PeerTubeSocket } from './peertube-socket' 21import { PeerTubeSocket } from './peertube-socket'
22import { isAbleToUploadVideo } from './user'
21import { getHLSDirectory } from './video-paths' 23import { getHLSDirectory } from './video-paths'
22 24
25import memoizee = require('memoizee')
23const NodeRtmpServer = require('node-media-server/node_rtmp_server') 26const NodeRtmpServer = require('node-media-server/node_rtmp_server')
24const context = require('node-media-server/node_core_ctx') 27const context = require('node-media-server/node_core_ctx')
25const nodeMediaServerLogger = require('node-media-server/node_core_logger') 28const nodeMediaServerLogger = require('node-media-server/node_core_logger')
@@ -53,6 +56,11 @@ class LiveManager {
53 private readonly transSessions = new Map<string, FfmpegCommand>() 56 private readonly transSessions = new Map<string, FfmpegCommand>()
54 private readonly videoSessions = new Map<number, string>() 57 private readonly videoSessions = new Map<number, string>()
55 private readonly segmentsSha256 = new Map<string, Map<string, string>>() 58 private readonly segmentsSha256 = new Map<string, Map<string, string>>()
59 private readonly livesPerUser = new Map<number, { liveId: number, videoId: number, size: number }[]>()
60
61 private readonly isAbleToUploadVideoWithCache = memoizee((userId: number) => {
62 return isAbleToUploadVideo(userId, 1000)
63 }, { maxAge: MEMOIZE_TTL.LIVE_ABLE_TO_UPLOAD })
56 64
57 private segmentsSha256Queue: AsyncQueue<SegmentSha256QueueParam> 65 private segmentsSha256Queue: AsyncQueue<SegmentSha256QueueParam>
58 private rtmpServer: any 66 private rtmpServer: any
@@ -127,7 +135,7 @@ class LiveManager {
127 135
128 this.abortSession(sessionId) 136 this.abortSession(sessionId)
129 137
130 this.onEndTransmuxing(videoId) 138 this.onEndTransmuxing(videoId, true)
131 .catch(err => logger.error('Cannot end transmuxing of video %d.', videoId, { err })) 139 .catch(err => logger.error('Cannot end transmuxing of video %d.', videoId, { err }))
132 } 140 }
133 141
@@ -196,8 +204,18 @@ class LiveManager {
196 originalResolution: number 204 originalResolution: number
197 }) { 205 }) {
198 const { sessionId, videoLive, playlist, streamPath, resolutionsEnabled, originalResolution } = options 206 const { sessionId, videoLive, playlist, streamPath, resolutionsEnabled, originalResolution } = options
207 const startStreamDateTime = new Date().getTime()
199 const allResolutions = resolutionsEnabled.concat([ originalResolution ]) 208 const allResolutions = resolutionsEnabled.concat([ originalResolution ])
200 209
210 const user = await UserModel.loadByLiveId(videoLive.id)
211 if (!this.livesPerUser.has(user.id)) {
212 this.livesPerUser.set(user.id, [])
213 }
214
215 const currentUserLive = { liveId: videoLive.id, videoId: videoLive.videoId, size: 0 }
216 const livesOfUser = this.livesPerUser.get(user.id)
217 livesOfUser.push(currentUserLive)
218
201 for (let i = 0; i < allResolutions.length; i++) { 219 for (let i = 0; i < allResolutions.length; i++) {
202 const resolution = allResolutions[i] 220 const resolution = allResolutions[i]
203 221
@@ -216,26 +234,47 @@ class LiveManager {
216 const outPath = getHLSDirectory(videoLive.Video) 234 const outPath = getHLSDirectory(videoLive.Video)
217 await ensureDir(outPath) 235 await ensureDir(outPath)
218 236
237 const deleteSegments = videoLive.saveReplay === false
238
219 const rtmpUrl = 'rtmp://127.0.0.1:' + config.rtmp.port + streamPath 239 const rtmpUrl = 'rtmp://127.0.0.1:' + config.rtmp.port + streamPath
220 const ffmpegExec = CONFIG.LIVE.TRANSCODING.ENABLED 240 const ffmpegExec = CONFIG.LIVE.TRANSCODING.ENABLED
221 ? runLiveTranscoding(rtmpUrl, outPath, allResolutions) 241 ? runLiveTranscoding(rtmpUrl, outPath, allResolutions, deleteSegments)
222 : runLiveMuxing(rtmpUrl, outPath) 242 : runLiveMuxing(rtmpUrl, outPath, deleteSegments)
223 243
224 logger.info('Running live muxing/transcoding.') 244 logger.info('Running live muxing/transcoding.')
225
226 this.transSessions.set(sessionId, ffmpegExec) 245 this.transSessions.set(sessionId, ffmpegExec)
227 246
228 const videoUUID = videoLive.Video.uuid 247 const videoUUID = videoLive.Video.uuid
229 const tsWatcher = chokidar.watch(outPath + '/*.ts') 248 const tsWatcher = chokidar.watch(outPath + '/*.ts')
230 249
231 const updateHandler = segmentPath => { 250 const updateSegment = segmentPath => this.segmentsSha256Queue.push({ operation: 'update', segmentPath, videoUUID })
232 this.segmentsSha256Queue.push({ operation: 'update', segmentPath, videoUUID }) 251
252 const addHandler = segmentPath => {
253 updateSegment(segmentPath)
254
255 if (this.isDurationConstraintValid(startStreamDateTime) !== true) {
256 this.stopSessionOf(videoLive.videoId)
257 }
258
259 if (videoLive.saveReplay === true) {
260 stat(segmentPath)
261 .then(segmentStat => {
262 currentUserLive.size += segmentStat.size
263 })
264 .then(() => this.isQuotaConstraintValid(user, videoLive))
265 .then(quotaValid => {
266 if (quotaValid !== true) {
267 this.stopSessionOf(videoLive.videoId)
268 }
269 })
270 .catch(err => logger.error('Cannot stat %s or check quota of %d.', segmentPath, user.id, { err }))
271 }
233 } 272 }
234 273
235 const deleteHandler = segmentPath => this.segmentsSha256Queue.push({ operation: 'delete', segmentPath, videoUUID }) 274 const deleteHandler = segmentPath => this.segmentsSha256Queue.push({ operation: 'delete', segmentPath, videoUUID })
236 275
237 tsWatcher.on('add', p => updateHandler(p)) 276 tsWatcher.on('add', p => addHandler(p))
238 tsWatcher.on('change', p => updateHandler(p)) 277 tsWatcher.on('change', p => updateSegment(p))
239 tsWatcher.on('unlink', p => deleteHandler(p)) 278 tsWatcher.on('unlink', p => deleteHandler(p))
240 279
241 const masterWatcher = chokidar.watch(outPath + '/master.m3u8') 280 const masterWatcher = chokidar.watch(outPath + '/master.m3u8')
@@ -280,7 +319,14 @@ class LiveManager {
280 ffmpegExec.on('end', () => onFFmpegEnded()) 319 ffmpegExec.on('end', () => onFFmpegEnded())
281 } 320 }
282 321
283 private async onEndTransmuxing (videoId: number) { 322 getLiveQuotaUsedByUser (userId: number) {
323 const currentLives = this.livesPerUser.get(userId)
324 if (!currentLives) return 0
325
326 return currentLives.reduce((sum, obj) => sum + obj.size, 0)
327 }
328
329 private async onEndTransmuxing (videoId: number, cleanupNow = false) {
284 try { 330 try {
285 const fullVideo = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId) 331 const fullVideo = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId)
286 if (!fullVideo) return 332 if (!fullVideo) return
@@ -290,7 +336,7 @@ class LiveManager {
290 payload: { 336 payload: {
291 videoId: fullVideo.id 337 videoId: fullVideo.id
292 } 338 }
293 }, { delay: VIDEO_LIVE.CLEANUP_DELAY }) 339 }, { delay: cleanupNow ? 0 : VIDEO_LIVE.CLEANUP_DELAY })
294 340
295 // FIXME: use end 341 // FIXME: use end
296 fullVideo.state = VideoState.WAITING_FOR_LIVE 342 fullVideo.state = VideoState.WAITING_FOR_LIVE
@@ -337,6 +383,23 @@ class LiveManager {
337 filesMap.delete(segmentName) 383 filesMap.delete(segmentName)
338 } 384 }
339 385
386 private isDurationConstraintValid (streamingStartTime: number) {
387 const maxDuration = CONFIG.LIVE.MAX_DURATION
388 // No limit
389 if (maxDuration === null) return true
390
391 const now = new Date().getTime()
392 const max = streamingStartTime + maxDuration
393
394 return now <= max
395 }
396
397 private async isQuotaConstraintValid (user: MUserId, live: MVideoLive) {
398 if (live.saveReplay !== true) return true
399
400 return this.isAbleToUploadVideoWithCache(user.id)
401 }
402
340 static get Instance () { 403 static get Instance () {
341 return this.instance || (this.instance = new this()) 404 return this.instance || (this.instance = new this())
342 } 405 }
diff --git a/server/lib/user.ts b/server/lib/user.ts
index aa14f0b54..d3338f329 100644
--- a/server/lib/user.ts
+++ b/server/lib/user.ts
@@ -1,20 +1,24 @@
1import { Transaction } from 'sequelize/types'
1import { v4 as uuidv4 } from 'uuid' 2import { v4 as uuidv4 } from 'uuid'
3import { UserModel } from '@server/models/account/user'
2import { ActivityPubActorType } from '../../shared/models/activitypub' 4import { ActivityPubActorType } from '../../shared/models/activitypub'
5import { UserNotificationSetting, UserNotificationSettingValue } from '../../shared/models/users'
3import { SERVER_ACTOR_NAME, WEBSERVER } from '../initializers/constants' 6import { SERVER_ACTOR_NAME, WEBSERVER } from '../initializers/constants'
7import { sequelizeTypescript } from '../initializers/database'
4import { AccountModel } from '../models/account/account' 8import { AccountModel } from '../models/account/account'
5import { buildActorInstance, setAsyncActorKeys } from './activitypub/actor'
6import { createLocalVideoChannel } from './video-channel'
7import { ActorModel } from '../models/activitypub/actor'
8import { UserNotificationSettingModel } from '../models/account/user-notification-setting' 9import { UserNotificationSettingModel } from '../models/account/user-notification-setting'
9import { UserNotificationSetting, UserNotificationSettingValue } from '../../shared/models/users' 10import { ActorModel } from '../models/activitypub/actor'
10import { createWatchLaterPlaylist } from './video-playlist'
11import { sequelizeTypescript } from '../initializers/database'
12import { Transaction } from 'sequelize/types'
13import { Redis } from './redis'
14import { Emailer } from './emailer'
15import { MAccountDefault, MActorDefault, MChannelActor } from '../types/models' 11import { MAccountDefault, MActorDefault, MChannelActor } from '../types/models'
16import { MUser, MUserDefault, MUserId } from '../types/models/user' 12import { MUser, MUserDefault, MUserId } from '../types/models/user'
13import { buildActorInstance, setAsyncActorKeys } from './activitypub/actor'
17import { getAccountActivityPubUrl } from './activitypub/url' 14import { getAccountActivityPubUrl } from './activitypub/url'
15import { Emailer } from './emailer'
16import { LiveManager } from './live-manager'
17import { Redis } from './redis'
18import { createLocalVideoChannel } from './video-channel'
19import { createWatchLaterPlaylist } from './video-playlist'
20
21import memoizee = require('memoizee')
18 22
19type ChannelNames = { name: string, displayName: string } 23type ChannelNames = { name: string, displayName: string }
20 24
@@ -116,13 +120,61 @@ async function sendVerifyUserEmail (user: MUser, isPendingEmail = false) {
116 await Emailer.Instance.addVerifyEmailJob(username, email, url) 120 await Emailer.Instance.addVerifyEmailJob(username, email, url)
117} 121}
118 122
123async function getOriginalVideoFileTotalFromUser (user: MUserId) {
124 // Don't use sequelize because we need to use a sub query
125 const query = UserModel.generateUserQuotaBaseSQL({
126 withSelect: true,
127 whereUserId: '$userId'
128 })
129
130 const base = await UserModel.getTotalRawQuery(query, user.id)
131
132 return base + LiveManager.Instance.getLiveQuotaUsedByUser(user.id)
133}
134
135// Returns cumulative size of all video files uploaded in the last 24 hours.
136async function getOriginalVideoFileTotalDailyFromUser (user: MUserId) {
137 // Don't use sequelize because we need to use a sub query
138 const query = UserModel.generateUserQuotaBaseSQL({
139 withSelect: true,
140 whereUserId: '$userId',
141 where: '"video"."createdAt" > now() - interval \'24 hours\''
142 })
143
144 const base = await UserModel.getTotalRawQuery(query, user.id)
145
146 return base + LiveManager.Instance.getLiveQuotaUsedByUser(user.id)
147}
148
149async function isAbleToUploadVideo (userId: number, size: number) {
150 const user = await UserModel.loadById(userId)
151
152 if (user.videoQuota === -1 && user.videoQuotaDaily === -1) return Promise.resolve(true)
153
154 const [ totalBytes, totalBytesDaily ] = await Promise.all([
155 getOriginalVideoFileTotalFromUser(user.id),
156 getOriginalVideoFileTotalDailyFromUser(user.id)
157 ])
158
159 const uploadedTotal = size + totalBytes
160 const uploadedDaily = size + totalBytesDaily
161
162 if (user.videoQuotaDaily === -1) return uploadedTotal < user.videoQuota
163 if (user.videoQuota === -1) return uploadedDaily < user.videoQuotaDaily
164
165 return uploadedTotal < user.videoQuota && uploadedDaily < user.videoQuotaDaily
166}
167
119// --------------------------------------------------------------------------- 168// ---------------------------------------------------------------------------
120 169
121export { 170export {
171 getOriginalVideoFileTotalFromUser,
172 getOriginalVideoFileTotalDailyFromUser,
122 createApplicationActor, 173 createApplicationActor,
123 createUserAccountAndChannelAndPlaylist, 174 createUserAccountAndChannelAndPlaylist,
124 createLocalAccountWithoutKeys, 175 createLocalAccountWithoutKeys,
125 sendVerifyUserEmail 176 sendVerifyUserEmail,
177 isAbleToUploadVideo
126} 178}
127 179
128// --------------------------------------------------------------------------- 180// ---------------------------------------------------------------------------
diff --git a/server/middlewares/validators/config.ts b/server/middlewares/validators/config.ts
index d3669f6be..41a6ae4f9 100644
--- a/server/middlewares/validators/config.ts
+++ b/server/middlewares/validators/config.ts
@@ -1,12 +1,13 @@
1import * as express from 'express' 1import * as express from 'express'
2import { body } from 'express-validator' 2import { body } from 'express-validator'
3import { isUserNSFWPolicyValid, isUserVideoQuotaDailyValid, isUserVideoQuotaValid } from '../../helpers/custom-validators/users' 3import { isIntOrNull } from '@server/helpers/custom-validators/misc'
4import { logger } from '../../helpers/logger' 4import { isEmailEnabled } from '@server/initializers/config'
5import { CustomConfig } from '../../../shared/models/server/custom-config.model' 5import { CustomConfig } from '../../../shared/models/server/custom-config.model'
6import { areValidationErrors } from './utils'
7import { isThemeNameValid } from '../../helpers/custom-validators/plugins' 6import { isThemeNameValid } from '../../helpers/custom-validators/plugins'
7import { isUserNSFWPolicyValid, isUserVideoQuotaDailyValid, isUserVideoQuotaValid } from '../../helpers/custom-validators/users'
8import { logger } from '../../helpers/logger'
8import { isThemeRegistered } from '../../lib/plugins/theme-utils' 9import { isThemeRegistered } from '../../lib/plugins/theme-utils'
9import { isEmailEnabled } from '@server/initializers/config' 10import { areValidationErrors } from './utils'
10 11
11const customConfigUpdateValidator = [ 12const customConfigUpdateValidator = [
12 body('instance.name').exists().withMessage('Should have a valid instance name'), 13 body('instance.name').exists().withMessage('Should have a valid instance name'),
@@ -43,6 +44,7 @@ const customConfigUpdateValidator = [
43 body('transcoding.resolutions.480p').isBoolean().withMessage('Should have a valid transcoding 480p resolution enabled boolean'), 44 body('transcoding.resolutions.480p').isBoolean().withMessage('Should have a valid transcoding 480p resolution enabled boolean'),
44 body('transcoding.resolutions.720p').isBoolean().withMessage('Should have a valid transcoding 720p resolution enabled boolean'), 45 body('transcoding.resolutions.720p').isBoolean().withMessage('Should have a valid transcoding 720p resolution enabled boolean'),
45 body('transcoding.resolutions.1080p').isBoolean().withMessage('Should have a valid transcoding 1080p resolution enabled boolean'), 46 body('transcoding.resolutions.1080p').isBoolean().withMessage('Should have a valid transcoding 1080p resolution enabled boolean'),
47 body('transcoding.resolutions.2160p').isBoolean().withMessage('Should have a valid transcoding 2160p resolution enabled boolean'),
46 48
47 body('transcoding.webtorrent.enabled').isBoolean().withMessage('Should have a valid webtorrent transcoding enabled boolean'), 49 body('transcoding.webtorrent.enabled').isBoolean().withMessage('Should have a valid webtorrent transcoding enabled boolean'),
48 body('transcoding.hls.enabled').isBoolean().withMessage('Should have a valid hls transcoding enabled boolean'), 50 body('transcoding.hls.enabled').isBoolean().withMessage('Should have a valid hls transcoding enabled boolean'),
@@ -60,6 +62,18 @@ const customConfigUpdateValidator = [
60 body('broadcastMessage.level').exists().withMessage('Should have a valid broadcast level'), 62 body('broadcastMessage.level').exists().withMessage('Should have a valid broadcast level'),
61 body('broadcastMessage.dismissable').isBoolean().withMessage('Should have a valid broadcast dismissable boolean'), 63 body('broadcastMessage.dismissable').isBoolean().withMessage('Should have a valid broadcast dismissable boolean'),
62 64
65 body('live.enabled').isBoolean().withMessage('Should have a valid live enabled boolean'),
66 body('live.allowReplay').isBoolean().withMessage('Should have a valid live allow replay boolean'),
67 body('live.maxDuration').custom(isIntOrNull).withMessage('Should have a valid live max duration'),
68 body('live.transcoding.enabled').isBoolean().withMessage('Should have a valid live transcoding enabled boolean'),
69 body('live.transcoding.threads').isInt().withMessage('Should have a valid live transcoding threads'),
70 body('live.transcoding.resolutions.240p').isBoolean().withMessage('Should have a valid transcoding 240p resolution enabled boolean'),
71 body('live.transcoding.resolutions.360p').isBoolean().withMessage('Should have a valid transcoding 360p resolution enabled boolean'),
72 body('live.transcoding.resolutions.480p').isBoolean().withMessage('Should have a valid transcoding 480p resolution enabled boolean'),
73 body('live.transcoding.resolutions.720p').isBoolean().withMessage('Should have a valid transcoding 720p resolution enabled boolean'),
74 body('live.transcoding.resolutions.1080p').isBoolean().withMessage('Should have a valid transcoding 1080p resolution enabled boolean'),
75 body('live.transcoding.resolutions.2160p').isBoolean().withMessage('Should have a valid transcoding 2160p resolution enabled boolean'),
76
63 body('search.remoteUri.users').isBoolean().withMessage('Should have a remote URI search for users boolean'), 77 body('search.remoteUri.users').isBoolean().withMessage('Should have a remote URI search for users boolean'),
64 body('search.remoteUri.anonymous').isBoolean().withMessage('Should have a valid remote URI search for anonymous boolean'), 78 body('search.remoteUri.anonymous').isBoolean().withMessage('Should have a valid remote URI search for anonymous boolean'),
65 body('search.searchIndex.enabled').isBoolean().withMessage('Should have a valid search index enabled boolean'), 79 body('search.searchIndex.enabled').isBoolean().withMessage('Should have a valid search index enabled boolean'),
@@ -71,8 +85,9 @@ const customConfigUpdateValidator = [
71 logger.debug('Checking customConfigUpdateValidator parameters', { parameters: req.body }) 85 logger.debug('Checking customConfigUpdateValidator parameters', { parameters: req.body })
72 86
73 if (areValidationErrors(req, res)) return 87 if (areValidationErrors(req, res)) return
74 if (!checkInvalidConfigIfEmailDisabled(req.body as CustomConfig, res)) return 88 if (!checkInvalidConfigIfEmailDisabled(req.body, res)) return
75 if (!checkInvalidTranscodingConfig(req.body as CustomConfig, res)) return 89 if (!checkInvalidTranscodingConfig(req.body, res)) return
90 if (!checkInvalidLiveConfig(req.body, res)) return
76 91
77 return next() 92 return next()
78 } 93 }
@@ -109,3 +124,16 @@ function checkInvalidTranscodingConfig (customConfig: CustomConfig, res: express
109 124
110 return true 125 return true
111} 126}
127
128function checkInvalidLiveConfig (customConfig: CustomConfig, res: express.Response) {
129 if (customConfig.live.enabled === false) return true
130
131 if (customConfig.live.allowReplay === true && customConfig.transcoding.enabled === false) {
132 res.status(400)
133 .send({ error: 'You cannot allow live replay if transcoding is not enabled' })
134 .end()
135 return false
136 }
137
138 return true
139}
diff --git a/server/middlewares/validators/users.ts b/server/middlewares/validators/users.ts
index 76ecff884..452c7fb93 100644
--- a/server/middlewares/validators/users.ts
+++ b/server/middlewares/validators/users.ts
@@ -497,7 +497,7 @@ export {
497 497
498function checkUserIdExist (idArg: number | string, res: express.Response, withStats = false) { 498function checkUserIdExist (idArg: number | string, res: express.Response, withStats = false) {
499 const id = parseInt(idArg + '', 10) 499 const id = parseInt(idArg + '', 10)
500 return checkUserExist(() => UserModel.loadById(id, withStats), res) 500 return checkUserExist(() => UserModel.loadByIdWithChannels(id, withStats), res)
501} 501}
502 502
503function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) { 503function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) {
diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts
index b022b2c23..ff90e347a 100644
--- a/server/middlewares/validators/videos/videos.ts
+++ b/server/middlewares/validators/videos/videos.ts
@@ -1,5 +1,6 @@
1import * as express from 'express' 1import * as express from 'express'
2import { body, param, query, ValidationChain } from 'express-validator' 2import { body, param, query, ValidationChain } from 'express-validator'
3import { isAbleToUploadVideo } from '@server/lib/user'
3import { getServerActor } from '@server/models/application/application' 4import { getServerActor } from '@server/models/application/application'
4import { MVideoFullLight } from '@server/types/models' 5import { MVideoFullLight } from '@server/types/models'
5import { ServerErrorCode, UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared' 6import { ServerErrorCode, UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared'
@@ -73,7 +74,7 @@ const videosAddValidator = getCommonVideoEditAttributes().concat([
73 74
74 if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req) 75 if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
75 76
76 if (await user.isAbleToUploadVideo(videoFile) === false) { 77 if (await isAbleToUploadVideo(user.id, videoFile.size) === false) {
77 res.status(403) 78 res.status(403)
78 .json({ error: 'The user video quota is exceeded with this video.' }) 79 .json({ error: 'The user video quota is exceeded with this video.' })
79 80
@@ -291,7 +292,7 @@ const videosAcceptChangeOwnershipValidator = [
291 292
292 const user = res.locals.oauth.token.User 293 const user = res.locals.oauth.token.User
293 const videoChangeOwnership = res.locals.videoChangeOwnership 294 const videoChangeOwnership = res.locals.videoChangeOwnership
294 const isAble = await user.isAbleToUploadVideo(videoChangeOwnership.Video.getMaxQualityFile()) 295 const isAble = await isAbleToUploadVideo(user.id, videoChangeOwnership.Video.getMaxQualityFile().size)
295 if (isAble === false) { 296 if (isAble === false) {
296 res.status(403) 297 res.status(403)
297 .json({ error: 'The user video quota is exceeded with this video.' }) 298 .json({ error: 'The user video quota is exceeded with this video.' })
diff --git a/server/models/account/user.ts b/server/models/account/user.ts
index 22e6715b4..e850d1e6d 100644
--- a/server/models/account/user.ts
+++ b/server/models/account/user.ts
@@ -23,6 +23,7 @@ import {
23} from 'sequelize-typescript' 23} from 'sequelize-typescript'
24import { 24import {
25 MMyUserFormattable, 25 MMyUserFormattable,
26 MUser,
26 MUserDefault, 27 MUserDefault,
27 MUserFormattable, 28 MUserFormattable,
28 MUserId, 29 MUserId,
@@ -70,6 +71,7 @@ import { VideoImportModel } from '../video/video-import'
70import { VideoPlaylistModel } from '../video/video-playlist' 71import { VideoPlaylistModel } from '../video/video-playlist'
71import { AccountModel } from './account' 72import { AccountModel } from './account'
72import { UserNotificationSettingModel } from './user-notification-setting' 73import { UserNotificationSettingModel } from './user-notification-setting'
74import { VideoLiveModel } from '../video/video-live'
73 75
74enum ScopeNames { 76enum ScopeNames {
75 FOR_ME_API = 'FOR_ME_API', 77 FOR_ME_API = 'FOR_ME_API',
@@ -540,7 +542,11 @@ export class UserModel extends Model<UserModel> {
540 return UserModel.findAll(query) 542 return UserModel.findAll(query)
541 } 543 }
542 544
543 static loadById (id: number, withStats = false): Bluebird<MUserDefault> { 545 static loadById (id: number): Bluebird<MUser> {
546 return UserModel.unscoped().findByPk(id)
547 }
548
549 static loadByIdWithChannels (id: number, withStats = false): Bluebird<MUserDefault> {
544 const scopes = [ 550 const scopes = [
545 ScopeNames.WITH_VIDEOCHANNELS 551 ScopeNames.WITH_VIDEOCHANNELS
546 ] 552 ]
@@ -685,26 +691,85 @@ export class UserModel extends Model<UserModel> {
685 return UserModel.findOne(query) 691 return UserModel.findOne(query)
686 } 692 }
687 693
688 static getOriginalVideoFileTotalFromUser (user: MUserId) { 694 static loadByLiveId (liveId: number): Bluebird<MUser> {
689 // Don't use sequelize because we need to use a sub query 695 const query = {
690 const query = UserModel.generateUserQuotaBaseSQL({ 696 include: [
691 withSelect: true, 697 {
692 whereUserId: '$userId' 698 attributes: [ 'id' ],
693 }) 699 model: AccountModel.unscoped(),
700 required: true,
701 include: [
702 {
703 attributes: [ 'id' ],
704 model: VideoChannelModel.unscoped(),
705 required: true,
706 include: [
707 {
708 attributes: [ 'id' ],
709 model: VideoModel.unscoped(),
710 required: true,
711 include: [
712 {
713 attributes: [ 'id', 'videoId' ],
714 model: VideoLiveModel.unscoped(),
715 required: true,
716 where: {
717 id: liveId
718 }
719 }
720 ]
721 }
722 ]
723 }
724 ]
725 }
726 ]
727 }
728
729 return UserModel.findOne(query)
730 }
731
732 static generateUserQuotaBaseSQL (options: {
733 whereUserId: '$userId' | '"UserModel"."id"'
734 withSelect: boolean
735 where?: string
736 }) {
737 const andWhere = options.where
738 ? 'AND ' + options.where
739 : ''
740
741 const videoChannelJoin = 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
742 'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' +
743 `WHERE "account"."userId" = ${options.whereUserId} ${andWhere}`
744
745 const webtorrentFiles = 'SELECT "videoFile"."size" AS "size", "video"."id" AS "videoId" FROM "videoFile" ' +
746 'INNER JOIN "video" ON "videoFile"."videoId" = "video"."id" ' +
747 videoChannelJoin
748
749 const hlsFiles = 'SELECT "videoFile"."size" AS "size", "video"."id" AS "videoId" FROM "videoFile" ' +
750 'INNER JOIN "videoStreamingPlaylist" ON "videoFile"."videoStreamingPlaylistId" = "videoStreamingPlaylist".id ' +
751 'INNER JOIN "video" ON "videoStreamingPlaylist"."videoId" = "video"."id" ' +
752 videoChannelJoin
694 753
695 return UserModel.getTotalRawQuery(query, user.id) 754 return 'SELECT COALESCE(SUM("size"), 0) AS "total" ' +
755 'FROM (' +
756 `SELECT MAX("t1"."size") AS "size" FROM (${webtorrentFiles} UNION ${hlsFiles}) t1 ` +
757 'GROUP BY "t1"."videoId"' +
758 ') t2'
696 } 759 }
697 760
698 // Returns cumulative size of all video files uploaded in the last 24 hours. 761 static getTotalRawQuery (query: string, userId: number) {
699 static getOriginalVideoFileTotalDailyFromUser (user: MUserId) { 762 const options = {
700 // Don't use sequelize because we need to use a sub query 763 bind: { userId },
701 const query = UserModel.generateUserQuotaBaseSQL({ 764 type: QueryTypes.SELECT as QueryTypes.SELECT
702 withSelect: true, 765 }
703 whereUserId: '$userId', 766
704 where: '"video"."createdAt" > now() - interval \'24 hours\'' 767 return UserModel.sequelize.query<{ total: string }>(query, options)
705 }) 768 .then(([ { total } ]) => {
769 if (total === null) return 0
706 770
707 return UserModel.getTotalRawQuery(query, user.id) 771 return parseInt(total, 10)
772 })
708 } 773 }
709 774
710 static async getStats () { 775 static async getStats () {
@@ -874,64 +939,4 @@ export class UserModel extends Model<UserModel> {
874 939
875 return Object.assign(formatted, { specialPlaylists }) 940 return Object.assign(formatted, { specialPlaylists })
876 } 941 }
877
878 async isAbleToUploadVideo (videoFile: { size: number }) {
879 if (this.videoQuota === -1 && this.videoQuotaDaily === -1) return Promise.resolve(true)
880
881 const [ totalBytes, totalBytesDaily ] = await Promise.all([
882 UserModel.getOriginalVideoFileTotalFromUser(this),
883 UserModel.getOriginalVideoFileTotalDailyFromUser(this)
884 ])
885
886 const uploadedTotal = videoFile.size + totalBytes
887 const uploadedDaily = videoFile.size + totalBytesDaily
888
889 if (this.videoQuotaDaily === -1) return uploadedTotal < this.videoQuota
890 if (this.videoQuota === -1) return uploadedDaily < this.videoQuotaDaily
891
892 return uploadedTotal < this.videoQuota && uploadedDaily < this.videoQuotaDaily
893 }
894
895 private static generateUserQuotaBaseSQL (options: {
896 whereUserId: '$userId' | '"UserModel"."id"'
897 withSelect: boolean
898 where?: string
899 }) {
900 const andWhere = options.where
901 ? 'AND ' + options.where
902 : ''
903
904 const videoChannelJoin = 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
905 'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' +
906 `WHERE "account"."userId" = ${options.whereUserId} ${andWhere}`
907
908 const webtorrentFiles = 'SELECT "videoFile"."size" AS "size", "video"."id" AS "videoId" FROM "videoFile" ' +
909 'INNER JOIN "video" ON "videoFile"."videoId" = "video"."id" ' +
910 videoChannelJoin
911
912 const hlsFiles = 'SELECT "videoFile"."size" AS "size", "video"."id" AS "videoId" FROM "videoFile" ' +
913 'INNER JOIN "videoStreamingPlaylist" ON "videoFile"."videoStreamingPlaylistId" = "videoStreamingPlaylist".id ' +
914 'INNER JOIN "video" ON "videoStreamingPlaylist"."videoId" = "video"."id" ' +
915 videoChannelJoin
916
917 return 'SELECT COALESCE(SUM("size"), 0) AS "total" ' +
918 'FROM (' +
919 `SELECT MAX("t1"."size") AS "size" FROM (${webtorrentFiles} UNION ${hlsFiles}) t1 ` +
920 'GROUP BY "t1"."videoId"' +
921 ') t2'
922 }
923
924 private static getTotalRawQuery (query: string, userId: number) {
925 const options = {
926 bind: { userId },
927 type: QueryTypes.SELECT as QueryTypes.SELECT
928 }
929
930 return UserModel.sequelize.query<{ total: string }>(query, options)
931 .then(([ { total } ]) => {
932 if (total === null) return 0
933
934 return parseInt(total, 10)
935 })
936 }
937} 942}
diff --git a/server/models/video/video-live.ts b/server/models/video/video-live.ts
index 8608bc84c..a1dd80d3c 100644
--- a/server/models/video/video-live.ts
+++ b/server/models/video/video-live.ts
@@ -30,10 +30,18 @@ import { VideoBlacklistModel } from './video-blacklist'
30}) 30})
31export class VideoLiveModel extends Model<VideoLiveModel> { 31export class VideoLiveModel extends Model<VideoLiveModel> {
32 32
33 @AllowNull(false) 33 @AllowNull(true)
34 @Column(DataType.STRING) 34 @Column(DataType.STRING)
35 streamKey: string 35 streamKey: string
36 36
37 @AllowNull(false)
38 @Column
39 perpetualLive: boolean
40
41 @AllowNull(false)
42 @Column
43 saveReplay: boolean
44
37 @CreatedAt 45 @CreatedAt
38 createdAt: Date 46 createdAt: Date
39 47
diff --git a/server/tests/api/check-params/config.ts b/server/tests/api/check-params/config.ts
index 35cb333ef..2882ceb7c 100644
--- a/server/tests/api/check-params/config.ts
+++ b/server/tests/api/check-params/config.ts
@@ -103,6 +103,9 @@ describe('Test config API validators', function () {
103 live: { 103 live: {
104 enabled: true, 104 enabled: true,
105 105
106 allowReplay: false,
107 maxDuration: null,
108
106 transcoding: { 109 transcoding: {
107 enabled: true, 110 enabled: true,
108 threads: 4, 111 threads: 4,
diff --git a/server/tests/api/server/config.ts b/server/tests/api/server/config.ts
index a46e179c2..a7f035362 100644
--- a/server/tests/api/server/config.ts
+++ b/server/tests/api/server/config.ts
@@ -79,6 +79,8 @@ function checkInitialConfig (server: ServerInfo, data: CustomConfig) {
79 expect(data.transcoding.hls.enabled).to.be.true 79 expect(data.transcoding.hls.enabled).to.be.true
80 80
81 expect(data.live.enabled).to.be.false 81 expect(data.live.enabled).to.be.false
82 expect(data.live.allowReplay).to.be.true
83 expect(data.live.maxDuration).to.equal(1000 * 3600 * 5)
82 expect(data.live.transcoding.enabled).to.be.false 84 expect(data.live.transcoding.enabled).to.be.false
83 expect(data.live.transcoding.threads).to.equal(2) 85 expect(data.live.transcoding.threads).to.equal(2)
84 expect(data.live.transcoding.resolutions['240p']).to.be.false 86 expect(data.live.transcoding.resolutions['240p']).to.be.false
@@ -162,6 +164,8 @@ function checkUpdatedConfig (data: CustomConfig) {
162 expect(data.transcoding.webtorrent.enabled).to.be.true 164 expect(data.transcoding.webtorrent.enabled).to.be.true
163 165
164 expect(data.live.enabled).to.be.true 166 expect(data.live.enabled).to.be.true
167 expect(data.live.allowReplay).to.be.false
168 expect(data.live.maxDuration).to.equal(5000)
165 expect(data.live.transcoding.enabled).to.be.true 169 expect(data.live.transcoding.enabled).to.be.true
166 expect(data.live.transcoding.threads).to.equal(4) 170 expect(data.live.transcoding.threads).to.equal(4)
167 expect(data.live.transcoding.resolutions['240p']).to.be.true 171 expect(data.live.transcoding.resolutions['240p']).to.be.true
@@ -324,6 +328,8 @@ describe('Test config', function () {
324 }, 328 },
325 live: { 329 live: {
326 enabled: true, 330 enabled: true,
331 allowReplay: false,
332 maxDuration: 5000,
327 transcoding: { 333 transcoding: {
328 enabled: true, 334 enabled: true,
329 threads: 4, 335 threads: 4,
diff --git a/shared/extra-utils/server/config.ts b/shared/extra-utils/server/config.ts
index 3606976bd..bb7e23d54 100644
--- a/shared/extra-utils/server/config.ts
+++ b/shared/extra-utils/server/config.ts
@@ -128,6 +128,8 @@ function updateCustomSubConfig (url: string, token: string, newConfig: DeepParti
128 }, 128 },
129 live: { 129 live: {
130 enabled: true, 130 enabled: true,
131 allowReplay: false,
132 maxDuration: null,
131 transcoding: { 133 transcoding: {
132 enabled: true, 134 enabled: true,
133 threads: 4, 135 threads: 4,
diff --git a/shared/models/server/custom-config.model.ts b/shared/models/server/custom-config.model.ts
index e609d1a33..11b2ef2eb 100644
--- a/shared/models/server/custom-config.model.ts
+++ b/shared/models/server/custom-config.model.ts
@@ -98,6 +98,9 @@ export interface CustomConfig {
98 live: { 98 live: {
99 enabled: boolean 99 enabled: boolean
100 100
101 allowReplay: boolean
102 maxDuration: number
103
101 transcoding: { 104 transcoding: {
102 enabled: boolean 105 enabled: boolean
103 threads: number 106 threads: number
diff --git a/shared/models/server/server-config.model.ts b/shared/models/server/server-config.model.ts
index 77694a627..1563d848e 100644
--- a/shared/models/server/server-config.model.ts
+++ b/shared/models/server/server-config.model.ts
@@ -101,6 +101,9 @@ export interface ServerConfig {
101 live: { 101 live: {
102 enabled: boolean 102 enabled: boolean
103 103
104 maxDuration: number
105 allowReplay: boolean
106
104 transcoding: { 107 transcoding: {
105 enabled: boolean 108 enabled: boolean
106 109