aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts3
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-live-configuration.component.html12
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-edit.component.html11
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-edit.component.ts25
-rw-r--r--client/src/app/+videos/+video-edit/video-update.component.ts4
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.component.ts34
-rw-r--r--client/src/assets/player/peertube-player-options-builder.ts99
-rw-r--r--client/src/standalone/videos/embed.ts28
-rw-r--r--config/default.yaml6
-rw-r--r--config/production.yaml.example6
-rw-r--r--server/controllers/api/config.ts3
-rw-r--r--server/controllers/api/videos/live.ts9
-rw-r--r--server/helpers/activitypub.ts4
-rw-r--r--server/helpers/custom-validators/activitypub/videos.ts4
-rw-r--r--server/helpers/custom-validators/video-lives.ts11
-rw-r--r--server/helpers/ffmpeg/ffmpeg-live.ts39
-rw-r--r--server/initializers/checker-before-init.ts4
-rw-r--r--server/initializers/config.ts6
-rw-r--r--server/initializers/constants.ts10
-rw-r--r--server/initializers/migrations/0690-live-latency-mode.ts35
-rw-r--r--server/lib/activitypub/videos/shared/object-to-model-attributes.ts1
-rw-r--r--server/lib/live/live-manager.ts7
-rw-r--r--server/lib/live/shared/muxing-session.ts9
-rw-r--r--server/lib/server-config-manager.ts4
-rw-r--r--server/middlewares/validators/videos/video-live.ts73
-rw-r--r--server/models/video/formatter/video-format-utils.ts39
-rw-r--r--server/models/video/sql/video/shared/video-table-attributes.ts1
-rw-r--r--server/models/video/video-live.ts11
-rw-r--r--server/tests/api/check-params/config.ts3
-rw-r--r--server/tests/api/check-params/live.ts32
-rw-r--r--server/tests/api/live/live.ts9
-rw-r--r--server/tests/api/server/config.ts5
-rw-r--r--shared/models/activitypub/objects/video-torrent-object.ts3
-rw-r--r--shared/models/server/custom-config.model.ts4
-rw-r--r--shared/models/server/server-config.model.ts6
-rw-r--r--shared/models/videos/live/index.ts1
-rw-r--r--shared/models/videos/live/live-video-create.model.ts2
-rw-r--r--shared/models/videos/live/live-video-latency-mode.enum.ts5
-rw-r--r--shared/models/videos/live/live-video-update.model.ts3
-rw-r--r--shared/models/videos/live/live-video.model.ts4
-rw-r--r--shared/server-commands/server/config-command.ts3
-rw-r--r--support/doc/api/openapi.yaml19
42 files changed, 516 insertions, 81 deletions
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 e3b6f8305..94f1021bf 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
@@ -189,6 +189,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
189 maxInstanceLives: MAX_INSTANCE_LIVES_VALIDATOR, 189 maxInstanceLives: MAX_INSTANCE_LIVES_VALIDATOR,
190 maxUserLives: MAX_USER_LIVES_VALIDATOR, 190 maxUserLives: MAX_USER_LIVES_VALIDATOR,
191 allowReplay: null, 191 allowReplay: null,
192 latencySetting: {
193 enabled: null
194 },
192 195
193 transcoding: { 196 transcoding: {
194 enabled: null, 197 enabled: null,
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-live-configuration.component.html b/client/src/app/+admin/config/edit-custom-config/edit-live-configuration.component.html
index 10d5278c1..8d6a4ce19 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-live-configuration.component.html
+++ b/client/src/app/+admin/config/edit-custom-config/edit-live-configuration.component.html
@@ -36,6 +36,18 @@
36 </my-peertube-checkbox> 36 </my-peertube-checkbox>
37 </div> 37 </div>
38 38
39 <div class="form-group" formGroupName="latencySetting" [ngClass]="getDisabledLiveClass()">
40 <my-peertube-checkbox
41 inputName="liveLatencySettingEnabled" formControlName="enabled"
42 i18n-labelText labelText="Allow your users to change live latency"
43 >
44 <ng-container ngProjectAs="description" i18n>
45 Small latency disables P2P and high latency can increase P2P ratio
46 </ng-container>
47
48 </my-peertube-checkbox>
49 </div>
50
39 <div class="form-group" [ngClass]="getDisabledLiveClass()"> 51 <div class="form-group" [ngClass]="getDisabledLiveClass()">
40 <label i18n for="liveMaxInstanceLives"> 52 <label i18n for="liveMaxInstanceLives">
41 Max simultaneous lives created on your instance <span class="text-muted">(-1 for "unlimited")</span> 53 Max simultaneous lives created on your instance <span class="text-muted">(-1 for "unlimited")</span>
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.html b/client/src/app/+videos/+video-edit/shared/video-edit.component.html
index 2281f8631..515daf15f 100644
--- a/client/src/app/+videos/+video-edit/shared/video-edit.component.html
+++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.html
@@ -289,6 +289,17 @@
289 </ng-container> 289 </ng-container>
290 </my-peertube-checkbox> 290 </my-peertube-checkbox>
291 </div> 291 </div>
292
293 <div class="form-group" *ngIf="isLatencyModeEnabled()">
294 <label i18n for="latencyMode">Latency mode</label>
295 <my-select-options
296 labelForId="latencyMode" [items]="latencyModes" formControlName="latencyMode" [clearable]="true"
297 ></my-select-options>
298
299 <div *ngIf="formErrors.latencyMode" class="form-error">
300 {{ formErrors.latencyMode }}
301 </div>
302 </div>
292 </div> 303 </div>
293 </div> 304 </div>
294 </ng-template> 305 </ng-template>
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts
index 2801fc519..a2399eafb 100644
--- a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts
+++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts
@@ -1,6 +1,6 @@
1import { forkJoin } from 'rxjs' 1import { forkJoin } from 'rxjs'
2import { map } from 'rxjs/operators' 2import { map } from 'rxjs/operators'
3import { SelectChannelItem } from 'src/types/select-options-item.model' 3import { SelectChannelItem, SelectOptionsItem } from 'src/types/select-options-item.model'
4import { ChangeDetectorRef, Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output, ViewChild } from '@angular/core' 4import { ChangeDetectorRef, Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'
5import { AbstractControl, FormArray, FormControl, FormGroup, Validators } from '@angular/forms' 5import { AbstractControl, FormArray, FormControl, FormGroup, Validators } from '@angular/forms'
6import { HooksService, PluginService, ServerService } from '@app/core' 6import { HooksService, PluginService, ServerService } from '@app/core'
@@ -26,6 +26,7 @@ import { PluginInfo } from '@root-helpers/plugins-manager'
26import { 26import {
27 HTMLServerConfig, 27 HTMLServerConfig,
28 LiveVideo, 28 LiveVideo,
29 LiveVideoLatencyMode,
29 RegisterClientFormFieldOptions, 30 RegisterClientFormFieldOptions,
30 RegisterClientVideoFieldOptions, 31 RegisterClientVideoFieldOptions,
31 VideoConstant, 32 VideoConstant,
@@ -78,6 +79,23 @@ export class VideoEditComponent implements OnInit, OnDestroy {
78 videoCategories: VideoConstant<number>[] = [] 79 videoCategories: VideoConstant<number>[] = []
79 videoLicences: VideoConstant<number>[] = [] 80 videoLicences: VideoConstant<number>[] = []
80 videoLanguages: VideoLanguages[] = [] 81 videoLanguages: VideoLanguages[] = []
82 latencyModes: SelectOptionsItem[] = [
83 {
84 id: LiveVideoLatencyMode.SMALL_LATENCY,
85 label: $localize`Small latency`,
86 description: $localize`Reduce latency to ~15s disabling P2P`
87 },
88 {
89 id: LiveVideoLatencyMode.DEFAULT,
90 label: $localize`Default`,
91 description: $localize`Average latency of 30s`
92 },
93 {
94 id: LiveVideoLatencyMode.HIGH_LATENCY,
95 label: $localize`High latency`,
96 description: $localize`Average latency of 60s increasing P2P ratio`
97 }
98 ]
81 99
82 pluginDataFormGroup: FormGroup 100 pluginDataFormGroup: FormGroup
83 101
@@ -141,6 +159,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
141 originallyPublishedAt: VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR, 159 originallyPublishedAt: VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR,
142 liveStreamKey: null, 160 liveStreamKey: null,
143 permanentLive: null, 161 permanentLive: null,
162 latencyMode: null,
144 saveReplay: null 163 saveReplay: null
145 } 164 }
146 165
@@ -273,6 +292,10 @@ export class VideoEditComponent implements OnInit, OnDestroy {
273 return this.form.value['permanentLive'] === true 292 return this.form.value['permanentLive'] === true
274 } 293 }
275 294
295 isLatencyModeEnabled () {
296 return this.serverConfig.live.latencySetting.enabled
297 }
298
276 isPluginFieldHidden (pluginField: PluginField) { 299 isPluginFieldHidden (pluginField: PluginField) {
277 if (typeof pluginField.commonOptions.hidden !== 'function') return false 300 if (typeof pluginField.commonOptions.hidden !== 'function') return false
278 301
diff --git a/client/src/app/+videos/+video-edit/video-update.component.ts b/client/src/app/+videos/+video-edit/video-update.component.ts
index d9e8344fc..9c4998f2e 100644
--- a/client/src/app/+videos/+video-edit/video-update.component.ts
+++ b/client/src/app/+videos/+video-edit/video-update.component.ts
@@ -64,6 +64,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
64 if (this.liveVideo) { 64 if (this.liveVideo) {
65 this.form.patchValue({ 65 this.form.patchValue({
66 saveReplay: this.liveVideo.saveReplay, 66 saveReplay: this.liveVideo.saveReplay,
67 latencyMode: this.liveVideo.latencyMode,
67 permanentLive: this.liveVideo.permanentLive 68 permanentLive: this.liveVideo.permanentLive
68 }) 69 })
69 } 70 }
@@ -127,7 +128,8 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
127 128
128 const liveVideoUpdate: LiveVideoUpdate = { 129 const liveVideoUpdate: LiveVideoUpdate = {
129 saveReplay: !!this.form.value.saveReplay, 130 saveReplay: !!this.form.value.saveReplay,
130 permanentLive: !!this.form.value.permanentLive 131 permanentLive: !!this.form.value.permanentLive,
132 latencyMode: this.form.value.latencyMode
131 } 133 }
132 134
133 // Don't update live attributes if they did not change 135 // Don't update live attributes if they did not change
diff --git a/client/src/app/+videos/+video-watch/video-watch.component.ts b/client/src/app/+videos/+video-watch/video-watch.component.ts
index 1f45c4d26..067d3bc84 100644
--- a/client/src/app/+videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/+videos/+video-watch/video-watch.component.ts
@@ -1,5 +1,5 @@
1import { Hotkey, HotkeysService } from 'angular2-hotkeys' 1import { Hotkey, HotkeysService } from 'angular2-hotkeys'
2import { forkJoin, Subscription } from 'rxjs' 2import { forkJoin, map, Observable, of, Subscription, switchMap } from 'rxjs'
3import { isP2PEnabled } from 'src/assets/player/utils' 3import { isP2PEnabled } from 'src/assets/player/utils'
4import { PlatformLocation } from '@angular/common' 4import { PlatformLocation } from '@angular/common'
5import { Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core' 5import { Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'
@@ -22,11 +22,13 @@ import { HooksService } from '@app/core/plugins/hooks.service'
22import { isXPercentInViewport, scrollToTop } from '@app/helpers' 22import { isXPercentInViewport, scrollToTop } from '@app/helpers'
23import { Video, VideoCaptionService, VideoDetails, VideoService } from '@app/shared/shared-main' 23import { Video, VideoCaptionService, VideoDetails, VideoService } from '@app/shared/shared-main'
24import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription' 24import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription'
25import { LiveVideoService } from '@app/shared/shared-video-live'
25import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist' 26import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
26import { timeToInt } from '@shared/core-utils' 27import { timeToInt } from '@shared/core-utils'
27import { 28import {
28 HTMLServerConfig, 29 HTMLServerConfig,
29 HttpStatusCode, 30 HttpStatusCode,
31 LiveVideo,
30 PeerTubeProblemDocument, 32 PeerTubeProblemDocument,
31 ServerErrorCode, 33 ServerErrorCode,
32 VideoCaption, 34 VideoCaption,
@@ -63,6 +65,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
63 65
64 video: VideoDetails = null 66 video: VideoDetails = null
65 videoCaptions: VideoCaption[] = [] 67 videoCaptions: VideoCaption[] = []
68 liveVideo: LiveVideo
66 69
67 playlistPosition: number 70 playlistPosition: number
68 playlist: VideoPlaylist = null 71 playlist: VideoPlaylist = null
@@ -89,6 +92,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
89 private router: Router, 92 private router: Router,
90 private videoService: VideoService, 93 private videoService: VideoService,
91 private playlistService: VideoPlaylistService, 94 private playlistService: VideoPlaylistService,
95 private liveVideoService: LiveVideoService,
92 private confirmService: ConfirmService, 96 private confirmService: ConfirmService,
93 private metaService: MetaService, 97 private metaService: MetaService,
94 private authService: AuthService, 98 private authService: AuthService,
@@ -239,12 +243,21 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
239 'filter:api.video-watch.video.get.result' 243 'filter:api.video-watch.video.get.result'
240 ) 244 )
241 245
246 const videoAndLiveObs: Observable<{ video: VideoDetails, live?: LiveVideo }> = videoObs.pipe(
247 switchMap(video => {
248 if (!video.isLive) return of({ video })
249
250 return this.liveVideoService.getVideoLive(video.uuid)
251 .pipe(map(live => ({ live, video })))
252 })
253 )
254
242 forkJoin([ 255 forkJoin([
243 videoObs, 256 videoAndLiveObs,
244 this.videoCaptionService.listCaptions(videoId), 257 this.videoCaptionService.listCaptions(videoId),
245 this.userService.getAnonymousOrLoggedUser() 258 this.userService.getAnonymousOrLoggedUser()
246 ]).subscribe({ 259 ]).subscribe({
247 next: ([ video, captionsResult, loggedInOrAnonymousUser ]) => { 260 next: ([ { video, live }, captionsResult, loggedInOrAnonymousUser ]) => {
248 const queryParams = this.route.snapshot.queryParams 261 const queryParams = this.route.snapshot.queryParams
249 262
250 const urlOptions = { 263 const urlOptions = {
@@ -261,7 +274,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
261 peertubeLink: false 274 peertubeLink: false
262 } 275 }
263 276
264 this.onVideoFetched({ video, videoCaptions: captionsResult.data, loggedInOrAnonymousUser, urlOptions }) 277 this.onVideoFetched({ video, live, videoCaptions: captionsResult.data, loggedInOrAnonymousUser, urlOptions })
265 .catch(err => this.handleGlobalError(err)) 278 .catch(err => this.handleGlobalError(err))
266 }, 279 },
267 280
@@ -330,16 +343,18 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
330 343
331 private async onVideoFetched (options: { 344 private async onVideoFetched (options: {
332 video: VideoDetails 345 video: VideoDetails
346 live: LiveVideo
333 videoCaptions: VideoCaption[] 347 videoCaptions: VideoCaption[]
334 urlOptions: URLOptions 348 urlOptions: URLOptions
335 loggedInOrAnonymousUser: User 349 loggedInOrAnonymousUser: User
336 }) { 350 }) {
337 const { video, videoCaptions, urlOptions, loggedInOrAnonymousUser } = options 351 const { video, live, videoCaptions, urlOptions, loggedInOrAnonymousUser } = options
338 352
339 this.subscribeToLiveEventsIfNeeded(this.video, video) 353 this.subscribeToLiveEventsIfNeeded(this.video, video)
340 354
341 this.video = video 355 this.video = video
342 this.videoCaptions = videoCaptions 356 this.videoCaptions = videoCaptions
357 this.liveVideo = live
343 358
344 // Re init attributes 359 // Re init attributes
345 this.playerPlaceholderImgSrc = undefined 360 this.playerPlaceholderImgSrc = undefined
@@ -387,6 +402,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
387 const params = { 402 const params = {
388 video: this.video, 403 video: this.video,
389 videoCaptions: this.videoCaptions, 404 videoCaptions: this.videoCaptions,
405 liveVideo: this.liveVideo,
390 urlOptions, 406 urlOptions,
391 loggedInOrAnonymousUser, 407 loggedInOrAnonymousUser,
392 user: this.user 408 user: this.user
@@ -532,12 +548,13 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
532 548
533 private buildPlayerManagerOptions (params: { 549 private buildPlayerManagerOptions (params: {
534 video: VideoDetails 550 video: VideoDetails
551 liveVideo: LiveVideo
535 videoCaptions: VideoCaption[] 552 videoCaptions: VideoCaption[]
536 urlOptions: CustomizationOptions & { playerMode: PlayerMode } 553 urlOptions: CustomizationOptions & { playerMode: PlayerMode }
537 loggedInOrAnonymousUser: User 554 loggedInOrAnonymousUser: User
538 user?: AuthUser 555 user?: AuthUser
539 }) { 556 }) {
540 const { video, videoCaptions, urlOptions, loggedInOrAnonymousUser, user } = params 557 const { video, liveVideo, videoCaptions, urlOptions, loggedInOrAnonymousUser, user } = params
541 558
542 const getStartTime = () => { 559 const getStartTime = () => {
543 const byUrl = urlOptions.startTime !== undefined 560 const byUrl = urlOptions.startTime !== undefined
@@ -562,6 +579,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
562 src: environment.apiUrl + c.captionPath 579 src: environment.apiUrl + c.captionPath
563 })) 580 }))
564 581
582 const liveOptions = video.isLive
583 ? { latencyMode: liveVideo.latencyMode }
584 : undefined
585
565 const options: PeertubePlayerManagerOptions = { 586 const options: PeertubePlayerManagerOptions = {
566 common: { 587 common: {
567 autoplay: this.isAutoplay(), 588 autoplay: this.isAutoplay(),
@@ -597,6 +618,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
597 embedTitle: video.name, 618 embedTitle: video.name,
598 619
599 isLive: video.isLive, 620 isLive: video.isLive,
621 liveOptions,
600 622
601 language: this.localeId, 623 language: this.localeId,
602 624
diff --git a/client/src/assets/player/peertube-player-options-builder.ts b/client/src/assets/player/peertube-player-options-builder.ts
index 7a82b128d..c9cbbbf4d 100644
--- a/client/src/assets/player/peertube-player-options-builder.ts
+++ b/client/src/assets/player/peertube-player-options-builder.ts
@@ -1,9 +1,10 @@
1import videojs from 'video.js' 1import videojs from 'video.js'
2import { HybridLoaderSettings } from '@peertube/p2p-media-loader-core'
2import { HlsJsEngineSettings } from '@peertube/p2p-media-loader-hlsjs' 3import { HlsJsEngineSettings } from '@peertube/p2p-media-loader-hlsjs'
3import { PluginsManager } from '@root-helpers/plugins-manager' 4import { PluginsManager } from '@root-helpers/plugins-manager'
4import { buildVideoLink, decorateVideoLink } from '@shared/core-utils' 5import { buildVideoLink, decorateVideoLink } from '@shared/core-utils'
5import { isDefaultLocale } from '@shared/core-utils/i18n' 6import { isDefaultLocale } from '@shared/core-utils/i18n'
6import { VideoFile } from '@shared/models' 7import { LiveVideoLatencyMode, VideoFile } from '@shared/models'
7import { copyToClipboard } from '../../root-helpers/utils' 8import { copyToClipboard } from '../../root-helpers/utils'
8import { RedundancyUrlManager } from './p2p-media-loader/redundancy-url-manager' 9import { RedundancyUrlManager } from './p2p-media-loader/redundancy-url-manager'
9import { segmentUrlBuilderFactory } from './p2p-media-loader/segment-url-builder' 10import { segmentUrlBuilderFactory } from './p2p-media-loader/segment-url-builder'
@@ -19,7 +20,6 @@ import {
19 VideoJSPluginOptions 20 VideoJSPluginOptions
20} from './peertube-videojs-typings' 21} from './peertube-videojs-typings'
21import { buildVideoOrPlaylistEmbed, getRtcConfig, isIOS, isSafari } from './utils' 22import { buildVideoOrPlaylistEmbed, getRtcConfig, isIOS, isSafari } from './utils'
22import { HybridLoaderSettings } from '@peertube/p2p-media-loader-core'
23 23
24export type PlayerMode = 'webtorrent' | 'p2p-media-loader' 24export type PlayerMode = 'webtorrent' | 'p2p-media-loader'
25 25
@@ -76,6 +76,9 @@ export interface CommonOptions extends CustomizationOptions {
76 embedTitle: string 76 embedTitle: string
77 77
78 isLive: boolean 78 isLive: boolean
79 liveOptions?: {
80 latencyMode: LiveVideoLatencyMode
81 }
79 82
80 language?: string 83 language?: string
81 84
@@ -250,21 +253,8 @@ export class PeertubePlayerOptionsBuilder {
250 .filter(t => t.startsWith('ws')) 253 .filter(t => t.startsWith('ws'))
251 254
252 const specificLiveOrVODOptions = this.options.common.isLive 255 const specificLiveOrVODOptions = this.options.common.isLive
253 ? { // Live 256 ? this.getP2PMediaLoaderLiveOptions()
254 requiredSegmentsPriority: 1 257 : this.getP2PMediaLoaderVODOptions()
255 }
256 : { // VOD
257 requiredSegmentsPriority: 3,
258
259 cachedSegmentExpiration: 86400000,
260 cachedSegmentsCount: 100,
261
262 httpDownloadMaxPriority: 9,
263 httpDownloadProbability: 0.06,
264 httpDownloadProbabilitySkipIfNoPeers: true,
265
266 p2pDownloadMaxPriority: 50
267 }
268 258
269 return { 259 return {
270 trackerAnnounce, 260 trackerAnnounce,
@@ -283,13 +273,57 @@ export class PeertubePlayerOptionsBuilder {
283 } 273 }
284 } 274 }
285 275
276 private getP2PMediaLoaderLiveOptions (): Partial<HybridLoaderSettings> {
277 const base = {
278 requiredSegmentsPriority: 1
279 }
280
281 const latencyMode = this.options.common.liveOptions.latencyMode
282
283 switch (latencyMode) {
284 case LiveVideoLatencyMode.SMALL_LATENCY:
285 return {
286 ...base,
287
288 useP2P: false,
289 httpDownloadProbability: 1
290 }
291
292 case LiveVideoLatencyMode.HIGH_LATENCY:
293 return base
294
295 default:
296 return base
297 }
298 }
299
300 private getP2PMediaLoaderVODOptions (): Partial<HybridLoaderSettings> {
301 return {
302 requiredSegmentsPriority: 3,
303
304 cachedSegmentExpiration: 86400000,
305 cachedSegmentsCount: 100,
306
307 httpDownloadMaxPriority: 9,
308 httpDownloadProbability: 0.06,
309 httpDownloadProbabilitySkipIfNoPeers: true,
310
311 p2pDownloadMaxPriority: 50
312 }
313 }
314
286 private getHLSOptions (p2pMediaLoaderConfig: HlsJsEngineSettings) { 315 private getHLSOptions (p2pMediaLoaderConfig: HlsJsEngineSettings) {
316 const specificLiveOrVODOptions = this.options.common.isLive
317 ? this.getHLSLiveOptions()
318 : this.getHLSVODOptions()
319
287 const base = { 320 const base = {
288 capLevelToPlayerSize: true, 321 capLevelToPlayerSize: true,
289 autoStartLoad: false, 322 autoStartLoad: false,
290 liveSyncDurationCount: 5,
291 323
292 loader: new this.p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass() 324 loader: new this.p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass(),
325
326 ...specificLiveOrVODOptions
293 } 327 }
294 328
295 const averageBandwidth = getAverageBandwidthInStore() 329 const averageBandwidth = getAverageBandwidthInStore()
@@ -305,6 +339,33 @@ export class PeertubePlayerOptionsBuilder {
305 } 339 }
306 } 340 }
307 341
342 private getHLSLiveOptions () {
343 const latencyMode = this.options.common.liveOptions.latencyMode
344
345 switch (latencyMode) {
346 case LiveVideoLatencyMode.SMALL_LATENCY:
347 return {
348 liveSyncDurationCount: 2
349 }
350
351 case LiveVideoLatencyMode.HIGH_LATENCY:
352 return {
353 liveSyncDurationCount: 10
354 }
355
356 default:
357 return {
358 liveSyncDurationCount: 5
359 }
360 }
361 }
362
363 private getHLSVODOptions () {
364 return {
365 liveSyncDurationCount: 5
366 }
367 }
368
308 private addWebTorrentOptions (plugins: VideoJSPluginOptions, alreadyPlayed: boolean) { 369 private addWebTorrentOptions (plugins: VideoJSPluginOptions, alreadyPlayed: boolean) {
309 const commonOptions = this.options.common 370 const commonOptions = this.options.common
310 const webtorrentOptions = this.options.webtorrent 371 const webtorrentOptions = this.options.webtorrent
diff --git a/client/src/standalone/videos/embed.ts b/client/src/standalone/videos/embed.ts
index 38ff39890..9e4d87911 100644
--- a/client/src/standalone/videos/embed.ts
+++ b/client/src/standalone/videos/embed.ts
@@ -6,6 +6,7 @@ import { peertubeTranslate } from '../../../../shared/core-utils/i18n'
6import { 6import {
7 HTMLServerConfig, 7 HTMLServerConfig,
8 HttpStatusCode, 8 HttpStatusCode,
9 LiveVideo,
9 OAuth2ErrorCode, 10 OAuth2ErrorCode,
10 ResultList, 11 ResultList,
11 UserRefreshToken, 12 UserRefreshToken,
@@ -94,6 +95,10 @@ export class PeerTubeEmbed {
94 return window.location.origin + '/api/v1/videos/' + id 95 return window.location.origin + '/api/v1/videos/' + id
95 } 96 }
96 97
98 getLiveUrl (videoId: string) {
99 return window.location.origin + '/api/v1/videos/live/' + videoId
100 }
101
97 refreshFetch (url: string, options?: RequestInit) { 102 refreshFetch (url: string, options?: RequestInit) {
98 return fetch(url, options) 103 return fetch(url, options)
99 .then((res: Response) => { 104 .then((res: Response) => {
@@ -166,6 +171,12 @@ export class PeerTubeEmbed {
166 return this.refreshFetch(this.getVideoUrl(videoId) + '/captions', { headers: this.headers }) 171 return this.refreshFetch(this.getVideoUrl(videoId) + '/captions', { headers: this.headers })
167 } 172 }
168 173
174 loadWithLive (video: VideoDetails) {
175 return this.refreshFetch(this.getLiveUrl(video.uuid), { headers: this.headers })
176 .then(res => res.json())
177 .then((live: LiveVideo) => ({ video, live }))
178 }
179
169 loadPlaylistInfo (playlistId: string): Promise<Response> { 180 loadPlaylistInfo (playlistId: string): Promise<Response> {
170 return this.refreshFetch(this.getPlaylistUrl(playlistId), { headers: this.headers }) 181 return this.refreshFetch(this.getPlaylistUrl(playlistId), { headers: this.headers })
171 } 182 }
@@ -475,13 +486,15 @@ export class PeerTubeEmbed {
475 .then(res => res.json()) 486 .then(res => res.json())
476 } 487 }
477 488
478 const videoInfoPromise = videoResponse.json() 489 const videoInfoPromise: Promise<{ video: VideoDetails, live?: LiveVideo }> = videoResponse.json()
479 .then((videoInfo: VideoDetails) => { 490 .then((videoInfo: VideoDetails) => {
480 this.loadParams(videoInfo) 491 this.loadParams(videoInfo)
481 492
482 if (!alreadyHadPlayer && !this.autoplay) this.loadPlaceholder(videoInfo) 493 if (!alreadyHadPlayer && !this.autoplay) this.buildPlaceholder(videoInfo)
483 494
484 return videoInfo 495 if (!videoInfo.isLive) return { video: videoInfo }
496
497 return this.loadWithLive(videoInfo)
485 }) 498 })
486 499
487 const [ videoInfoTmp, serverTranslations, captionsResponse, PeertubePlayerManagerModule ] = await Promise.all([ 500 const [ videoInfoTmp, serverTranslations, captionsResponse, PeertubePlayerManagerModule ] = await Promise.all([
@@ -493,11 +506,15 @@ export class PeerTubeEmbed {
493 506
494 await this.loadPlugins(serverTranslations) 507 await this.loadPlugins(serverTranslations)
495 508
496 const videoInfo: VideoDetails = videoInfoTmp 509 const { video: videoInfo, live } = videoInfoTmp
497 510
498 const PeertubePlayerManager = PeertubePlayerManagerModule.PeertubePlayerManager 511 const PeertubePlayerManager = PeertubePlayerManagerModule.PeertubePlayerManager
499 const videoCaptions = await this.buildCaptions(serverTranslations, captionsResponse) 512 const videoCaptions = await this.buildCaptions(serverTranslations, captionsResponse)
500 513
514 const liveOptions = videoInfo.isLive
515 ? { latencyMode: live.latencyMode }
516 : undefined
517
501 const playlistPlugin = this.currentPlaylistElement 518 const playlistPlugin = this.currentPlaylistElement
502 ? { 519 ? {
503 elements: this.playlistElements, 520 elements: this.playlistElements,
@@ -545,6 +562,7 @@ export class PeerTubeEmbed {
545 videoUUID: videoInfo.uuid, 562 videoUUID: videoInfo.uuid,
546 563
547 isLive: videoInfo.isLive, 564 isLive: videoInfo.isLive,
565 liveOptions,
548 566
549 playerElement: this.playerElement, 567 playerElement: this.playerElement,
550 onPlayerElementChange: (element: HTMLVideoElement) => { 568 onPlayerElementChange: (element: HTMLVideoElement) => {
@@ -726,7 +744,7 @@ export class PeerTubeEmbed {
726 return [] 744 return []
727 } 745 }
728 746
729 private loadPlaceholder (video: VideoDetails) { 747 private buildPlaceholder (video: VideoDetails) {
730 const placeholder = this.getPlaceholderElement() 748 const placeholder = this.getPlaceholderElement()
731 749
732 const url = window.location.origin + video.previewPath 750 const url = window.location.origin + video.previewPath
diff --git a/config/default.yaml b/config/default.yaml
index d76894b52..898395705 100644
--- a/config/default.yaml
+++ b/config/default.yaml
@@ -392,6 +392,12 @@ live:
392 # /!\ transcoding.enabled (and not live.transcoding.enabled) has to be true to create a replay 392 # /!\ transcoding.enabled (and not live.transcoding.enabled) has to be true to create a replay
393 allow_replay: true 393 allow_replay: true
394 394
395 # Allow your users to change latency settings (small latency/default/high latency)
396 # Small latency live streams cannot use P2P
397 # High latency live streams can increase P2P ratio
398 latency_setting:
399 enabled: true
400
395 # Your firewall should accept traffic from this port in TCP if you enable live 401 # Your firewall should accept traffic from this port in TCP if you enable live
396 rtmp: 402 rtmp:
397 enabled: true 403 enabled: true
diff --git a/config/production.yaml.example b/config/production.yaml.example
index 45d26190a..03afe5841 100644
--- a/config/production.yaml.example
+++ b/config/production.yaml.example
@@ -400,6 +400,12 @@ live:
400 # /!\ transcoding.enabled (and not live.transcoding.enabled) has to be true to create a replay 400 # /!\ transcoding.enabled (and not live.transcoding.enabled) has to be true to create a replay
401 allow_replay: true 401 allow_replay: true
402 402
403 # Allow your users to change latency settings (small latency/default/high latency)
404 # Small latency live streams cannot use P2P
405 # High latency live streams can increase P2P ratio
406 latency_setting:
407 enabled: true
408
403 # Your firewall should accept traffic from this port in TCP if you enable live 409 # Your firewall should accept traffic from this port in TCP if you enable live
404 rtmp: 410 rtmp:
405 enabled: true 411 enabled: true
diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts
index 821ed4ad3..376143cb8 100644
--- a/server/controllers/api/config.ts
+++ b/server/controllers/api/config.ts
@@ -237,6 +237,9 @@ function customConfig (): CustomConfig {
237 live: { 237 live: {
238 enabled: CONFIG.LIVE.ENABLED, 238 enabled: CONFIG.LIVE.ENABLED,
239 allowReplay: CONFIG.LIVE.ALLOW_REPLAY, 239 allowReplay: CONFIG.LIVE.ALLOW_REPLAY,
240 latencySetting: {
241 enabled: CONFIG.LIVE.LATENCY_SETTING.ENABLED
242 },
240 maxDuration: CONFIG.LIVE.MAX_DURATION, 243 maxDuration: CONFIG.LIVE.MAX_DURATION,
241 maxInstanceLives: CONFIG.LIVE.MAX_INSTANCE_LIVES, 244 maxInstanceLives: CONFIG.LIVE.MAX_INSTANCE_LIVES,
242 maxUserLives: CONFIG.LIVE.MAX_USER_LIVES, 245 maxUserLives: CONFIG.LIVE.MAX_USER_LIVES,
diff --git a/server/controllers/api/videos/live.ts b/server/controllers/api/videos/live.ts
index 49cabb6f3..c6f038079 100644
--- a/server/controllers/api/videos/live.ts
+++ b/server/controllers/api/videos/live.ts
@@ -1,4 +1,5 @@
1import express from 'express' 1import express from 'express'
2import { exists } from '@server/helpers/custom-validators/misc'
2import { createReqFiles } from '@server/helpers/express-utils' 3import { createReqFiles } from '@server/helpers/express-utils'
3import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants' 4import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants'
4import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url' 5import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url'
@@ -9,7 +10,7 @@ import { videoLiveAddValidator, videoLiveGetValidator, videoLiveUpdateValidator
9import { VideoLiveModel } from '@server/models/video/video-live' 10import { VideoLiveModel } from '@server/models/video/video-live'
10import { MVideoDetails, MVideoFullLight } from '@server/types/models' 11import { MVideoDetails, MVideoFullLight } from '@server/types/models'
11import { buildUUID, uuidToShort } from '@shared/extra-utils' 12import { buildUUID, uuidToShort } from '@shared/extra-utils'
12import { HttpStatusCode, LiveVideoCreate, LiveVideoUpdate, VideoState } from '@shared/models' 13import { HttpStatusCode, LiveVideoCreate, LiveVideoLatencyMode, LiveVideoUpdate, VideoState } from '@shared/models'
13import { logger } from '../../../helpers/logger' 14import { logger } from '../../../helpers/logger'
14import { sequelizeTypescript } from '../../../initializers/database' 15import { sequelizeTypescript } from '../../../initializers/database'
15import { updateVideoMiniatureFromExisting } from '../../../lib/thumbnail' 16import { updateVideoMiniatureFromExisting } from '../../../lib/thumbnail'
@@ -60,8 +61,9 @@ async function updateLiveVideo (req: express.Request, res: express.Response) {
60 const video = res.locals.videoAll 61 const video = res.locals.videoAll
61 const videoLive = res.locals.videoLive 62 const videoLive = res.locals.videoLive
62 63
63 videoLive.saveReplay = body.saveReplay || false 64 if (exists(body.saveReplay)) videoLive.saveReplay = body.saveReplay
64 videoLive.permanentLive = body.permanentLive || false 65 if (exists(body.permanentLive)) videoLive.permanentLive = body.permanentLive
66 if (exists(body.latencyMode)) videoLive.latencyMode = body.latencyMode
65 67
66 video.VideoLive = await videoLive.save() 68 video.VideoLive = await videoLive.save()
67 69
@@ -87,6 +89,7 @@ async function addLiveVideo (req: express.Request, res: express.Response) {
87 const videoLive = new VideoLiveModel() 89 const videoLive = new VideoLiveModel()
88 videoLive.saveReplay = videoInfo.saveReplay || false 90 videoLive.saveReplay = videoInfo.saveReplay || false
89 videoLive.permanentLive = videoInfo.permanentLive || false 91 videoLive.permanentLive = videoInfo.permanentLive || false
92 videoLive.latencyMode = videoInfo.latencyMode || LiveVideoLatencyMode.DEFAULT
90 videoLive.streamKey = buildUUID() 93 videoLive.streamKey = buildUUID()
91 94
92 const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({ 95 const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({
diff --git a/server/helpers/activitypub.ts b/server/helpers/activitypub.ts
index cbba2f51c..d0bcc6785 100644
--- a/server/helpers/activitypub.ts
+++ b/server/helpers/activitypub.ts
@@ -50,6 +50,10 @@ function getContextData (type: ContextType) {
50 '@type': 'sc:Boolean', 50 '@type': 'sc:Boolean',
51 '@id': 'pt:permanentLive' 51 '@id': 'pt:permanentLive'
52 }, 52 },
53 latencyMode: {
54 '@type': 'sc:Number',
55 '@id': 'pt:latencyMode'
56 },
53 57
54 Infohash: 'pt:Infohash', 58 Infohash: 'pt:Infohash',
55 Playlist: 'pt:Playlist', 59 Playlist: 'pt:Playlist',
diff --git a/server/helpers/custom-validators/activitypub/videos.ts b/server/helpers/custom-validators/activitypub/videos.ts
index a41d37810..80a321117 100644
--- a/server/helpers/custom-validators/activitypub/videos.ts
+++ b/server/helpers/custom-validators/activitypub/videos.ts
@@ -1,10 +1,11 @@
1import validator from 'validator' 1import validator from 'validator'
2import { logger } from '@server/helpers/logger' 2import { logger } from '@server/helpers/logger'
3import { ActivityTrackerUrlObject, ActivityVideoFileMetadataUrlObject } from '@shared/models' 3import { ActivityTrackerUrlObject, ActivityVideoFileMetadataUrlObject } from '@shared/models'
4import { VideoState } from '../../../../shared/models/videos' 4import { LiveVideoLatencyMode, VideoState } from '../../../../shared/models/videos'
5import { ACTIVITY_PUB, CONSTRAINTS_FIELDS } from '../../../initializers/constants' 5import { ACTIVITY_PUB, CONSTRAINTS_FIELDS } from '../../../initializers/constants'
6import { peertubeTruncate } from '../../core-utils' 6import { peertubeTruncate } from '../../core-utils'
7import { exists, isArray, isBooleanValid, isDateValid, isUUIDValid } from '../misc' 7import { exists, isArray, isBooleanValid, isDateValid, isUUIDValid } from '../misc'
8import { isLiveLatencyModeValid } from '../video-lives'
8import { 9import {
9 isVideoDurationValid, 10 isVideoDurationValid,
10 isVideoNameValid, 11 isVideoNameValid,
@@ -65,6 +66,7 @@ function sanitizeAndCheckVideoTorrentObject (video: any) {
65 if (!isBooleanValid(video.isLiveBroadcast)) video.isLiveBroadcast = false 66 if (!isBooleanValid(video.isLiveBroadcast)) video.isLiveBroadcast = false
66 if (!isBooleanValid(video.liveSaveReplay)) video.liveSaveReplay = false 67 if (!isBooleanValid(video.liveSaveReplay)) video.liveSaveReplay = false
67 if (!isBooleanValid(video.permanentLive)) video.permanentLive = false 68 if (!isBooleanValid(video.permanentLive)) video.permanentLive = false
69 if (!isLiveLatencyModeValid(video.latencyMode)) video.latencyMode = LiveVideoLatencyMode.DEFAULT
68 70
69 return isActivityPubUrlValid(video.id) && 71 return isActivityPubUrlValid(video.id) &&
70 isVideoNameValid(video.name) && 72 isVideoNameValid(video.name) &&
diff --git a/server/helpers/custom-validators/video-lives.ts b/server/helpers/custom-validators/video-lives.ts
new file mode 100644
index 000000000..69d08ae68
--- /dev/null
+++ b/server/helpers/custom-validators/video-lives.ts
@@ -0,0 +1,11 @@
1import { LiveVideoLatencyMode } from '@shared/models'
2
3function isLiveLatencyModeValid (value: any) {
4 return [ LiveVideoLatencyMode.DEFAULT, LiveVideoLatencyMode.SMALL_LATENCY, LiveVideoLatencyMode.HIGH_LATENCY ].includes(value)
5}
6
7// ---------------------------------------------------------------------------
8
9export {
10 isLiveLatencyModeValid
11}
diff --git a/server/helpers/ffmpeg/ffmpeg-live.ts b/server/helpers/ffmpeg/ffmpeg-live.ts
index ff571626c..fd20971eb 100644
--- a/server/helpers/ffmpeg/ffmpeg-live.ts
+++ b/server/helpers/ffmpeg/ffmpeg-live.ts
@@ -1,7 +1,7 @@
1import { FfmpegCommand, FilterSpecification } from 'fluent-ffmpeg' 1import { FfmpegCommand, FilterSpecification } from 'fluent-ffmpeg'
2import { join } from 'path' 2import { join } from 'path'
3import { VIDEO_LIVE } from '@server/initializers/constants' 3import { VIDEO_LIVE } from '@server/initializers/constants'
4import { AvailableEncoders } from '@shared/models' 4import { AvailableEncoders, LiveVideoLatencyMode } from '@shared/models'
5import { logger, loggerTagsFactory } from '../logger' 5import { logger, loggerTagsFactory } from '../logger'
6import { buildStreamSuffix, getFFmpeg, getScaleFilter, StreamType } from './ffmpeg-commons' 6import { buildStreamSuffix, getFFmpeg, getScaleFilter, StreamType } from './ffmpeg-commons'
7import { getEncoderBuilderResult } from './ffmpeg-encoders' 7import { getEncoderBuilderResult } from './ffmpeg-encoders'
@@ -15,6 +15,7 @@ async function getLiveTranscodingCommand (options: {
15 15
16 outPath: string 16 outPath: string
17 masterPlaylistName: string 17 masterPlaylistName: string
18 latencyMode: LiveVideoLatencyMode
18 19
19 resolutions: number[] 20 resolutions: number[]
20 21
@@ -26,7 +27,7 @@ async function getLiveTranscodingCommand (options: {
26 availableEncoders: AvailableEncoders 27 availableEncoders: AvailableEncoders
27 profile: string 28 profile: string
28}) { 29}) {
29 const { inputUrl, outPath, resolutions, fps, bitrate, availableEncoders, profile, masterPlaylistName, ratio } = options 30 const { inputUrl, outPath, resolutions, fps, bitrate, availableEncoders, profile, masterPlaylistName, ratio, latencyMode } = options
30 31
31 const command = getFFmpeg(inputUrl, 'live') 32 const command = getFFmpeg(inputUrl, 'live')
32 33
@@ -120,14 +121,21 @@ async function getLiveTranscodingCommand (options: {
120 121
121 command.complexFilter(complexFilter) 122 command.complexFilter(complexFilter)
122 123
123 addDefaultLiveHLSParams(command, outPath, masterPlaylistName) 124 addDefaultLiveHLSParams({ command, outPath, masterPlaylistName, latencyMode })
124 125
125 command.outputOption('-var_stream_map', varStreamMap.join(' ')) 126 command.outputOption('-var_stream_map', varStreamMap.join(' '))
126 127
127 return command 128 return command
128} 129}
129 130
130function getLiveMuxingCommand (inputUrl: string, outPath: string, masterPlaylistName: string) { 131function getLiveMuxingCommand (options: {
132 inputUrl: string
133 outPath: string
134 masterPlaylistName: string
135 latencyMode: LiveVideoLatencyMode
136}) {
137 const { inputUrl, outPath, masterPlaylistName, latencyMode } = options
138
131 const command = getFFmpeg(inputUrl, 'live') 139 const command = getFFmpeg(inputUrl, 'live')
132 140
133 command.outputOption('-c:v copy') 141 command.outputOption('-c:v copy')
@@ -135,22 +143,39 @@ function getLiveMuxingCommand (inputUrl: string, outPath: string, masterPlaylist
135 command.outputOption('-map 0:a?') 143 command.outputOption('-map 0:a?')
136 command.outputOption('-map 0:v?') 144 command.outputOption('-map 0:v?')
137 145
138 addDefaultLiveHLSParams(command, outPath, masterPlaylistName) 146 addDefaultLiveHLSParams({ command, outPath, masterPlaylistName, latencyMode })
139 147
140 return command 148 return command
141} 149}
142 150
151function getLiveSegmentTime (latencyMode: LiveVideoLatencyMode) {
152 if (latencyMode === LiveVideoLatencyMode.SMALL_LATENCY) {
153 return VIDEO_LIVE.SEGMENT_TIME_SECONDS.SMALL_LATENCY
154 }
155
156 return VIDEO_LIVE.SEGMENT_TIME_SECONDS.DEFAULT_LATENCY
157}
158
143// --------------------------------------------------------------------------- 159// ---------------------------------------------------------------------------
144 160
145export { 161export {
162 getLiveSegmentTime,
163
146 getLiveTranscodingCommand, 164 getLiveTranscodingCommand,
147 getLiveMuxingCommand 165 getLiveMuxingCommand
148} 166}
149 167
150// --------------------------------------------------------------------------- 168// ---------------------------------------------------------------------------
151 169
152function addDefaultLiveHLSParams (command: FfmpegCommand, outPath: string, masterPlaylistName: string) { 170function addDefaultLiveHLSParams (options: {
153 command.outputOption('-hls_time ' + VIDEO_LIVE.SEGMENT_TIME_SECONDS) 171 command: FfmpegCommand
172 outPath: string
173 masterPlaylistName: string
174 latencyMode: LiveVideoLatencyMode
175}) {
176 const { command, outPath, masterPlaylistName, latencyMode } = options
177
178 command.outputOption('-hls_time ' + getLiveSegmentTime(latencyMode))
154 command.outputOption('-hls_list_size ' + VIDEO_LIVE.SEGMENTS_LIST_SIZE) 179 command.outputOption('-hls_list_size ' + VIDEO_LIVE.SEGMENTS_LIST_SIZE)
155 command.outputOption('-hls_flags delete_segments+independent_segments') 180 command.outputOption('-hls_flags delete_segments+independent_segments')
156 command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`) 181 command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`)
diff --git a/server/initializers/checker-before-init.ts b/server/initializers/checker-before-init.ts
index 10dd98f43..fa311f708 100644
--- a/server/initializers/checker-before-init.ts
+++ b/server/initializers/checker-before-init.ts
@@ -49,8 +49,8 @@ function checkMissedConfig () {
49 'peertube.check_latest_version.enabled', 'peertube.check_latest_version.url', 49 'peertube.check_latest_version.enabled', 'peertube.check_latest_version.url',
50 'search.remote_uri.users', 'search.remote_uri.anonymous', 'search.search_index.enabled', 'search.search_index.url', 50 'search.remote_uri.users', 'search.remote_uri.anonymous', 'search.search_index.enabled', 'search.search_index.url',
51 'search.search_index.disable_local_search', 'search.search_index.is_default_search', 51 'search.search_index.disable_local_search', 'search.search_index.is_default_search',
52 'live.enabled', 'live.allow_replay', 'live.max_duration', 'live.max_user_lives', 'live.max_instance_lives', 52 'live.enabled', 'live.allow_replay', 'live.latency_setting.enabled', 'live.max_duration',
53 'live.rtmp.enabled', 'live.rtmp.port', 'live.rtmp.hostname', 53 'live.max_user_lives', 'live.max_instance_lives', 'live.rtmp.enabled', 'live.rtmp.port', 'live.rtmp.hostname',
54 'live.rtmps.enabled', 'live.rtmps.port', 'live.rtmps.hostname', 'live.rtmps.key_file', 'live.rtmps.cert_file', 54 'live.rtmps.enabled', 'live.rtmps.port', 'live.rtmps.hostname', 'live.rtmps.key_file', 'live.rtmps.cert_file',
55 'live.transcoding.enabled', 'live.transcoding.threads', 'live.transcoding.profile', 55 'live.transcoding.enabled', 'live.transcoding.threads', 'live.transcoding.profile',
56 'live.transcoding.resolutions.144p', 'live.transcoding.resolutions.240p', 'live.transcoding.resolutions.360p', 56 'live.transcoding.resolutions.144p', 'live.transcoding.resolutions.240p', 'live.transcoding.resolutions.360p',
diff --git a/server/initializers/config.ts b/server/initializers/config.ts
index 7a13a1368..6dcca9b67 100644
--- a/server/initializers/config.ts
+++ b/server/initializers/config.ts
@@ -4,9 +4,9 @@ import { dirname, join } from 'path'
4import { decacheModule } from '@server/helpers/decache' 4import { decacheModule } from '@server/helpers/decache'
5import { VideoRedundancyConfigFilter } from '@shared/models/redundancy/video-redundancy-config-filter.type' 5import { VideoRedundancyConfigFilter } from '@shared/models/redundancy/video-redundancy-config-filter.type'
6import { BroadcastMessageLevel } from '@shared/models/server' 6import { BroadcastMessageLevel } from '@shared/models/server'
7import { buildPath, root } from '../../shared/core-utils'
7import { VideoPrivacy, VideosRedundancyStrategy } from '../../shared/models' 8import { VideoPrivacy, VideosRedundancyStrategy } from '../../shared/models'
8import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type' 9import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type'
9import { buildPath, root } from '../../shared/core-utils'
10import { parseBytes, parseDurationToMs } from '../helpers/core-utils' 10import { parseBytes, parseDurationToMs } from '../helpers/core-utils'
11 11
12// Use a variable to reload the configuration if we need 12// Use a variable to reload the configuration if we need
@@ -296,6 +296,10 @@ const CONFIG = {
296 296
297 get ALLOW_REPLAY () { return config.get<boolean>('live.allow_replay') }, 297 get ALLOW_REPLAY () { return config.get<boolean>('live.allow_replay') },
298 298
299 LATENCY_SETTING: {
300 get ENABLED () { return config.get<boolean>('live.latency_setting.enabled') }
301 },
302
299 RTMP: { 303 RTMP: {
300 get ENABLED () { return config.get<boolean>('live.rtmp.enabled') }, 304 get ENABLED () { return config.get<boolean>('live.rtmp.enabled') },
301 get PORT () { return config.get<number>('live.rtmp.port') }, 305 get PORT () { return config.get<number>('live.rtmp.port') },
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 7bc2877aa..1c849b561 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -24,7 +24,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
24 24
25// --------------------------------------------------------------------------- 25// ---------------------------------------------------------------------------
26 26
27const LAST_MIGRATION_VERSION = 685 27const LAST_MIGRATION_VERSION = 690
28 28
29// --------------------------------------------------------------------------- 29// ---------------------------------------------------------------------------
30 30
@@ -700,7 +700,10 @@ const RESUMABLE_UPLOAD_SESSION_LIFETIME = SCHEDULER_INTERVALS_MS.REMOVE_DANGLING
700const VIDEO_LIVE = { 700const VIDEO_LIVE = {
701 EXTENSION: '.ts', 701 EXTENSION: '.ts',
702 CLEANUP_DELAY: 1000 * 60 * 5, // 5 minutes 702 CLEANUP_DELAY: 1000 * 60 * 5, // 5 minutes
703 SEGMENT_TIME_SECONDS: 4, // 4 seconds 703 SEGMENT_TIME_SECONDS: {
704 DEFAULT_LATENCY: 4, // 4 seconds
705 SMALL_LATENCY: 2 // 2 seconds
706 },
704 SEGMENTS_LIST_SIZE: 15, // 15 maximum segments in live playlist 707 SEGMENTS_LIST_SIZE: 15, // 15 maximum segments in live playlist
705 REPLAY_DIRECTORY: 'replay', 708 REPLAY_DIRECTORY: 'replay',
706 EDGE_LIVE_DELAY_SEGMENTS_NOTIFICATION: 4, 709 EDGE_LIVE_DELAY_SEGMENTS_NOTIFICATION: 4,
@@ -842,7 +845,8 @@ if (isTestInstance() === true) {
842 PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME = 5000 845 PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME = 5000
843 846
844 VIDEO_LIVE.CLEANUP_DELAY = 5000 847 VIDEO_LIVE.CLEANUP_DELAY = 5000
845 VIDEO_LIVE.SEGMENT_TIME_SECONDS = 2 848 VIDEO_LIVE.SEGMENT_TIME_SECONDS.DEFAULT_LATENCY = 2
849 VIDEO_LIVE.SEGMENT_TIME_SECONDS.SMALL_LATENCY = 1
846 VIDEO_LIVE.EDGE_LIVE_DELAY_SEGMENTS_NOTIFICATION = 1 850 VIDEO_LIVE.EDGE_LIVE_DELAY_SEGMENTS_NOTIFICATION = 1
847} 851}
848 852
diff --git a/server/initializers/migrations/0690-live-latency-mode.ts b/server/initializers/migrations/0690-live-latency-mode.ts
new file mode 100644
index 000000000..c31a61364
--- /dev/null
+++ b/server/initializers/migrations/0690-live-latency-mode.ts
@@ -0,0 +1,35 @@
1import { LiveVideoLatencyMode } from '@shared/models'
2import * as Sequelize from 'sequelize'
3
4async function up (utils: {
5 transaction: Sequelize.Transaction
6 queryInterface: Sequelize.QueryInterface
7 sequelize: Sequelize.Sequelize
8 db: any
9}): Promise<void> {
10 await utils.queryInterface.addColumn('videoLive', 'latencyMode', {
11 type: Sequelize.INTEGER,
12 defaultValue: null,
13 allowNull: true
14 }, { transaction: utils.transaction })
15
16 {
17 const query = `UPDATE "videoLive" SET "latencyMode" = ${LiveVideoLatencyMode.DEFAULT}`
18 await utils.sequelize.query(query, { type: Sequelize.QueryTypes.UPDATE, transaction: utils.transaction })
19 }
20
21 await utils.queryInterface.changeColumn('videoLive', 'latencyMode', {
22 type: Sequelize.INTEGER,
23 defaultValue: null,
24 allowNull: false
25 }, { transaction: utils.transaction })
26}
27
28function down () {
29 throw new Error('Not implemented.')
30}
31
32export {
33 up,
34 down
35}
diff --git a/server/lib/activitypub/videos/shared/object-to-model-attributes.ts b/server/lib/activitypub/videos/shared/object-to-model-attributes.ts
index 1e1479869..c97217669 100644
--- a/server/lib/activitypub/videos/shared/object-to-model-attributes.ts
+++ b/server/lib/activitypub/videos/shared/object-to-model-attributes.ts
@@ -151,6 +151,7 @@ function getLiveAttributesFromObject (video: MVideoId, videoObject: VideoObject)
151 return { 151 return {
152 saveReplay: videoObject.liveSaveReplay, 152 saveReplay: videoObject.liveSaveReplay,
153 permanentLive: videoObject.permanentLive, 153 permanentLive: videoObject.permanentLive,
154 latencyMode: videoObject.latencyMode,
154 videoId: video.id 155 videoId: video.id
155 } 156 }
156} 157}
diff --git a/server/lib/live/live-manager.ts b/server/lib/live/live-manager.ts
index 21c34a9a4..920d3a5ec 100644
--- a/server/lib/live/live-manager.ts
+++ b/server/lib/live/live-manager.ts
@@ -5,9 +5,10 @@ import { createServer as createServerTLS, Server as ServerTLS } from 'tls'
5import { 5import {
6 computeLowerResolutionsToTranscode, 6 computeLowerResolutionsToTranscode,
7 ffprobePromise, 7 ffprobePromise,
8 getLiveSegmentTime,
8 getVideoStreamBitrate, 9 getVideoStreamBitrate,
9 getVideoStreamFPS, 10 getVideoStreamDimensionsInfo,
10 getVideoStreamDimensionsInfo 11 getVideoStreamFPS
11} from '@server/helpers/ffmpeg' 12} from '@server/helpers/ffmpeg'
12import { logger, loggerTagsFactory } from '@server/helpers/logger' 13import { logger, loggerTagsFactory } from '@server/helpers/logger'
13import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config' 14import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config'
@@ -353,7 +354,7 @@ class LiveManager {
353 .catch(err => logger.error('Cannot federate live video %s.', video.url, { err, ...localLTags })) 354 .catch(err => logger.error('Cannot federate live video %s.', video.url, { err, ...localLTags }))
354 355
355 PeerTubeSocket.Instance.sendVideoLiveNewState(video) 356 PeerTubeSocket.Instance.sendVideoLiveNewState(video)
356 }, VIDEO_LIVE.SEGMENT_TIME_SECONDS * 1000 * VIDEO_LIVE.EDGE_LIVE_DELAY_SEGMENTS_NOTIFICATION) 357 }, getLiveSegmentTime(live.latencyMode) * 1000 * VIDEO_LIVE.EDGE_LIVE_DELAY_SEGMENTS_NOTIFICATION)
357 } catch (err) { 358 } catch (err) {
358 logger.error('Cannot save/federate live video %d.', videoId, { err, ...localLTags }) 359 logger.error('Cannot save/federate live video %d.', videoId, { err, ...localLTags })
359 } 360 }
diff --git a/server/lib/live/shared/muxing-session.ts b/server/lib/live/shared/muxing-session.ts
index f5f473039..a703f5b5f 100644
--- a/server/lib/live/shared/muxing-session.ts
+++ b/server/lib/live/shared/muxing-session.ts
@@ -125,6 +125,8 @@ class MuxingSession extends EventEmitter {
125 outPath, 125 outPath,
126 masterPlaylistName: this.streamingPlaylist.playlistFilename, 126 masterPlaylistName: this.streamingPlaylist.playlistFilename,
127 127
128 latencyMode: this.videoLive.latencyMode,
129
128 resolutions: this.allResolutions, 130 resolutions: this.allResolutions,
129 fps: this.fps, 131 fps: this.fps,
130 bitrate: this.bitrate, 132 bitrate: this.bitrate,
@@ -133,7 +135,12 @@ class MuxingSession extends EventEmitter {
133 availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(), 135 availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
134 profile: CONFIG.LIVE.TRANSCODING.PROFILE 136 profile: CONFIG.LIVE.TRANSCODING.PROFILE
135 }) 137 })
136 : getLiveMuxingCommand(this.inputUrl, outPath, this.streamingPlaylist.playlistFilename) 138 : getLiveMuxingCommand({
139 inputUrl: this.inputUrl,
140 outPath,
141 masterPlaylistName: this.streamingPlaylist.playlistFilename,
142 latencyMode: this.videoLive.latencyMode
143 })
137 144
138 logger.info('Running live muxing/transcoding for %s.', this.videoUUID, this.lTags()) 145 logger.info('Running live muxing/transcoding for %s.', this.videoUUID, this.lTags())
139 146
diff --git a/server/lib/server-config-manager.ts b/server/lib/server-config-manager.ts
index 43ca2332b..744186cfc 100644
--- a/server/lib/server-config-manager.ts
+++ b/server/lib/server-config-manager.ts
@@ -137,6 +137,10 @@ class ServerConfigManager {
137 enabled: CONFIG.LIVE.ENABLED, 137 enabled: CONFIG.LIVE.ENABLED,
138 138
139 allowReplay: CONFIG.LIVE.ALLOW_REPLAY, 139 allowReplay: CONFIG.LIVE.ALLOW_REPLAY,
140 latencySetting: {
141 enabled: CONFIG.LIVE.LATENCY_SETTING.ENABLED
142 },
143
140 maxDuration: CONFIG.LIVE.MAX_DURATION, 144 maxDuration: CONFIG.LIVE.MAX_DURATION,
141 maxInstanceLives: CONFIG.LIVE.MAX_INSTANCE_LIVES, 145 maxInstanceLives: CONFIG.LIVE.MAX_INSTANCE_LIVES,
142 maxUserLives: CONFIG.LIVE.MAX_USER_LIVES, 146 maxUserLives: CONFIG.LIVE.MAX_USER_LIVES,
diff --git a/server/middlewares/validators/videos/video-live.ts b/server/middlewares/validators/videos/video-live.ts
index 6c7601e05..8e52c953f 100644
--- a/server/middlewares/validators/videos/video-live.ts
+++ b/server/middlewares/validators/videos/video-live.ts
@@ -1,12 +1,21 @@
1import express from 'express' 1import express from 'express'
2import { body } from 'express-validator' 2import { body } from 'express-validator'
3import { isLiveLatencyModeValid } from '@server/helpers/custom-validators/video-lives'
3import { CONSTRAINTS_FIELDS } from '@server/initializers/constants' 4import { CONSTRAINTS_FIELDS } from '@server/initializers/constants'
4import { isLocalLiveVideoAccepted } from '@server/lib/moderation' 5import { isLocalLiveVideoAccepted } from '@server/lib/moderation'
5import { Hooks } from '@server/lib/plugins/hooks' 6import { Hooks } from '@server/lib/plugins/hooks'
6import { VideoModel } from '@server/models/video/video' 7import { VideoModel } from '@server/models/video/video'
7import { VideoLiveModel } from '@server/models/video/video-live' 8import { VideoLiveModel } from '@server/models/video/video-live'
8import { HttpStatusCode, ServerErrorCode, UserRight, VideoState } from '@shared/models' 9import {
9import { isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc' 10 HttpStatusCode,
11 LiveVideoCreate,
12 LiveVideoLatencyMode,
13 LiveVideoUpdate,
14 ServerErrorCode,
15 UserRight,
16 VideoState
17} from '@shared/models'
18import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc'
10import { isVideoNameValid } from '../../../helpers/custom-validators/videos' 19import { isVideoNameValid } from '../../../helpers/custom-validators/videos'
11import { cleanUpReqFiles } from '../../../helpers/express-utils' 20import { cleanUpReqFiles } from '../../../helpers/express-utils'
12import { logger } from '../../../helpers/logger' 21import { logger } from '../../../helpers/logger'
@@ -67,6 +76,12 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
67 .customSanitizer(toBooleanOrNull) 76 .customSanitizer(toBooleanOrNull)
68 .custom(isBooleanValid).withMessage('Should have a valid permanentLive attribute'), 77 .custom(isBooleanValid).withMessage('Should have a valid permanentLive attribute'),
69 78
79 body('latencyMode')
80 .optional()
81 .customSanitizer(toIntOrNull)
82 .custom(isLiveLatencyModeValid)
83 .withMessage('Should have a valid latency mode attribute'),
84
70 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 85 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
71 logger.debug('Checking videoLiveAddValidator parameters', { parameters: req.body }) 86 logger.debug('Checking videoLiveAddValidator parameters', { parameters: req.body })
72 87
@@ -82,7 +97,9 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
82 }) 97 })
83 } 98 }
84 99
85 if (CONFIG.LIVE.ALLOW_REPLAY !== true && req.body.saveReplay === true) { 100 const body: LiveVideoCreate = req.body
101
102 if (hasValidSaveReplay(body) !== true) {
86 cleanUpReqFiles(req) 103 cleanUpReqFiles(req)
87 104
88 return res.fail({ 105 return res.fail({
@@ -92,14 +109,23 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
92 }) 109 })
93 } 110 }
94 111
95 if (req.body.permanentLive && req.body.saveReplay) { 112 if (hasValidLatencyMode(body) !== true) {
113 cleanUpReqFiles(req)
114
115 return res.fail({
116 status: HttpStatusCode.FORBIDDEN_403,
117 message: 'Custom latency mode is not allowed by this instance'
118 })
119 }
120
121 if (body.permanentLive && body.saveReplay) {
96 cleanUpReqFiles(req) 122 cleanUpReqFiles(req)
97 123
98 return res.fail({ message: 'Cannot set this live as permanent while saving its replay' }) 124 return res.fail({ message: 'Cannot set this live as permanent while saving its replay' })
99 } 125 }
100 126
101 const user = res.locals.oauth.token.User 127 const user = res.locals.oauth.token.User
102 if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req) 128 if (!await doesVideoChannelOfAccountExist(body.channelId, user, res)) return cleanUpReqFiles(req)
103 129
104 if (CONFIG.LIVE.MAX_INSTANCE_LIVES !== -1) { 130 if (CONFIG.LIVE.MAX_INSTANCE_LIVES !== -1) {
105 const totalInstanceLives = await VideoModel.countLocalLives() 131 const totalInstanceLives = await VideoModel.countLocalLives()
@@ -141,19 +167,34 @@ const videoLiveUpdateValidator = [
141 .customSanitizer(toBooleanOrNull) 167 .customSanitizer(toBooleanOrNull)
142 .custom(isBooleanValid).withMessage('Should have a valid saveReplay attribute'), 168 .custom(isBooleanValid).withMessage('Should have a valid saveReplay attribute'),
143 169
170 body('latencyMode')
171 .optional()
172 .customSanitizer(toIntOrNull)
173 .custom(isLiveLatencyModeValid)
174 .withMessage('Should have a valid latency mode attribute'),
175
144 (req: express.Request, res: express.Response, next: express.NextFunction) => { 176 (req: express.Request, res: express.Response, next: express.NextFunction) => {
145 logger.debug('Checking videoLiveUpdateValidator parameters', { parameters: req.body }) 177 logger.debug('Checking videoLiveUpdateValidator parameters', { parameters: req.body })
146 178
147 if (areValidationErrors(req, res)) return 179 if (areValidationErrors(req, res)) return
148 180
149 if (req.body.permanentLive && req.body.saveReplay) { 181 const body: LiveVideoUpdate = req.body
182
183 if (body.permanentLive && body.saveReplay) {
150 return res.fail({ message: 'Cannot set this live as permanent while saving its replay' }) 184 return res.fail({ message: 'Cannot set this live as permanent while saving its replay' })
151 } 185 }
152 186
153 if (CONFIG.LIVE.ALLOW_REPLAY !== true && req.body.saveReplay === true) { 187 if (hasValidSaveReplay(body) !== true) {
154 return res.fail({ 188 return res.fail({
155 status: HttpStatusCode.FORBIDDEN_403, 189 status: HttpStatusCode.FORBIDDEN_403,
156 message: 'Saving live replay is not allowed instance' 190 message: 'Saving live replay is not allowed by this instance'
191 })
192 }
193
194 if (hasValidLatencyMode(body) !== true) {
195 return res.fail({
196 status: HttpStatusCode.FORBIDDEN_403,
197 message: 'Custom latency mode is not allowed by this instance'
157 }) 198 })
158 } 199 }
159 200
@@ -203,3 +244,19 @@ async function isLiveVideoAccepted (req: express.Request, res: express.Response)
203 244
204 return true 245 return true
205} 246}
247
248function hasValidSaveReplay (body: LiveVideoUpdate | LiveVideoCreate) {
249 if (CONFIG.LIVE.ALLOW_REPLAY !== true && body.saveReplay === true) return false
250
251 return true
252}
253
254function hasValidLatencyMode (body: LiveVideoUpdate | LiveVideoCreate) {
255 if (
256 CONFIG.LIVE.LATENCY_SETTING.ENABLED !== true &&
257 exists(body.latencyMode) &&
258 body.latencyMode !== LiveVideoLatencyMode.DEFAULT
259 ) return false
260
261 return true
262}
diff --git a/server/models/video/formatter/video-format-utils.ts b/server/models/video/formatter/video-format-utils.ts
index 7456f37c5..611edf0b9 100644
--- a/server/models/video/formatter/video-format-utils.ts
+++ b/server/models/video/formatter/video-format-utils.ts
@@ -411,15 +411,6 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
411 views: video.views, 411 views: video.views,
412 sensitive: video.nsfw, 412 sensitive: video.nsfw,
413 waitTranscoding: video.waitTranscoding, 413 waitTranscoding: video.waitTranscoding,
414 isLiveBroadcast: video.isLive,
415
416 liveSaveReplay: video.isLive
417 ? video.VideoLive.saveReplay
418 : null,
419
420 permanentLive: video.isLive
421 ? video.VideoLive.permanentLive
422 : null,
423 414
424 state: video.state, 415 state: video.state,
425 commentsEnabled: video.commentsEnabled, 416 commentsEnabled: video.commentsEnabled,
@@ -431,10 +422,13 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
431 : null, 422 : null,
432 423
433 updated: video.updatedAt.toISOString(), 424 updated: video.updatedAt.toISOString(),
425
434 mediaType: 'text/markdown', 426 mediaType: 'text/markdown',
435 content: video.description, 427 content: video.description,
436 support: video.support, 428 support: video.support,
429
437 subtitleLanguage, 430 subtitleLanguage,
431
438 icon: icons.map(i => ({ 432 icon: icons.map(i => ({
439 type: 'Image', 433 type: 'Image',
440 url: i.getFileUrl(video), 434 url: i.getFileUrl(video),
@@ -442,11 +436,14 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
442 width: i.width, 436 width: i.width,
443 height: i.height 437 height: i.height
444 })), 438 })),
439
445 url, 440 url,
441
446 likes: getLocalVideoLikesActivityPubUrl(video), 442 likes: getLocalVideoLikesActivityPubUrl(video),
447 dislikes: getLocalVideoDislikesActivityPubUrl(video), 443 dislikes: getLocalVideoDislikesActivityPubUrl(video),
448 shares: getLocalVideoSharesActivityPubUrl(video), 444 shares: getLocalVideoSharesActivityPubUrl(video),
449 comments: getLocalVideoCommentsActivityPubUrl(video), 445 comments: getLocalVideoCommentsActivityPubUrl(video),
446
450 attributedTo: [ 447 attributedTo: [
451 { 448 {
452 type: 'Person', 449 type: 'Person',
@@ -456,7 +453,9 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
456 type: 'Group', 453 type: 'Group',
457 id: video.VideoChannel.Actor.url 454 id: video.VideoChannel.Actor.url
458 } 455 }
459 ] 456 ],
457
458 ...buildLiveAPAttributes(video)
460 } 459 }
461} 460}
462 461
@@ -500,3 +499,23 @@ export {
500 getPrivacyLabel, 499 getPrivacyLabel,
501 getStateLabel 500 getStateLabel
502} 501}
502
503// ---------------------------------------------------------------------------
504
505function buildLiveAPAttributes (video: MVideoAP) {
506 if (!video.isLive) {
507 return {
508 isLiveBroadcast: false,
509 liveSaveReplay: null,
510 permanentLive: null,
511 latencyMode: null
512 }
513 }
514
515 return {
516 isLiveBroadcast: true,
517 liveSaveReplay: video.VideoLive.saveReplay,
518 permanentLive: video.VideoLive.permanentLive,
519 latencyMode: video.VideoLive.latencyMode
520 }
521}
diff --git a/server/models/video/sql/video/shared/video-table-attributes.ts b/server/models/video/sql/video/shared/video-table-attributes.ts
index f4d9e99fd..e2c1c0f6d 100644
--- a/server/models/video/sql/video/shared/video-table-attributes.ts
+++ b/server/models/video/sql/video/shared/video-table-attributes.ts
@@ -158,6 +158,7 @@ export class VideoTableAttributes {
158 'streamKey', 158 'streamKey',
159 'saveReplay', 159 'saveReplay',
160 'permanentLive', 160 'permanentLive',
161 'latencyMode',
161 'videoId', 162 'videoId',
162 'createdAt', 163 'createdAt',
163 'updatedAt' 164 'updatedAt'
diff --git a/server/models/video/video-live.ts b/server/models/video/video-live.ts
index e3fdcc0ba..904f712b4 100644
--- a/server/models/video/video-live.ts
+++ b/server/models/video/video-live.ts
@@ -1,11 +1,11 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, DefaultScope, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' 1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, DefaultScope, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { CONFIG } from '@server/initializers/config'
2import { WEBSERVER } from '@server/initializers/constants' 3import { WEBSERVER } from '@server/initializers/constants'
3import { MVideoLive, MVideoLiveVideo } from '@server/types/models' 4import { MVideoLive, MVideoLiveVideo } from '@server/types/models'
5import { LiveVideo, LiveVideoLatencyMode, VideoState } from '@shared/models'
4import { AttributesOnly } from '@shared/typescript-utils' 6import { AttributesOnly } from '@shared/typescript-utils'
5import { LiveVideo, VideoState } from '@shared/models'
6import { VideoModel } from './video' 7import { VideoModel } from './video'
7import { VideoBlacklistModel } from './video-blacklist' 8import { VideoBlacklistModel } from './video-blacklist'
8import { CONFIG } from '@server/initializers/config'
9 9
10@DefaultScope(() => ({ 10@DefaultScope(() => ({
11 include: [ 11 include: [
@@ -44,6 +44,10 @@ export class VideoLiveModel extends Model<Partial<AttributesOnly<VideoLiveModel>
44 @Column 44 @Column
45 permanentLive: boolean 45 permanentLive: boolean
46 46
47 @AllowNull(false)
48 @Column
49 latencyMode: LiveVideoLatencyMode
50
47 @CreatedAt 51 @CreatedAt
48 createdAt: Date 52 createdAt: Date
49 53
@@ -113,7 +117,8 @@ export class VideoLiveModel extends Model<Partial<AttributesOnly<VideoLiveModel>
113 117
114 streamKey: this.streamKey, 118 streamKey: this.streamKey,
115 permanentLive: this.permanentLive, 119 permanentLive: this.permanentLive,
116 saveReplay: this.saveReplay 120 saveReplay: this.saveReplay,
121 latencyMode: this.latencyMode
117 } 122 }
118 } 123 }
119} 124}
diff --git a/server/tests/api/check-params/config.ts b/server/tests/api/check-params/config.ts
index ce067a892..900f642c2 100644
--- a/server/tests/api/check-params/config.ts
+++ b/server/tests/api/check-params/config.ts
@@ -125,6 +125,9 @@ describe('Test config API validators', function () {
125 enabled: true, 125 enabled: true,
126 126
127 allowReplay: false, 127 allowReplay: false,
128 latencySetting: {
129 enabled: false
130 },
128 maxDuration: 30, 131 maxDuration: 30,
129 maxInstanceLives: -1, 132 maxInstanceLives: -1,
130 maxUserLives: 50, 133 maxUserLives: 50,
diff --git a/server/tests/api/check-params/live.ts b/server/tests/api/check-params/live.ts
index 8aee6164c..b253f5e20 100644
--- a/server/tests/api/check-params/live.ts
+++ b/server/tests/api/check-params/live.ts
@@ -3,7 +3,7 @@
3import 'mocha' 3import 'mocha'
4import { omit } from 'lodash' 4import { omit } from 'lodash'
5import { buildAbsoluteFixturePath } from '@shared/core-utils' 5import { buildAbsoluteFixturePath } from '@shared/core-utils'
6import { HttpStatusCode, VideoCreateResult, VideoPrivacy } from '@shared/models' 6import { HttpStatusCode, LiveVideoLatencyMode, VideoCreateResult, VideoPrivacy } from '@shared/models'
7import { 7import {
8 cleanupTests, 8 cleanupTests,
9 createSingleServer, 9 createSingleServer,
@@ -38,6 +38,9 @@ describe('Test video lives API validator', function () {
38 newConfig: { 38 newConfig: {
39 live: { 39 live: {
40 enabled: true, 40 enabled: true,
41 latencySetting: {
42 enabled: false
43 },
41 maxInstanceLives: 20, 44 maxInstanceLives: 20,
42 maxUserLives: 20, 45 maxUserLives: 20,
43 allowReplay: true 46 allowReplay: true
@@ -81,7 +84,8 @@ describe('Test video lives API validator', function () {
81 privacy: VideoPrivacy.PUBLIC, 84 privacy: VideoPrivacy.PUBLIC,
82 channelId, 85 channelId,
83 saveReplay: false, 86 saveReplay: false,
84 permanentLive: false 87 permanentLive: false,
88 latencyMode: LiveVideoLatencyMode.DEFAULT
85 } 89 }
86 }) 90 })
87 91
@@ -214,6 +218,18 @@ describe('Test video lives API validator', function () {
214 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) 218 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
215 }) 219 })
216 220
221 it('Should fail with bad latency setting', async function () {
222 const fields = { ...baseCorrectParams, latencyMode: 42 }
223
224 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
225 })
226
227 it('Should fail to set latency if the server does not allow it', async function () {
228 const fields = { ...baseCorrectParams, latencyMode: LiveVideoLatencyMode.HIGH_LATENCY }
229
230 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
231 })
232
217 it('Should succeed with the correct parameters', async function () { 233 it('Should succeed with the correct parameters', async function () {
218 this.timeout(30000) 234 this.timeout(30000)
219 235
@@ -393,6 +409,18 @@ describe('Test video lives API validator', function () {
393 await command.update({ videoId: video.id, fields, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) 409 await command.update({ videoId: video.id, fields, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
394 }) 410 })
395 411
412 it('Should fail with bad latency setting', async function () {
413 const fields = { latencyMode: 42 }
414
415 await command.update({ videoId: video.id, fields, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
416 })
417
418 it('Should fail to set latency if the server does not allow it', async function () {
419 const fields = { latencyMode: LiveVideoLatencyMode.HIGH_LATENCY }
420
421 await command.update({ videoId: video.id, fields, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
422 })
423
396 it('Should succeed with the correct params', async function () { 424 it('Should succeed with the correct params', async function () {
397 await command.update({ videoId: video.id, fields: { saveReplay: false } }) 425 await command.update({ videoId: video.id, fields: { saveReplay: false } })
398 await command.update({ videoId: video.uuid, fields: { saveReplay: false } }) 426 await command.update({ videoId: video.uuid, fields: { saveReplay: false } })
diff --git a/server/tests/api/live/live.ts b/server/tests/api/live/live.ts
index d756a02c1..aeb039696 100644
--- a/server/tests/api/live/live.ts
+++ b/server/tests/api/live/live.ts
@@ -10,6 +10,7 @@ import {
10 HttpStatusCode, 10 HttpStatusCode,
11 LiveVideo, 11 LiveVideo,
12 LiveVideoCreate, 12 LiveVideoCreate,
13 LiveVideoLatencyMode,
13 VideoDetails, 14 VideoDetails,
14 VideoPrivacy, 15 VideoPrivacy,
15 VideoState, 16 VideoState,
@@ -52,6 +53,9 @@ describe('Test live', function () {
52 live: { 53 live: {
53 enabled: true, 54 enabled: true,
54 allowReplay: true, 55 allowReplay: true,
56 latencySetting: {
57 enabled: true
58 },
55 transcoding: { 59 transcoding: {
56 enabled: false 60 enabled: false
57 } 61 }
@@ -85,6 +89,7 @@ describe('Test live', function () {
85 commentsEnabled: false, 89 commentsEnabled: false,
86 downloadEnabled: false, 90 downloadEnabled: false,
87 saveReplay: true, 91 saveReplay: true,
92 latencyMode: LiveVideoLatencyMode.SMALL_LATENCY,
88 privacy: VideoPrivacy.PUBLIC, 93 privacy: VideoPrivacy.PUBLIC,
89 previewfile: 'video_short1-preview.webm.jpg', 94 previewfile: 'video_short1-preview.webm.jpg',
90 thumbnailfile: 'video_short1.webm.jpg' 95 thumbnailfile: 'video_short1.webm.jpg'
@@ -131,6 +136,7 @@ describe('Test live', function () {
131 } 136 }
132 137
133 expect(live.saveReplay).to.be.true 138 expect(live.saveReplay).to.be.true
139 expect(live.latencyMode).to.equal(LiveVideoLatencyMode.SMALL_LATENCY)
134 } 140 }
135 }) 141 })
136 142
@@ -175,7 +181,7 @@ describe('Test live', function () {
175 it('Should update the live', async function () { 181 it('Should update the live', async function () {
176 this.timeout(10000) 182 this.timeout(10000)
177 183
178 await commands[0].update({ videoId: liveVideoUUID, fields: { saveReplay: false } }) 184 await commands[0].update({ videoId: liveVideoUUID, fields: { saveReplay: false, latencyMode: LiveVideoLatencyMode.DEFAULT } })
179 await waitJobs(servers) 185 await waitJobs(servers)
180 }) 186 })
181 187
@@ -192,6 +198,7 @@ describe('Test live', function () {
192 } 198 }
193 199
194 expect(live.saveReplay).to.be.false 200 expect(live.saveReplay).to.be.false
201 expect(live.latencyMode).to.equal(LiveVideoLatencyMode.DEFAULT)
195 } 202 }
196 }) 203 })
197 204
diff --git a/server/tests/api/server/config.ts b/server/tests/api/server/config.ts
index 565b2953a..5028b65e6 100644
--- a/server/tests/api/server/config.ts
+++ b/server/tests/api/server/config.ts
@@ -82,6 +82,7 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
82 82
83 expect(data.live.enabled).to.be.false 83 expect(data.live.enabled).to.be.false
84 expect(data.live.allowReplay).to.be.false 84 expect(data.live.allowReplay).to.be.false
85 expect(data.live.latencySetting.enabled).to.be.true
85 expect(data.live.maxDuration).to.equal(-1) 86 expect(data.live.maxDuration).to.equal(-1)
86 expect(data.live.maxInstanceLives).to.equal(20) 87 expect(data.live.maxInstanceLives).to.equal(20)
87 expect(data.live.maxUserLives).to.equal(3) 88 expect(data.live.maxUserLives).to.equal(3)
@@ -185,6 +186,7 @@ function checkUpdatedConfig (data: CustomConfig) {
185 186
186 expect(data.live.enabled).to.be.true 187 expect(data.live.enabled).to.be.true
187 expect(data.live.allowReplay).to.be.true 188 expect(data.live.allowReplay).to.be.true
189 expect(data.live.latencySetting.enabled).to.be.false
188 expect(data.live.maxDuration).to.equal(5000) 190 expect(data.live.maxDuration).to.equal(5000)
189 expect(data.live.maxInstanceLives).to.equal(-1) 191 expect(data.live.maxInstanceLives).to.equal(-1)
190 expect(data.live.maxUserLives).to.equal(10) 192 expect(data.live.maxUserLives).to.equal(10)
@@ -326,6 +328,9 @@ const newCustomConfig: CustomConfig = {
326 live: { 328 live: {
327 enabled: true, 329 enabled: true,
328 allowReplay: true, 330 allowReplay: true,
331 latencySetting: {
332 enabled: false
333 },
329 maxDuration: 5000, 334 maxDuration: 5000,
330 maxInstanceLives: -1, 335 maxInstanceLives: -1,
331 maxUserLives: 10, 336 maxUserLives: 10,
diff --git a/shared/models/activitypub/objects/video-torrent-object.ts b/shared/models/activitypub/objects/video-torrent-object.ts
index 9faa3bb87..23d54bdbd 100644
--- a/shared/models/activitypub/objects/video-torrent-object.ts
+++ b/shared/models/activitypub/objects/video-torrent-object.ts
@@ -5,7 +5,7 @@ import {
5 ActivityTagObject, 5 ActivityTagObject,
6 ActivityUrlObject 6 ActivityUrlObject
7} from './common-objects' 7} from './common-objects'
8import { VideoState } from '../../videos' 8import { LiveVideoLatencyMode, VideoState } from '../../videos'
9 9
10export interface VideoObject { 10export interface VideoObject {
11 type: 'Video' 11 type: 'Video'
@@ -25,6 +25,7 @@ export interface VideoObject {
25 isLiveBroadcast: boolean 25 isLiveBroadcast: boolean
26 liveSaveReplay: boolean 26 liveSaveReplay: boolean
27 permanentLive: boolean 27 permanentLive: boolean
28 latencyMode: LiveVideoLatencyMode
28 29
29 commentsEnabled: boolean 30 commentsEnabled: boolean
30 downloadEnabled: boolean 31 downloadEnabled: boolean
diff --git a/shared/models/server/custom-config.model.ts b/shared/models/server/custom-config.model.ts
index c9e7654de..5df606566 100644
--- a/shared/models/server/custom-config.model.ts
+++ b/shared/models/server/custom-config.model.ts
@@ -131,6 +131,10 @@ export interface CustomConfig {
131 131
132 allowReplay: boolean 132 allowReplay: boolean
133 133
134 latencySetting: {
135 enabled: boolean
136 }
137
134 maxDuration: number 138 maxDuration: number
135 maxInstanceLives: number 139 maxInstanceLives: number
136 maxUserLives: number 140 maxUserLives: number
diff --git a/shared/models/server/server-config.model.ts b/shared/models/server/server-config.model.ts
index b06019bb8..d7fbed13c 100644
--- a/shared/models/server/server-config.model.ts
+++ b/shared/models/server/server-config.model.ts
@@ -149,10 +149,14 @@ export interface ServerConfig {
149 live: { 149 live: {
150 enabled: boolean 150 enabled: boolean
151 151
152 allowReplay: boolean
153 latencySetting: {
154 enabled: boolean
155 }
156
152 maxDuration: number 157 maxDuration: number
153 maxInstanceLives: number 158 maxInstanceLives: number
154 maxUserLives: number 159 maxUserLives: number
155 allowReplay: boolean
156 160
157 transcoding: { 161 transcoding: {
158 enabled: boolean 162 enabled: boolean
diff --git a/shared/models/videos/live/index.ts b/shared/models/videos/live/index.ts
index a36f42a7d..68f32092a 100644
--- a/shared/models/videos/live/index.ts
+++ b/shared/models/videos/live/index.ts
@@ -1,5 +1,6 @@
1export * from './live-video-create.model' 1export * from './live-video-create.model'
2export * from './live-video-event-payload.model' 2export * from './live-video-event-payload.model'
3export * from './live-video-event.type' 3export * from './live-video-event.type'
4export * from './live-video-latency-mode.enum'
4export * from './live-video-update.model' 5export * from './live-video-update.model'
5export * from './live-video.model' 6export * from './live-video.model'
diff --git a/shared/models/videos/live/live-video-create.model.ts b/shared/models/videos/live/live-video-create.model.ts
index caa7acc17..49ccaf45b 100644
--- a/shared/models/videos/live/live-video-create.model.ts
+++ b/shared/models/videos/live/live-video-create.model.ts
@@ -1,6 +1,8 @@
1import { LiveVideoLatencyMode } from '.'
1import { VideoCreate } from '../video-create.model' 2import { VideoCreate } from '../video-create.model'
2 3
3export interface LiveVideoCreate extends VideoCreate { 4export interface LiveVideoCreate extends VideoCreate {
4 saveReplay?: boolean 5 saveReplay?: boolean
5 permanentLive?: boolean 6 permanentLive?: boolean
7 latencyMode?: LiveVideoLatencyMode
6} 8}
diff --git a/shared/models/videos/live/live-video-latency-mode.enum.ts b/shared/models/videos/live/live-video-latency-mode.enum.ts
new file mode 100644
index 000000000..4285e1d41
--- /dev/null
+++ b/shared/models/videos/live/live-video-latency-mode.enum.ts
@@ -0,0 +1,5 @@
1export const enum LiveVideoLatencyMode {
2 DEFAULT = 1,
3 HIGH_LATENCY = 2,
4 SMALL_LATENCY = 3
5}
diff --git a/shared/models/videos/live/live-video-update.model.ts b/shared/models/videos/live/live-video-update.model.ts
index a39c44797..93bb4d30d 100644
--- a/shared/models/videos/live/live-video-update.model.ts
+++ b/shared/models/videos/live/live-video-update.model.ts
@@ -1,4 +1,7 @@
1import { LiveVideoLatencyMode } from './live-video-latency-mode.enum'
2
1export interface LiveVideoUpdate { 3export interface LiveVideoUpdate {
2 permanentLive?: boolean 4 permanentLive?: boolean
3 saveReplay?: boolean 5 saveReplay?: boolean
6 latencyMode?: LiveVideoLatencyMode
4} 7}
diff --git a/shared/models/videos/live/live-video.model.ts b/shared/models/videos/live/live-video.model.ts
index 815a93804..2d3169941 100644
--- a/shared/models/videos/live/live-video.model.ts
+++ b/shared/models/videos/live/live-video.model.ts
@@ -1,8 +1,12 @@
1import { LiveVideoLatencyMode } from './live-video-latency-mode.enum'
2
1export interface LiveVideo { 3export interface LiveVideo {
2 rtmpUrl: string 4 rtmpUrl: string
3 rtmpsUrl: string 5 rtmpsUrl: string
4 6
5 streamKey: string 7 streamKey: string
8
6 saveReplay: boolean 9 saveReplay: boolean
7 permanentLive: boolean 10 permanentLive: boolean
11 latencyMode: LiveVideoLatencyMode
8} 12}
diff --git a/shared/server-commands/server/config-command.ts b/shared/server-commands/server/config-command.ts
index c0042060b..e47a0d346 100644
--- a/shared/server-commands/server/config-command.ts
+++ b/shared/server-commands/server/config-command.ts
@@ -292,6 +292,9 @@ export class ConfigCommand extends AbstractCommand {
292 live: { 292 live: {
293 enabled: true, 293 enabled: true,
294 allowReplay: false, 294 allowReplay: false,
295 latencySetting: {
296 enabled: false
297 },
295 maxDuration: -1, 298 maxDuration: -1,
296 maxInstanceLives: -1, 299 maxInstanceLives: -1,
297 maxUserLives: 50, 300 maxUserLives: 50,
diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml
index 70f2d97f5..5ce1f228a 100644
--- a/support/doc/api/openapi.yaml
+++ b/support/doc/api/openapi.yaml
@@ -2295,6 +2295,9 @@ paths:
2295 permanentLive: 2295 permanentLive:
2296 description: User can stream multiple times in a permanent live 2296 description: User can stream multiple times in a permanent live
2297 type: boolean 2297 type: boolean
2298 latencyMode:
2299 description: User can select live latency mode if enabled by the instance
2300 $ref: '#/components/schemas/LiveVideoLatencyMode'
2298 thumbnailfile: 2301 thumbnailfile:
2299 description: Live video/replay thumbnail file 2302 description: Live video/replay thumbnail file
2300 type: string 2303 type: string
@@ -5291,6 +5294,14 @@ components:
5291 description: 'Admin flags for the user (None = `0`, Bypass video blocklist = `1`)' 5294 description: 'Admin flags for the user (None = `0`, Bypass video blocklist = `1`)'
5292 example: 1 5295 example: 1
5293 5296
5297 LiveVideoLatencyMode:
5298 type: integer
5299 enum:
5300 - 1
5301 - 2
5302 - 3
5303 description: 'The live latency mode (Default = `1`, HIght latency = `2`, Small Latency = `3`)'
5304
5294 VideoStateConstant: 5305 VideoStateConstant:
5295 properties: 5306 properties:
5296 id: 5307 id:
@@ -7482,6 +7493,9 @@ components:
7482 permanentLive: 7493 permanentLive:
7483 description: User can stream multiple times in a permanent live 7494 description: User can stream multiple times in a permanent live
7484 type: boolean 7495 type: boolean
7496 latencyMode:
7497 description: User can select live latency mode if enabled by the instance
7498 $ref: '#/components/schemas/LiveVideoLatencyMode'
7485 7499
7486 LiveVideoResponse: 7500 LiveVideoResponse:
7487 properties: 7501 properties:
@@ -7497,8 +7511,9 @@ components:
7497 permanentLive: 7511 permanentLive:
7498 description: User can stream multiple times in a permanent live 7512 description: User can stream multiple times in a permanent live
7499 type: boolean 7513 type: boolean
7500 7514 latencyMode:
7501 7515 description: User can select live latency mode if enabled by the instance
7516 $ref: '#/components/schemas/LiveVideoLatencyMode'
7502 7517
7503 callbacks: 7518 callbacks:
7504 searchIndex: 7519 searchIndex: