Merge remote-tracking branch 'weblate/develop' into develop

This commit is contained in:
Chocobozzz 2022-03-11 11:11:00 +01:00
commit 4495806f2f
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
47 changed files with 8191 additions and 7373 deletions

View File

@ -189,6 +189,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
maxInstanceLives: MAX_INSTANCE_LIVES_VALIDATOR, maxInstanceLives: MAX_INSTANCE_LIVES_VALIDATOR,
maxUserLives: MAX_USER_LIVES_VALIDATOR, maxUserLives: MAX_USER_LIVES_VALIDATOR,
allowReplay: null, allowReplay: null,
latencySetting: {
enabled: null
},
transcoding: { transcoding: {
enabled: null, enabled: null,

View File

@ -36,6 +36,18 @@
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
<div class="form-group" formGroupName="latencySetting" [ngClass]="getDisabledLiveClass()">
<my-peertube-checkbox
inputName="liveLatencySettingEnabled" formControlName="enabled"
i18n-labelText labelText="Allow your users to change live latency"
>
<ng-container ngProjectAs="description" i18n>
Small latency disables P2P and high latency can increase P2P ratio
</ng-container>
</my-peertube-checkbox>
</div>
<div class="form-group" [ngClass]="getDisabledLiveClass()"> <div class="form-group" [ngClass]="getDisabledLiveClass()">
<label i18n for="liveMaxInstanceLives"> <label i18n for="liveMaxInstanceLives">
Max simultaneous lives created on your instance <span class="text-muted">(-1 for "unlimited")</span> Max simultaneous lives created on your instance <span class="text-muted">(-1 for "unlimited")</span>

View File

@ -289,6 +289,17 @@
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
<div class="form-group" *ngIf="isLatencyModeEnabled()">
<label i18n for="latencyMode">Latency mode</label>
<my-select-options
labelForId="latencyMode" [items]="latencyModes" formControlName="latencyMode" [clearable]="true"
></my-select-options>
<div *ngIf="formErrors.latencyMode" class="form-error">
{{ formErrors.latencyMode }}
</div>
</div>
</div> </div>
</div> </div>
</ng-template> </ng-template>

View File

@ -1,6 +1,6 @@
import { forkJoin } from 'rxjs' import { forkJoin } from 'rxjs'
import { map } from 'rxjs/operators' import { map } from 'rxjs/operators'
import { SelectChannelItem } from 'src/types/select-options-item.model' import { SelectChannelItem, SelectOptionsItem } from 'src/types/select-options-item.model'
import { ChangeDetectorRef, Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output, ViewChild } from '@angular/core' import { ChangeDetectorRef, Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'
import { AbstractControl, FormArray, FormControl, FormGroup, Validators } from '@angular/forms' import { AbstractControl, FormArray, FormControl, FormGroup, Validators } from '@angular/forms'
import { HooksService, PluginService, ServerService } from '@app/core' import { HooksService, PluginService, ServerService } from '@app/core'
@ -26,6 +26,7 @@ import { PluginInfo } from '@root-helpers/plugins-manager'
import { import {
HTMLServerConfig, HTMLServerConfig,
LiveVideo, LiveVideo,
LiveVideoLatencyMode,
RegisterClientFormFieldOptions, RegisterClientFormFieldOptions,
RegisterClientVideoFieldOptions, RegisterClientVideoFieldOptions,
VideoConstant, VideoConstant,
@ -78,6 +79,23 @@ export class VideoEditComponent implements OnInit, OnDestroy {
videoCategories: VideoConstant<number>[] = [] videoCategories: VideoConstant<number>[] = []
videoLicences: VideoConstant<number>[] = [] videoLicences: VideoConstant<number>[] = []
videoLanguages: VideoLanguages[] = [] videoLanguages: VideoLanguages[] = []
latencyModes: SelectOptionsItem[] = [
{
id: LiveVideoLatencyMode.SMALL_LATENCY,
label: $localize`Small latency`,
description: $localize`Reduce latency to ~15s disabling P2P`
},
{
id: LiveVideoLatencyMode.DEFAULT,
label: $localize`Default`,
description: $localize`Average latency of 30s`
},
{
id: LiveVideoLatencyMode.HIGH_LATENCY,
label: $localize`High latency`,
description: $localize`Average latency of 60s increasing P2P ratio`
}
]
pluginDataFormGroup: FormGroup pluginDataFormGroup: FormGroup
@ -141,6 +159,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
originallyPublishedAt: VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR, originallyPublishedAt: VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR,
liveStreamKey: null, liveStreamKey: null,
permanentLive: null, permanentLive: null,
latencyMode: null,
saveReplay: null saveReplay: null
} }
@ -273,6 +292,10 @@ export class VideoEditComponent implements OnInit, OnDestroy {
return this.form.value['permanentLive'] === true return this.form.value['permanentLive'] === true
} }
isLatencyModeEnabled () {
return this.serverConfig.live.latencySetting.enabled
}
isPluginFieldHidden (pluginField: PluginField) { isPluginFieldHidden (pluginField: PluginField) {
if (typeof pluginField.commonOptions.hidden !== 'function') return false if (typeof pluginField.commonOptions.hidden !== 'function') return false

View File

@ -64,6 +64,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
if (this.liveVideo) { if (this.liveVideo) {
this.form.patchValue({ this.form.patchValue({
saveReplay: this.liveVideo.saveReplay, saveReplay: this.liveVideo.saveReplay,
latencyMode: this.liveVideo.latencyMode,
permanentLive: this.liveVideo.permanentLive permanentLive: this.liveVideo.permanentLive
}) })
} }
@ -127,7 +128,8 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
const liveVideoUpdate: LiveVideoUpdate = { const liveVideoUpdate: LiveVideoUpdate = {
saveReplay: !!this.form.value.saveReplay, saveReplay: !!this.form.value.saveReplay,
permanentLive: !!this.form.value.permanentLive permanentLive: !!this.form.value.permanentLive,
latencyMode: this.form.value.latencyMode
} }
// Don't update live attributes if they did not change // Don't update live attributes if they did not change

View File

@ -1,5 +1,5 @@
import { Hotkey, HotkeysService } from 'angular2-hotkeys' import { Hotkey, HotkeysService } from 'angular2-hotkeys'
import { forkJoin, Subscription } from 'rxjs' import { forkJoin, map, Observable, of, Subscription, switchMap } from 'rxjs'
import { isP2PEnabled } from 'src/assets/player/utils' import { isP2PEnabled } from 'src/assets/player/utils'
import { PlatformLocation } from '@angular/common' import { PlatformLocation } from '@angular/common'
import { Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core' import { Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'
@ -22,11 +22,13 @@ import { HooksService } from '@app/core/plugins/hooks.service'
import { isXPercentInViewport, scrollToTop } from '@app/helpers' import { isXPercentInViewport, scrollToTop } from '@app/helpers'
import { Video, VideoCaptionService, VideoDetails, VideoService } from '@app/shared/shared-main' import { Video, VideoCaptionService, VideoDetails, VideoService } from '@app/shared/shared-main'
import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription' import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription'
import { LiveVideoService } from '@app/shared/shared-video-live'
import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist' import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
import { timeToInt } from '@shared/core-utils' import { timeToInt } from '@shared/core-utils'
import { import {
HTMLServerConfig, HTMLServerConfig,
HttpStatusCode, HttpStatusCode,
LiveVideo,
PeerTubeProblemDocument, PeerTubeProblemDocument,
ServerErrorCode, ServerErrorCode,
VideoCaption, VideoCaption,
@ -63,6 +65,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
video: VideoDetails = null video: VideoDetails = null
videoCaptions: VideoCaption[] = [] videoCaptions: VideoCaption[] = []
liveVideo: LiveVideo
playlistPosition: number playlistPosition: number
playlist: VideoPlaylist = null playlist: VideoPlaylist = null
@ -89,6 +92,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
private router: Router, private router: Router,
private videoService: VideoService, private videoService: VideoService,
private playlistService: VideoPlaylistService, private playlistService: VideoPlaylistService,
private liveVideoService: LiveVideoService,
private confirmService: ConfirmService, private confirmService: ConfirmService,
private metaService: MetaService, private metaService: MetaService,
private authService: AuthService, private authService: AuthService,
@ -239,12 +243,21 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
'filter:api.video-watch.video.get.result' 'filter:api.video-watch.video.get.result'
) )
const videoAndLiveObs: Observable<{ video: VideoDetails, live?: LiveVideo }> = videoObs.pipe(
switchMap(video => {
if (!video.isLive) return of({ video })
return this.liveVideoService.getVideoLive(video.uuid)
.pipe(map(live => ({ live, video })))
})
)
forkJoin([ forkJoin([
videoObs, videoAndLiveObs,
this.videoCaptionService.listCaptions(videoId), this.videoCaptionService.listCaptions(videoId),
this.userService.getAnonymousOrLoggedUser() this.userService.getAnonymousOrLoggedUser()
]).subscribe({ ]).subscribe({
next: ([ video, captionsResult, loggedInOrAnonymousUser ]) => { next: ([ { video, live }, captionsResult, loggedInOrAnonymousUser ]) => {
const queryParams = this.route.snapshot.queryParams const queryParams = this.route.snapshot.queryParams
const urlOptions = { const urlOptions = {
@ -261,7 +274,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
peertubeLink: false peertubeLink: false
} }
this.onVideoFetched({ video, videoCaptions: captionsResult.data, loggedInOrAnonymousUser, urlOptions }) this.onVideoFetched({ video, live, videoCaptions: captionsResult.data, loggedInOrAnonymousUser, urlOptions })
.catch(err => this.handleGlobalError(err)) .catch(err => this.handleGlobalError(err))
}, },
@ -330,16 +343,18 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
private async onVideoFetched (options: { private async onVideoFetched (options: {
video: VideoDetails video: VideoDetails
live: LiveVideo
videoCaptions: VideoCaption[] videoCaptions: VideoCaption[]
urlOptions: URLOptions urlOptions: URLOptions
loggedInOrAnonymousUser: User loggedInOrAnonymousUser: User
}) { }) {
const { video, videoCaptions, urlOptions, loggedInOrAnonymousUser } = options const { video, live, videoCaptions, urlOptions, loggedInOrAnonymousUser } = options
this.subscribeToLiveEventsIfNeeded(this.video, video) this.subscribeToLiveEventsIfNeeded(this.video, video)
this.video = video this.video = video
this.videoCaptions = videoCaptions this.videoCaptions = videoCaptions
this.liveVideo = live
// Re init attributes // Re init attributes
this.playerPlaceholderImgSrc = undefined this.playerPlaceholderImgSrc = undefined
@ -387,6 +402,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
const params = { const params = {
video: this.video, video: this.video,
videoCaptions: this.videoCaptions, videoCaptions: this.videoCaptions,
liveVideo: this.liveVideo,
urlOptions, urlOptions,
loggedInOrAnonymousUser, loggedInOrAnonymousUser,
user: this.user user: this.user
@ -532,12 +548,13 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
private buildPlayerManagerOptions (params: { private buildPlayerManagerOptions (params: {
video: VideoDetails video: VideoDetails
liveVideo: LiveVideo
videoCaptions: VideoCaption[] videoCaptions: VideoCaption[]
urlOptions: CustomizationOptions & { playerMode: PlayerMode } urlOptions: CustomizationOptions & { playerMode: PlayerMode }
loggedInOrAnonymousUser: User loggedInOrAnonymousUser: User
user?: AuthUser user?: AuthUser
}) { }) {
const { video, videoCaptions, urlOptions, loggedInOrAnonymousUser, user } = params const { video, liveVideo, videoCaptions, urlOptions, loggedInOrAnonymousUser, user } = params
const getStartTime = () => { const getStartTime = () => {
const byUrl = urlOptions.startTime !== undefined const byUrl = urlOptions.startTime !== undefined
@ -562,6 +579,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
src: environment.apiUrl + c.captionPath src: environment.apiUrl + c.captionPath
})) }))
const liveOptions = video.isLive
? { latencyMode: liveVideo.latencyMode }
: undefined
const options: PeertubePlayerManagerOptions = { const options: PeertubePlayerManagerOptions = {
common: { common: {
autoplay: this.isAutoplay(), autoplay: this.isAutoplay(),
@ -597,6 +618,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
embedTitle: video.name, embedTitle: video.name,
isLive: video.isLive, isLive: video.isLive,
liveOptions,
language: this.localeId, language: this.localeId,

View File

@ -1,9 +1,10 @@
import videojs from 'video.js' import videojs from 'video.js'
import { HybridLoaderSettings } from '@peertube/p2p-media-loader-core'
import { HlsJsEngineSettings } from '@peertube/p2p-media-loader-hlsjs' import { HlsJsEngineSettings } from '@peertube/p2p-media-loader-hlsjs'
import { PluginsManager } from '@root-helpers/plugins-manager' import { PluginsManager } from '@root-helpers/plugins-manager'
import { buildVideoLink, decorateVideoLink } from '@shared/core-utils' import { buildVideoLink, decorateVideoLink } from '@shared/core-utils'
import { isDefaultLocale } from '@shared/core-utils/i18n' import { isDefaultLocale } from '@shared/core-utils/i18n'
import { VideoFile } from '@shared/models' import { LiveVideoLatencyMode, VideoFile } from '@shared/models'
import { copyToClipboard } from '../../root-helpers/utils' import { copyToClipboard } from '../../root-helpers/utils'
import { RedundancyUrlManager } from './p2p-media-loader/redundancy-url-manager' import { RedundancyUrlManager } from './p2p-media-loader/redundancy-url-manager'
import { segmentUrlBuilderFactory } from './p2p-media-loader/segment-url-builder' import { segmentUrlBuilderFactory } from './p2p-media-loader/segment-url-builder'
@ -19,7 +20,6 @@ import {
VideoJSPluginOptions VideoJSPluginOptions
} from './peertube-videojs-typings' } from './peertube-videojs-typings'
import { buildVideoOrPlaylistEmbed, getRtcConfig, isIOS, isSafari } from './utils' import { buildVideoOrPlaylistEmbed, getRtcConfig, isIOS, isSafari } from './utils'
import { HybridLoaderSettings } from '@peertube/p2p-media-loader-core'
export type PlayerMode = 'webtorrent' | 'p2p-media-loader' export type PlayerMode = 'webtorrent' | 'p2p-media-loader'
@ -76,6 +76,9 @@ export interface CommonOptions extends CustomizationOptions {
embedTitle: string embedTitle: string
isLive: boolean isLive: boolean
liveOptions?: {
latencyMode: LiveVideoLatencyMode
}
language?: string language?: string
@ -250,21 +253,8 @@ export class PeertubePlayerOptionsBuilder {
.filter(t => t.startsWith('ws')) .filter(t => t.startsWith('ws'))
const specificLiveOrVODOptions = this.options.common.isLive const specificLiveOrVODOptions = this.options.common.isLive
? { // Live ? this.getP2PMediaLoaderLiveOptions()
requiredSegmentsPriority: 1 : this.getP2PMediaLoaderVODOptions()
}
: { // VOD
requiredSegmentsPriority: 3,
cachedSegmentExpiration: 86400000,
cachedSegmentsCount: 100,
httpDownloadMaxPriority: 9,
httpDownloadProbability: 0.06,
httpDownloadProbabilitySkipIfNoPeers: true,
p2pDownloadMaxPriority: 50
}
return { return {
trackerAnnounce, trackerAnnounce,
@ -283,13 +273,57 @@ export class PeertubePlayerOptionsBuilder {
} }
} }
private getP2PMediaLoaderLiveOptions (): Partial<HybridLoaderSettings> {
const base = {
requiredSegmentsPriority: 1
}
const latencyMode = this.options.common.liveOptions.latencyMode
switch (latencyMode) {
case LiveVideoLatencyMode.SMALL_LATENCY:
return {
...base,
useP2P: false,
httpDownloadProbability: 1
}
case LiveVideoLatencyMode.HIGH_LATENCY:
return base
default:
return base
}
}
private getP2PMediaLoaderVODOptions (): Partial<HybridLoaderSettings> {
return {
requiredSegmentsPriority: 3,
cachedSegmentExpiration: 86400000,
cachedSegmentsCount: 100,
httpDownloadMaxPriority: 9,
httpDownloadProbability: 0.06,
httpDownloadProbabilitySkipIfNoPeers: true,
p2pDownloadMaxPriority: 50
}
}
private getHLSOptions (p2pMediaLoaderConfig: HlsJsEngineSettings) { private getHLSOptions (p2pMediaLoaderConfig: HlsJsEngineSettings) {
const specificLiveOrVODOptions = this.options.common.isLive
? this.getHLSLiveOptions()
: this.getHLSVODOptions()
const base = { const base = {
capLevelToPlayerSize: true, capLevelToPlayerSize: true,
autoStartLoad: false, autoStartLoad: false,
liveSyncDurationCount: 5,
loader: new this.p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass() loader: new this.p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass(),
...specificLiveOrVODOptions
} }
const averageBandwidth = getAverageBandwidthInStore() const averageBandwidth = getAverageBandwidthInStore()
@ -305,6 +339,33 @@ export class PeertubePlayerOptionsBuilder {
} }
} }
private getHLSLiveOptions () {
const latencyMode = this.options.common.liveOptions.latencyMode
switch (latencyMode) {
case LiveVideoLatencyMode.SMALL_LATENCY:
return {
liveSyncDurationCount: 2
}
case LiveVideoLatencyMode.HIGH_LATENCY:
return {
liveSyncDurationCount: 10
}
default:
return {
liveSyncDurationCount: 5
}
}
}
private getHLSVODOptions () {
return {
liveSyncDurationCount: 5
}
}
private addWebTorrentOptions (plugins: VideoJSPluginOptions, alreadyPlayed: boolean) { private addWebTorrentOptions (plugins: VideoJSPluginOptions, alreadyPlayed: boolean) {
const commonOptions = this.options.common const commonOptions = this.options.common
const webtorrentOptions = this.options.webtorrent const webtorrentOptions = this.options.webtorrent

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -6,6 +6,7 @@ import { peertubeTranslate } from '../../../../shared/core-utils/i18n'
import { import {
HTMLServerConfig, HTMLServerConfig,
HttpStatusCode, HttpStatusCode,
LiveVideo,
OAuth2ErrorCode, OAuth2ErrorCode,
ResultList, ResultList,
UserRefreshToken, UserRefreshToken,
@ -94,6 +95,10 @@ export class PeerTubeEmbed {
return window.location.origin + '/api/v1/videos/' + id return window.location.origin + '/api/v1/videos/' + id
} }
getLiveUrl (videoId: string) {
return window.location.origin + '/api/v1/videos/live/' + videoId
}
refreshFetch (url: string, options?: RequestInit) { refreshFetch (url: string, options?: RequestInit) {
return fetch(url, options) return fetch(url, options)
.then((res: Response) => { .then((res: Response) => {
@ -166,6 +171,12 @@ export class PeerTubeEmbed {
return this.refreshFetch(this.getVideoUrl(videoId) + '/captions', { headers: this.headers }) return this.refreshFetch(this.getVideoUrl(videoId) + '/captions', { headers: this.headers })
} }
loadWithLive (video: VideoDetails) {
return this.refreshFetch(this.getLiveUrl(video.uuid), { headers: this.headers })
.then(res => res.json())
.then((live: LiveVideo) => ({ video, live }))
}
loadPlaylistInfo (playlistId: string): Promise<Response> { loadPlaylistInfo (playlistId: string): Promise<Response> {
return this.refreshFetch(this.getPlaylistUrl(playlistId), { headers: this.headers }) return this.refreshFetch(this.getPlaylistUrl(playlistId), { headers: this.headers })
} }
@ -475,13 +486,15 @@ export class PeerTubeEmbed {
.then(res => res.json()) .then(res => res.json())
} }
const videoInfoPromise = videoResponse.json() const videoInfoPromise: Promise<{ video: VideoDetails, live?: LiveVideo }> = videoResponse.json()
.then((videoInfo: VideoDetails) => { .then((videoInfo: VideoDetails) => {
this.loadParams(videoInfo) this.loadParams(videoInfo)
if (!alreadyHadPlayer && !this.autoplay) this.loadPlaceholder(videoInfo) if (!alreadyHadPlayer && !this.autoplay) this.buildPlaceholder(videoInfo)
return videoInfo if (!videoInfo.isLive) return { video: videoInfo }
return this.loadWithLive(videoInfo)
}) })
const [ videoInfoTmp, serverTranslations, captionsResponse, PeertubePlayerManagerModule ] = await Promise.all([ const [ videoInfoTmp, serverTranslations, captionsResponse, PeertubePlayerManagerModule ] = await Promise.all([
@ -493,11 +506,15 @@ export class PeerTubeEmbed {
await this.loadPlugins(serverTranslations) await this.loadPlugins(serverTranslations)
const videoInfo: VideoDetails = videoInfoTmp const { video: videoInfo, live } = videoInfoTmp
const PeertubePlayerManager = PeertubePlayerManagerModule.PeertubePlayerManager const PeertubePlayerManager = PeertubePlayerManagerModule.PeertubePlayerManager
const videoCaptions = await this.buildCaptions(serverTranslations, captionsResponse) const videoCaptions = await this.buildCaptions(serverTranslations, captionsResponse)
const liveOptions = videoInfo.isLive
? { latencyMode: live.latencyMode }
: undefined
const playlistPlugin = this.currentPlaylistElement const playlistPlugin = this.currentPlaylistElement
? { ? {
elements: this.playlistElements, elements: this.playlistElements,
@ -545,6 +562,7 @@ export class PeerTubeEmbed {
videoUUID: videoInfo.uuid, videoUUID: videoInfo.uuid,
isLive: videoInfo.isLive, isLive: videoInfo.isLive,
liveOptions,
playerElement: this.playerElement, playerElement: this.playerElement,
onPlayerElementChange: (element: HTMLVideoElement) => { onPlayerElementChange: (element: HTMLVideoElement) => {
@ -726,7 +744,7 @@ export class PeerTubeEmbed {
return [] return []
} }
private loadPlaceholder (video: VideoDetails) { private buildPlaceholder (video: VideoDetails) {
const placeholder = this.getPlaceholderElement() const placeholder = this.getPlaceholderElement()
const url = window.location.origin + video.previewPath const url = window.location.origin + video.previewPath

View File

@ -392,6 +392,12 @@ live:
# /!\ transcoding.enabled (and not live.transcoding.enabled) has to be true to create a replay # /!\ transcoding.enabled (and not live.transcoding.enabled) has to be true to create a replay
allow_replay: true allow_replay: true
# Allow your users to change latency settings (small latency/default/high latency)
# Small latency live streams cannot use P2P
# High latency live streams can increase P2P ratio
latency_setting:
enabled: true
# Your firewall should accept traffic from this port in TCP if you enable live # Your firewall should accept traffic from this port in TCP if you enable live
rtmp: rtmp:
enabled: true enabled: true

View File

@ -400,6 +400,12 @@ live:
# /!\ transcoding.enabled (and not live.transcoding.enabled) has to be true to create a replay # /!\ transcoding.enabled (and not live.transcoding.enabled) has to be true to create a replay
allow_replay: true allow_replay: true
# Allow your users to change latency settings (small latency/default/high latency)
# Small latency live streams cannot use P2P
# High latency live streams can increase P2P ratio
latency_setting:
enabled: true
# Your firewall should accept traffic from this port in TCP if you enable live # Your firewall should accept traffic from this port in TCP if you enable live
rtmp: rtmp:
enabled: true enabled: true

View File

@ -237,6 +237,9 @@ function customConfig (): CustomConfig {
live: { live: {
enabled: CONFIG.LIVE.ENABLED, enabled: CONFIG.LIVE.ENABLED,
allowReplay: CONFIG.LIVE.ALLOW_REPLAY, allowReplay: CONFIG.LIVE.ALLOW_REPLAY,
latencySetting: {
enabled: CONFIG.LIVE.LATENCY_SETTING.ENABLED
},
maxDuration: CONFIG.LIVE.MAX_DURATION, maxDuration: CONFIG.LIVE.MAX_DURATION,
maxInstanceLives: CONFIG.LIVE.MAX_INSTANCE_LIVES, maxInstanceLives: CONFIG.LIVE.MAX_INSTANCE_LIVES,
maxUserLives: CONFIG.LIVE.MAX_USER_LIVES, maxUserLives: CONFIG.LIVE.MAX_USER_LIVES,

View File

@ -1,4 +1,5 @@
import express from 'express' import express from 'express'
import { exists } from '@server/helpers/custom-validators/misc'
import { createReqFiles } from '@server/helpers/express-utils' import { createReqFiles } from '@server/helpers/express-utils'
import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants' import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants'
import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url' import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url'
@ -9,7 +10,7 @@ import { videoLiveAddValidator, videoLiveGetValidator, videoLiveUpdateValidator
import { VideoLiveModel } from '@server/models/video/video-live' import { VideoLiveModel } from '@server/models/video/video-live'
import { MVideoDetails, MVideoFullLight } from '@server/types/models' import { MVideoDetails, MVideoFullLight } from '@server/types/models'
import { buildUUID, uuidToShort } from '@shared/extra-utils' import { buildUUID, uuidToShort } from '@shared/extra-utils'
import { HttpStatusCode, LiveVideoCreate, LiveVideoUpdate, VideoState } from '@shared/models' import { HttpStatusCode, LiveVideoCreate, LiveVideoLatencyMode, LiveVideoUpdate, VideoState } from '@shared/models'
import { logger } from '../../../helpers/logger' import { logger } from '../../../helpers/logger'
import { sequelizeTypescript } from '../../../initializers/database' import { sequelizeTypescript } from '../../../initializers/database'
import { updateVideoMiniatureFromExisting } from '../../../lib/thumbnail' import { updateVideoMiniatureFromExisting } from '../../../lib/thumbnail'
@ -60,8 +61,9 @@ async function updateLiveVideo (req: express.Request, res: express.Response) {
const video = res.locals.videoAll const video = res.locals.videoAll
const videoLive = res.locals.videoLive const videoLive = res.locals.videoLive
videoLive.saveReplay = body.saveReplay || false if (exists(body.saveReplay)) videoLive.saveReplay = body.saveReplay
videoLive.permanentLive = body.permanentLive || false if (exists(body.permanentLive)) videoLive.permanentLive = body.permanentLive
if (exists(body.latencyMode)) videoLive.latencyMode = body.latencyMode
video.VideoLive = await videoLive.save() video.VideoLive = await videoLive.save()
@ -87,6 +89,7 @@ async function addLiveVideo (req: express.Request, res: express.Response) {
const videoLive = new VideoLiveModel() const videoLive = new VideoLiveModel()
videoLive.saveReplay = videoInfo.saveReplay || false videoLive.saveReplay = videoInfo.saveReplay || false
videoLive.permanentLive = videoInfo.permanentLive || false videoLive.permanentLive = videoInfo.permanentLive || false
videoLive.latencyMode = videoInfo.latencyMode || LiveVideoLatencyMode.DEFAULT
videoLive.streamKey = buildUUID() videoLive.streamKey = buildUUID()
const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({ const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({

View File

@ -50,6 +50,10 @@ function getContextData (type: ContextType) {
'@type': 'sc:Boolean', '@type': 'sc:Boolean',
'@id': 'pt:permanentLive' '@id': 'pt:permanentLive'
}, },
latencyMode: {
'@type': 'sc:Number',
'@id': 'pt:latencyMode'
},
Infohash: 'pt:Infohash', Infohash: 'pt:Infohash',
Playlist: 'pt:Playlist', Playlist: 'pt:Playlist',

View File

@ -1,10 +1,11 @@
import validator from 'validator' import validator from 'validator'
import { logger } from '@server/helpers/logger' import { logger } from '@server/helpers/logger'
import { ActivityTrackerUrlObject, ActivityVideoFileMetadataUrlObject } from '@shared/models' import { ActivityTrackerUrlObject, ActivityVideoFileMetadataUrlObject } from '@shared/models'
import { VideoState } from '../../../../shared/models/videos' import { LiveVideoLatencyMode, VideoState } from '../../../../shared/models/videos'
import { ACTIVITY_PUB, CONSTRAINTS_FIELDS } from '../../../initializers/constants' import { ACTIVITY_PUB, CONSTRAINTS_FIELDS } from '../../../initializers/constants'
import { peertubeTruncate } from '../../core-utils' import { peertubeTruncate } from '../../core-utils'
import { exists, isArray, isBooleanValid, isDateValid, isUUIDValid } from '../misc' import { exists, isArray, isBooleanValid, isDateValid, isUUIDValid } from '../misc'
import { isLiveLatencyModeValid } from '../video-lives'
import { import {
isVideoDurationValid, isVideoDurationValid,
isVideoNameValid, isVideoNameValid,
@ -65,6 +66,7 @@ function sanitizeAndCheckVideoTorrentObject (video: any) {
if (!isBooleanValid(video.isLiveBroadcast)) video.isLiveBroadcast = false if (!isBooleanValid(video.isLiveBroadcast)) video.isLiveBroadcast = false
if (!isBooleanValid(video.liveSaveReplay)) video.liveSaveReplay = false if (!isBooleanValid(video.liveSaveReplay)) video.liveSaveReplay = false
if (!isBooleanValid(video.permanentLive)) video.permanentLive = false if (!isBooleanValid(video.permanentLive)) video.permanentLive = false
if (!isLiveLatencyModeValid(video.latencyMode)) video.latencyMode = LiveVideoLatencyMode.DEFAULT
return isActivityPubUrlValid(video.id) && return isActivityPubUrlValid(video.id) &&
isVideoNameValid(video.name) && isVideoNameValid(video.name) &&

View File

@ -0,0 +1,11 @@
import { LiveVideoLatencyMode } from '@shared/models'
function isLiveLatencyModeValid (value: any) {
return [ LiveVideoLatencyMode.DEFAULT, LiveVideoLatencyMode.SMALL_LATENCY, LiveVideoLatencyMode.HIGH_LATENCY ].includes(value)
}
// ---------------------------------------------------------------------------
export {
isLiveLatencyModeValid
}

View File

@ -1,7 +1,7 @@
import { FfmpegCommand, FilterSpecification } from 'fluent-ffmpeg' import { FfmpegCommand, FilterSpecification } from 'fluent-ffmpeg'
import { join } from 'path' import { join } from 'path'
import { VIDEO_LIVE } from '@server/initializers/constants' import { VIDEO_LIVE } from '@server/initializers/constants'
import { AvailableEncoders } from '@shared/models' import { AvailableEncoders, LiveVideoLatencyMode } from '@shared/models'
import { logger, loggerTagsFactory } from '../logger' import { logger, loggerTagsFactory } from '../logger'
import { buildStreamSuffix, getFFmpeg, getScaleFilter, StreamType } from './ffmpeg-commons' import { buildStreamSuffix, getFFmpeg, getScaleFilter, StreamType } from './ffmpeg-commons'
import { getEncoderBuilderResult } from './ffmpeg-encoders' import { getEncoderBuilderResult } from './ffmpeg-encoders'
@ -15,6 +15,7 @@ async function getLiveTranscodingCommand (options: {
outPath: string outPath: string
masterPlaylistName: string masterPlaylistName: string
latencyMode: LiveVideoLatencyMode
resolutions: number[] resolutions: number[]
@ -26,7 +27,7 @@ async function getLiveTranscodingCommand (options: {
availableEncoders: AvailableEncoders availableEncoders: AvailableEncoders
profile: string profile: string
}) { }) {
const { inputUrl, outPath, resolutions, fps, bitrate, availableEncoders, profile, masterPlaylistName, ratio } = options const { inputUrl, outPath, resolutions, fps, bitrate, availableEncoders, profile, masterPlaylistName, ratio, latencyMode } = options
const command = getFFmpeg(inputUrl, 'live') const command = getFFmpeg(inputUrl, 'live')
@ -120,14 +121,21 @@ async function getLiveTranscodingCommand (options: {
command.complexFilter(complexFilter) command.complexFilter(complexFilter)
addDefaultLiveHLSParams(command, outPath, masterPlaylistName) addDefaultLiveHLSParams({ command, outPath, masterPlaylistName, latencyMode })
command.outputOption('-var_stream_map', varStreamMap.join(' ')) command.outputOption('-var_stream_map', varStreamMap.join(' '))
return command return command
} }
function getLiveMuxingCommand (inputUrl: string, outPath: string, masterPlaylistName: string) { function getLiveMuxingCommand (options: {
inputUrl: string
outPath: string
masterPlaylistName: string
latencyMode: LiveVideoLatencyMode
}) {
const { inputUrl, outPath, masterPlaylistName, latencyMode } = options
const command = getFFmpeg(inputUrl, 'live') const command = getFFmpeg(inputUrl, 'live')
command.outputOption('-c:v copy') command.outputOption('-c:v copy')
@ -135,22 +143,39 @@ function getLiveMuxingCommand (inputUrl: string, outPath: string, masterPlaylist
command.outputOption('-map 0:a?') command.outputOption('-map 0:a?')
command.outputOption('-map 0:v?') command.outputOption('-map 0:v?')
addDefaultLiveHLSParams(command, outPath, masterPlaylistName) addDefaultLiveHLSParams({ command, outPath, masterPlaylistName, latencyMode })
return command return command
} }
function getLiveSegmentTime (latencyMode: LiveVideoLatencyMode) {
if (latencyMode === LiveVideoLatencyMode.SMALL_LATENCY) {
return VIDEO_LIVE.SEGMENT_TIME_SECONDS.SMALL_LATENCY
}
return VIDEO_LIVE.SEGMENT_TIME_SECONDS.DEFAULT_LATENCY
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export { export {
getLiveSegmentTime,
getLiveTranscodingCommand, getLiveTranscodingCommand,
getLiveMuxingCommand getLiveMuxingCommand
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function addDefaultLiveHLSParams (command: FfmpegCommand, outPath: string, masterPlaylistName: string) { function addDefaultLiveHLSParams (options: {
command.outputOption('-hls_time ' + VIDEO_LIVE.SEGMENT_TIME_SECONDS) command: FfmpegCommand
outPath: string
masterPlaylistName: string
latencyMode: LiveVideoLatencyMode
}) {
const { command, outPath, masterPlaylistName, latencyMode } = options
command.outputOption('-hls_time ' + getLiveSegmentTime(latencyMode))
command.outputOption('-hls_list_size ' + VIDEO_LIVE.SEGMENTS_LIST_SIZE) command.outputOption('-hls_list_size ' + VIDEO_LIVE.SEGMENTS_LIST_SIZE)
command.outputOption('-hls_flags delete_segments+independent_segments') command.outputOption('-hls_flags delete_segments+independent_segments')
command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`) command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`)

View File

@ -49,8 +49,8 @@ function checkMissedConfig () {
'peertube.check_latest_version.enabled', 'peertube.check_latest_version.url', 'peertube.check_latest_version.enabled', 'peertube.check_latest_version.url',
'search.remote_uri.users', 'search.remote_uri.anonymous', 'search.search_index.enabled', 'search.search_index.url', 'search.remote_uri.users', 'search.remote_uri.anonymous', 'search.search_index.enabled', 'search.search_index.url',
'search.search_index.disable_local_search', 'search.search_index.is_default_search', 'search.search_index.disable_local_search', 'search.search_index.is_default_search',
'live.enabled', 'live.allow_replay', 'live.max_duration', 'live.max_user_lives', 'live.max_instance_lives', 'live.enabled', 'live.allow_replay', 'live.latency_setting.enabled', 'live.max_duration',
'live.rtmp.enabled', 'live.rtmp.port', 'live.rtmp.hostname', 'live.max_user_lives', 'live.max_instance_lives', 'live.rtmp.enabled', 'live.rtmp.port', 'live.rtmp.hostname',
'live.rtmps.enabled', 'live.rtmps.port', 'live.rtmps.hostname', 'live.rtmps.key_file', 'live.rtmps.cert_file', 'live.rtmps.enabled', 'live.rtmps.port', 'live.rtmps.hostname', 'live.rtmps.key_file', 'live.rtmps.cert_file',
'live.transcoding.enabled', 'live.transcoding.threads', 'live.transcoding.profile', 'live.transcoding.enabled', 'live.transcoding.threads', 'live.transcoding.profile',
'live.transcoding.resolutions.144p', 'live.transcoding.resolutions.240p', 'live.transcoding.resolutions.360p', 'live.transcoding.resolutions.144p', 'live.transcoding.resolutions.240p', 'live.transcoding.resolutions.360p',

View File

@ -4,9 +4,9 @@ import { dirname, join } from 'path'
import { decacheModule } from '@server/helpers/decache' import { decacheModule } from '@server/helpers/decache'
import { VideoRedundancyConfigFilter } from '@shared/models/redundancy/video-redundancy-config-filter.type' import { VideoRedundancyConfigFilter } from '@shared/models/redundancy/video-redundancy-config-filter.type'
import { BroadcastMessageLevel } from '@shared/models/server' import { BroadcastMessageLevel } from '@shared/models/server'
import { buildPath, root } from '../../shared/core-utils'
import { VideoPrivacy, VideosRedundancyStrategy } from '../../shared/models' import { VideoPrivacy, VideosRedundancyStrategy } from '../../shared/models'
import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type' import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type'
import { buildPath, root } from '../../shared/core-utils'
import { parseBytes, parseDurationToMs } from '../helpers/core-utils' import { parseBytes, parseDurationToMs } from '../helpers/core-utils'
// Use a variable to reload the configuration if we need // Use a variable to reload the configuration if we need
@ -296,6 +296,10 @@ const CONFIG = {
get ALLOW_REPLAY () { return config.get<boolean>('live.allow_replay') }, get ALLOW_REPLAY () { return config.get<boolean>('live.allow_replay') },
LATENCY_SETTING: {
get ENABLED () { return config.get<boolean>('live.latency_setting.enabled') }
},
RTMP: { RTMP: {
get ENABLED () { return config.get<boolean>('live.rtmp.enabled') }, get ENABLED () { return config.get<boolean>('live.rtmp.enabled') },
get PORT () { return config.get<number>('live.rtmp.port') }, get PORT () { return config.get<number>('live.rtmp.port') },

View File

@ -24,7 +24,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const LAST_MIGRATION_VERSION = 685 const LAST_MIGRATION_VERSION = 690
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -700,7 +700,10 @@ const RESUMABLE_UPLOAD_SESSION_LIFETIME = SCHEDULER_INTERVALS_MS.REMOVE_DANGLING
const VIDEO_LIVE = { const VIDEO_LIVE = {
EXTENSION: '.ts', EXTENSION: '.ts',
CLEANUP_DELAY: 1000 * 60 * 5, // 5 minutes CLEANUP_DELAY: 1000 * 60 * 5, // 5 minutes
SEGMENT_TIME_SECONDS: 4, // 4 seconds SEGMENT_TIME_SECONDS: {
DEFAULT_LATENCY: 4, // 4 seconds
SMALL_LATENCY: 2 // 2 seconds
},
SEGMENTS_LIST_SIZE: 15, // 15 maximum segments in live playlist SEGMENTS_LIST_SIZE: 15, // 15 maximum segments in live playlist
REPLAY_DIRECTORY: 'replay', REPLAY_DIRECTORY: 'replay',
EDGE_LIVE_DELAY_SEGMENTS_NOTIFICATION: 4, EDGE_LIVE_DELAY_SEGMENTS_NOTIFICATION: 4,
@ -842,7 +845,8 @@ if (isTestInstance() === true) {
PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME = 5000 PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME = 5000
VIDEO_LIVE.CLEANUP_DELAY = 5000 VIDEO_LIVE.CLEANUP_DELAY = 5000
VIDEO_LIVE.SEGMENT_TIME_SECONDS = 2 VIDEO_LIVE.SEGMENT_TIME_SECONDS.DEFAULT_LATENCY = 2
VIDEO_LIVE.SEGMENT_TIME_SECONDS.SMALL_LATENCY = 1
VIDEO_LIVE.EDGE_LIVE_DELAY_SEGMENTS_NOTIFICATION = 1 VIDEO_LIVE.EDGE_LIVE_DELAY_SEGMENTS_NOTIFICATION = 1
} }

View File

@ -0,0 +1,35 @@
import { LiveVideoLatencyMode } from '@shared/models'
import * as Sequelize from 'sequelize'
async function up (utils: {
transaction: Sequelize.Transaction
queryInterface: Sequelize.QueryInterface
sequelize: Sequelize.Sequelize
db: any
}): Promise<void> {
await utils.queryInterface.addColumn('videoLive', 'latencyMode', {
type: Sequelize.INTEGER,
defaultValue: null,
allowNull: true
}, { transaction: utils.transaction })
{
const query = `UPDATE "videoLive" SET "latencyMode" = ${LiveVideoLatencyMode.DEFAULT}`
await utils.sequelize.query(query, { type: Sequelize.QueryTypes.UPDATE, transaction: utils.transaction })
}
await utils.queryInterface.changeColumn('videoLive', 'latencyMode', {
type: Sequelize.INTEGER,
defaultValue: null,
allowNull: false
}, { transaction: utils.transaction })
}
function down () {
throw new Error('Not implemented.')
}
export {
up,
down
}

View File

@ -151,6 +151,7 @@ function getLiveAttributesFromObject (video: MVideoId, videoObject: VideoObject)
return { return {
saveReplay: videoObject.liveSaveReplay, saveReplay: videoObject.liveSaveReplay,
permanentLive: videoObject.permanentLive, permanentLive: videoObject.permanentLive,
latencyMode: videoObject.latencyMode,
videoId: video.id videoId: video.id
} }
} }

View File

@ -5,9 +5,10 @@ import { createServer as createServerTLS, Server as ServerTLS } from 'tls'
import { import {
computeLowerResolutionsToTranscode, computeLowerResolutionsToTranscode,
ffprobePromise, ffprobePromise,
getLiveSegmentTime,
getVideoStreamBitrate, getVideoStreamBitrate,
getVideoStreamFPS, getVideoStreamDimensionsInfo,
getVideoStreamDimensionsInfo getVideoStreamFPS
} from '@server/helpers/ffmpeg' } from '@server/helpers/ffmpeg'
import { logger, loggerTagsFactory } from '@server/helpers/logger' import { logger, loggerTagsFactory } from '@server/helpers/logger'
import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config' import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config'
@ -353,7 +354,7 @@ class LiveManager {
.catch(err => logger.error('Cannot federate live video %s.', video.url, { err, ...localLTags })) .catch(err => logger.error('Cannot federate live video %s.', video.url, { err, ...localLTags }))
PeerTubeSocket.Instance.sendVideoLiveNewState(video) PeerTubeSocket.Instance.sendVideoLiveNewState(video)
}, VIDEO_LIVE.SEGMENT_TIME_SECONDS * 1000 * VIDEO_LIVE.EDGE_LIVE_DELAY_SEGMENTS_NOTIFICATION) }, getLiveSegmentTime(live.latencyMode) * 1000 * VIDEO_LIVE.EDGE_LIVE_DELAY_SEGMENTS_NOTIFICATION)
} catch (err) { } catch (err) {
logger.error('Cannot save/federate live video %d.', videoId, { err, ...localLTags }) logger.error('Cannot save/federate live video %d.', videoId, { err, ...localLTags })
} }

View File

@ -125,6 +125,8 @@ class MuxingSession extends EventEmitter {
outPath, outPath,
masterPlaylistName: this.streamingPlaylist.playlistFilename, masterPlaylistName: this.streamingPlaylist.playlistFilename,
latencyMode: this.videoLive.latencyMode,
resolutions: this.allResolutions, resolutions: this.allResolutions,
fps: this.fps, fps: this.fps,
bitrate: this.bitrate, bitrate: this.bitrate,
@ -133,7 +135,12 @@ class MuxingSession extends EventEmitter {
availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(), availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
profile: CONFIG.LIVE.TRANSCODING.PROFILE profile: CONFIG.LIVE.TRANSCODING.PROFILE
}) })
: getLiveMuxingCommand(this.inputUrl, outPath, this.streamingPlaylist.playlistFilename) : getLiveMuxingCommand({
inputUrl: this.inputUrl,
outPath,
masterPlaylistName: this.streamingPlaylist.playlistFilename,
latencyMode: this.videoLive.latencyMode
})
logger.info('Running live muxing/transcoding for %s.', this.videoUUID, this.lTags()) logger.info('Running live muxing/transcoding for %s.', this.videoUUID, this.lTags())

View File

@ -137,6 +137,10 @@ class ServerConfigManager {
enabled: CONFIG.LIVE.ENABLED, enabled: CONFIG.LIVE.ENABLED,
allowReplay: CONFIG.LIVE.ALLOW_REPLAY, allowReplay: CONFIG.LIVE.ALLOW_REPLAY,
latencySetting: {
enabled: CONFIG.LIVE.LATENCY_SETTING.ENABLED
},
maxDuration: CONFIG.LIVE.MAX_DURATION, maxDuration: CONFIG.LIVE.MAX_DURATION,
maxInstanceLives: CONFIG.LIVE.MAX_INSTANCE_LIVES, maxInstanceLives: CONFIG.LIVE.MAX_INSTANCE_LIVES,
maxUserLives: CONFIG.LIVE.MAX_USER_LIVES, maxUserLives: CONFIG.LIVE.MAX_USER_LIVES,

View File

@ -1,12 +1,21 @@
import express from 'express' import express from 'express'
import { body } from 'express-validator' import { body } from 'express-validator'
import { isLiveLatencyModeValid } from '@server/helpers/custom-validators/video-lives'
import { CONSTRAINTS_FIELDS } from '@server/initializers/constants' import { CONSTRAINTS_FIELDS } from '@server/initializers/constants'
import { isLocalLiveVideoAccepted } from '@server/lib/moderation' import { isLocalLiveVideoAccepted } from '@server/lib/moderation'
import { Hooks } from '@server/lib/plugins/hooks' import { Hooks } from '@server/lib/plugins/hooks'
import { VideoModel } from '@server/models/video/video' import { VideoModel } from '@server/models/video/video'
import { VideoLiveModel } from '@server/models/video/video-live' import { VideoLiveModel } from '@server/models/video/video-live'
import { HttpStatusCode, ServerErrorCode, UserRight, VideoState } from '@shared/models' import {
import { isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc' HttpStatusCode,
LiveVideoCreate,
LiveVideoLatencyMode,
LiveVideoUpdate,
ServerErrorCode,
UserRight,
VideoState
} from '@shared/models'
import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc'
import { isVideoNameValid } from '../../../helpers/custom-validators/videos' import { isVideoNameValid } from '../../../helpers/custom-validators/videos'
import { cleanUpReqFiles } from '../../../helpers/express-utils' import { cleanUpReqFiles } from '../../../helpers/express-utils'
import { logger } from '../../../helpers/logger' import { logger } from '../../../helpers/logger'
@ -67,6 +76,12 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
.customSanitizer(toBooleanOrNull) .customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have a valid permanentLive attribute'), .custom(isBooleanValid).withMessage('Should have a valid permanentLive attribute'),
body('latencyMode')
.optional()
.customSanitizer(toIntOrNull)
.custom(isLiveLatencyModeValid)
.withMessage('Should have a valid latency mode attribute'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => { async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking videoLiveAddValidator parameters', { parameters: req.body }) logger.debug('Checking videoLiveAddValidator parameters', { parameters: req.body })
@ -82,7 +97,9 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
}) })
} }
if (CONFIG.LIVE.ALLOW_REPLAY !== true && req.body.saveReplay === true) { const body: LiveVideoCreate = req.body
if (hasValidSaveReplay(body) !== true) {
cleanUpReqFiles(req) cleanUpReqFiles(req)
return res.fail({ return res.fail({
@ -92,14 +109,23 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
}) })
} }
if (req.body.permanentLive && req.body.saveReplay) { if (hasValidLatencyMode(body) !== true) {
cleanUpReqFiles(req)
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Custom latency mode is not allowed by this instance'
})
}
if (body.permanentLive && body.saveReplay) {
cleanUpReqFiles(req) cleanUpReqFiles(req)
return res.fail({ message: 'Cannot set this live as permanent while saving its replay' }) return res.fail({ message: 'Cannot set this live as permanent while saving its replay' })
} }
const user = res.locals.oauth.token.User const user = res.locals.oauth.token.User
if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req) if (!await doesVideoChannelOfAccountExist(body.channelId, user, res)) return cleanUpReqFiles(req)
if (CONFIG.LIVE.MAX_INSTANCE_LIVES !== -1) { if (CONFIG.LIVE.MAX_INSTANCE_LIVES !== -1) {
const totalInstanceLives = await VideoModel.countLocalLives() const totalInstanceLives = await VideoModel.countLocalLives()
@ -141,19 +167,34 @@ const videoLiveUpdateValidator = [
.customSanitizer(toBooleanOrNull) .customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have a valid saveReplay attribute'), .custom(isBooleanValid).withMessage('Should have a valid saveReplay attribute'),
body('latencyMode')
.optional()
.customSanitizer(toIntOrNull)
.custom(isLiveLatencyModeValid)
.withMessage('Should have a valid latency mode attribute'),
(req: express.Request, res: express.Response, next: express.NextFunction) => { (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking videoLiveUpdateValidator parameters', { parameters: req.body }) logger.debug('Checking videoLiveUpdateValidator parameters', { parameters: req.body })
if (areValidationErrors(req, res)) return if (areValidationErrors(req, res)) return
if (req.body.permanentLive && req.body.saveReplay) { const body: LiveVideoUpdate = req.body
if (body.permanentLive && body.saveReplay) {
return res.fail({ message: 'Cannot set this live as permanent while saving its replay' }) return res.fail({ message: 'Cannot set this live as permanent while saving its replay' })
} }
if (CONFIG.LIVE.ALLOW_REPLAY !== true && req.body.saveReplay === true) { if (hasValidSaveReplay(body) !== true) {
return res.fail({ return res.fail({
status: HttpStatusCode.FORBIDDEN_403, status: HttpStatusCode.FORBIDDEN_403,
message: 'Saving live replay is not allowed instance' message: 'Saving live replay is not allowed by this instance'
})
}
if (hasValidLatencyMode(body) !== true) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Custom latency mode is not allowed by this instance'
}) })
} }
@ -203,3 +244,19 @@ async function isLiveVideoAccepted (req: express.Request, res: express.Response)
return true return true
} }
function hasValidSaveReplay (body: LiveVideoUpdate | LiveVideoCreate) {
if (CONFIG.LIVE.ALLOW_REPLAY !== true && body.saveReplay === true) return false
return true
}
function hasValidLatencyMode (body: LiveVideoUpdate | LiveVideoCreate) {
if (
CONFIG.LIVE.LATENCY_SETTING.ENABLED !== true &&
exists(body.latencyMode) &&
body.latencyMode !== LiveVideoLatencyMode.DEFAULT
) return false
return true
}

View File

@ -411,15 +411,6 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
views: video.views, views: video.views,
sensitive: video.nsfw, sensitive: video.nsfw,
waitTranscoding: video.waitTranscoding, waitTranscoding: video.waitTranscoding,
isLiveBroadcast: video.isLive,
liveSaveReplay: video.isLive
? video.VideoLive.saveReplay
: null,
permanentLive: video.isLive
? video.VideoLive.permanentLive
: null,
state: video.state, state: video.state,
commentsEnabled: video.commentsEnabled, commentsEnabled: video.commentsEnabled,
@ -431,10 +422,13 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
: null, : null,
updated: video.updatedAt.toISOString(), updated: video.updatedAt.toISOString(),
mediaType: 'text/markdown', mediaType: 'text/markdown',
content: video.description, content: video.description,
support: video.support, support: video.support,
subtitleLanguage, subtitleLanguage,
icon: icons.map(i => ({ icon: icons.map(i => ({
type: 'Image', type: 'Image',
url: i.getFileUrl(video), url: i.getFileUrl(video),
@ -442,11 +436,14 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
width: i.width, width: i.width,
height: i.height height: i.height
})), })),
url, url,
likes: getLocalVideoLikesActivityPubUrl(video), likes: getLocalVideoLikesActivityPubUrl(video),
dislikes: getLocalVideoDislikesActivityPubUrl(video), dislikes: getLocalVideoDislikesActivityPubUrl(video),
shares: getLocalVideoSharesActivityPubUrl(video), shares: getLocalVideoSharesActivityPubUrl(video),
comments: getLocalVideoCommentsActivityPubUrl(video), comments: getLocalVideoCommentsActivityPubUrl(video),
attributedTo: [ attributedTo: [
{ {
type: 'Person', type: 'Person',
@ -456,7 +453,9 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
type: 'Group', type: 'Group',
id: video.VideoChannel.Actor.url id: video.VideoChannel.Actor.url
} }
] ],
...buildLiveAPAttributes(video)
} }
} }
@ -500,3 +499,23 @@ export {
getPrivacyLabel, getPrivacyLabel,
getStateLabel getStateLabel
} }
// ---------------------------------------------------------------------------
function buildLiveAPAttributes (video: MVideoAP) {
if (!video.isLive) {
return {
isLiveBroadcast: false,
liveSaveReplay: null,
permanentLive: null,
latencyMode: null
}
}
return {
isLiveBroadcast: true,
liveSaveReplay: video.VideoLive.saveReplay,
permanentLive: video.VideoLive.permanentLive,
latencyMode: video.VideoLive.latencyMode
}
}

View File

@ -158,6 +158,7 @@ export class VideoTableAttributes {
'streamKey', 'streamKey',
'saveReplay', 'saveReplay',
'permanentLive', 'permanentLive',
'latencyMode',
'videoId', 'videoId',
'createdAt', 'createdAt',
'updatedAt' 'updatedAt'

View File

@ -1,11 +1,11 @@
import { AllowNull, BelongsTo, Column, CreatedAt, DataType, DefaultScope, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' import { AllowNull, BelongsTo, Column, CreatedAt, DataType, DefaultScope, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
import { CONFIG } from '@server/initializers/config'
import { WEBSERVER } from '@server/initializers/constants' import { WEBSERVER } from '@server/initializers/constants'
import { MVideoLive, MVideoLiveVideo } from '@server/types/models' import { MVideoLive, MVideoLiveVideo } from '@server/types/models'
import { LiveVideo, LiveVideoLatencyMode, VideoState } from '@shared/models'
import { AttributesOnly } from '@shared/typescript-utils' import { AttributesOnly } from '@shared/typescript-utils'
import { LiveVideo, VideoState } from '@shared/models'
import { VideoModel } from './video' import { VideoModel } from './video'
import { VideoBlacklistModel } from './video-blacklist' import { VideoBlacklistModel } from './video-blacklist'
import { CONFIG } from '@server/initializers/config'
@DefaultScope(() => ({ @DefaultScope(() => ({
include: [ include: [
@ -44,6 +44,10 @@ export class VideoLiveModel extends Model<Partial<AttributesOnly<VideoLiveModel>
@Column @Column
permanentLive: boolean permanentLive: boolean
@AllowNull(false)
@Column
latencyMode: LiveVideoLatencyMode
@CreatedAt @CreatedAt
createdAt: Date createdAt: Date
@ -113,7 +117,8 @@ export class VideoLiveModel extends Model<Partial<AttributesOnly<VideoLiveModel>
streamKey: this.streamKey, streamKey: this.streamKey,
permanentLive: this.permanentLive, permanentLive: this.permanentLive,
saveReplay: this.saveReplay saveReplay: this.saveReplay,
latencyMode: this.latencyMode
} }
} }
} }

View File

@ -125,6 +125,9 @@ describe('Test config API validators', function () {
enabled: true, enabled: true,
allowReplay: false, allowReplay: false,
latencySetting: {
enabled: false
},
maxDuration: 30, maxDuration: 30,
maxInstanceLives: -1, maxInstanceLives: -1,
maxUserLives: 50, maxUserLives: 50,

View File

@ -3,7 +3,7 @@
import 'mocha' import 'mocha'
import { omit } from 'lodash' import { omit } from 'lodash'
import { buildAbsoluteFixturePath } from '@shared/core-utils' import { buildAbsoluteFixturePath } from '@shared/core-utils'
import { HttpStatusCode, VideoCreateResult, VideoPrivacy } from '@shared/models' import { HttpStatusCode, LiveVideoLatencyMode, VideoCreateResult, VideoPrivacy } from '@shared/models'
import { import {
cleanupTests, cleanupTests,
createSingleServer, createSingleServer,
@ -38,6 +38,9 @@ describe('Test video lives API validator', function () {
newConfig: { newConfig: {
live: { live: {
enabled: true, enabled: true,
latencySetting: {
enabled: false
},
maxInstanceLives: 20, maxInstanceLives: 20,
maxUserLives: 20, maxUserLives: 20,
allowReplay: true allowReplay: true
@ -81,7 +84,8 @@ describe('Test video lives API validator', function () {
privacy: VideoPrivacy.PUBLIC, privacy: VideoPrivacy.PUBLIC,
channelId, channelId,
saveReplay: false, saveReplay: false,
permanentLive: false permanentLive: false,
latencyMode: LiveVideoLatencyMode.DEFAULT
} }
}) })
@ -214,6 +218,18 @@ describe('Test video lives API validator', function () {
await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
}) })
it('Should fail with bad latency setting', async function () {
const fields = { ...baseCorrectParams, latencyMode: 42 }
await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
})
it('Should fail to set latency if the server does not allow it', async function () {
const fields = { ...baseCorrectParams, latencyMode: LiveVideoLatencyMode.HIGH_LATENCY }
await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
})
it('Should succeed with the correct parameters', async function () { it('Should succeed with the correct parameters', async function () {
this.timeout(30000) this.timeout(30000)
@ -393,6 +409,18 @@ describe('Test video lives API validator', function () {
await command.update({ videoId: video.id, fields, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) await command.update({ videoId: video.id, fields, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
}) })
it('Should fail with bad latency setting', async function () {
const fields = { latencyMode: 42 }
await command.update({ videoId: video.id, fields, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
})
it('Should fail to set latency if the server does not allow it', async function () {
const fields = { latencyMode: LiveVideoLatencyMode.HIGH_LATENCY }
await command.update({ videoId: video.id, fields, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
})
it('Should succeed with the correct params', async function () { it('Should succeed with the correct params', async function () {
await command.update({ videoId: video.id, fields: { saveReplay: false } }) await command.update({ videoId: video.id, fields: { saveReplay: false } })
await command.update({ videoId: video.uuid, fields: { saveReplay: false } }) await command.update({ videoId: video.uuid, fields: { saveReplay: false } })

View File

@ -10,6 +10,7 @@ import {
HttpStatusCode, HttpStatusCode,
LiveVideo, LiveVideo,
LiveVideoCreate, LiveVideoCreate,
LiveVideoLatencyMode,
VideoDetails, VideoDetails,
VideoPrivacy, VideoPrivacy,
VideoState, VideoState,
@ -52,6 +53,9 @@ describe('Test live', function () {
live: { live: {
enabled: true, enabled: true,
allowReplay: true, allowReplay: true,
latencySetting: {
enabled: true
},
transcoding: { transcoding: {
enabled: false enabled: false
} }
@ -85,6 +89,7 @@ describe('Test live', function () {
commentsEnabled: false, commentsEnabled: false,
downloadEnabled: false, downloadEnabled: false,
saveReplay: true, saveReplay: true,
latencyMode: LiveVideoLatencyMode.SMALL_LATENCY,
privacy: VideoPrivacy.PUBLIC, privacy: VideoPrivacy.PUBLIC,
previewfile: 'video_short1-preview.webm.jpg', previewfile: 'video_short1-preview.webm.jpg',
thumbnailfile: 'video_short1.webm.jpg' thumbnailfile: 'video_short1.webm.jpg'
@ -131,6 +136,7 @@ describe('Test live', function () {
} }
expect(live.saveReplay).to.be.true expect(live.saveReplay).to.be.true
expect(live.latencyMode).to.equal(LiveVideoLatencyMode.SMALL_LATENCY)
} }
}) })
@ -175,7 +181,7 @@ describe('Test live', function () {
it('Should update the live', async function () { it('Should update the live', async function () {
this.timeout(10000) this.timeout(10000)
await commands[0].update({ videoId: liveVideoUUID, fields: { saveReplay: false } }) await commands[0].update({ videoId: liveVideoUUID, fields: { saveReplay: false, latencyMode: LiveVideoLatencyMode.DEFAULT } })
await waitJobs(servers) await waitJobs(servers)
}) })
@ -192,6 +198,7 @@ describe('Test live', function () {
} }
expect(live.saveReplay).to.be.false expect(live.saveReplay).to.be.false
expect(live.latencyMode).to.equal(LiveVideoLatencyMode.DEFAULT)
} }
}) })

View File

@ -82,6 +82,7 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
expect(data.live.enabled).to.be.false expect(data.live.enabled).to.be.false
expect(data.live.allowReplay).to.be.false expect(data.live.allowReplay).to.be.false
expect(data.live.latencySetting.enabled).to.be.true
expect(data.live.maxDuration).to.equal(-1) expect(data.live.maxDuration).to.equal(-1)
expect(data.live.maxInstanceLives).to.equal(20) expect(data.live.maxInstanceLives).to.equal(20)
expect(data.live.maxUserLives).to.equal(3) expect(data.live.maxUserLives).to.equal(3)
@ -185,6 +186,7 @@ function checkUpdatedConfig (data: CustomConfig) {
expect(data.live.enabled).to.be.true expect(data.live.enabled).to.be.true
expect(data.live.allowReplay).to.be.true expect(data.live.allowReplay).to.be.true
expect(data.live.latencySetting.enabled).to.be.false
expect(data.live.maxDuration).to.equal(5000) expect(data.live.maxDuration).to.equal(5000)
expect(data.live.maxInstanceLives).to.equal(-1) expect(data.live.maxInstanceLives).to.equal(-1)
expect(data.live.maxUserLives).to.equal(10) expect(data.live.maxUserLives).to.equal(10)
@ -326,6 +328,9 @@ const newCustomConfig: CustomConfig = {
live: { live: {
enabled: true, enabled: true,
allowReplay: true, allowReplay: true,
latencySetting: {
enabled: false
},
maxDuration: 5000, maxDuration: 5000,
maxInstanceLives: -1, maxInstanceLives: -1,
maxUserLives: 10, maxUserLives: 10,

View File

@ -5,7 +5,7 @@ import {
ActivityTagObject, ActivityTagObject,
ActivityUrlObject ActivityUrlObject
} from './common-objects' } from './common-objects'
import { VideoState } from '../../videos' import { LiveVideoLatencyMode, VideoState } from '../../videos'
export interface VideoObject { export interface VideoObject {
type: 'Video' type: 'Video'
@ -25,6 +25,7 @@ export interface VideoObject {
isLiveBroadcast: boolean isLiveBroadcast: boolean
liveSaveReplay: boolean liveSaveReplay: boolean
permanentLive: boolean permanentLive: boolean
latencyMode: LiveVideoLatencyMode
commentsEnabled: boolean commentsEnabled: boolean
downloadEnabled: boolean downloadEnabled: boolean

View File

@ -131,6 +131,10 @@ export interface CustomConfig {
allowReplay: boolean allowReplay: boolean
latencySetting: {
enabled: boolean
}
maxDuration: number maxDuration: number
maxInstanceLives: number maxInstanceLives: number
maxUserLives: number maxUserLives: number

View File

@ -149,10 +149,14 @@ export interface ServerConfig {
live: { live: {
enabled: boolean enabled: boolean
allowReplay: boolean
latencySetting: {
enabled: boolean
}
maxDuration: number maxDuration: number
maxInstanceLives: number maxInstanceLives: number
maxUserLives: number maxUserLives: number
allowReplay: boolean
transcoding: { transcoding: {
enabled: boolean enabled: boolean

View File

@ -1,5 +1,6 @@
export * from './live-video-create.model' export * from './live-video-create.model'
export * from './live-video-event-payload.model' export * from './live-video-event-payload.model'
export * from './live-video-event.type' export * from './live-video-event.type'
export * from './live-video-latency-mode.enum'
export * from './live-video-update.model' export * from './live-video-update.model'
export * from './live-video.model' export * from './live-video.model'

View File

@ -1,6 +1,8 @@
import { LiveVideoLatencyMode } from '.'
import { VideoCreate } from '../video-create.model' import { VideoCreate } from '../video-create.model'
export interface LiveVideoCreate extends VideoCreate { export interface LiveVideoCreate extends VideoCreate {
saveReplay?: boolean saveReplay?: boolean
permanentLive?: boolean permanentLive?: boolean
latencyMode?: LiveVideoLatencyMode
} }

View File

@ -0,0 +1,5 @@
export const enum LiveVideoLatencyMode {
DEFAULT = 1,
HIGH_LATENCY = 2,
SMALL_LATENCY = 3
}

View File

@ -1,4 +1,7 @@
import { LiveVideoLatencyMode } from './live-video-latency-mode.enum'
export interface LiveVideoUpdate { export interface LiveVideoUpdate {
permanentLive?: boolean permanentLive?: boolean
saveReplay?: boolean saveReplay?: boolean
latencyMode?: LiveVideoLatencyMode
} }

View File

@ -1,8 +1,12 @@
import { LiveVideoLatencyMode } from './live-video-latency-mode.enum'
export interface LiveVideo { export interface LiveVideo {
rtmpUrl: string rtmpUrl: string
rtmpsUrl: string rtmpsUrl: string
streamKey: string streamKey: string
saveReplay: boolean saveReplay: boolean
permanentLive: boolean permanentLive: boolean
latencyMode: LiveVideoLatencyMode
} }

View File

@ -292,6 +292,9 @@ export class ConfigCommand extends AbstractCommand {
live: { live: {
enabled: true, enabled: true,
allowReplay: false, allowReplay: false,
latencySetting: {
enabled: false
},
maxDuration: -1, maxDuration: -1,
maxInstanceLives: -1, maxInstanceLives: -1,
maxUserLives: 50, maxUserLives: 50,

View File

@ -2295,6 +2295,9 @@ paths:
permanentLive: permanentLive:
description: User can stream multiple times in a permanent live description: User can stream multiple times in a permanent live
type: boolean type: boolean
latencyMode:
description: User can select live latency mode if enabled by the instance
$ref: '#/components/schemas/LiveVideoLatencyMode'
thumbnailfile: thumbnailfile:
description: Live video/replay thumbnail file description: Live video/replay thumbnail file
type: string type: string
@ -5291,6 +5294,14 @@ components:
description: 'Admin flags for the user (None = `0`, Bypass video blocklist = `1`)' description: 'Admin flags for the user (None = `0`, Bypass video blocklist = `1`)'
example: 1 example: 1
LiveVideoLatencyMode:
type: integer
enum:
- 1
- 2
- 3
description: 'The live latency mode (Default = `1`, HIght latency = `2`, Small Latency = `3`)'
VideoStateConstant: VideoStateConstant:
properties: properties:
id: id:
@ -7482,6 +7493,9 @@ components:
permanentLive: permanentLive:
description: User can stream multiple times in a permanent live description: User can stream multiple times in a permanent live
type: boolean type: boolean
latencyMode:
description: User can select live latency mode if enabled by the instance
$ref: '#/components/schemas/LiveVideoLatencyMode'
LiveVideoResponse: LiveVideoResponse:
properties: properties:
@ -7497,8 +7511,9 @@ components:
permanentLive: permanentLive:
description: User can stream multiple times in a permanent live description: User can stream multiple times in a permanent live
type: boolean type: boolean
latencyMode:
description: User can select live latency mode if enabled by the instance
$ref: '#/components/schemas/LiveVideoLatencyMode'
callbacks: callbacks:
searchIndex: searchIndex: