Add latency setting support
This commit is contained in:
parent
01dd04cd5a
commit
f443a74649
|
@ -189,6 +189,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
|
|||
maxInstanceLives: MAX_INSTANCE_LIVES_VALIDATOR,
|
||||
maxUserLives: MAX_USER_LIVES_VALIDATOR,
|
||||
allowReplay: null,
|
||||
latencySetting: {
|
||||
enabled: null
|
||||
},
|
||||
|
||||
transcoding: {
|
||||
enabled: null,
|
||||
|
|
|
@ -36,6 +36,18 @@
|
|||
</my-peertube-checkbox>
|
||||
</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()">
|
||||
<label i18n for="liveMaxInstanceLives">
|
||||
Max simultaneous lives created on your instance <span class="text-muted">(-1 for "unlimited")</span>
|
||||
|
|
|
@ -289,6 +289,17 @@
|
|||
</ng-container>
|
||||
</my-peertube-checkbox>
|
||||
</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>
|
||||
</ng-template>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { forkJoin } from 'rxjs'
|
||||
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 { AbstractControl, FormArray, FormControl, FormGroup, Validators } from '@angular/forms'
|
||||
import { HooksService, PluginService, ServerService } from '@app/core'
|
||||
|
@ -26,6 +26,7 @@ import { PluginInfo } from '@root-helpers/plugins-manager'
|
|||
import {
|
||||
HTMLServerConfig,
|
||||
LiveVideo,
|
||||
LiveVideoLatencyMode,
|
||||
RegisterClientFormFieldOptions,
|
||||
RegisterClientVideoFieldOptions,
|
||||
VideoConstant,
|
||||
|
@ -78,6 +79,23 @@ export class VideoEditComponent implements OnInit, OnDestroy {
|
|||
videoCategories: VideoConstant<number>[] = []
|
||||
videoLicences: VideoConstant<number>[] = []
|
||||
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
|
||||
|
||||
|
@ -141,6 +159,7 @@ export class VideoEditComponent implements OnInit, OnDestroy {
|
|||
originallyPublishedAt: VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR,
|
||||
liveStreamKey: null,
|
||||
permanentLive: null,
|
||||
latencyMode: null,
|
||||
saveReplay: null
|
||||
}
|
||||
|
||||
|
@ -273,6 +292,10 @@ export class VideoEditComponent implements OnInit, OnDestroy {
|
|||
return this.form.value['permanentLive'] === true
|
||||
}
|
||||
|
||||
isLatencyModeEnabled () {
|
||||
return this.serverConfig.live.latencySetting.enabled
|
||||
}
|
||||
|
||||
isPluginFieldHidden (pluginField: PluginField) {
|
||||
if (typeof pluginField.commonOptions.hidden !== 'function') return false
|
||||
|
||||
|
|
|
@ -64,6 +64,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
|
|||
if (this.liveVideo) {
|
||||
this.form.patchValue({
|
||||
saveReplay: this.liveVideo.saveReplay,
|
||||
latencyMode: this.liveVideo.latencyMode,
|
||||
permanentLive: this.liveVideo.permanentLive
|
||||
})
|
||||
}
|
||||
|
@ -127,7 +128,8 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
|
|||
|
||||
const liveVideoUpdate: LiveVideoUpdate = {
|
||||
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
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
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 { PlatformLocation } from '@angular/common'
|
||||
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 { Video, VideoCaptionService, VideoDetails, VideoService } from '@app/shared/shared-main'
|
||||
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 { timeToInt } from '@shared/core-utils'
|
||||
import {
|
||||
HTMLServerConfig,
|
||||
HttpStatusCode,
|
||||
LiveVideo,
|
||||
PeerTubeProblemDocument,
|
||||
ServerErrorCode,
|
||||
VideoCaption,
|
||||
|
@ -63,6 +65,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
|
||||
video: VideoDetails = null
|
||||
videoCaptions: VideoCaption[] = []
|
||||
liveVideo: LiveVideo
|
||||
|
||||
playlistPosition: number
|
||||
playlist: VideoPlaylist = null
|
||||
|
@ -89,6 +92,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
private router: Router,
|
||||
private videoService: VideoService,
|
||||
private playlistService: VideoPlaylistService,
|
||||
private liveVideoService: LiveVideoService,
|
||||
private confirmService: ConfirmService,
|
||||
private metaService: MetaService,
|
||||
private authService: AuthService,
|
||||
|
@ -239,12 +243,21 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
'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([
|
||||
videoObs,
|
||||
videoAndLiveObs,
|
||||
this.videoCaptionService.listCaptions(videoId),
|
||||
this.userService.getAnonymousOrLoggedUser()
|
||||
]).subscribe({
|
||||
next: ([ video, captionsResult, loggedInOrAnonymousUser ]) => {
|
||||
next: ([ { video, live }, captionsResult, loggedInOrAnonymousUser ]) => {
|
||||
const queryParams = this.route.snapshot.queryParams
|
||||
|
||||
const urlOptions = {
|
||||
|
@ -261,7 +274,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
peertubeLink: false
|
||||
}
|
||||
|
||||
this.onVideoFetched({ video, videoCaptions: captionsResult.data, loggedInOrAnonymousUser, urlOptions })
|
||||
this.onVideoFetched({ video, live, videoCaptions: captionsResult.data, loggedInOrAnonymousUser, urlOptions })
|
||||
.catch(err => this.handleGlobalError(err))
|
||||
},
|
||||
|
||||
|
@ -330,16 +343,18 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
|
||||
private async onVideoFetched (options: {
|
||||
video: VideoDetails
|
||||
live: LiveVideo
|
||||
videoCaptions: VideoCaption[]
|
||||
urlOptions: URLOptions
|
||||
loggedInOrAnonymousUser: User
|
||||
}) {
|
||||
const { video, videoCaptions, urlOptions, loggedInOrAnonymousUser } = options
|
||||
const { video, live, videoCaptions, urlOptions, loggedInOrAnonymousUser } = options
|
||||
|
||||
this.subscribeToLiveEventsIfNeeded(this.video, video)
|
||||
|
||||
this.video = video
|
||||
this.videoCaptions = videoCaptions
|
||||
this.liveVideo = live
|
||||
|
||||
// Re init attributes
|
||||
this.playerPlaceholderImgSrc = undefined
|
||||
|
@ -387,6 +402,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
const params = {
|
||||
video: this.video,
|
||||
videoCaptions: this.videoCaptions,
|
||||
liveVideo: this.liveVideo,
|
||||
urlOptions,
|
||||
loggedInOrAnonymousUser,
|
||||
user: this.user
|
||||
|
@ -532,12 +548,13 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
|
||||
private buildPlayerManagerOptions (params: {
|
||||
video: VideoDetails
|
||||
liveVideo: LiveVideo
|
||||
videoCaptions: VideoCaption[]
|
||||
urlOptions: CustomizationOptions & { playerMode: PlayerMode }
|
||||
loggedInOrAnonymousUser: User
|
||||
user?: AuthUser
|
||||
}) {
|
||||
const { video, videoCaptions, urlOptions, loggedInOrAnonymousUser, user } = params
|
||||
const { video, liveVideo, videoCaptions, urlOptions, loggedInOrAnonymousUser, user } = params
|
||||
|
||||
const getStartTime = () => {
|
||||
const byUrl = urlOptions.startTime !== undefined
|
||||
|
@ -562,6 +579,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
src: environment.apiUrl + c.captionPath
|
||||
}))
|
||||
|
||||
const liveOptions = video.isLive
|
||||
? { latencyMode: liveVideo.latencyMode }
|
||||
: undefined
|
||||
|
||||
const options: PeertubePlayerManagerOptions = {
|
||||
common: {
|
||||
autoplay: this.isAutoplay(),
|
||||
|
@ -597,6 +618,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
embedTitle: video.name,
|
||||
|
||||
isLive: video.isLive,
|
||||
liveOptions,
|
||||
|
||||
language: this.localeId,
|
||||
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import videojs from 'video.js'
|
||||
import { HybridLoaderSettings } from '@peertube/p2p-media-loader-core'
|
||||
import { HlsJsEngineSettings } from '@peertube/p2p-media-loader-hlsjs'
|
||||
import { PluginsManager } from '@root-helpers/plugins-manager'
|
||||
import { buildVideoLink, decorateVideoLink } from '@shared/core-utils'
|
||||
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 { RedundancyUrlManager } from './p2p-media-loader/redundancy-url-manager'
|
||||
import { segmentUrlBuilderFactory } from './p2p-media-loader/segment-url-builder'
|
||||
|
@ -19,7 +20,6 @@ import {
|
|||
VideoJSPluginOptions
|
||||
} from './peertube-videojs-typings'
|
||||
import { buildVideoOrPlaylistEmbed, getRtcConfig, isIOS, isSafari } from './utils'
|
||||
import { HybridLoaderSettings } from '@peertube/p2p-media-loader-core'
|
||||
|
||||
export type PlayerMode = 'webtorrent' | 'p2p-media-loader'
|
||||
|
||||
|
@ -76,6 +76,9 @@ export interface CommonOptions extends CustomizationOptions {
|
|||
embedTitle: string
|
||||
|
||||
isLive: boolean
|
||||
liveOptions?: {
|
||||
latencyMode: LiveVideoLatencyMode
|
||||
}
|
||||
|
||||
language?: string
|
||||
|
||||
|
@ -250,21 +253,8 @@ export class PeertubePlayerOptionsBuilder {
|
|||
.filter(t => t.startsWith('ws'))
|
||||
|
||||
const specificLiveOrVODOptions = this.options.common.isLive
|
||||
? { // Live
|
||||
requiredSegmentsPriority: 1
|
||||
}
|
||||
: { // VOD
|
||||
requiredSegmentsPriority: 3,
|
||||
|
||||
cachedSegmentExpiration: 86400000,
|
||||
cachedSegmentsCount: 100,
|
||||
|
||||
httpDownloadMaxPriority: 9,
|
||||
httpDownloadProbability: 0.06,
|
||||
httpDownloadProbabilitySkipIfNoPeers: true,
|
||||
|
||||
p2pDownloadMaxPriority: 50
|
||||
}
|
||||
? this.getP2PMediaLoaderLiveOptions()
|
||||
: this.getP2PMediaLoaderVODOptions()
|
||||
|
||||
return {
|
||||
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) {
|
||||
const specificLiveOrVODOptions = this.options.common.isLive
|
||||
? this.getHLSLiveOptions()
|
||||
: this.getHLSVODOptions()
|
||||
|
||||
const base = {
|
||||
capLevelToPlayerSize: true,
|
||||
autoStartLoad: false,
|
||||
liveSyncDurationCount: 5,
|
||||
|
||||
loader: new this.p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass()
|
||||
loader: new this.p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass(),
|
||||
|
||||
...specificLiveOrVODOptions
|
||||
}
|
||||
|
||||
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) {
|
||||
const commonOptions = this.options.common
|
||||
const webtorrentOptions = this.options.webtorrent
|
||||
|
|
|
@ -6,6 +6,7 @@ import { peertubeTranslate } from '../../../../shared/core-utils/i18n'
|
|||
import {
|
||||
HTMLServerConfig,
|
||||
HttpStatusCode,
|
||||
LiveVideo,
|
||||
OAuth2ErrorCode,
|
||||
ResultList,
|
||||
UserRefreshToken,
|
||||
|
@ -94,6 +95,10 @@ export class PeerTubeEmbed {
|
|||
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) {
|
||||
return fetch(url, options)
|
||||
.then((res: Response) => {
|
||||
|
@ -166,6 +171,12 @@ export class PeerTubeEmbed {
|
|||
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> {
|
||||
return this.refreshFetch(this.getPlaylistUrl(playlistId), { headers: this.headers })
|
||||
}
|
||||
|
@ -475,13 +486,15 @@ export class PeerTubeEmbed {
|
|||
.then(res => res.json())
|
||||
}
|
||||
|
||||
const videoInfoPromise = videoResponse.json()
|
||||
const videoInfoPromise: Promise<{ video: VideoDetails, live?: LiveVideo }> = videoResponse.json()
|
||||
.then((videoInfo: VideoDetails) => {
|
||||
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([
|
||||
|
@ -493,11 +506,15 @@ export class PeerTubeEmbed {
|
|||
|
||||
await this.loadPlugins(serverTranslations)
|
||||
|
||||
const videoInfo: VideoDetails = videoInfoTmp
|
||||
const { video: videoInfo, live } = videoInfoTmp
|
||||
|
||||
const PeertubePlayerManager = PeertubePlayerManagerModule.PeertubePlayerManager
|
||||
const videoCaptions = await this.buildCaptions(serverTranslations, captionsResponse)
|
||||
|
||||
const liveOptions = videoInfo.isLive
|
||||
? { latencyMode: live.latencyMode }
|
||||
: undefined
|
||||
|
||||
const playlistPlugin = this.currentPlaylistElement
|
||||
? {
|
||||
elements: this.playlistElements,
|
||||
|
@ -545,6 +562,7 @@ export class PeerTubeEmbed {
|
|||
videoUUID: videoInfo.uuid,
|
||||
|
||||
isLive: videoInfo.isLive,
|
||||
liveOptions,
|
||||
|
||||
playerElement: this.playerElement,
|
||||
onPlayerElementChange: (element: HTMLVideoElement) => {
|
||||
|
@ -726,7 +744,7 @@ export class PeerTubeEmbed {
|
|||
return []
|
||||
}
|
||||
|
||||
private loadPlaceholder (video: VideoDetails) {
|
||||
private buildPlaceholder (video: VideoDetails) {
|
||||
const placeholder = this.getPlaceholderElement()
|
||||
|
||||
const url = window.location.origin + video.previewPath
|
||||
|
|
|
@ -392,6 +392,12 @@ live:
|
|||
# /!\ transcoding.enabled (and not live.transcoding.enabled) has to be true to create a replay
|
||||
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
|
||||
rtmp:
|
||||
enabled: true
|
||||
|
|
|
@ -400,6 +400,12 @@ live:
|
|||
# /!\ transcoding.enabled (and not live.transcoding.enabled) has to be true to create a replay
|
||||
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
|
||||
rtmp:
|
||||
enabled: true
|
||||
|
|
|
@ -237,6 +237,9 @@ function customConfig (): CustomConfig {
|
|||
live: {
|
||||
enabled: CONFIG.LIVE.ENABLED,
|
||||
allowReplay: CONFIG.LIVE.ALLOW_REPLAY,
|
||||
latencySetting: {
|
||||
enabled: CONFIG.LIVE.LATENCY_SETTING.ENABLED
|
||||
},
|
||||
maxDuration: CONFIG.LIVE.MAX_DURATION,
|
||||
maxInstanceLives: CONFIG.LIVE.MAX_INSTANCE_LIVES,
|
||||
maxUserLives: CONFIG.LIVE.MAX_USER_LIVES,
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import express from 'express'
|
||||
import { exists } from '@server/helpers/custom-validators/misc'
|
||||
import { createReqFiles } from '@server/helpers/express-utils'
|
||||
import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants'
|
||||
import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url'
|
||||
|
@ -9,7 +10,7 @@ import { videoLiveAddValidator, videoLiveGetValidator, videoLiveUpdateValidator
|
|||
import { VideoLiveModel } from '@server/models/video/video-live'
|
||||
import { MVideoDetails, MVideoFullLight } from '@server/types/models'
|
||||
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 { sequelizeTypescript } from '../../../initializers/database'
|
||||
import { updateVideoMiniatureFromExisting } from '../../../lib/thumbnail'
|
||||
|
@ -60,8 +61,9 @@ async function updateLiveVideo (req: express.Request, res: express.Response) {
|
|||
const video = res.locals.videoAll
|
||||
const videoLive = res.locals.videoLive
|
||||
|
||||
videoLive.saveReplay = body.saveReplay || false
|
||||
videoLive.permanentLive = body.permanentLive || false
|
||||
if (exists(body.saveReplay)) videoLive.saveReplay = body.saveReplay
|
||||
if (exists(body.permanentLive)) videoLive.permanentLive = body.permanentLive
|
||||
if (exists(body.latencyMode)) videoLive.latencyMode = body.latencyMode
|
||||
|
||||
video.VideoLive = await videoLive.save()
|
||||
|
||||
|
@ -87,6 +89,7 @@ async function addLiveVideo (req: express.Request, res: express.Response) {
|
|||
const videoLive = new VideoLiveModel()
|
||||
videoLive.saveReplay = videoInfo.saveReplay || false
|
||||
videoLive.permanentLive = videoInfo.permanentLive || false
|
||||
videoLive.latencyMode = videoInfo.latencyMode || LiveVideoLatencyMode.DEFAULT
|
||||
videoLive.streamKey = buildUUID()
|
||||
|
||||
const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({
|
||||
|
|
|
@ -50,6 +50,10 @@ function getContextData (type: ContextType) {
|
|||
'@type': 'sc:Boolean',
|
||||
'@id': 'pt:permanentLive'
|
||||
},
|
||||
latencyMode: {
|
||||
'@type': 'sc:Number',
|
||||
'@id': 'pt:latencyMode'
|
||||
},
|
||||
|
||||
Infohash: 'pt:Infohash',
|
||||
Playlist: 'pt:Playlist',
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import validator from 'validator'
|
||||
import { logger } from '@server/helpers/logger'
|
||||
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 { peertubeTruncate } from '../../core-utils'
|
||||
import { exists, isArray, isBooleanValid, isDateValid, isUUIDValid } from '../misc'
|
||||
import { isLiveLatencyModeValid } from '../video-lives'
|
||||
import {
|
||||
isVideoDurationValid,
|
||||
isVideoNameValid,
|
||||
|
@ -65,6 +66,7 @@ function sanitizeAndCheckVideoTorrentObject (video: any) {
|
|||
if (!isBooleanValid(video.isLiveBroadcast)) video.isLiveBroadcast = false
|
||||
if (!isBooleanValid(video.liveSaveReplay)) video.liveSaveReplay = false
|
||||
if (!isBooleanValid(video.permanentLive)) video.permanentLive = false
|
||||
if (!isLiveLatencyModeValid(video.latencyMode)) video.latencyMode = LiveVideoLatencyMode.DEFAULT
|
||||
|
||||
return isActivityPubUrlValid(video.id) &&
|
||||
isVideoNameValid(video.name) &&
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import { FfmpegCommand, FilterSpecification } from 'fluent-ffmpeg'
|
||||
import { join } from 'path'
|
||||
import { VIDEO_LIVE } from '@server/initializers/constants'
|
||||
import { AvailableEncoders } from '@shared/models'
|
||||
import { AvailableEncoders, LiveVideoLatencyMode } from '@shared/models'
|
||||
import { logger, loggerTagsFactory } from '../logger'
|
||||
import { buildStreamSuffix, getFFmpeg, getScaleFilter, StreamType } from './ffmpeg-commons'
|
||||
import { getEncoderBuilderResult } from './ffmpeg-encoders'
|
||||
|
@ -15,6 +15,7 @@ async function getLiveTranscodingCommand (options: {
|
|||
|
||||
outPath: string
|
||||
masterPlaylistName: string
|
||||
latencyMode: LiveVideoLatencyMode
|
||||
|
||||
resolutions: number[]
|
||||
|
||||
|
@ -26,7 +27,7 @@ async function getLiveTranscodingCommand (options: {
|
|||
availableEncoders: AvailableEncoders
|
||||
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')
|
||||
|
||||
|
@ -120,14 +121,21 @@ async function getLiveTranscodingCommand (options: {
|
|||
|
||||
command.complexFilter(complexFilter)
|
||||
|
||||
addDefaultLiveHLSParams(command, outPath, masterPlaylistName)
|
||||
addDefaultLiveHLSParams({ command, outPath, masterPlaylistName, latencyMode })
|
||||
|
||||
command.outputOption('-var_stream_map', varStreamMap.join(' '))
|
||||
|
||||
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')
|
||||
|
||||
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:v?')
|
||||
|
||||
addDefaultLiveHLSParams(command, outPath, masterPlaylistName)
|
||||
addDefaultLiveHLSParams({ command, outPath, masterPlaylistName, latencyMode })
|
||||
|
||||
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 {
|
||||
getLiveSegmentTime,
|
||||
|
||||
getLiveTranscodingCommand,
|
||||
getLiveMuxingCommand
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function addDefaultLiveHLSParams (command: FfmpegCommand, outPath: string, masterPlaylistName: string) {
|
||||
command.outputOption('-hls_time ' + VIDEO_LIVE.SEGMENT_TIME_SECONDS)
|
||||
function addDefaultLiveHLSParams (options: {
|
||||
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_flags delete_segments+independent_segments')
|
||||
command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`)
|
||||
|
|
|
@ -49,8 +49,8 @@ function checkMissedConfig () {
|
|||
'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.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.rtmp.enabled', 'live.rtmp.port', 'live.rtmp.hostname',
|
||||
'live.enabled', 'live.allow_replay', 'live.latency_setting.enabled', 'live.max_duration',
|
||||
'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.transcoding.enabled', 'live.transcoding.threads', 'live.transcoding.profile',
|
||||
'live.transcoding.resolutions.144p', 'live.transcoding.resolutions.240p', 'live.transcoding.resolutions.360p',
|
||||
|
|
|
@ -4,9 +4,9 @@ import { dirname, join } from 'path'
|
|||
import { decacheModule } from '@server/helpers/decache'
|
||||
import { VideoRedundancyConfigFilter } from '@shared/models/redundancy/video-redundancy-config-filter.type'
|
||||
import { BroadcastMessageLevel } from '@shared/models/server'
|
||||
import { buildPath, root } from '../../shared/core-utils'
|
||||
import { VideoPrivacy, VideosRedundancyStrategy } from '../../shared/models'
|
||||
import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type'
|
||||
import { buildPath, root } from '../../shared/core-utils'
|
||||
import { parseBytes, parseDurationToMs } from '../helpers/core-utils'
|
||||
|
||||
// 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') },
|
||||
|
||||
LATENCY_SETTING: {
|
||||
get ENABLED () { return config.get<boolean>('live.latency_setting.enabled') }
|
||||
},
|
||||
|
||||
RTMP: {
|
||||
get ENABLED () { return config.get<boolean>('live.rtmp.enabled') },
|
||||
get PORT () { return config.get<number>('live.rtmp.port') },
|
||||
|
|
|
@ -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 = {
|
||||
EXTENSION: '.ts',
|
||||
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
|
||||
REPLAY_DIRECTORY: 'replay',
|
||||
EDGE_LIVE_DELAY_SEGMENTS_NOTIFICATION: 4,
|
||||
|
@ -842,7 +845,8 @@ if (isTestInstance() === true) {
|
|||
PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME = 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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -151,6 +151,7 @@ function getLiveAttributesFromObject (video: MVideoId, videoObject: VideoObject)
|
|||
return {
|
||||
saveReplay: videoObject.liveSaveReplay,
|
||||
permanentLive: videoObject.permanentLive,
|
||||
latencyMode: videoObject.latencyMode,
|
||||
videoId: video.id
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,9 +5,10 @@ import { createServer as createServerTLS, Server as ServerTLS } from 'tls'
|
|||
import {
|
||||
computeLowerResolutionsToTranscode,
|
||||
ffprobePromise,
|
||||
getLiveSegmentTime,
|
||||
getVideoStreamBitrate,
|
||||
getVideoStreamFPS,
|
||||
getVideoStreamDimensionsInfo
|
||||
getVideoStreamDimensionsInfo,
|
||||
getVideoStreamFPS
|
||||
} from '@server/helpers/ffmpeg'
|
||||
import { logger, loggerTagsFactory } from '@server/helpers/logger'
|
||||
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 }))
|
||||
|
||||
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) {
|
||||
logger.error('Cannot save/federate live video %d.', videoId, { err, ...localLTags })
|
||||
}
|
||||
|
|
|
@ -125,6 +125,8 @@ class MuxingSession extends EventEmitter {
|
|||
outPath,
|
||||
masterPlaylistName: this.streamingPlaylist.playlistFilename,
|
||||
|
||||
latencyMode: this.videoLive.latencyMode,
|
||||
|
||||
resolutions: this.allResolutions,
|
||||
fps: this.fps,
|
||||
bitrate: this.bitrate,
|
||||
|
@ -133,7 +135,12 @@ class MuxingSession extends EventEmitter {
|
|||
availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
|
||||
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())
|
||||
|
||||
|
|
|
@ -137,6 +137,10 @@ class ServerConfigManager {
|
|||
enabled: CONFIG.LIVE.ENABLED,
|
||||
|
||||
allowReplay: CONFIG.LIVE.ALLOW_REPLAY,
|
||||
latencySetting: {
|
||||
enabled: CONFIG.LIVE.LATENCY_SETTING.ENABLED
|
||||
},
|
||||
|
||||
maxDuration: CONFIG.LIVE.MAX_DURATION,
|
||||
maxInstanceLives: CONFIG.LIVE.MAX_INSTANCE_LIVES,
|
||||
maxUserLives: CONFIG.LIVE.MAX_USER_LIVES,
|
||||
|
|
|
@ -1,12 +1,21 @@
|
|||
import express from 'express'
|
||||
import { body } from 'express-validator'
|
||||
import { isLiveLatencyModeValid } from '@server/helpers/custom-validators/video-lives'
|
||||
import { CONSTRAINTS_FIELDS } from '@server/initializers/constants'
|
||||
import { isLocalLiveVideoAccepted } from '@server/lib/moderation'
|
||||
import { Hooks } from '@server/lib/plugins/hooks'
|
||||
import { VideoModel } from '@server/models/video/video'
|
||||
import { VideoLiveModel } from '@server/models/video/video-live'
|
||||
import { HttpStatusCode, ServerErrorCode, UserRight, VideoState } from '@shared/models'
|
||||
import { isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc'
|
||||
import {
|
||||
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 { cleanUpReqFiles } from '../../../helpers/express-utils'
|
||||
import { logger } from '../../../helpers/logger'
|
||||
|
@ -67,6 +76,12 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
|
|||
.customSanitizer(toBooleanOrNull)
|
||||
.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) => {
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
return res.fail({ message: 'Cannot set this live as permanent while saving its replay' })
|
||||
}
|
||||
|
||||
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) {
|
||||
const totalInstanceLives = await VideoModel.countLocalLives()
|
||||
|
@ -141,19 +167,34 @@ const videoLiveUpdateValidator = [
|
|||
.customSanitizer(toBooleanOrNull)
|
||||
.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) => {
|
||||
logger.debug('Checking videoLiveUpdateValidator parameters', { parameters: req.body })
|
||||
|
||||
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' })
|
||||
}
|
||||
|
||||
if (CONFIG.LIVE.ALLOW_REPLAY !== true && req.body.saveReplay === true) {
|
||||
if (hasValidSaveReplay(body) !== true) {
|
||||
return res.fail({
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
@ -411,15 +411,6 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
|
|||
views: video.views,
|
||||
sensitive: video.nsfw,
|
||||
waitTranscoding: video.waitTranscoding,
|
||||
isLiveBroadcast: video.isLive,
|
||||
|
||||
liveSaveReplay: video.isLive
|
||||
? video.VideoLive.saveReplay
|
||||
: null,
|
||||
|
||||
permanentLive: video.isLive
|
||||
? video.VideoLive.permanentLive
|
||||
: null,
|
||||
|
||||
state: video.state,
|
||||
commentsEnabled: video.commentsEnabled,
|
||||
|
@ -431,10 +422,13 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
|
|||
: null,
|
||||
|
||||
updated: video.updatedAt.toISOString(),
|
||||
|
||||
mediaType: 'text/markdown',
|
||||
content: video.description,
|
||||
support: video.support,
|
||||
|
||||
subtitleLanguage,
|
||||
|
||||
icon: icons.map(i => ({
|
||||
type: 'Image',
|
||||
url: i.getFileUrl(video),
|
||||
|
@ -442,11 +436,14 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
|
|||
width: i.width,
|
||||
height: i.height
|
||||
})),
|
||||
|
||||
url,
|
||||
|
||||
likes: getLocalVideoLikesActivityPubUrl(video),
|
||||
dislikes: getLocalVideoDislikesActivityPubUrl(video),
|
||||
shares: getLocalVideoSharesActivityPubUrl(video),
|
||||
comments: getLocalVideoCommentsActivityPubUrl(video),
|
||||
|
||||
attributedTo: [
|
||||
{
|
||||
type: 'Person',
|
||||
|
@ -456,7 +453,9 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
|
|||
type: 'Group',
|
||||
id: video.VideoChannel.Actor.url
|
||||
}
|
||||
]
|
||||
],
|
||||
|
||||
...buildLiveAPAttributes(video)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -500,3 +499,23 @@ export {
|
|||
getPrivacyLabel,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -158,6 +158,7 @@ export class VideoTableAttributes {
|
|||
'streamKey',
|
||||
'saveReplay',
|
||||
'permanentLive',
|
||||
'latencyMode',
|
||||
'videoId',
|
||||
'createdAt',
|
||||
'updatedAt'
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
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 { MVideoLive, MVideoLiveVideo } from '@server/types/models'
|
||||
import { LiveVideo, LiveVideoLatencyMode, VideoState } from '@shared/models'
|
||||
import { AttributesOnly } from '@shared/typescript-utils'
|
||||
import { LiveVideo, VideoState } from '@shared/models'
|
||||
import { VideoModel } from './video'
|
||||
import { VideoBlacklistModel } from './video-blacklist'
|
||||
import { CONFIG } from '@server/initializers/config'
|
||||
|
||||
@DefaultScope(() => ({
|
||||
include: [
|
||||
|
@ -44,6 +44,10 @@ export class VideoLiveModel extends Model<Partial<AttributesOnly<VideoLiveModel>
|
|||
@Column
|
||||
permanentLive: boolean
|
||||
|
||||
@AllowNull(false)
|
||||
@Column
|
||||
latencyMode: LiveVideoLatencyMode
|
||||
|
||||
@CreatedAt
|
||||
createdAt: Date
|
||||
|
||||
|
@ -113,7 +117,8 @@ export class VideoLiveModel extends Model<Partial<AttributesOnly<VideoLiveModel>
|
|||
|
||||
streamKey: this.streamKey,
|
||||
permanentLive: this.permanentLive,
|
||||
saveReplay: this.saveReplay
|
||||
saveReplay: this.saveReplay,
|
||||
latencyMode: this.latencyMode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -125,6 +125,9 @@ describe('Test config API validators', function () {
|
|||
enabled: true,
|
||||
|
||||
allowReplay: false,
|
||||
latencySetting: {
|
||||
enabled: false
|
||||
},
|
||||
maxDuration: 30,
|
||||
maxInstanceLives: -1,
|
||||
maxUserLives: 50,
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
import 'mocha'
|
||||
import { omit } from 'lodash'
|
||||
import { buildAbsoluteFixturePath } from '@shared/core-utils'
|
||||
import { HttpStatusCode, VideoCreateResult, VideoPrivacy } from '@shared/models'
|
||||
import { HttpStatusCode, LiveVideoLatencyMode, VideoCreateResult, VideoPrivacy } from '@shared/models'
|
||||
import {
|
||||
cleanupTests,
|
||||
createSingleServer,
|
||||
|
@ -38,6 +38,9 @@ describe('Test video lives API validator', function () {
|
|||
newConfig: {
|
||||
live: {
|
||||
enabled: true,
|
||||
latencySetting: {
|
||||
enabled: false
|
||||
},
|
||||
maxInstanceLives: 20,
|
||||
maxUserLives: 20,
|
||||
allowReplay: true
|
||||
|
@ -81,7 +84,8 @@ describe('Test video lives API validator', function () {
|
|||
privacy: VideoPrivacy.PUBLIC,
|
||||
channelId,
|
||||
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 })
|
||||
})
|
||||
|
||||
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 () {
|
||||
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 })
|
||||
})
|
||||
|
||||
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 () {
|
||||
await command.update({ videoId: video.id, fields: { saveReplay: false } })
|
||||
await command.update({ videoId: video.uuid, fields: { saveReplay: false } })
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
HttpStatusCode,
|
||||
LiveVideo,
|
||||
LiveVideoCreate,
|
||||
LiveVideoLatencyMode,
|
||||
VideoDetails,
|
||||
VideoPrivacy,
|
||||
VideoState,
|
||||
|
@ -52,6 +53,9 @@ describe('Test live', function () {
|
|||
live: {
|
||||
enabled: true,
|
||||
allowReplay: true,
|
||||
latencySetting: {
|
||||
enabled: true
|
||||
},
|
||||
transcoding: {
|
||||
enabled: false
|
||||
}
|
||||
|
@ -85,6 +89,7 @@ describe('Test live', function () {
|
|||
commentsEnabled: false,
|
||||
downloadEnabled: false,
|
||||
saveReplay: true,
|
||||
latencyMode: LiveVideoLatencyMode.SMALL_LATENCY,
|
||||
privacy: VideoPrivacy.PUBLIC,
|
||||
previewfile: 'video_short1-preview.webm.jpg',
|
||||
thumbnailfile: 'video_short1.webm.jpg'
|
||||
|
@ -131,6 +136,7 @@ describe('Test live', function () {
|
|||
}
|
||||
|
||||
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 () {
|
||||
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)
|
||||
})
|
||||
|
||||
|
@ -192,6 +198,7 @@ describe('Test live', function () {
|
|||
}
|
||||
|
||||
expect(live.saveReplay).to.be.false
|
||||
expect(live.latencyMode).to.equal(LiveVideoLatencyMode.DEFAULT)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
@ -82,6 +82,7 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
|
|||
|
||||
expect(data.live.enabled).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.maxInstanceLives).to.equal(20)
|
||||
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.allowReplay).to.be.true
|
||||
expect(data.live.latencySetting.enabled).to.be.false
|
||||
expect(data.live.maxDuration).to.equal(5000)
|
||||
expect(data.live.maxInstanceLives).to.equal(-1)
|
||||
expect(data.live.maxUserLives).to.equal(10)
|
||||
|
@ -326,6 +328,9 @@ const newCustomConfig: CustomConfig = {
|
|||
live: {
|
||||
enabled: true,
|
||||
allowReplay: true,
|
||||
latencySetting: {
|
||||
enabled: false
|
||||
},
|
||||
maxDuration: 5000,
|
||||
maxInstanceLives: -1,
|
||||
maxUserLives: 10,
|
||||
|
|
|
@ -5,7 +5,7 @@ import {
|
|||
ActivityTagObject,
|
||||
ActivityUrlObject
|
||||
} from './common-objects'
|
||||
import { VideoState } from '../../videos'
|
||||
import { LiveVideoLatencyMode, VideoState } from '../../videos'
|
||||
|
||||
export interface VideoObject {
|
||||
type: 'Video'
|
||||
|
@ -25,6 +25,7 @@ export interface VideoObject {
|
|||
isLiveBroadcast: boolean
|
||||
liveSaveReplay: boolean
|
||||
permanentLive: boolean
|
||||
latencyMode: LiveVideoLatencyMode
|
||||
|
||||
commentsEnabled: boolean
|
||||
downloadEnabled: boolean
|
||||
|
|
|
@ -131,6 +131,10 @@ export interface CustomConfig {
|
|||
|
||||
allowReplay: boolean
|
||||
|
||||
latencySetting: {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
maxDuration: number
|
||||
maxInstanceLives: number
|
||||
maxUserLives: number
|
||||
|
|
|
@ -149,10 +149,14 @@ export interface ServerConfig {
|
|||
live: {
|
||||
enabled: boolean
|
||||
|
||||
allowReplay: boolean
|
||||
latencySetting: {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
maxDuration: number
|
||||
maxInstanceLives: number
|
||||
maxUserLives: number
|
||||
allowReplay: boolean
|
||||
|
||||
transcoding: {
|
||||
enabled: boolean
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
export * from './live-video-create.model'
|
||||
export * from './live-video-event-payload.model'
|
||||
export * from './live-video-event.type'
|
||||
export * from './live-video-latency-mode.enum'
|
||||
export * from './live-video-update.model'
|
||||
export * from './live-video.model'
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import { LiveVideoLatencyMode } from '.'
|
||||
import { VideoCreate } from '../video-create.model'
|
||||
|
||||
export interface LiveVideoCreate extends VideoCreate {
|
||||
saveReplay?: boolean
|
||||
permanentLive?: boolean
|
||||
latencyMode?: LiveVideoLatencyMode
|
||||
}
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
export const enum LiveVideoLatencyMode {
|
||||
DEFAULT = 1,
|
||||
HIGH_LATENCY = 2,
|
||||
SMALL_LATENCY = 3
|
||||
}
|
|
@ -1,4 +1,7 @@
|
|||
import { LiveVideoLatencyMode } from './live-video-latency-mode.enum'
|
||||
|
||||
export interface LiveVideoUpdate {
|
||||
permanentLive?: boolean
|
||||
saveReplay?: boolean
|
||||
latencyMode?: LiveVideoLatencyMode
|
||||
}
|
||||
|
|
|
@ -1,8 +1,12 @@
|
|||
import { LiveVideoLatencyMode } from './live-video-latency-mode.enum'
|
||||
|
||||
export interface LiveVideo {
|
||||
rtmpUrl: string
|
||||
rtmpsUrl: string
|
||||
|
||||
streamKey: string
|
||||
|
||||
saveReplay: boolean
|
||||
permanentLive: boolean
|
||||
latencyMode: LiveVideoLatencyMode
|
||||
}
|
||||
|
|
|
@ -292,6 +292,9 @@ export class ConfigCommand extends AbstractCommand {
|
|||
live: {
|
||||
enabled: true,
|
||||
allowReplay: false,
|
||||
latencySetting: {
|
||||
enabled: false
|
||||
},
|
||||
maxDuration: -1,
|
||||
maxInstanceLives: -1,
|
||||
maxUserLives: 50,
|
||||
|
|
|
@ -2295,6 +2295,9 @@ paths:
|
|||
permanentLive:
|
||||
description: User can stream multiple times in a permanent live
|
||||
type: boolean
|
||||
latencyMode:
|
||||
description: User can select live latency mode if enabled by the instance
|
||||
$ref: '#/components/schemas/LiveVideoLatencyMode'
|
||||
thumbnailfile:
|
||||
description: Live video/replay thumbnail file
|
||||
type: string
|
||||
|
@ -5291,6 +5294,14 @@ components:
|
|||
description: 'Admin flags for the user (None = `0`, Bypass video blocklist = `1`)'
|
||||
example: 1
|
||||
|
||||
LiveVideoLatencyMode:
|
||||
type: integer
|
||||
enum:
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
description: 'The live latency mode (Default = `1`, HIght latency = `2`, Small Latency = `3`)'
|
||||
|
||||
VideoStateConstant:
|
||||
properties:
|
||||
id:
|
||||
|
@ -7482,6 +7493,9 @@ components:
|
|||
permanentLive:
|
||||
description: User can stream multiple times in a permanent live
|
||||
type: boolean
|
||||
latencyMode:
|
||||
description: User can select live latency mode if enabled by the instance
|
||||
$ref: '#/components/schemas/LiveVideoLatencyMode'
|
||||
|
||||
LiveVideoResponse:
|
||||
properties:
|
||||
|
@ -7497,8 +7511,9 @@ components:
|
|||
permanentLive:
|
||||
description: User can stream multiple times in a permanent live
|
||||
type: boolean
|
||||
|
||||
|
||||
latencyMode:
|
||||
description: User can select live latency mode if enabled by the instance
|
||||
$ref: '#/components/schemas/LiveVideoLatencyMode'
|
||||
|
||||
callbacks:
|
||||
searchIndex:
|
||||
|
|
Loading…
Reference in New Issue