Live streaming implementation first step

This commit is contained in:
Chocobozzz 2020-09-17 09:20:52 +02:00 committed by Chocobozzz
parent 110d463fec
commit c6c0fa6cd8
80 changed files with 2752 additions and 1303 deletions

View File

@ -699,6 +699,87 @@
</ng-template> </ng-template>
</ng-container> </ng-container>
<ng-container ngbNavItem="live">
<a ngbNavLink i18n>Live streaming</a>
<ng-template ngbNavContent>
<div class="form-row mt-5">
<div class="form-group col-12 col-lg-4 col-xl-3">
<div i18n class="inner-form-title">LIVE</div>
<div i18n class="inner-form-description">
Add ability for your users to do live streaming on your instance.
</div>
</div>
<div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
<ng-container formGroupName="live">
<div class="form-group">
<my-peertube-checkbox inputName="liveEnabled" formControlName="enabled">
<ng-template ptTemplate="label">
<ng-container i18n>Allow live streaming</ng-container>
</ng-template>
<ng-template ptTemplate="help">
<ng-container i18n>Enabling live streaming requires trust in your users and extra moderation work</ng-container>
</ng-template>
<ng-container ngProjectAs="extra" formGroupName="transcoding">
<div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isLiveEnabled() }">
<my-peertube-checkbox
inputName="liveTranscodingEnabled" formControlName="enabled"
i18n-labelText labelText="Enable live transcoding"
>
<ng-container ngProjectAs="description">
Requires a lot of CPU!
</ng-container>
</my-peertube-checkbox>
</div>
<div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isLiveEnabled() || !isLiveTranscodingEnabled() }">
<label i18n for="liveTranscodingThreads">Live transcoding threads</label>
<div class="peertube-select-container">
<select id="liveTranscodingThreads" formControlName="threads" class="form-control">
<option *ngFor="let transcodingThreadOption of transcodingThreadOptions" [value]="transcodingThreadOption.value">
{{ transcodingThreadOption.label }}
</option>
</select>
</div>
<div *ngIf="formErrors.live.transcoding.threads" class="form-error">{{ formErrors.live.transcoding.threads }}</div>
</div>
<div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isLiveEnabled() || !isLiveTranscodingEnabled() }">
<label i18n for="liveTranscodingThreads">Live resolutions to generate</label>
<div class="ml-2 mt-2 d-flex flex-column">
<ng-container formGroupName="resolutions">
<div class="form-group" *ngFor="let resolution of liveResolutions">
<my-peertube-checkbox
[inputName]="getResolutionKey(resolution.id)" [formControlName]="resolution.id"
labelText="{{resolution.label}}"
>
<ng-template *ngIf="resolution.description" ptTemplate="help">
<div [innerHTML]="resolution.description"></div>
</ng-template>
</my-peertube-checkbox>
</div>
</ng-container>
</div>
</div>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</div>
</div>
</ng-template>
</ng-container>
<ng-container ngbNavItem="advanced-configuration"> <ng-container ngbNavItem="advanced-configuration">
<a ngbNavLink i18n>Advanced configuration</a> <a ngbNavLink i18n>Advanced configuration</a>
@ -814,7 +895,7 @@
<div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isTranscodingEnabled() }"> <div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isTranscodingEnabled() }">
<label i18n for="transcodingThreads">Resolutions to generate</label> <label i18n>Resolutions to generate</label>
<div class="ml-2 mt-2 d-flex flex-column"> <div class="ml-2 mt-2 d-flex flex-column">
<ng-container formGroupName="resolutions"> <ng-container formGroupName="resolutions">

View File

@ -34,6 +34,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
customConfig: CustomConfig customConfig: CustomConfig
resolutions: { id: string, label: string, description?: string }[] = [] resolutions: { id: string, label: string, description?: string }[] = []
liveResolutions: { id: string, label: string, description?: string }[] = []
transcodingThreadOptions: { label: string, value: number }[] = [] transcodingThreadOptions: { label: string, value: number }[] = []
languageItems: SelectOptionsItem[] = [] languageItems: SelectOptionsItem[] = []
@ -82,6 +83,8 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
} }
] ]
this.liveResolutions = this.resolutions.filter(r => r.id !== '0p')
this.transcodingThreadOptions = [ this.transcodingThreadOptions = [
{ value: 0, label: $localize`Auto (via ffmpeg)` }, { value: 0, label: $localize`Auto (via ffmpeg)` },
{ value: 1, label: '1' }, { value: 1, label: '1' },
@ -198,6 +201,15 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
enabled: null enabled: null
} }
}, },
live: {
enabled: null,
transcoding: {
enabled: null,
threads: TRANSCODING_THREADS_VALIDATOR,
resolutions: {}
}
},
autoBlacklist: { autoBlacklist: {
videos: { videos: {
ofUsers: { ofUsers: {
@ -245,13 +257,24 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
const defaultValues = { const defaultValues = {
transcoding: { transcoding: {
resolutions: {} resolutions: {}
},
live: {
transcoding: {
resolutions: {}
}
} }
} }
for (const resolution of this.resolutions) { for (const resolution of this.resolutions) {
defaultValues.transcoding.resolutions[resolution.id] = 'false' defaultValues.transcoding.resolutions[resolution.id] = 'false'
formGroupData.transcoding.resolutions[resolution.id] = null formGroupData.transcoding.resolutions[resolution.id] = null
} }
for (const resolution of this.liveResolutions) {
defaultValues.live.transcoding.resolutions[resolution.id] = 'false'
formGroupData.live.transcoding.resolutions[resolution.id] = null
}
this.buildForm(formGroupData) this.buildForm(formGroupData)
this.loadForm() this.loadForm()
this.checkTranscodingFields() this.checkTranscodingFields()
@ -268,6 +291,14 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
return this.form.value['transcoding']['enabled'] === true return this.form.value['transcoding']['enabled'] === true
} }
isLiveEnabled () {
return this.form.value['live']['enabled'] === true
}
isLiveTranscodingEnabled () {
return this.form.value['live']['transcoding']['enabled'] === true
}
isSignupEnabled () { isSignupEnabled () {
return this.form.value['signup']['enabled'] === true return this.form.value['signup']['enabled'] === true
} }

View File

@ -0,0 +1,35 @@
import { FormGroup } from '@angular/forms'
import { VideoEdit } from '@app/shared/shared-main'
function hydrateFormFromVideo (formGroup: FormGroup, video: VideoEdit, thumbnailFiles: boolean) {
formGroup.patchValue(video.toFormPatch())
if (thumbnailFiles === false) return
const objects = [
{
url: 'thumbnailUrl',
name: 'thumbnailfile'
},
{
url: 'previewUrl',
name: 'previewfile'
}
]
for (const obj of objects) {
if (!video[obj.url]) continue
fetch(video[obj.url])
.then(response => response.blob())
.then(data => {
formGroup.patchValue({
[ obj.name ]: data
})
})
}
}
export {
hydrateFormFromVideo
}

View File

@ -195,6 +195,29 @@
</ng-template> </ng-template>
</ng-container> </ng-container>
<ng-container ngbNavItem *ngIf="videoLive">
<a ngbNavLink i18n>Live settings</a>
<ng-template ngbNavContent>
<div class="row live-settings">
<div class="col-md-12">
<div class="form-group">
<label for="videoLiveRTMPUrl" i18n>Live RTMP Url</label>
<my-input-readonly-copy id="videoLiveRTMPUrl" [value]="videoLive.rtmpUrl"></my-input-readonly-copy>
</div>
<div class="form-group">
<label for="videoLiveStreamKey" i18n>Live stream key</label>
<my-input-readonly-copy id="videoLiveStreamKey" [value]="videoLive.streamKey"></my-input-readonly-copy>
</div>
</div>
</div>
</ng-template>
</ng-container>
<ng-container ngbNavItem> <ng-container ngbNavItem>
<a ngbNavLink i18n>Advanced settings</a> <a ngbNavLink i18n>Advanced settings</a>

View File

@ -20,10 +20,11 @@ import {
import { FormReactiveValidationMessages, FormValidatorService, SelectChannelItem } from '@app/shared/shared-forms' import { FormReactiveValidationMessages, FormValidatorService, SelectChannelItem } from '@app/shared/shared-forms'
import { InstanceService } from '@app/shared/shared-instance' import { InstanceService } from '@app/shared/shared-instance'
import { VideoCaptionEdit, VideoEdit, VideoService } from '@app/shared/shared-main' import { VideoCaptionEdit, VideoEdit, VideoService } from '@app/shared/shared-main'
import { ServerConfig, VideoConstant, VideoPrivacy } from '@shared/models' import { ServerConfig, VideoConstant, VideoLive, VideoPrivacy } from '@shared/models'
import { RegisterClientFormFieldOptions, RegisterClientVideoFieldOptions } from '@shared/models/plugins/register-client-form-field.model' import { RegisterClientFormFieldOptions, RegisterClientVideoFieldOptions } from '@shared/models/plugins/register-client-form-field.model'
import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service' import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service'
import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component' import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
import { VideoEditType } from './video-edit.type'
type VideoLanguages = VideoConstant<string> & { group?: string } type VideoLanguages = VideoConstant<string> & { group?: string }
@ -40,7 +41,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
@Input() schedulePublicationPossible = true @Input() schedulePublicationPossible = true
@Input() videoCaptions: (VideoCaptionEdit & { captionPath?: string })[] = [] @Input() videoCaptions: (VideoCaptionEdit & { captionPath?: string })[] = []
@Input() waitTranscodingEnabled = true @Input() waitTranscodingEnabled = true
@Input() type: 'import-url' | 'import-torrent' | 'upload' | 'update' @Input() type: VideoEditType
@Input() videoLive: VideoLive
@ViewChild('videoCaptionAddModal', { static: true }) videoCaptionAddModal: VideoCaptionAddModalComponent @ViewChild('videoCaptionAddModal', { static: true }) videoCaptionAddModal: VideoCaptionAddModalComponent
@ -124,7 +126,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
previewfile: null, previewfile: null,
support: VIDEO_SUPPORT_VALIDATOR, support: VIDEO_SUPPORT_VALIDATOR,
schedulePublicationAt: VIDEO_SCHEDULE_PUBLICATION_AT_VALIDATOR, schedulePublicationAt: VIDEO_SCHEDULE_PUBLICATION_AT_VALIDATOR,
originallyPublishedAt: VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR originallyPublishedAt: VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR,
liveStreamKey: null
} }
this.formValidatorService.updateForm( this.formValidatorService.updateForm(
@ -320,7 +323,12 @@ export class VideoEditComponent implements OnInit, OnDestroy {
const currentSupport = this.form.value[ 'support' ] const currentSupport = this.form.value[ 'support' ]
// First time we set the channel? // First time we set the channel?
if (isNaN(oldChannelId) && !currentSupport) return this.updateSupportField(newChannel.support) if (isNaN(oldChannelId)) {
// Fill support if it's empty
if (!currentSupport) this.updateSupportField(newChannel.support)
return
}
const oldChannel = this.userVideoChannels.find(c => c.id === oldChannelId) const oldChannel = this.userVideoChannels.find(c => c.id === oldChannelId)
if (!newChannel || !oldChannel) { if (!newChannel || !oldChannel) {

View File

@ -0,0 +1 @@
export type VideoEditType = 'update' | 'upload' | 'import-url' | 'import-torrent' | 'go-live'

View File

@ -0,0 +1,47 @@
<div *ngIf="!isInUpdateForm" class="upload-video-container">
<div class="first-step-block">
<my-global-icon class="upload-icon" iconName="upload" aria-hidden="true"></my-global-icon>
<div class="form-group">
<label i18n for="first-step-channel">Channel</label>
<my-select-channel
labelForId="first-step-channel" [items]="userVideoChannels" [(ngModel)]="firstStepChannelId"
></my-select-channel>
</div>
<div class="form-group">
<label i18n for="first-step-privacy">Privacy</label>
<my-select-options
labelForId="first-step-privacy" [items]="videoPrivacies" [(ngModel)]="firstStepPrivacyId"
></my-select-options>
</div>
<input
type="button" i18n-value value="Go Live" (click)="goLive()"
/>
</div>
</div>
<div *ngIf="error" class="alert alert-danger">
<div i18n>Sorry, but something went wrong</div>
{{ error }}
</div>
<!-- Hidden because we want to load the component -->
<form [hidden]="!isInUpdateForm" novalidate [formGroup]="form">
<my-video-edit
[form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [schedulePublicationPossible]="false"
[validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels" [videoLive]="videoLive"
type="go-live"
></my-video-edit>
<div class="submit-container">
<div class="submit-button"
(click)="updateSecondStep()"
[ngClass]="{ disabled: !form.valid }"
>
<my-global-icon iconName="circle-tick" aria-hidden="true"></my-global-icon>
<input type="button" i18n-value value="Update" />
</div>
</div>
</form>

View File

@ -0,0 +1,129 @@
import { Component, EventEmitter, OnInit, Output } from '@angular/core'
import { Router } from '@angular/router'
import { AuthService, CanComponentDeactivate, Notifier, ServerService } from '@app/core'
import { scrollToTop } from '@app/helpers'
import { FormValidatorService } from '@app/shared/shared-forms'
import { VideoCaptionService, VideoEdit, VideoService, VideoLiveService } from '@app/shared/shared-main'
import { LoadingBarService } from '@ngx-loading-bar/core'
import { VideoCreate, VideoLive, VideoPrivacy } from '@shared/models'
import { VideoSend } from './video-send'
@Component({
selector: 'my-video-go-live',
templateUrl: './video-go-live.component.html',
styleUrls: [
'../shared/video-edit.component.scss',
'./video-send.scss'
]
})
export class VideoGoLiveComponent extends VideoSend implements OnInit, CanComponentDeactivate {
@Output() firstStepDone = new EventEmitter<string>()
@Output() firstStepError = new EventEmitter<void>()
isInUpdateForm = false
videoLive: VideoLive
videoId: number
videoUUID: string
error: string
protected readonly DEFAULT_VIDEO_PRIVACY = VideoPrivacy.PUBLIC
constructor (
protected formValidatorService: FormValidatorService,
protected loadingBar: LoadingBarService,
protected notifier: Notifier,
protected authService: AuthService,
protected serverService: ServerService,
protected videoService: VideoService,
protected videoCaptionService: VideoCaptionService,
private videoLiveService: VideoLiveService,
private router: Router
) {
super()
}
ngOnInit () {
super.ngOnInit()
}
canDeactivate () {
return { canDeactivate: true }
}
goLive () {
const video: VideoCreate = {
name: 'Live',
privacy: VideoPrivacy.PRIVATE,
nsfw: this.serverConfig.instance.isNSFW,
waitTranscoding: true,
commentsEnabled: true,
downloadEnabled: true,
channelId: this.firstStepChannelId
}
this.firstStepDone.emit(name)
// Go live in private mode, but correctly fill the update form with the first user choice
const toPatch = Object.assign({}, video, { privacy: this.firstStepPrivacyId })
this.form.patchValue(toPatch)
this.videoLiveService.goLive(video).subscribe(
res => {
this.videoId = res.video.id
this.videoUUID = res.video.uuid
this.isInUpdateForm = true
this.fetchVideoLive()
},
err => {
this.firstStepError.emit()
this.notifier.error(err.message)
}
)
}
updateSecondStep () {
if (this.checkForm() === false) {
return
}
const video = new VideoEdit()
video.patch(this.form.value)
video.id = this.videoId
video.uuid = this.videoUUID
// Update the video
this.updateVideoAndCaptions(video)
.subscribe(
() => {
this.notifier.success($localize`Live published.`)
this.router.navigate([ '/videos/watch', video.uuid ])
},
err => {
this.error = err.message
scrollToTop()
console.error(err)
}
)
}
private fetchVideoLive () {
this.videoLiveService.getVideoLive(this.videoId)
.subscribe(
videoLive => {
this.videoLive = videoLive
},
err => {
this.firstStepError.emit()
this.notifier.error(err.message)
}
)
}
}

View File

@ -6,6 +6,7 @@ import { FormValidatorService } from '@app/shared/shared-forms'
import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main' import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main'
import { LoadingBarService } from '@ngx-loading-bar/core' import { LoadingBarService } from '@ngx-loading-bar/core'
import { VideoPrivacy, VideoUpdate } from '@shared/models' import { VideoPrivacy, VideoUpdate } from '@shared/models'
import { hydrateFormFromVideo } from '../shared/video-edit-utils'
import { VideoSend } from './video-send' import { VideoSend } from './video-send'
@Component({ @Component({
@ -99,7 +100,7 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Ca
previewUrl: null previewUrl: null
})) }))
this.hydrateFormFromVideo() hydrateFormFromVideo(this.form, this.video, false)
}, },
err => { err => {
@ -136,10 +137,5 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Ca
console.error(err) console.error(err)
} }
) )
}
private hydrateFormFromVideo () {
this.form.patchValue(this.video.toFormPatch())
} }
} }

View File

@ -7,6 +7,7 @@ import { FormValidatorService } from '@app/shared/shared-forms'
import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main' import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main'
import { LoadingBarService } from '@ngx-loading-bar/core' import { LoadingBarService } from '@ngx-loading-bar/core'
import { VideoPrivacy, VideoUpdate } from '@shared/models' import { VideoPrivacy, VideoUpdate } from '@shared/models'
import { hydrateFormFromVideo } from '../shared/video-edit-utils'
import { VideoSend } from './video-send' import { VideoSend } from './video-send'
@Component({ @Component({
@ -109,7 +110,7 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, CanCom
this.videoCaptions = videoCaptions this.videoCaptions = videoCaptions
this.hydrateFormFromVideo() hydrateFormFromVideo(this.form, this.video, true)
}, },
err => { err => {
@ -146,31 +147,5 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, CanCom
console.error(err) console.error(err)
} }
) )
}
private hydrateFormFromVideo () {
this.form.patchValue(this.video.toFormPatch())
const objects = [
{
url: 'thumbnailUrl',
name: 'thumbnailfile'
},
{
url: 'previewUrl',
name: 'previewfile'
}
]
for (const obj of objects) {
fetch(this.video[obj.url])
.then(response => response.blob())
.then(data => {
this.form.patchValue({
[ obj.name ]: data
})
})
}
} }
} }

View File

@ -157,7 +157,6 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
this.waitTranscodingEnabled = false this.waitTranscodingEnabled = false
} }
const privacy = this.firstStepPrivacyId.toString()
const nsfw = this.serverConfig.instance.isNSFW const nsfw = this.serverConfig.instance.isNSFW
const waitTranscoding = true const waitTranscoding = true
const commentsEnabled = true const commentsEnabled = true

View File

@ -50,7 +50,17 @@
<my-video-import-torrent #videoImportTorrent (firstStepDone)="onFirstStepDone('import-torrent', $event)" (firstStepError)="onError()"></my-video-import-torrent> <my-video-import-torrent #videoImportTorrent (firstStepDone)="onFirstStepDone('import-torrent', $event)" (firstStepError)="onError()"></my-video-import-torrent>
</ng-template> </ng-template>
</ng-container> </ng-container>
<ng-container ngbNavItem *ngIf="isVideoLiveEnabled()">
<a ngbNavLink>
<span i18n>Go live</span>
</a>
<ng-template ngbNavContent>
<my-video-go-live #videoGoLive (firstStepDone)="onFirstStepDone('go-live', $event)" (firstStepError)="onError()"></my-video-go-live>
</ng-template>
</ng-container>
</div> </div>
<div [ngbNavOutlet]="nav"></div> <div [ngbNavOutlet]="nav"></div>
</div> </div>

View File

@ -1,6 +1,8 @@
import { Component, HostListener, OnInit, ViewChild } from '@angular/core' import { Component, HostListener, OnInit, ViewChild } from '@angular/core'
import { AuthService, AuthUser, CanComponentDeactivate, ServerService } from '@app/core' import { AuthService, AuthUser, CanComponentDeactivate, ServerService } from '@app/core'
import { ServerConfig } from '@shared/models' import { ServerConfig } from '@shared/models'
import { VideoEditType } from './shared/video-edit.type'
import { VideoGoLiveComponent } from './video-add-components/video-go-live.component'
import { VideoImportTorrentComponent } from './video-add-components/video-import-torrent.component' import { VideoImportTorrentComponent } from './video-add-components/video-import-torrent.component'
import { VideoImportUrlComponent } from './video-add-components/video-import-url.component' import { VideoImportUrlComponent } from './video-add-components/video-import-url.component'
import { VideoUploadComponent } from './video-add-components/video-upload.component' import { VideoUploadComponent } from './video-add-components/video-upload.component'
@ -14,10 +16,11 @@ export class VideoAddComponent implements OnInit, CanComponentDeactivate {
@ViewChild('videoUpload') videoUpload: VideoUploadComponent @ViewChild('videoUpload') videoUpload: VideoUploadComponent
@ViewChild('videoImportUrl') videoImportUrl: VideoImportUrlComponent @ViewChild('videoImportUrl') videoImportUrl: VideoImportUrlComponent
@ViewChild('videoImportTorrent') videoImportTorrent: VideoImportTorrentComponent @ViewChild('videoImportTorrent') videoImportTorrent: VideoImportTorrentComponent
@ViewChild('videoGoLive') videoGoLive: VideoGoLiveComponent
user: AuthUser = null user: AuthUser = null
secondStepType: 'upload' | 'import-url' | 'import-torrent' secondStepType: VideoEditType
videoName: string videoName: string
serverConfig: ServerConfig serverConfig: ServerConfig
@ -41,7 +44,7 @@ export class VideoAddComponent implements OnInit, CanComponentDeactivate {
this.user = this.auth.getUser() this.user = this.auth.getUser()
} }
onFirstStepDone (type: 'upload' | 'import-url' | 'import-torrent', videoName: string) { onFirstStepDone (type: VideoEditType, videoName: string) {
this.secondStepType = type this.secondStepType = type
this.videoName = videoName this.videoName = videoName
} }
@ -62,9 +65,9 @@ export class VideoAddComponent implements OnInit, CanComponentDeactivate {
} }
canDeactivate (): { canDeactivate: boolean, text?: string} { canDeactivate (): { canDeactivate: boolean, text?: string} {
if (this.secondStepType === 'upload') return this.videoUpload.canDeactivate()
if (this.secondStepType === 'import-url') return this.videoImportUrl.canDeactivate() if (this.secondStepType === 'import-url') return this.videoImportUrl.canDeactivate()
if (this.secondStepType === 'import-torrent') return this.videoImportTorrent.canDeactivate() if (this.secondStepType === 'import-torrent') return this.videoImportTorrent.canDeactivate()
if (this.secondStepType === 'go-live') return this.videoGoLive.canDeactivate()
return { canDeactivate: true } return { canDeactivate: true }
} }
@ -77,6 +80,10 @@ export class VideoAddComponent implements OnInit, CanComponentDeactivate {
return this.serverConfig.import.videos.torrent.enabled return this.serverConfig.import.videos.torrent.enabled
} }
isVideoLiveEnabled () {
return this.serverConfig.live.enabled
}
isInSecondStep () { isInSecondStep () {
return !!this.secondStepType return !!this.secondStepType
} }

View File

@ -4,6 +4,7 @@ import { VideoEditModule } from './shared/video-edit.module'
import { DragDropDirective } from './video-add-components/drag-drop.directive' import { DragDropDirective } from './video-add-components/drag-drop.directive'
import { VideoImportTorrentComponent } from './video-add-components/video-import-torrent.component' import { VideoImportTorrentComponent } from './video-add-components/video-import-torrent.component'
import { VideoImportUrlComponent } from './video-add-components/video-import-url.component' import { VideoImportUrlComponent } from './video-add-components/video-import-url.component'
import { VideoGoLiveComponent } from './video-add-components/video-go-live.component'
import { VideoUploadComponent } from './video-add-components/video-upload.component' import { VideoUploadComponent } from './video-add-components/video-upload.component'
import { VideoAddRoutingModule } from './video-add-routing.module' import { VideoAddRoutingModule } from './video-add-routing.module'
import { VideoAddComponent } from './video-add.component' import { VideoAddComponent } from './video-add.component'
@ -20,7 +21,8 @@ import { VideoAddComponent } from './video-add.component'
VideoUploadComponent, VideoUploadComponent,
VideoImportUrlComponent, VideoImportUrlComponent,
VideoImportTorrentComponent, VideoImportTorrentComponent,
DragDropDirective DragDropDirective,
VideoGoLiveComponent
], ],
exports: [ ], exports: [ ],

View File

@ -11,6 +11,7 @@
[validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels" [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels"
[videoCaptions]="videoCaptions" [waitTranscodingEnabled]="waitTranscodingEnabled" [videoCaptions]="videoCaptions" [waitTranscodingEnabled]="waitTranscodingEnabled"
type="update" (pluginFieldsAdded)="hydratePluginFieldsFromVideo()" type="update" (pluginFieldsAdded)="hydratePluginFieldsFromVideo()"
[videoLive]="videoLive"
></my-video-edit> ></my-video-edit>
<div class="submit-container"> <div class="submit-container">

View File

@ -5,7 +5,8 @@ import { Notifier } from '@app/core'
import { FormReactive, FormValidatorService, SelectChannelItem } from '@app/shared/shared-forms' import { FormReactive, FormValidatorService, SelectChannelItem } from '@app/shared/shared-forms'
import { VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main' import { VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main'
import { LoadingBarService } from '@ngx-loading-bar/core' import { LoadingBarService } from '@ngx-loading-bar/core'
import { VideoPrivacy } from '@shared/models' import { VideoPrivacy, VideoLive } from '@shared/models'
import { hydrateFormFromVideo } from './shared/video-edit-utils'
@Component({ @Component({
selector: 'my-videos-update', selector: 'my-videos-update',
@ -14,11 +15,12 @@ import { VideoPrivacy } from '@shared/models'
}) })
export class VideoUpdateComponent extends FormReactive implements OnInit { export class VideoUpdateComponent extends FormReactive implements OnInit {
video: VideoEdit video: VideoEdit
userVideoChannels: SelectChannelItem[] = []
videoCaptions: VideoCaptionEdit[] = []
videoLive: VideoLive
isUpdatingVideo = false isUpdatingVideo = false
userVideoChannels: SelectChannelItem[] = []
schedulePublicationPossible = false schedulePublicationPossible = false
videoCaptions: VideoCaptionEdit[] = []
waitTranscodingEnabled = true waitTranscodingEnabled = true
private updateDone = false private updateDone = false
@ -40,10 +42,11 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
this.route.data this.route.data
.pipe(map(data => data.videoData)) .pipe(map(data => data.videoData))
.subscribe(({ video, videoChannels, videoCaptions }) => { .subscribe(({ video, videoChannels, videoCaptions, videoLive }) => {
this.video = new VideoEdit(video) this.video = new VideoEdit(video)
this.userVideoChannels = videoChannels this.userVideoChannels = videoChannels
this.videoCaptions = videoCaptions this.videoCaptions = videoCaptions
this.videoLive = videoLive
this.schedulePublicationPossible = this.video.privacy === VideoPrivacy.PRIVATE this.schedulePublicationPossible = this.video.privacy === VideoPrivacy.PRIVATE
@ -53,7 +56,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
} }
// FIXME: Angular does not detect the change inside this subscription, so use the patched setTimeout // FIXME: Angular does not detect the change inside this subscription, so use the patched setTimeout
setTimeout(() => this.hydrateFormFromVideo()) setTimeout(() => hydrateFormFromVideo(this.form, this.video, true))
}, },
err => { err => {
@ -133,29 +136,4 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
pluginData: this.video.pluginData pluginData: this.video.pluginData
}) })
} }
private hydrateFormFromVideo () {
this.form.patchValue(this.video.toFormPatch())
const objects = [
{
url: 'thumbnailUrl',
name: 'thumbnailfile'
},
{
url: 'previewUrl',
name: 'previewfile'
}
]
for (const obj of objects) {
fetch(this.video[obj.url])
.then(response => response.blob())
.then(data => {
this.form.patchValue({
[ obj.name ]: data
})
})
}
}
} }

View File

@ -1,13 +1,14 @@
import { forkJoin } from 'rxjs' import { forkJoin, of } from 'rxjs'
import { map, switchMap } from 'rxjs/operators' import { map, switchMap } from 'rxjs/operators'
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { ActivatedRouteSnapshot, Resolve } from '@angular/router' import { ActivatedRouteSnapshot, Resolve } from '@angular/router'
import { VideoCaptionService, VideoChannelService, VideoService } from '@app/shared/shared-main' import { VideoCaptionService, VideoChannelService, VideoDetails, VideoLiveService, VideoService } from '@app/shared/shared-main'
@Injectable() @Injectable()
export class VideoUpdateResolver implements Resolve<any> { export class VideoUpdateResolver implements Resolve<any> {
constructor ( constructor (
private videoService: VideoService, private videoService: VideoService,
private videoLiveService: VideoLiveService,
private videoChannelService: VideoChannelService, private videoChannelService: VideoChannelService,
private videoCaptionService: VideoCaptionService private videoCaptionService: VideoCaptionService
) { ) {
@ -18,32 +19,38 @@ export class VideoUpdateResolver implements Resolve<any> {
return this.videoService.getVideo({ videoId: uuid }) return this.videoService.getVideo({ videoId: uuid })
.pipe( .pipe(
switchMap(video => { switchMap(video => forkJoin(this.buildVideoObservables(video))),
return forkJoin([ map(([ video, videoChannels, videoCaptions, videoLive ]) => ({ video, videoChannels, videoCaptions, videoLive }))
this.videoService
.loadCompleteDescription(video.descriptionPath)
.pipe(map(description => Object.assign(video, { description }))),
this.videoChannelService
.listAccountVideoChannels(video.account)
.pipe(
map(result => result.data),
map(videoChannels => videoChannels.map(c => ({
id: c.id,
label: c.displayName,
support: c.support,
avatarPath: c.avatar?.path
})))
),
this.videoCaptionService
.listCaptions(video.id)
.pipe(
map(result => result.data)
)
])
}),
map(([ video, videoChannels, videoCaptions ]) => ({ video, videoChannels, videoCaptions }))
) )
} }
private buildVideoObservables (video: VideoDetails) {
return [
this.videoService
.loadCompleteDescription(video.descriptionPath)
.pipe(map(description => Object.assign(video, { description }))),
this.videoChannelService
.listAccountVideoChannels(video.account)
.pipe(
map(result => result.data),
map(videoChannels => videoChannels.map(c => ({
id: c.id,
label: c.displayName,
support: c.support,
avatarPath: c.avatar?.path
})))
),
this.videoCaptionService
.listCaptions(video.id)
.pipe(
map(result => result.data)
),
video.isLive
? this.videoLiveService.getVideoLive(video.id)
: of(undefined)
]
}
} }

View File

@ -2,6 +2,7 @@ import { Observable, of, ReplaySubject } from 'rxjs'
import { catchError, first, map, shareReplay } from 'rxjs/operators' import { catchError, first, map, shareReplay } from 'rxjs/operators'
import { HttpClient } from '@angular/common/http' import { HttpClient } from '@angular/common/http'
import { Inject, Injectable, LOCALE_ID, NgZone } from '@angular/core' import { Inject, Injectable, LOCALE_ID, NgZone } from '@angular/core'
import { VideoEditType } from '@app/+videos/+video-edit/shared/video-edit.type'
import { AuthService } from '@app/core/auth' import { AuthService } from '@app/core/auth'
import { Notifier } from '@app/core/notification' import { Notifier } from '@app/core/notification'
import { MarkdownService } from '@app/core/renderer' import { MarkdownService } from '@app/core/renderer'
@ -192,7 +193,7 @@ export class PluginService implements ClientHook {
: PluginType.THEME : PluginType.THEME
} }
getRegisteredVideoFormFields (type: 'import-url' | 'import-torrent' | 'upload' | 'update') { getRegisteredVideoFormFields (type: VideoEditType) {
return this.formFields.video.filter(f => f.videoFormOptions.type === type) return this.formFields.video.filter(f => f.videoFormOptions.type === type)
} }

View File

@ -74,6 +74,13 @@ export class ServerService {
enabled: true enabled: true
} }
}, },
live: {
enabled: false,
transcoding: {
enabled: false,
enabledResolutions: []
}
},
avatar: { avatar: {
file: { file: {
size: { max: 0 }, size: { max: 0 },

View File

@ -1,5 +1,5 @@
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<input #urlInput (click)="urlInput.select()" type="text" class="form-control readonly" readonly [value]="value" /> <input [id]="id" #urlInput (click)="urlInput.select()" type="text" class="form-control readonly" readonly [value]="value" />
<div class="input-group-append"> <div class="input-group-append">
<button [cdkCopyToClipboard]="urlInput.value" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary"> <button [cdkCopyToClipboard]="urlInput.value" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary">

View File

@ -1,5 +1,6 @@
import { Component, Input } from '@angular/core' import { Component, Input } from '@angular/core'
import { Notifier } from '@app/core' import { Notifier } from '@app/core'
import { FormGroup } from '@angular/forms'
@Component({ @Component({
selector: 'my-input-readonly-copy', selector: 'my-input-readonly-copy',
@ -7,6 +8,7 @@ import { Notifier } from '@app/core'
styleUrls: [ './input-readonly-copy.component.scss' ] styleUrls: [ './input-readonly-copy.component.scss' ]
}) })
export class InputReadonlyCopyComponent { export class InputReadonlyCopyComponent {
@Input() id: string
@Input() value = '' @Input() value = ''
constructor (private notifier: Notifier) { } constructor (private notifier: Notifier) { }

View File

@ -63,6 +63,24 @@
</td> </td>
</tr> </tr>
<tr>
<th i18n class="label" colspan="2">Live streaming</th>
</tr>
<tr>
<th i18n class="sub-label" scope="row">Live streaming enabled</th>
<td>
<my-feature-boolean [value]="serverConfig.live.enabled"></my-feature-boolean>
</td>
</tr>
<tr>
<th i18n class="sub-label" scope="row">Transcode live video in multiple resolutions</th>
<td>
<my-feature-boolean [value]="serverConfig.live.transcoding.enabled && serverConfig.live.transcoding.enabledResolutions.length > 1"></my-feature-boolean>
</td>
</tr>
<tr> <tr>
<th i18n class="label" colspan="2">Import</th> <th i18n class="label" colspan="2">Import</th>
</tr> </tr>

View File

@ -23,7 +23,7 @@ import { FeedComponent } from './feeds'
import { LoaderComponent, SmallLoaderComponent } from './loaders' import { LoaderComponent, SmallLoaderComponent } from './loaders'
import { HelpComponent, ListOverflowComponent, TopMenuDropdownComponent } from './misc' import { HelpComponent, ListOverflowComponent, TopMenuDropdownComponent } from './misc'
import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users' import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users'
import { RedundancyService, VideoImportService, VideoOwnershipService, VideoService } from './video' import { RedundancyService, VideoImportService, VideoOwnershipService, VideoService, VideoLiveService } from './video'
import { VideoCaptionService } from './video-caption' import { VideoCaptionService } from './video-caption'
import { VideoChannelService } from './video-channel' import { VideoChannelService } from './video-channel'
@ -142,6 +142,7 @@ import { VideoChannelService } from './video-channel'
RedundancyService, RedundancyService,
VideoImportService, VideoImportService,
VideoOwnershipService, VideoOwnershipService,
VideoLiveService,
VideoService, VideoService,
VideoCaptionService, VideoCaptionService,

View File

@ -2,6 +2,7 @@ export * from './redundancy.service'
export * from './video-details.model' export * from './video-details.model'
export * from './video-edit.model' export * from './video-edit.model'
export * from './video-import.service' export * from './video-import.service'
export * from './video-live.service'
export * from './video-ownership.service' export * from './video-ownership.service'
export * from './video.model' export * from './video.model'
export * from './video.service' export * from './video.service'

View File

@ -62,8 +62,11 @@ export class VideoDetails extends Video implements VideoDetailsServerModel {
} }
getFiles () { getFiles () {
if (this.files.length === 0) return this.getHlsPlaylist().files if (this.files.length !== 0) return this.files
return this.files const hls = this.getHlsPlaylist()
if (hls) return hls.files
return []
} }
} }

View File

@ -0,0 +1,28 @@
import { catchError } from 'rxjs/operators'
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { RestExtractor } from '@app/core'
import { VideoCreate, VideoLive } from '@shared/models'
import { environment } from '../../../../environments/environment'
@Injectable()
export class VideoLiveService {
static BASE_VIDEO_LIVE_URL = environment.apiUrl + '/api/v1/videos/live/'
constructor (
private authHttp: HttpClient,
private restExtractor: RestExtractor
) {}
goLive (video: VideoCreate) {
return this.authHttp
.post<{ video: { id: number, uuid: string } }>(VideoLiveService.BASE_VIDEO_LIVE_URL, video)
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
getVideoLive (videoId: number | string) {
return this.authHttp
.get<VideoLive>(VideoLiveService.BASE_VIDEO_LIVE_URL + videoId)
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
}

View File

@ -40,6 +40,8 @@ export class Video implements VideoServerModel {
thumbnailPath: string thumbnailPath: string
thumbnailUrl: string thumbnailUrl: string
isLive: boolean
previewPath: string previewPath: string
previewUrl: string previewUrl: string
@ -103,6 +105,8 @@ export class Video implements VideoServerModel {
this.state = hash.state this.state = hash.state
this.description = hash.description this.description = hash.description
this.isLive = hash.isLive
this.duration = hash.duration this.duration = hash.duration
this.durationLabel = durationToString(hash.duration) this.durationLabel = durationToString(hash.duration)
@ -113,10 +117,14 @@ export class Video implements VideoServerModel {
this.name = hash.name this.name = hash.name
this.thumbnailPath = hash.thumbnailPath this.thumbnailPath = hash.thumbnailPath
this.thumbnailUrl = hash.thumbnailUrl || (absoluteAPIUrl + hash.thumbnailPath) this.thumbnailUrl = this.thumbnailPath
? hash.thumbnailUrl || (absoluteAPIUrl + hash.thumbnailPath)
: null
this.previewPath = hash.previewPath this.previewPath = hash.previewPath
this.previewUrl = hash.previewUrl || (absoluteAPIUrl + hash.previewPath) this.previewUrl = this.previewPath
? hash.previewUrl || (absoluteAPIUrl + hash.previewPath)
: null
this.embedPath = hash.embedPath this.embedPath = hash.embedPath
this.embedUrl = hash.embedUrl || (getAbsoluteEmbedUrl() + hash.embedPath) this.embedUrl = hash.embedUrl || (getAbsoluteEmbedUrl() + hash.embedPath)

View File

@ -18,7 +18,8 @@ import {
VideoFilter, VideoFilter,
VideoPrivacy, VideoPrivacy,
VideoSortField, VideoSortField,
VideoUpdate VideoUpdate,
VideoCreate
} from '@shared/models' } from '@shared/models'
import { environment } from '../../../../environments/environment' import { environment } from '../../../../environments/environment'
import { Account } from '../account/account.model' import { Account } from '../account/account.model'

View File

@ -1,17 +1,42 @@
import { Segment } from 'p2p-media-loader-core' import { Segment } from 'p2p-media-loader-core'
import { basename } from 'path' import { basename } from 'path'
type SegmentsJSON = { [filename: string]: string | { [byterange: string]: string } }
function segmentValidatorFactory (segmentsSha256Url: string) { function segmentValidatorFactory (segmentsSha256Url: string) {
const segmentsJSON = fetchSha256Segments(segmentsSha256Url) let segmentsJSON = fetchSha256Segments(segmentsSha256Url)
const regex = /bytes=(\d+)-(\d+)/ const regex = /bytes=(\d+)-(\d+)/
return async function segmentValidator (segment: Segment) { return async function segmentValidator (segment: Segment, canRefetchSegmentHashes = true) {
const filename = basename(segment.url) const filename = basename(segment.url)
const captured = regex.exec(segment.range)
const range = captured[1] + '-' + captured[2] const segmentValue = (await segmentsJSON)[filename]
if (!segmentValue && !canRefetchSegmentHashes) {
throw new Error(`Unknown segment name ${filename} in segment validator`)
}
if (!segmentValue) {
console.log('Refetching sha segments.')
// Refetch
segmentsJSON = fetchSha256Segments(segmentsSha256Url)
segmentValidator(segment, false)
return
}
let hashShouldBe: string
let range = ''
if (typeof segmentValue === 'string') {
hashShouldBe = segmentValue
} else {
const captured = regex.exec(segment.range)
range = captured[1] + '-' + captured[2]
hashShouldBe = segmentValue[range]
}
const hashShouldBe = (await segmentsJSON)[filename][range]
if (hashShouldBe === undefined) { if (hashShouldBe === undefined) {
throw new Error(`Unknown segment name ${filename}/${range} in segment validator`) throw new Error(`Unknown segment name ${filename}/${range} in segment validator`)
} }
@ -36,7 +61,7 @@ export {
function fetchSha256Segments (url: string) { function fetchSha256Segments (url: string) {
return fetch(url) return fetch(url)
.then(res => res.json()) .then(res => res.json() as Promise<SegmentsJSON>)
.catch(err => { .catch(err => {
console.error('Cannot get sha256 segments', err) console.error('Cannot get sha256 segments', err)
return {} return {}

View File

@ -325,7 +325,7 @@ export class PeertubePlayerManager {
trackerAnnounce, trackerAnnounce,
segmentValidator: segmentValidatorFactory(options.p2pMediaLoader.segmentsSha256Url), segmentValidator: segmentValidatorFactory(options.p2pMediaLoader.segmentsSha256Url),
rtcConfig: getRtcConfig(), rtcConfig: getRtcConfig(),
requiredSegmentsPriority: 5, requiredSegmentsPriority: 1,
segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager), segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager),
useP2P: getStoredP2PEnabled(), useP2P: getStoredP2PEnabled(),
consumeOnly consumeOnly
@ -353,7 +353,7 @@ export class PeertubePlayerManager {
hlsjsConfig: { hlsjsConfig: {
capLevelToPlayerSize: true, capLevelToPlayerSize: true,
autoStartLoad: false, autoStartLoad: false,
liveSyncDurationCount: 7, liveSyncDurationCount: 5,
loader: new p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass() loader: new p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass()
} }
} }

View File

@ -556,9 +556,9 @@ export class PeerTubeEmbed {
Object.assign(options, { Object.assign(options, {
p2pMediaLoader: { p2pMediaLoader: {
playlistUrl: hlsPlaylist.playlistUrl, playlistUrl: 'http://localhost:9000/live/toto/master.m3u8',
segmentsSha256Url: hlsPlaylist.segmentsSha256Url, segmentsSha256Url: hlsPlaylist.segmentsSha256Url,
redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl), redundancyBaseUrls: [],
trackerAnnounce: videoInfo.trackerUrls, trackerAnnounce: videoInfo.trackerUrls,
videoFiles: hlsPlaylist.files videoFiles: hlsPlaylist.files
} as P2PMediaLoaderOptions } as P2PMediaLoaderOptions

View File

@ -243,6 +243,24 @@ transcoding:
hls: hls:
enabled: false enabled: false
live:
enabled: false
rtmp:
port: 1935
transcoding:
enabled: false
threads: 2
resolutions:
240p: false
360p: false
480p: false
720p: false
1080p: false
2160p: false
import: import:
# Add ability for your users to import remote videos (from YouTube, torrent...) # Add ability for your users to import remote videos (from YouTube, torrent...)
videos: videos:

View File

@ -37,24 +37,24 @@ log:
contact_form: contact_form:
enabled: true enabled: true
#
redundancy: #redundancy:
videos: # videos:
check_interval: '1 minute' # check_interval: '1 minute'
strategies: # strategies:
- # -
size: '1000MB' # size: '1000MB'
min_lifetime: '10 minutes' # min_lifetime: '10 minutes'
strategy: 'most-views' # strategy: 'most-views'
- # -
size: '1000MB' # size: '1000MB'
min_lifetime: '10 minutes' # min_lifetime: '10 minutes'
strategy: 'trending' # strategy: 'trending'
- # -
size: '1000MB' # size: '1000MB'
min_lifetime: '10 minutes' # min_lifetime: '10 minutes'
strategy: 'recently-added' # strategy: 'recently-added'
min_views: 1 # min_views: 1
cache: cache:
previews: previews:
@ -82,6 +82,24 @@ transcoding:
hls: hls:
enabled: true enabled: true
live:
enabled: false
rtmp:
port: 1935
transcoding:
enabled: false
threads: 2
resolutions:
240p: false
360p: false
480p: false
720p: false
1080p: false
2160p: false
import: import:
videos: videos:
http: http:

View File

@ -92,6 +92,7 @@
"body-parser": "^1.12.4", "body-parser": "^1.12.4",
"bull": "^3.4.2", "bull": "^3.4.2",
"bytes": "^3.0.0", "bytes": "^3.0.0",
"chokidar": "^3.4.2",
"commander": "^6.0.0", "commander": "^6.0.0",
"config": "^3.0.0", "config": "^3.0.0",
"cookie-parser": "^1.4.3", "cookie-parser": "^1.4.3",
@ -122,6 +123,7 @@
"memoizee": "^0.4.14", "memoizee": "^0.4.14",
"morgan": "^1.5.3", "morgan": "^1.5.3",
"multer": "^1.1.0", "multer": "^1.1.0",
"node-media-server": "^2.1.4",
"nodemailer": "^6.0.0", "nodemailer": "^6.0.0",
"oauth2-server": "3.1.0-beta.1", "oauth2-server": "3.1.0-beta.1",
"parse-torrent": "^7.0.0", "parse-torrent": "^7.0.0",

View File

@ -43,7 +43,7 @@ async function run () {
if (program.generateHls) { if (program.generateHls) {
const resolutionsEnabled = program.resolution const resolutionsEnabled = program.resolution
? [ program.resolution ] ? [ program.resolution ]
: computeResolutionsToTranscode(videoFileResolution).concat([ videoFileResolution ]) : computeResolutionsToTranscode(videoFileResolution, 'vod').concat([ videoFileResolution ])
for (const resolution of resolutionsEnabled) { for (const resolution of resolutionsEnabled) {
dataInput.push({ dataInput.push({

View File

@ -130,7 +130,7 @@ async function run () {
for (const playlist of video.VideoStreamingPlaylists) { for (const playlist of video.VideoStreamingPlaylists) {
playlist.playlistUrl = WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsMasterPlaylistStaticPath(video.uuid) playlist.playlistUrl = WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsMasterPlaylistStaticPath(video.uuid)
playlist.segmentsSha256Url = WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsSha256SegmentsStaticPath(video.uuid) playlist.segmentsSha256Url = WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsSha256SegmentsStaticPath(video.uuid, video.isLive)
await playlist.save() await playlist.save()
} }

View File

@ -98,10 +98,12 @@ import {
staticRouter, staticRouter,
lazyStaticRouter, lazyStaticRouter,
servicesRouter, servicesRouter,
liveRouter,
pluginsRouter, pluginsRouter,
webfingerRouter, webfingerRouter,
trackerRouter, trackerRouter,
createWebsocketTrackerServer, botsRouter createWebsocketTrackerServer,
botsRouter
} from './server/controllers' } from './server/controllers'
import { advertiseDoNotTrack } from './server/middlewares/dnt' import { advertiseDoNotTrack } from './server/middlewares/dnt'
import { Redis } from './server/lib/redis' import { Redis } from './server/lib/redis'
@ -119,6 +121,7 @@ import { updateStreamingPlaylistsInfohashesIfNeeded } from './server/lib/hls'
import { PluginsCheckScheduler } from './server/lib/schedulers/plugins-check-scheduler' import { PluginsCheckScheduler } from './server/lib/schedulers/plugins-check-scheduler'
import { Hooks } from './server/lib/plugins/hooks' import { Hooks } from './server/lib/plugins/hooks'
import { PluginManager } from './server/lib/plugins/plugin-manager' import { PluginManager } from './server/lib/plugins/plugin-manager'
import { LiveManager } from '@server/lib/live-manager'
// ----------- Command line ----------- // ----------- Command line -----------
@ -139,14 +142,14 @@ if (isTestInstance()) {
} }
// For the logger // For the logger
morgan.token<express.Request>('remote-addr', req => { morgan.token('remote-addr', req => {
if (CONFIG.LOG.ANONYMIZE_IP === true || req.get('DNT') === '1') { if (CONFIG.LOG.ANONYMIZE_IP === true || req.get('DNT') === '1') {
return anonymize(req.ip, 16, 16) return anonymize(req.ip, 16, 16)
} }
return req.ip return req.ip
}) })
morgan.token<express.Request>('user-agent', req => { morgan.token('user-agent', req => {
if (req.get('DNT') === '1') { if (req.get('DNT') === '1') {
return useragent.parse(req.get('user-agent')).family return useragent.parse(req.get('user-agent')).family
} }
@ -183,6 +186,9 @@ app.use(apiRoute, apiRouter)
// Services (oembed...) // Services (oembed...)
app.use('/services', servicesRouter) app.use('/services', servicesRouter)
// Live streaming
app.use('/live', liveRouter)
// Plugins & themes // Plugins & themes
app.use('/', pluginsRouter) app.use('/', pluginsRouter)
@ -271,6 +277,9 @@ async function startApplication () {
if (cli.plugins) await PluginManager.Instance.registerPluginsAndThemes() if (cli.plugins) await PluginManager.Instance.registerPluginsAndThemes()
LiveManager.Instance.init()
if (CONFIG.LIVE.ENABLED) LiveManager.Instance.run()
// Make server listening // Make server listening
server.listen(port, hostname, () => { server.listen(port, hostname, () => {
logger.info('Server listening on %s:%d', hostname, port) logger.info('Server listening on %s:%d', hostname, port)

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View File

@ -113,7 +113,15 @@ async function getConfig (req: express.Request, res: express.Response) {
webtorrent: { webtorrent: {
enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED
}, },
enabledResolutions: getEnabledResolutions() enabledResolutions: getEnabledResolutions('vod')
},
live: {
enabled: CONFIG.LIVE.ENABLED,
transcoding: {
enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
enabledResolutions: getEnabledResolutions('live')
}
}, },
import: { import: {
videos: { videos: {
@ -232,7 +240,7 @@ async function deleteCustomConfig (req: express.Request, res: express.Response)
const data = customConfig() const data = customConfig()
return res.json(data).end() return res.json(data)
} }
async function updateCustomConfig (req: express.Request, res: express.Response) { async function updateCustomConfig (req: express.Request, res: express.Response) {
@ -254,7 +262,7 @@ async function updateCustomConfig (req: express.Request, res: express.Response)
oldCustomConfigAuditKeys oldCustomConfigAuditKeys
) )
return res.json(data).end() return res.json(data)
} }
function getRegisteredThemes () { function getRegisteredThemes () {
@ -268,9 +276,13 @@ function getRegisteredThemes () {
})) }))
} }
function getEnabledResolutions () { function getEnabledResolutions (type: 'vod' | 'live') {
return Object.keys(CONFIG.TRANSCODING.RESOLUTIONS) const transcoding = type === 'vod'
.filter(key => CONFIG.TRANSCODING.ENABLED && CONFIG.TRANSCODING.RESOLUTIONS[key] === true) ? CONFIG.TRANSCODING
: CONFIG.LIVE.TRANSCODING
return Object.keys(transcoding.RESOLUTIONS)
.filter(key => transcoding.ENABLED && transcoding.RESOLUTIONS[key] === true)
.map(r => parseInt(r, 10)) .map(r => parseInt(r, 10))
} }
@ -411,6 +423,21 @@ function customConfig (): CustomConfig {
enabled: CONFIG.TRANSCODING.HLS.ENABLED enabled: CONFIG.TRANSCODING.HLS.ENABLED
} }
}, },
live: {
enabled: CONFIG.LIVE.ENABLED,
transcoding: {
enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
threads: CONFIG.LIVE.TRANSCODING.THREADS,
resolutions: {
'240p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['240p'],
'360p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['360p'],
'480p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['480p'],
'720p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['720p'],
'1080p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['1080p'],
'2160p': CONFIG.LIVE.TRANSCODING.RESOLUTIONS['2160p']
}
}
},
import: { import: {
videos: { videos: {
http: { http: {

View File

@ -63,6 +63,7 @@ import { blacklistRouter } from './blacklist'
import { videoCaptionsRouter } from './captions' import { videoCaptionsRouter } from './captions'
import { videoCommentRouter } from './comment' import { videoCommentRouter } from './comment'
import { videoImportsRouter } from './import' import { videoImportsRouter } from './import'
import { liveRouter } from './live'
import { ownershipVideoRouter } from './ownership' import { ownershipVideoRouter } from './ownership'
import { rateVideoRouter } from './rate' import { rateVideoRouter } from './rate'
import { watchingRouter } from './watching' import { watchingRouter } from './watching'
@ -96,6 +97,7 @@ videosRouter.use('/', videoCaptionsRouter)
videosRouter.use('/', videoImportsRouter) videosRouter.use('/', videoImportsRouter)
videosRouter.use('/', ownershipVideoRouter) videosRouter.use('/', ownershipVideoRouter)
videosRouter.use('/', watchingRouter) videosRouter.use('/', watchingRouter)
videosRouter.use('/', liveRouter)
videosRouter.get('/categories', listVideoCategories) videosRouter.get('/categories', listVideoCategories)
videosRouter.get('/licences', listVideoLicences) videosRouter.get('/licences', listVideoLicences)
@ -304,7 +306,7 @@ async function addVideo (req: express.Request, res: express.Response) {
id: videoCreated.id, id: videoCreated.id,
uuid: videoCreated.uuid uuid: videoCreated.uuid
} }
}).end() })
} }
async function updateVideo (req: express.Request, res: express.Response) { async function updateVideo (req: express.Request, res: express.Response) {

View File

@ -0,0 +1,116 @@
import * as express from 'express'
import { v4 as uuidv4 } from 'uuid'
import { createReqFiles } from '@server/helpers/express-utils'
import { CONFIG } from '@server/initializers/config'
import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants'
import { getVideoActivityPubUrl } from '@server/lib/activitypub/url'
import { videoLiveAddValidator, videoLiveGetValidator } from '@server/middlewares/validators/videos/video-live'
import { VideoLiveModel } from '@server/models/video/video-live'
import { MVideoDetails, MVideoFullLight } from '@server/types/models'
import { VideoCreate, VideoPrivacy, VideoState } from '../../../../shared'
import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type'
import { logger } from '../../../helpers/logger'
import { sequelizeTypescript } from '../../../initializers/database'
import { createVideoMiniatureFromExisting } from '../../../lib/thumbnail'
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares'
import { TagModel } from '../../../models/video/tag'
import { VideoModel } from '../../../models/video/video'
import { buildLocalVideoFromCreate } from '@server/lib/video'
const liveRouter = express.Router()
const reqVideoFileLive = createReqFiles(
[ 'thumbnailfile', 'previewfile' ],
MIMETYPES.IMAGE.MIMETYPE_EXT,
{
thumbnailfile: CONFIG.STORAGE.TMP_DIR,
previewfile: CONFIG.STORAGE.TMP_DIR
}
)
liveRouter.post('/live',
authenticate,
reqVideoFileLive,
asyncMiddleware(videoLiveAddValidator),
asyncRetryTransactionMiddleware(addLiveVideo)
)
liveRouter.get('/live/:videoId',
authenticate,
asyncMiddleware(videoLiveGetValidator),
asyncRetryTransactionMiddleware(getVideoLive)
)
// ---------------------------------------------------------------------------
export {
liveRouter
}
// ---------------------------------------------------------------------------
async function getVideoLive (req: express.Request, res: express.Response) {
const videoLive = res.locals.videoLive
return res.json(videoLive.toFormattedJSON())
}
async function addLiveVideo (req: express.Request, res: express.Response) {
const videoInfo: VideoCreate = req.body
// Prepare data so we don't block the transaction
const videoData = buildLocalVideoFromCreate(videoInfo, res.locals.videoChannel.id)
videoData.isLive = true
const videoLive = new VideoLiveModel()
videoLive.streamKey = uuidv4()
const video = new VideoModel(videoData) as MVideoDetails
video.url = getVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object
// Process thumbnail or create it from the video
const thumbnailField = req.files ? req.files['thumbnailfile'] : null
const thumbnailModel = thumbnailField
? await createVideoMiniatureFromExisting(thumbnailField[0].path, video, ThumbnailType.MINIATURE, false)
: await createVideoMiniatureFromExisting(ASSETS_PATH.DEFAULT_LIVE_BACKGROUND, video, ThumbnailType.MINIATURE, true)
// Process preview or create it from the video
const previewField = req.files ? req.files['previewfile'] : null
const previewModel = previewField
? await createVideoMiniatureFromExisting(previewField[0].path, video, ThumbnailType.PREVIEW, false)
: await createVideoMiniatureFromExisting(ASSETS_PATH.DEFAULT_LIVE_BACKGROUND, video, ThumbnailType.PREVIEW, true)
const { videoCreated } = await sequelizeTypescript.transaction(async t => {
const sequelizeOptions = { transaction: t }
const videoCreated = await video.save(sequelizeOptions) as MVideoFullLight
if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t)
if (previewModel) await videoCreated.addAndSaveThumbnail(previewModel, t)
// Do not forget to add video channel information to the created video
videoCreated.VideoChannel = res.locals.videoChannel
videoLive.videoId = videoCreated.id
await videoLive.save(sequelizeOptions)
// Create tags
if (videoInfo.tags !== undefined) {
const tagInstances = await TagModel.findOrCreateTags(videoInfo.tags, t)
await video.$set('Tags', tagInstances, sequelizeOptions)
video.Tags = tagInstances
}
logger.info('Video live %s with uuid %s created.', videoInfo.name, videoCreated.uuid)
return { videoCreated }
})
return res.json({
video: {
id: videoCreated.id,
uuid: videoCreated.uuid
}
})
}

View File

@ -5,6 +5,7 @@ export * from './feeds'
export * from './services' export * from './services'
export * from './static' export * from './static'
export * from './lazy-static' export * from './lazy-static'
export * from './live'
export * from './webfinger' export * from './webfinger'
export * from './tracker' export * from './tracker'
export * from './bots' export * from './bots'

View File

@ -0,0 +1,29 @@
import * as express from 'express'
import { mapToJSON } from '@server/helpers/core-utils'
import { LiveManager } from '@server/lib/live-manager'
const liveRouter = express.Router()
liveRouter.use('/segments-sha256/:videoUUID',
getSegmentsSha256
)
// ---------------------------------------------------------------------------
export {
liveRouter
}
// ---------------------------------------------------------------------------
function getSegmentsSha256 (req: express.Request, res: express.Response) {
const videoUUID = req.params.videoUUID
const result = LiveManager.Instance.getSegmentsSha256(videoUUID)
if (!result) {
return res.sendStatus(404)
}
return res.json(mapToJSON(result))
}

View File

@ -260,7 +260,14 @@ async function generateNodeinfo (req: express.Request, res: express.Response) {
webtorrent: { webtorrent: {
enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED
}, },
enabledResolutions: getEnabledResolutions() enabledResolutions: getEnabledResolutions('vod')
},
live: {
enabled: CONFIG.LIVE.ENABLED,
transcoding: {
enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
enabledResolutions: getEnabledResolutions('live')
}
}, },
import: { import: {
videos: { videos: {

View File

@ -175,6 +175,16 @@ function pageToStartAndCount (page: number, itemsPerPage: number) {
return { start, count: itemsPerPage } return { start, count: itemsPerPage }
} }
function mapToJSON (map: Map<any, any>) {
const obj: any = {}
for (const [ k, v ] of map) {
obj[k] = v
}
return obj
}
function buildPath (path: string) { function buildPath (path: string) {
if (isAbsolute(path)) return path if (isAbsolute(path)) return path
@ -263,6 +273,7 @@ export {
sha256, sha256,
sha1, sha1,
mapToJSON,
promisify0, promisify0,
promisify1, promisify1,

View File

@ -8,7 +8,8 @@ import {
VIDEO_LICENCES, VIDEO_LICENCES,
VIDEO_PRIVACIES, VIDEO_PRIVACIES,
VIDEO_RATE_TYPES, VIDEO_RATE_TYPES,
VIDEO_STATES VIDEO_STATES,
VIDEO_LIVE
} from '../../initializers/constants' } from '../../initializers/constants'
import { exists, isArray, isDateValid, isFileValid } from './misc' import { exists, isArray, isDateValid, isFileValid } from './misc'
import * as magnetUtil from 'magnet-uri' import * as magnetUtil from 'magnet-uri'
@ -77,7 +78,7 @@ function isVideoRatingTypeValid (value: string) {
} }
function isVideoFileExtnameValid (value: string) { function isVideoFileExtnameValid (value: string) {
return exists(value) && MIMETYPES.VIDEO.EXT_MIMETYPE[value] !== undefined return exists(value) && (value === VIDEO_LIVE.EXTENSION || MIMETYPES.VIDEO.EXT_MIMETYPE[value] !== undefined)
} }
function isVideoFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) { function isVideoFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) {

View File

@ -1,13 +1,13 @@
import * as ffmpeg from 'fluent-ffmpeg' import * as ffmpeg from 'fluent-ffmpeg'
import { readFile, remove, writeFile } from 'fs-extra'
import { dirname, join } from 'path' import { dirname, join } from 'path'
import { VideoFileMetadata } from '@shared/models/videos/video-file-metadata'
import { getMaxBitrate, getTargetBitrate, VideoResolution } from '../../shared/models/videos' import { getMaxBitrate, getTargetBitrate, VideoResolution } from '../../shared/models/videos'
import { checkFFmpegEncoders } from '../initializers/checker-before-init'
import { CONFIG } from '../initializers/config'
import { FFMPEG_NICE, VIDEO_TRANSCODING_FPS } from '../initializers/constants' import { FFMPEG_NICE, VIDEO_TRANSCODING_FPS } from '../initializers/constants'
import { processImage } from './image-utils' import { processImage } from './image-utils'
import { logger } from './logger' import { logger } from './logger'
import { checkFFmpegEncoders } from '../initializers/checker-before-init'
import { readFile, remove, writeFile } from 'fs-extra'
import { CONFIG } from '../initializers/config'
import { VideoFileMetadata } from '@shared/models/videos/video-file-metadata'
/** /**
* A toolbox to play with audio * A toolbox to play with audio
@ -74,9 +74,12 @@ namespace audio {
} }
} }
function computeResolutionsToTranscode (videoFileResolution: number) { function computeResolutionsToTranscode (videoFileResolution: number, type: 'vod' | 'live') {
const configResolutions = type === 'vod'
? CONFIG.TRANSCODING.RESOLUTIONS
: CONFIG.LIVE.TRANSCODING.RESOLUTIONS
const resolutionsEnabled: number[] = [] const resolutionsEnabled: number[] = []
const configResolutions = CONFIG.TRANSCODING.RESOLUTIONS
// Put in the order we want to proceed jobs // Put in the order we want to proceed jobs
const resolutions = [ const resolutions = [
@ -270,14 +273,13 @@ type TranscodeOptions =
function transcode (options: TranscodeOptions) { function transcode (options: TranscodeOptions) {
return new Promise<void>(async (res, rej) => { return new Promise<void>(async (res, rej) => {
try { try {
// we set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems let command = getFFmpeg(options.inputPath)
let command = ffmpeg(options.inputPath, { niceness: FFMPEG_NICE.TRANSCODING, cwd: CONFIG.STORAGE.TMP_DIR })
.output(options.outputPath) .output(options.outputPath)
if (options.type === 'quick-transcode') { if (options.type === 'quick-transcode') {
command = buildQuickTranscodeCommand(command) command = buildQuickTranscodeCommand(command)
} else if (options.type === 'hls') { } else if (options.type === 'hls') {
command = await buildHLSCommand(command, options) command = await buildHLSVODCommand(command, options)
} else if (options.type === 'merge-audio') { } else if (options.type === 'merge-audio') {
command = await buildAudioMergeCommand(command, options) command = await buildAudioMergeCommand(command, options)
} else if (options.type === 'only-audio') { } else if (options.type === 'only-audio') {
@ -286,11 +288,6 @@ function transcode (options: TranscodeOptions) {
command = await buildx264Command(command, options) command = await buildx264Command(command, options)
} }
if (CONFIG.TRANSCODING.THREADS > 0) {
// if we don't set any threads ffmpeg will chose automatically
command = command.outputOption('-threads ' + CONFIG.TRANSCODING.THREADS)
}
command command
.on('error', (err, stdout, stderr) => { .on('error', (err, stdout, stderr) => {
logger.error('Error in transcoding job.', { stdout, stderr }) logger.error('Error in transcoding job.', { stdout, stderr })
@ -356,16 +353,89 @@ function convertWebPToJPG (path: string, destination: string): Promise<void> {
}) })
} }
function runLiveTranscoding (rtmpUrl: string, outPath: string, resolutions: number[]) {
const command = getFFmpeg(rtmpUrl)
command.inputOption('-fflags nobuffer')
const varStreamMap: string[] = []
command.complexFilter([
{
inputs: '[v:0]',
filter: 'split',
options: resolutions.length,
outputs: resolutions.map(r => `vtemp${r}`)
},
...resolutions.map(r => ({
inputs: `vtemp${r}`,
filter: 'scale',
options: `w=-2:h=${r}`,
outputs: `vout${r}`
}))
])
const liveFPS = VIDEO_TRANSCODING_FPS.AVERAGE
command.withFps(liveFPS)
command.outputOption('-b_strategy 1')
command.outputOption('-bf 16')
command.outputOption('-preset superfast')
command.outputOption('-level 3.1')
command.outputOption('-map_metadata -1')
command.outputOption('-pix_fmt yuv420p')
for (let i = 0; i < resolutions.length; i++) {
const resolution = resolutions[i]
command.outputOption(`-map [vout${resolution}]`)
command.outputOption(`-c:v:${i} libx264`)
command.outputOption(`-b:v:${i} ${getTargetBitrate(resolution, liveFPS, VIDEO_TRANSCODING_FPS)}`)
command.outputOption(`-map a:0`)
command.outputOption(`-c:a:${i} aac`)
varStreamMap.push(`v:${i},a:${i}`)
}
addDefaultLiveHLSParams(command, outPath)
command.outputOption('-var_stream_map', varStreamMap.join(' '))
command.run()
return command
}
function runLiveMuxing (rtmpUrl: string, outPath: string) {
const command = getFFmpeg(rtmpUrl)
command.inputOption('-fflags nobuffer')
command.outputOption('-c:v copy')
command.outputOption('-c:a copy')
command.outputOption('-map 0:a?')
command.outputOption('-map 0:v?')
addDefaultLiveHLSParams(command, outPath)
command.run()
return command
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export { export {
getVideoStreamCodec, getVideoStreamCodec,
getAudioStreamCodec, getAudioStreamCodec,
runLiveMuxing,
convertWebPToJPG, convertWebPToJPG,
getVideoStreamSize, getVideoStreamSize,
getVideoFileResolution, getVideoFileResolution,
getMetadataFromFile, getMetadataFromFile,
getDurationFromVideoFile, getDurationFromVideoFile,
runLiveTranscoding,
generateImageFromVideoFile, generateImageFromVideoFile,
TranscodeOptions, TranscodeOptions,
TranscodeOptionsType, TranscodeOptionsType,
@ -379,6 +449,25 @@ export {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function addDefaultX264Params (command: ffmpeg.FfmpegCommand) {
command.outputOption('-level 3.1') // 3.1 is the minimal resource allocation for our highest supported resolution
.outputOption('-b_strategy 1') // NOTE: b-strategy 1 - heuristic algorithm, 16 is optimal B-frames for it
.outputOption('-bf 16') // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16
.outputOption('-pix_fmt yuv420p') // allows import of source material with incompatible pixel formats (e.g. MJPEG video)
.outputOption('-map_metadata -1') // strip all metadata
}
function addDefaultLiveHLSParams (command: ffmpeg.FfmpegCommand, outPath: string) {
command.outputOption('-hls_time 4')
command.outputOption('-hls_list_size 15')
command.outputOption('-hls_flags delete_segments')
command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%d.ts')}`)
command.outputOption('-master_pl_name master.m3u8')
command.outputOption(`-f hls`)
command.output(join(outPath, '%v.m3u8'))
}
async function buildx264Command (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) { async function buildx264Command (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) {
let fps = await getVideoFileFPS(options.inputPath) let fps = await getVideoFileFPS(options.inputPath)
if ( if (
@ -438,7 +527,7 @@ function buildQuickTranscodeCommand (command: ffmpeg.FfmpegCommand) {
return command return command
} }
async function buildHLSCommand (command: ffmpeg.FfmpegCommand, options: HLSTranscodeOptions) { async function buildHLSVODCommand (command: ffmpeg.FfmpegCommand, options: HLSTranscodeOptions) {
const videoPath = getHLSVideoPath(options) const videoPath = getHLSVideoPath(options)
if (options.copyCodecs) command = presetCopy(command) if (options.copyCodecs) command = presetCopy(command)
@ -508,13 +597,10 @@ async function presetH264 (command: ffmpeg.FfmpegCommand, input: string, resolut
let localCommand = command let localCommand = command
.format('mp4') .format('mp4')
.videoCodec('libx264') .videoCodec('libx264')
.outputOption('-level 3.1') // 3.1 is the minimal resource allocation for our highest supported resolution
.outputOption('-b_strategy 1') // NOTE: b-strategy 1 - heuristic algorithm, 16 is optimal B-frames for it
.outputOption('-bf 16') // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16
.outputOption('-pix_fmt yuv420p') // allows import of source material with incompatible pixel formats (e.g. MJPEG video)
.outputOption('-map_metadata -1') // strip all metadata
.outputOption('-movflags faststart') .outputOption('-movflags faststart')
addDefaultX264Params(localCommand)
const parsedAudio = await audio.get(input) const parsedAudio = await audio.get(input)
if (!parsedAudio.audioStream) { if (!parsedAudio.audioStream) {
@ -565,3 +651,15 @@ function presetOnlyAudio (command: ffmpeg.FfmpegCommand): ffmpeg.FfmpegCommand {
.audioCodec('copy') .audioCodec('copy')
.noVideo() .noVideo()
} }
function getFFmpeg (input: string) {
// We set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems
const command = ffmpeg(input, { niceness: FFMPEG_NICE.TRANSCODING, cwd: CONFIG.STORAGE.TMP_DIR })
if (CONFIG.TRANSCODING.THREADS > 0) {
// If we don't set any threads ffmpeg will chose automatically
command.outputOption('-threads ' + CONFIG.TRANSCODING.THREADS)
}
return command
}

View File

@ -198,6 +198,27 @@ const CONFIG = {
get ENABLED () { return config.get<boolean>('transcoding.webtorrent.enabled') } get ENABLED () { return config.get<boolean>('transcoding.webtorrent.enabled') }
} }
}, },
LIVE: {
get ENABLED () { return config.get<boolean>('live.enabled') },
RTMP: {
get PORT () { return config.get<number>('live.rtmp.port') }
},
TRANSCODING: {
get ENABLED () { return config.get<boolean>('live.transcoding.enabled') },
get THREADS () { return config.get<number>('live.transcoding.threads') },
RESOLUTIONS: {
get '240p' () { return config.get<boolean>('live.transcoding.resolutions.240p') },
get '360p' () { return config.get<boolean>('live.transcoding.resolutions.360p') },
get '480p' () { return config.get<boolean>('live.transcoding.resolutions.480p') },
get '720p' () { return config.get<boolean>('live.transcoding.resolutions.720p') },
get '1080p' () { return config.get<boolean>('live.transcoding.resolutions.1080p') },
get '2160p' () { return config.get<boolean>('live.transcoding.resolutions.2160p') }
}
}
},
IMPORT: { IMPORT: {
VIDEOS: { VIDEOS: {
HTTP: { HTTP: {

View File

@ -23,7 +23,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const LAST_MIGRATION_VERSION = 530 const LAST_MIGRATION_VERSION = 540
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -50,7 +50,8 @@ const WEBSERVER = {
SCHEME: '', SCHEME: '',
WS: '', WS: '',
HOSTNAME: '', HOSTNAME: '',
PORT: 0 PORT: 0,
RTMP_URL: ''
} }
// Sortable columns per schema // Sortable columns per schema
@ -264,7 +265,7 @@ const CONSTRAINTS_FIELDS = {
VIEWS: { min: 0 }, VIEWS: { min: 0 },
LIKES: { min: 0 }, LIKES: { min: 0 },
DISLIKES: { min: 0 }, DISLIKES: { min: 0 },
FILE_SIZE: { min: 10 }, FILE_SIZE: { min: -1 },
URL: { min: 3, max: 2000 } // Length URL: { min: 3, max: 2000 } // Length
}, },
VIDEO_PLAYLISTS: { VIDEO_PLAYLISTS: {
@ -370,39 +371,41 @@ const VIDEO_LICENCES = {
const VIDEO_LANGUAGES: { [id: string]: string } = {} const VIDEO_LANGUAGES: { [id: string]: string } = {}
const VIDEO_PRIVACIES = { const VIDEO_PRIVACIES: { [ id in VideoPrivacy ]: string } = {
[VideoPrivacy.PUBLIC]: 'Public', [VideoPrivacy.PUBLIC]: 'Public',
[VideoPrivacy.UNLISTED]: 'Unlisted', [VideoPrivacy.UNLISTED]: 'Unlisted',
[VideoPrivacy.PRIVATE]: 'Private', [VideoPrivacy.PRIVATE]: 'Private',
[VideoPrivacy.INTERNAL]: 'Internal' [VideoPrivacy.INTERNAL]: 'Internal'
} }
const VIDEO_STATES = { const VIDEO_STATES: { [ id in VideoState ]: string } = {
[VideoState.PUBLISHED]: 'Published', [VideoState.PUBLISHED]: 'Published',
[VideoState.TO_TRANSCODE]: 'To transcode', [VideoState.TO_TRANSCODE]: 'To transcode',
[VideoState.TO_IMPORT]: 'To import' [VideoState.TO_IMPORT]: 'To import',
[VideoState.WAITING_FOR_LIVE]: 'Waiting for livestream',
[VideoState.LIVE_ENDED]: 'Livestream ended'
} }
const VIDEO_IMPORT_STATES = { const VIDEO_IMPORT_STATES: { [ id in VideoImportState ]: string } = {
[VideoImportState.FAILED]: 'Failed', [VideoImportState.FAILED]: 'Failed',
[VideoImportState.PENDING]: 'Pending', [VideoImportState.PENDING]: 'Pending',
[VideoImportState.SUCCESS]: 'Success', [VideoImportState.SUCCESS]: 'Success',
[VideoImportState.REJECTED]: 'Rejected' [VideoImportState.REJECTED]: 'Rejected'
} }
const ABUSE_STATES = { const ABUSE_STATES: { [ id in AbuseState ]: string } = {
[AbuseState.PENDING]: 'Pending', [AbuseState.PENDING]: 'Pending',
[AbuseState.REJECTED]: 'Rejected', [AbuseState.REJECTED]: 'Rejected',
[AbuseState.ACCEPTED]: 'Accepted' [AbuseState.ACCEPTED]: 'Accepted'
} }
const VIDEO_PLAYLIST_PRIVACIES = { const VIDEO_PLAYLIST_PRIVACIES: { [ id in VideoPlaylistPrivacy ]: string } = {
[VideoPlaylistPrivacy.PUBLIC]: 'Public', [VideoPlaylistPrivacy.PUBLIC]: 'Public',
[VideoPlaylistPrivacy.UNLISTED]: 'Unlisted', [VideoPlaylistPrivacy.UNLISTED]: 'Unlisted',
[VideoPlaylistPrivacy.PRIVATE]: 'Private' [VideoPlaylistPrivacy.PRIVATE]: 'Private'
} }
const VIDEO_PLAYLIST_TYPES = { const VIDEO_PLAYLIST_TYPES: { [ id in VideoPlaylistType ]: string } = {
[VideoPlaylistType.REGULAR]: 'Regular', [VideoPlaylistType.REGULAR]: 'Regular',
[VideoPlaylistType.WATCH_LATER]: 'Watch later' [VideoPlaylistType.WATCH_LATER]: 'Watch later'
} }
@ -600,6 +603,17 @@ const LRU_CACHE = {
const HLS_STREAMING_PLAYLIST_DIRECTORY = join(CONFIG.STORAGE.STREAMING_PLAYLISTS_DIR, 'hls') const HLS_STREAMING_PLAYLIST_DIRECTORY = join(CONFIG.STORAGE.STREAMING_PLAYLISTS_DIR, 'hls')
const HLS_REDUNDANCY_DIRECTORY = join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls') const HLS_REDUNDANCY_DIRECTORY = join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls')
const VIDEO_LIVE = {
EXTENSION: '.ts',
RTMP: {
CHUNK_SIZE: 60000,
GOP_CACHE: true,
PING: 60,
PING_TIMEOUT: 30,
BASE_PATH: 'live'
}
}
const MEMOIZE_TTL = { const MEMOIZE_TTL = {
OVERVIEWS_SAMPLE: 1000 * 3600 * 4, // 4 hours OVERVIEWS_SAMPLE: 1000 * 3600 * 4, // 4 hours
INFO_HASH_EXISTS: 1000 * 3600 * 12 // 12 hours INFO_HASH_EXISTS: 1000 * 3600 * 12 // 12 hours
@ -622,7 +636,8 @@ const REDUNDANCY = {
const ACCEPT_HEADERS = [ 'html', 'application/json' ].concat(ACTIVITY_PUB.POTENTIAL_ACCEPT_HEADERS) const ACCEPT_HEADERS = [ 'html', 'application/json' ].concat(ACTIVITY_PUB.POTENTIAL_ACCEPT_HEADERS)
const ASSETS_PATH = { const ASSETS_PATH = {
DEFAULT_AUDIO_BACKGROUND: join(root(), 'dist', 'server', 'assets', 'default-audio-background.jpg') DEFAULT_AUDIO_BACKGROUND: join(root(), 'dist', 'server', 'assets', 'default-audio-background.jpg'),
DEFAULT_LIVE_BACKGROUND: join(root(), 'dist', 'server', 'assets', 'default-live-background.jpg')
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -688,9 +703,9 @@ if (isTestInstance() === true) {
STATIC_MAX_AGE.SERVER = '0' STATIC_MAX_AGE.SERVER = '0'
ACTIVITY_PUB.COLLECTION_ITEMS_PER_PAGE = 2 ACTIVITY_PUB.COLLECTION_ITEMS_PER_PAGE = 2
ACTIVITY_PUB.ACTOR_REFRESH_INTERVAL = 10 * 1000 // 10 seconds ACTIVITY_PUB.ACTOR_REFRESH_INTERVAL = 100 * 10000 // 10 seconds
ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL = 10 * 1000 // 10 seconds ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL = 100 * 10000 // 10 seconds
ACTIVITY_PUB.VIDEO_PLAYLIST_REFRESH_INTERVAL = 10 * 1000 // 10 seconds ACTIVITY_PUB.VIDEO_PLAYLIST_REFRESH_INTERVAL = 100 * 10000 // 10 seconds
CONSTRAINTS_FIELDS.ACTORS.AVATAR.FILE_SIZE.max = 100 * 1024 // 100KB CONSTRAINTS_FIELDS.ACTORS.AVATAR.FILE_SIZE.max = 100 * 1024 // 100KB
@ -737,6 +752,7 @@ const FILES_CONTENT_HASH = {
export { export {
WEBSERVER, WEBSERVER,
API_VERSION, API_VERSION,
VIDEO_LIVE,
PEERTUBE_VERSION, PEERTUBE_VERSION,
LAZY_STATIC_PATHS, LAZY_STATIC_PATHS,
SEARCH_INDEX, SEARCH_INDEX,
@ -892,10 +908,14 @@ function buildVideoMimetypeExt () {
function updateWebserverUrls () { function updateWebserverUrls () {
WEBSERVER.URL = sanitizeUrl(CONFIG.WEBSERVER.SCHEME + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT) WEBSERVER.URL = sanitizeUrl(CONFIG.WEBSERVER.SCHEME + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT)
WEBSERVER.HOST = sanitizeHost(CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT, REMOTE_SCHEME.HTTP) WEBSERVER.HOST = sanitizeHost(CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT, REMOTE_SCHEME.HTTP)
WEBSERVER.SCHEME = CONFIG.WEBSERVER.SCHEME
WEBSERVER.WS = CONFIG.WEBSERVER.WS WEBSERVER.WS = CONFIG.WEBSERVER.WS
WEBSERVER.SCHEME = CONFIG.WEBSERVER.SCHEME
WEBSERVER.HOSTNAME = CONFIG.WEBSERVER.HOSTNAME WEBSERVER.HOSTNAME = CONFIG.WEBSERVER.HOSTNAME
WEBSERVER.PORT = CONFIG.WEBSERVER.PORT WEBSERVER.PORT = CONFIG.WEBSERVER.PORT
WEBSERVER.PORT = CONFIG.WEBSERVER.PORT
WEBSERVER.RTMP_URL = 'rtmp://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.LIVE.RTMP.PORT + '/' + VIDEO_LIVE.RTMP.BASE_PATH
} }
function updateWebserverConfig () { function updateWebserverConfig () {

View File

@ -1,11 +1,11 @@
import { QueryTypes, Transaction } from 'sequelize' import { QueryTypes, Transaction } from 'sequelize'
import { Sequelize as SequelizeTypescript } from 'sequelize-typescript' import { Sequelize as SequelizeTypescript } from 'sequelize-typescript'
import { AbuseModel } from '@server/models/abuse/abuse'
import { AbuseMessageModel } from '@server/models/abuse/abuse-message'
import { VideoAbuseModel } from '@server/models/abuse/video-abuse'
import { VideoCommentAbuseModel } from '@server/models/abuse/video-comment-abuse'
import { isTestInstance } from '../helpers/core-utils' import { isTestInstance } from '../helpers/core-utils'
import { logger } from '../helpers/logger' import { logger } from '../helpers/logger'
import { AbuseModel } from '../models/abuse/abuse'
import { AbuseMessageModel } from '../models/abuse/abuse-message'
import { VideoAbuseModel } from '../models/abuse/video-abuse'
import { VideoCommentAbuseModel } from '../models/abuse/video-comment-abuse'
import { AccountModel } from '../models/account/account' import { AccountModel } from '../models/account/account'
import { AccountBlocklistModel } from '../models/account/account-blocklist' import { AccountBlocklistModel } from '../models/account/account-blocklist'
import { AccountVideoRateModel } from '../models/account/account-video-rate' import { AccountVideoRateModel } from '../models/account/account-video-rate'
@ -34,6 +34,7 @@ import { VideoChannelModel } from '../models/video/video-channel'
import { VideoCommentModel } from '../models/video/video-comment' import { VideoCommentModel } from '../models/video/video-comment'
import { VideoFileModel } from '../models/video/video-file' import { VideoFileModel } from '../models/video/video-file'
import { VideoImportModel } from '../models/video/video-import' import { VideoImportModel } from '../models/video/video-import'
import { VideoLiveModel } from '../models/video/video-live'
import { VideoPlaylistModel } from '../models/video/video-playlist' import { VideoPlaylistModel } from '../models/video/video-playlist'
import { VideoPlaylistElementModel } from '../models/video/video-playlist-element' import { VideoPlaylistElementModel } from '../models/video/video-playlist-element'
import { VideoShareModel } from '../models/video/video-share' import { VideoShareModel } from '../models/video/video-share'
@ -118,6 +119,7 @@ async function initDatabaseModels (silent: boolean) {
VideoViewModel, VideoViewModel,
VideoRedundancyModel, VideoRedundancyModel,
UserVideoHistoryModel, UserVideoHistoryModel,
VideoLiveModel,
AccountBlocklistModel, AccountBlocklistModel,
ServerBlocklistModel, ServerBlocklistModel,
UserNotificationModel, UserNotificationModel,

View File

@ -0,0 +1,39 @@
import * as Sequelize from 'sequelize'
async function up (utils: {
transaction: Sequelize.Transaction
queryInterface: Sequelize.QueryInterface
sequelize: Sequelize.Sequelize
}): Promise<void> {
{
const query = `
CREATE TABLE IF NOT EXISTS "videoLive" (
"id" SERIAL ,
"streamKey" VARCHAR(255) NOT NULL,
"videoId" INTEGER NOT NULL REFERENCES "video" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL,
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
PRIMARY KEY ("id")
);
`
await utils.sequelize.query(query)
}
{
await utils.queryInterface.addColumn('video', 'isLive', {
type: Sequelize.BOOLEAN,
defaultValue: false,
allowNull: false
})
}
}
function down (options) {
throw new Error('Not implemented.')
}
export {
up,
down
}

View File

@ -0,0 +1,26 @@
import * as Sequelize from 'sequelize'
async function up (utils: {
transaction: Sequelize.Transaction
queryInterface: Sequelize.QueryInterface
sequelize: Sequelize.Sequelize
}): Promise<void> {
{
const data = {
type: Sequelize.STRING,
defaultValue: null,
allowNull: true
}
await utils.queryInterface.changeColumn('videoFile', 'infoHash', data)
}
}
function down (options) {
throw new Error('Not implemented.')
}
export {
up,
down
}

View File

@ -65,7 +65,7 @@ async function updateMasterHLSPlaylist (video: MVideoWithFile) {
await writeFile(masterPlaylistPath, masterPlaylists.join('\n') + '\n') await writeFile(masterPlaylistPath, masterPlaylists.join('\n') + '\n')
} }
async function updateSha256Segments (video: MVideoWithFile) { async function updateSha256VODSegments (video: MVideoWithFile) {
const json: { [filename: string]: { [range: string]: string } } = {} const json: { [filename: string]: { [range: string]: string } } = {}
const playlistDirectory = join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid) const playlistDirectory = join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid)
@ -101,6 +101,11 @@ async function updateSha256Segments (video: MVideoWithFile) {
await outputJSON(outputPath, json) await outputJSON(outputPath, json)
} }
async function buildSha256Segment (segmentPath: string) {
const buf = await readFile(segmentPath)
return sha256(buf)
}
function getRangesFromPlaylist (playlistContent: string) { function getRangesFromPlaylist (playlistContent: string) {
const ranges: { offset: number, length: number }[] = [] const ranges: { offset: number, length: number }[] = []
const lines = playlistContent.split('\n') const lines = playlistContent.split('\n')
@ -187,7 +192,8 @@ function downloadPlaylistSegments (playlistUrl: string, destinationDir: string,
export { export {
updateMasterHLSPlaylist, updateMasterHLSPlaylist,
updateSha256Segments, updateSha256VODSegments,
buildSha256Segment,
downloadPlaylistSegments, downloadPlaylistSegments,
updateStreamingPlaylistsInfohashesIfNeeded updateStreamingPlaylistsInfohashesIfNeeded
} }

View File

@ -84,7 +84,7 @@ async function onVideoFileOptimizerSuccess (videoArg: MVideoWithFile, payload: O
if (!videoDatabase) return undefined if (!videoDatabase) return undefined
// Create transcoding jobs if there are enabled resolutions // Create transcoding jobs if there are enabled resolutions
const resolutionsEnabled = computeResolutionsToTranscode(videoFileResolution) const resolutionsEnabled = computeResolutionsToTranscode(videoFileResolution, 'vod')
logger.info( logger.info(
'Resolutions computed for video %s and origin file resolution of %d.', videoDatabase.uuid, videoFileResolution, 'Resolutions computed for video %s and origin file resolution of %d.', videoDatabase.uuid, videoFileResolution,
{ resolutions: resolutionsEnabled } { resolutions: resolutionsEnabled }

310
server/lib/live-manager.ts Normal file
View File

@ -0,0 +1,310 @@
import { AsyncQueue, queue } from 'async'
import * as chokidar from 'chokidar'
import { FfmpegCommand } from 'fluent-ffmpeg'
import { ensureDir, readdir, remove } from 'fs-extra'
import { basename, join } from 'path'
import { computeResolutionsToTranscode, runLiveMuxing, runLiveTranscoding } from '@server/helpers/ffmpeg-utils'
import { logger } from '@server/helpers/logger'
import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config'
import { P2P_MEDIA_LOADER_PEER_VERSION, VIDEO_LIVE, WEBSERVER } from '@server/initializers/constants'
import { VideoFileModel } from '@server/models/video/video-file'
import { VideoLiveModel } from '@server/models/video/video-live'
import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
import { MStreamingPlaylist, MVideo, MVideoLiveVideo } from '@server/types/models'
import { VideoState, VideoStreamingPlaylistType } from '@shared/models'
import { buildSha256Segment } from './hls'
import { getHLSDirectory } from './video-paths'
const NodeRtmpServer = require('node-media-server/node_rtmp_server')
const context = require('node-media-server/node_core_ctx')
const nodeMediaServerLogger = require('node-media-server/node_core_logger')
// Disable node media server logs
nodeMediaServerLogger.setLogType(0)
const config = {
rtmp: {
port: CONFIG.LIVE.RTMP.PORT,
chunk_size: VIDEO_LIVE.RTMP.CHUNK_SIZE,
gop_cache: VIDEO_LIVE.RTMP.GOP_CACHE,
ping: VIDEO_LIVE.RTMP.PING,
ping_timeout: VIDEO_LIVE.RTMP.PING_TIMEOUT
},
transcoding: {
ffmpeg: 'ffmpeg'
}
}
type SegmentSha256QueueParam = {
operation: 'update' | 'delete'
videoUUID: string
segmentPath: string
}
class LiveManager {
private static instance: LiveManager
private readonly transSessions = new Map<string, FfmpegCommand>()
private readonly segmentsSha256 = new Map<string, Map<string, string>>()
private segmentsSha256Queue: AsyncQueue<SegmentSha256QueueParam>
private rtmpServer: any
private constructor () {
}
init () {
this.getContext().nodeEvent.on('postPublish', (sessionId: string, streamPath: string) => {
logger.debug('RTMP received stream', { id: sessionId, streamPath })
const splittedPath = streamPath.split('/')
if (splittedPath.length !== 3 || splittedPath[1] !== VIDEO_LIVE.RTMP.BASE_PATH) {
logger.warn('Live path is incorrect.', { streamPath })
return this.abortSession(sessionId)
}
this.handleSession(sessionId, streamPath, splittedPath[2])
.catch(err => logger.error('Cannot handle sessions.', { err }))
})
this.getContext().nodeEvent.on('donePublish', sessionId => {
this.abortSession(sessionId)
})
this.segmentsSha256Queue = queue<SegmentSha256QueueParam, Error>((options, cb) => {
const promise = options.operation === 'update'
? this.addSegmentSha(options)
: Promise.resolve(this.removeSegmentSha(options))
promise.then(() => cb())
.catch(err => {
logger.error('Cannot update/remove sha segment %s.', options.segmentPath, { err })
cb()
})
})
registerConfigChangedHandler(() => {
if (!this.rtmpServer && CONFIG.LIVE.ENABLED === true) {
this.run()
return
}
if (this.rtmpServer && CONFIG.LIVE.ENABLED === false) {
this.stop()
}
})
}
run () {
logger.info('Running RTMP server.')
this.rtmpServer = new NodeRtmpServer(config)
this.rtmpServer.run()
}
stop () {
logger.info('Stopping RTMP server.')
this.rtmpServer.stop()
this.rtmpServer = undefined
}
getSegmentsSha256 (videoUUID: string) {
return this.segmentsSha256.get(videoUUID)
}
private getContext () {
return context
}
private abortSession (id: string) {
const session = this.getContext().sessions.get(id)
if (session) session.stop()
const transSession = this.transSessions.get(id)
if (transSession) transSession.kill('SIGKILL')
}
private async handleSession (sessionId: string, streamPath: string, streamKey: string) {
const videoLive = await VideoLiveModel.loadByStreamKey(streamKey)
if (!videoLive) {
logger.warn('Unknown live video with stream key %s.', streamKey)
return this.abortSession(sessionId)
}
const video = videoLive.Video
const playlistUrl = WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsMasterPlaylistStaticPath(video.uuid)
const session = this.getContext().sessions.get(sessionId)
const resolutionsEnabled = CONFIG.LIVE.TRANSCODING.ENABLED
? computeResolutionsToTranscode(session.videoHeight, 'live')
: []
logger.info('Will mux/transcode live video of original resolution %d.', session.videoHeight, { resolutionsEnabled })
const [ videoStreamingPlaylist ] = await VideoStreamingPlaylistModel.upsert({
videoId: video.id,
playlistUrl,
segmentsSha256Url: WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsSha256SegmentsStaticPath(video.uuid, video.isLive),
p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrl, resolutionsEnabled),
p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
type: VideoStreamingPlaylistType.HLS
}, { returning: true }) as [ MStreamingPlaylist, boolean ]
video.state = VideoState.PUBLISHED
await video.save()
// FIXME: federation?
return this.runMuxing({
sessionId,
videoLive,
playlist: videoStreamingPlaylist,
streamPath,
originalResolution: session.videoHeight,
resolutionsEnabled
})
}
private async runMuxing (options: {
sessionId: string
videoLive: MVideoLiveVideo
playlist: MStreamingPlaylist
streamPath: string
resolutionsEnabled: number[]
originalResolution: number
}) {
const { sessionId, videoLive, playlist, streamPath, resolutionsEnabled, originalResolution } = options
const allResolutions = resolutionsEnabled.concat([ originalResolution ])
for (let i = 0; i < allResolutions.length; i++) {
const resolution = allResolutions[i]
VideoFileModel.upsert({
resolution,
size: -1,
extname: '.ts',
infoHash: null,
fps: -1,
videoStreamingPlaylistId: playlist.id
}).catch(err => {
logger.error('Cannot create file for live streaming.', { err })
})
}
const outPath = getHLSDirectory(videoLive.Video)
await ensureDir(outPath)
const rtmpUrl = 'rtmp://127.0.0.1:' + config.rtmp.port + streamPath
const ffmpegExec = CONFIG.LIVE.TRANSCODING.ENABLED
? runLiveTranscoding(rtmpUrl, outPath, allResolutions)
: runLiveMuxing(rtmpUrl, outPath)
logger.info('Running live muxing/transcoding.')
this.transSessions.set(sessionId, ffmpegExec)
const onFFmpegEnded = () => {
watcher.close()
.catch(err => logger.error('Cannot close watcher of %s.', outPath, { err }))
this.onEndTransmuxing(videoLive.Video, playlist, streamPath, outPath)
.catch(err => logger.error('Error in closed transmuxing.', { err }))
}
ffmpegExec.on('error', (err, stdout, stderr) => {
onFFmpegEnded()
// Don't care that we killed the ffmpeg process
if (err?.message?.includes('SIGKILL')) return
logger.error('Live transcoding error.', { err, stdout, stderr })
})
ffmpegExec.on('end', () => onFFmpegEnded())
const videoUUID = videoLive.Video.uuid
const watcher = chokidar.watch(outPath + '/*.ts')
const updateHandler = segmentPath => this.segmentsSha256Queue.push({ operation: 'update', segmentPath, videoUUID })
const deleteHandler = segmentPath => this.segmentsSha256Queue.push({ operation: 'delete', segmentPath, videoUUID })
watcher.on('add', p => updateHandler(p))
watcher.on('change', p => updateHandler(p))
watcher.on('unlink', p => deleteHandler(p))
}
private async onEndTransmuxing (video: MVideo, playlist: MStreamingPlaylist, streamPath: string, outPath: string) {
logger.info('RTMP transmuxing for %s ended.', streamPath)
const files = await readdir(outPath)
for (const filename of files) {
if (
filename.endsWith('.ts') ||
filename.endsWith('.m3u8') ||
filename.endsWith('.mpd') ||
filename.endsWith('.m4s') ||
filename.endsWith('.tmp')
) {
const p = join(outPath, filename)
remove(p)
.catch(err => logger.error('Cannot remove %s.', p, { err }))
}
}
playlist.destroy()
.catch(err => logger.error('Cannot remove live streaming playlist.', { err }))
video.state = VideoState.LIVE_ENDED
video.save()
.catch(err => logger.error('Cannot save new video state of live streaming.', { err }))
}
private async addSegmentSha (options: SegmentSha256QueueParam) {
const segmentName = basename(options.segmentPath)
logger.debug('Updating live sha segment %s.', options.segmentPath)
const shaResult = await buildSha256Segment(options.segmentPath)
if (!this.segmentsSha256.has(options.videoUUID)) {
this.segmentsSha256.set(options.videoUUID, new Map())
}
const filesMap = this.segmentsSha256.get(options.videoUUID)
filesMap.set(segmentName, shaResult)
}
private removeSegmentSha (options: SegmentSha256QueueParam) {
const segmentName = basename(options.segmentPath)
logger.debug('Removing live sha segment %s.', options.segmentPath)
const filesMap = this.segmentsSha256.get(options.videoUUID)
if (!filesMap) {
logger.warn('Unknown files map to remove sha for %s.', options.videoUUID)
return
}
if (!filesMap.has(segmentName)) {
logger.warn('Unknown segment in files map for video %s and segment %s.', options.videoUUID, options.segmentPath)
return
}
filesMap.delete(segmentName)
}
static get Instance () {
return this.instance || (this.instance = new this())
}
}
// ---------------------------------------------------------------------------
export {
LiveManager
}

View File

@ -27,7 +27,8 @@ function generateWebTorrentVideoName (uuid: string, resolution: number, extname:
function getVideoFilePath (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile, isRedundancy = false) { function getVideoFilePath (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile, isRedundancy = false) {
if (isStreamingPlaylist(videoOrPlaylist)) { if (isStreamingPlaylist(videoOrPlaylist)) {
const video = extractVideo(videoOrPlaylist) const video = extractVideo(videoOrPlaylist)
return join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid, getVideoFilename(videoOrPlaylist, videoFile))
return join(getHLSDirectory(video), getVideoFilename(videoOrPlaylist, videoFile))
} }
const baseDir = isRedundancy ? CONFIG.STORAGE.REDUNDANCY_DIR : CONFIG.STORAGE.VIDEOS_DIR const baseDir = isRedundancy ? CONFIG.STORAGE.REDUNDANCY_DIR : CONFIG.STORAGE.VIDEOS_DIR

View File

@ -13,13 +13,14 @@ import { copyFile, ensureDir, move, remove, stat } from 'fs-extra'
import { logger } from '../helpers/logger' import { logger } from '../helpers/logger'
import { VideoResolution } from '../../shared/models/videos' import { VideoResolution } from '../../shared/models/videos'
import { VideoFileModel } from '../models/video/video-file' import { VideoFileModel } from '../models/video/video-file'
import { updateMasterHLSPlaylist, updateSha256Segments } from './hls' import { updateMasterHLSPlaylist, updateSha256VODSegments } from './hls'
import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist' import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
import { VideoStreamingPlaylistType } from '../../shared/models/videos/video-streaming-playlist.type' import { VideoStreamingPlaylistType } from '../../shared/models/videos/video-streaming-playlist.type'
import { CONFIG } from '../initializers/config' import { CONFIG } from '../initializers/config'
import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoWithAllFiles, MVideoWithFile } from '@server/types/models' import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoWithAllFiles, MVideoWithFile } from '@server/types/models'
import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
import { generateVideoStreamingPlaylistName, getVideoFilename, getVideoFilePath } from './video-paths' import { generateVideoStreamingPlaylistName, getVideoFilename, getVideoFilePath } from './video-paths'
import { spawn } from 'child_process'
/** /**
* Optimize the original video file and replace it. The resolution is not changed. * Optimize the original video file and replace it. The resolution is not changed.
@ -182,7 +183,7 @@ async function generateHlsPlaylist (video: MVideoWithFile, resolution: VideoReso
const [ videoStreamingPlaylist ] = await VideoStreamingPlaylistModel.upsert({ const [ videoStreamingPlaylist ] = await VideoStreamingPlaylistModel.upsert({
videoId: video.id, videoId: video.id,
playlistUrl, playlistUrl,
segmentsSha256Url: WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsSha256SegmentsStaticPath(video.uuid), segmentsSha256Url: WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsSha256SegmentsStaticPath(video.uuid, video.isLive),
p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrl, video.VideoFiles), p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrl, video.VideoFiles),
p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION, p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
@ -213,7 +214,7 @@ async function generateHlsPlaylist (video: MVideoWithFile, resolution: VideoReso
video.setHLSPlaylist(videoStreamingPlaylist) video.setHLSPlaylist(videoStreamingPlaylist)
await updateMasterHLSPlaylist(video) await updateMasterHLSPlaylist(video)
await updateSha256Segments(video) await updateSha256VODSegments(video)
return video return video
} }

31
server/lib/video.ts Normal file
View File

@ -0,0 +1,31 @@
import { VideoModel } from '@server/models/video/video'
import { FilteredModelAttributes } from '@server/types'
import { VideoCreate, VideoPrivacy, VideoState } from '@shared/models'
function buildLocalVideoFromCreate (videoInfo: VideoCreate, channelId: number): FilteredModelAttributes<VideoModel> {
return {
name: videoInfo.name,
remote: false,
category: videoInfo.category,
licence: videoInfo.licence,
language: videoInfo.language,
commentsEnabled: videoInfo.commentsEnabled !== false, // If the value is not "false", the default is "true"
downloadEnabled: videoInfo.downloadEnabled !== false,
waitTranscoding: videoInfo.waitTranscoding || false,
state: VideoState.WAITING_FOR_LIVE,
nsfw: videoInfo.nsfw || false,
description: videoInfo.description,
support: videoInfo.support,
privacy: videoInfo.privacy || VideoPrivacy.PRIVATE,
duration: 0,
channelId: channelId,
originallyPublishedAt: videoInfo.originallyPublishedAt
}
}
// ---------------------------------------------------------------------------
export {
buildLocalVideoFromCreate
}

View File

@ -0,0 +1,66 @@
import * as express from 'express'
import { body, param } from 'express-validator'
import { checkUserCanManageVideo, doesVideoChannelOfAccountExist, doesVideoExist } from '@server/helpers/middlewares/videos'
import { UserRight } from '@shared/models'
import { isIdOrUUIDValid, isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc'
import { isVideoNameValid } from '../../../helpers/custom-validators/videos'
import { cleanUpReqFiles } from '../../../helpers/express-utils'
import { logger } from '../../../helpers/logger'
import { CONFIG } from '../../../initializers/config'
import { areValidationErrors } from '../utils'
import { getCommonVideoEditAttributes } from './videos'
import { VideoLiveModel } from '@server/models/video/video-live'
const videoLiveGetValidator = [
param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking videoLiveGetValidator parameters', { parameters: req.body })
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res, 'all')) return
// Check if the user who did the request is able to update the video
const user = res.locals.oauth.token.User
if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return
const videoLive = await VideoLiveModel.loadByVideoId(res.locals.videoAll.id)
if (!videoLive) return res.sendStatus(404)
res.locals.videoLive = videoLive
return next()
}
]
const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
body('channelId')
.customSanitizer(toIntOrNull)
.custom(isIdValid).withMessage('Should have correct video channel id'),
body('name')
.custom(isVideoNameValid).withMessage('Should have a valid name'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking videoLiveAddValidator parameters', { parameters: req.body })
if (CONFIG.LIVE.ENABLED !== true) {
return res.status(403)
.json({ error: 'Live is not enabled on this instance' })
}
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
const user = res.locals.oauth.token.User
if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
return next()
}
])
// ---------------------------------------------------------------------------
export {
videoLiveAddValidator,
videoLiveGetValidator
}

View File

@ -123,8 +123,8 @@ export class VideoFileModel extends Model<VideoFileModel> {
@Column @Column
extname: string extname: string
@AllowNull(false) @AllowNull(true)
@Is('VideoFileInfohash', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash')) @Is('VideoFileInfohash', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash', true))
@Column @Column
infoHash: string infoHash: string

View File

@ -77,6 +77,8 @@ function videoModelToFormattedJSON (video: MVideoFormattable, options?: VideoFor
publishedAt: video.publishedAt, publishedAt: video.publishedAt,
originallyPublishedAt: video.originallyPublishedAt, originallyPublishedAt: video.originallyPublishedAt,
isLive: video.isLive,
account: video.VideoChannel.Account.toFormattedSummaryJSON(), account: video.VideoChannel.Account.toFormattedSummaryJSON(),
channel: video.VideoChannel.toFormattedSummaryJSON(), channel: video.VideoChannel.toFormattedSummaryJSON(),

View File

@ -0,0 +1,74 @@
import { AllowNull, BelongsTo, Column, CreatedAt, DataType, DefaultScope, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
import { WEBSERVER } from '@server/initializers/constants'
import { MVideoLive, MVideoLiveVideo } from '@server/types/models'
import { VideoLive } from '@shared/models/videos/video-live.model'
import { VideoModel } from './video'
@DefaultScope(() => ({
include: [
{
model: VideoModel,
required: true
}
]
}))
@Table({
tableName: 'videoLive',
indexes: [
{
fields: [ 'videoId' ],
unique: true
}
]
})
export class VideoLiveModel extends Model<VideoLiveModel> {
@AllowNull(false)
@Column(DataType.STRING)
streamKey: string
@CreatedAt
createdAt: Date
@UpdatedAt
updatedAt: Date
@ForeignKey(() => VideoModel)
@Column
videoId: number
@BelongsTo(() => VideoModel, {
foreignKey: {
allowNull: false
},
onDelete: 'cascade'
})
Video: VideoModel
static loadByStreamKey (streamKey: string) {
const query = {
where: {
streamKey
}
}
return VideoLiveModel.findOne<MVideoLiveVideo>(query)
}
static loadByVideoId (videoId: number) {
const query = {
where: {
videoId
}
}
return VideoLiveModel.findOne<MVideoLive>(query)
}
toFormattedJSON (): VideoLive {
return {
rtmpUrl: WEBSERVER.RTMP_URL,
streamKey: this.streamKey
}
}
}

View File

@ -173,7 +173,9 @@ export class VideoStreamingPlaylistModel extends Model<VideoStreamingPlaylistMod
return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsPlaylistFilename(resolution)) return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsPlaylistFilename(resolution))
} }
static getHlsSha256SegmentsStaticPath (videoUUID: string) { static getHlsSha256SegmentsStaticPath (videoUUID: string, isLive: boolean) {
if (isLive) return join('/live', 'segments-sha256', videoUUID)
return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsSha256SegmentsFilename()) return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsSha256SegmentsFilename())
} }

View File

@ -549,6 +549,11 @@ export class VideoModel extends Model<VideoModel> {
@Column @Column
remote: boolean remote: boolean
@AllowNull(false)
@Default(false)
@Column
isLive: boolean
@AllowNull(false) @AllowNull(false)
@Is('VideoUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url')) @Is('VideoUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
@Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max)) @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))

View File

@ -100,6 +100,22 @@ describe('Test config API validators', function () {
enabled: false enabled: false
} }
}, },
live: {
enabled: true,
transcoding: {
enabled: true,
threads: 4,
resolutions: {
'240p': true,
'360p': true,
'480p': true,
'720p': true,
'1080p': true,
'2160p': true
}
}
},
import: { import: {
videos: { videos: {
http: { http: {

View File

@ -64,6 +64,7 @@ function checkInitialConfig (server: ServerInfo, data: CustomConfig) {
expect(data.user.videoQuota).to.equal(5242880) expect(data.user.videoQuota).to.equal(5242880)
expect(data.user.videoQuotaDaily).to.equal(-1) expect(data.user.videoQuotaDaily).to.equal(-1)
expect(data.transcoding.enabled).to.be.false expect(data.transcoding.enabled).to.be.false
expect(data.transcoding.allowAdditionalExtensions).to.be.false expect(data.transcoding.allowAdditionalExtensions).to.be.false
expect(data.transcoding.allowAudioFiles).to.be.false expect(data.transcoding.allowAudioFiles).to.be.false
@ -77,6 +78,16 @@ function checkInitialConfig (server: ServerInfo, data: CustomConfig) {
expect(data.transcoding.webtorrent.enabled).to.be.true expect(data.transcoding.webtorrent.enabled).to.be.true
expect(data.transcoding.hls.enabled).to.be.true expect(data.transcoding.hls.enabled).to.be.true
expect(data.live.enabled).to.be.false
expect(data.live.transcoding.enabled).to.be.false
expect(data.live.transcoding.threads).to.equal(2)
expect(data.live.transcoding.resolutions['240p']).to.be.false
expect(data.live.transcoding.resolutions['360p']).to.be.false
expect(data.live.transcoding.resolutions['480p']).to.be.false
expect(data.live.transcoding.resolutions['720p']).to.be.false
expect(data.live.transcoding.resolutions['1080p']).to.be.false
expect(data.live.transcoding.resolutions['2160p']).to.be.false
expect(data.import.videos.http.enabled).to.be.true expect(data.import.videos.http.enabled).to.be.true
expect(data.import.videos.torrent.enabled).to.be.true expect(data.import.videos.torrent.enabled).to.be.true
expect(data.autoBlacklist.videos.ofUsers.enabled).to.be.false expect(data.autoBlacklist.videos.ofUsers.enabled).to.be.false
@ -150,6 +161,16 @@ function checkUpdatedConfig (data: CustomConfig) {
expect(data.transcoding.hls.enabled).to.be.false expect(data.transcoding.hls.enabled).to.be.false
expect(data.transcoding.webtorrent.enabled).to.be.true expect(data.transcoding.webtorrent.enabled).to.be.true
expect(data.live.enabled).to.be.true
expect(data.live.transcoding.enabled).to.be.true
expect(data.live.transcoding.threads).to.equal(4)
expect(data.live.transcoding.resolutions['240p']).to.be.true
expect(data.live.transcoding.resolutions['360p']).to.be.true
expect(data.live.transcoding.resolutions['480p']).to.be.true
expect(data.live.transcoding.resolutions['720p']).to.be.true
expect(data.live.transcoding.resolutions['1080p']).to.be.true
expect(data.live.transcoding.resolutions['2160p']).to.be.true
expect(data.import.videos.http.enabled).to.be.false expect(data.import.videos.http.enabled).to.be.false
expect(data.import.videos.torrent.enabled).to.be.false expect(data.import.videos.torrent.enabled).to.be.false
expect(data.autoBlacklist.videos.ofUsers.enabled).to.be.true expect(data.autoBlacklist.videos.ofUsers.enabled).to.be.true
@ -301,6 +322,21 @@ describe('Test config', function () {
enabled: false enabled: false
} }
}, },
live: {
enabled: true,
transcoding: {
enabled: true,
threads: 4,
resolutions: {
'240p': true,
'360p': true,
'480p': true,
'720p': true,
'1080p': true,
'2160p': true
}
}
},
import: { import: {
videos: { videos: {
http: { http: {

View File

@ -83,7 +83,7 @@ describe('Test video transcoding', function () {
}) })
it('Should transcode video on server 2', async function () { it('Should transcode video on server 2', async function () {
this.timeout(60000) this.timeout(120000)
const videoAttributes = { const videoAttributes = {
name: 'my super name for server 2', name: 'my super name for server 2',

View File

@ -9,6 +9,7 @@ export * from './video-channels'
export * from './video-comment' export * from './video-comment'
export * from './video-file' export * from './video-file'
export * from './video-import' export * from './video-import'
export * from './video-live'
export * from './video-playlist' export * from './video-playlist'
export * from './video-playlist-element' export * from './video-playlist-element'
export * from './video-rate' export * from './video-rate'

View File

@ -0,0 +1,15 @@
import { VideoLiveModel } from '@server/models/video/video-live'
import { PickWith } from '@shared/core-utils'
import { MVideo } from './video'
type Use<K extends keyof VideoLiveModel, M> = PickWith<VideoLiveModel, K, M>
// ############################################################################
export type MVideoLive = Omit<VideoLiveModel, 'Video'>
// ############################################################################
export type MVideoLiveVideo =
MVideoLive &
Use<'Video', MVideo>

View File

@ -9,7 +9,8 @@ import {
MVideoFile, MVideoFile,
MVideoImmutable, MVideoImmutable,
MVideoPlaylistFull, MVideoPlaylistFull,
MVideoPlaylistFullSummary MVideoPlaylistFullSummary,
MVideoLive
} from '@server/types/models' } from '@server/types/models'
import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token' import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token'
import { MPlugin, MServer, MServerBlocklist } from '@server/types/models/server' import { MPlugin, MServer, MServerBlocklist } from '@server/types/models/server'
@ -68,6 +69,8 @@ declare module 'express' {
onlyVideoWithRights?: MVideoWithRights onlyVideoWithRights?: MVideoWithRights
videoId?: MVideoIdThumbnail videoId?: MVideoIdThumbnail
videoLive?: MVideoLive
videoShare?: MVideoShareActor videoShare?: MVideoShareActor
videoFile?: MVideoFile videoFile?: MVideoFile

View File

@ -126,6 +126,21 @@ function updateCustomSubConfig (url: string, token: string, newConfig: DeepParti
enabled: false enabled: false
} }
}, },
live: {
enabled: true,
transcoding: {
enabled: true,
threads: 4,
resolutions: {
'240p': true,
'360p': true,
'480p': true,
'720p': true,
'1080p': true,
'2160p': true
}
}
},
import: { import: {
videos: { videos: {
http: { http: {

View File

@ -1,6 +1,15 @@
import { NSFWPolicyType } from '../videos/nsfw-policy.type' import { NSFWPolicyType } from '../videos/nsfw-policy.type'
import { BroadcastMessageLevel } from './broadcast-message-level.type' import { BroadcastMessageLevel } from './broadcast-message-level.type'
export type ConfigResolutions = {
'240p': boolean
'360p': boolean
'480p': boolean
'720p': boolean
'1080p': boolean
'2160p': boolean
}
export interface CustomConfig { export interface CustomConfig {
instance: { instance: {
name: string name: string
@ -75,15 +84,7 @@ export interface CustomConfig {
allowAudioFiles: boolean allowAudioFiles: boolean
threads: number threads: number
resolutions: { resolutions: ConfigResolutions & { '0p': boolean }
'0p': boolean
'240p': boolean
'360p': boolean
'480p': boolean
'720p': boolean
'1080p': boolean
'2160p': boolean
}
webtorrent: { webtorrent: {
enabled: boolean enabled: boolean
@ -94,6 +95,16 @@ export interface CustomConfig {
} }
} }
live: {
enabled: boolean
transcoding: {
enabled: boolean
threads: number
resolutions: ConfigResolutions
}
}
import: { import: {
videos: { videos: {
http: { http: {

View File

@ -98,6 +98,16 @@ export interface ServerConfig {
enabledResolutions: number[] enabledResolutions: number[]
} }
live: {
enabled: boolean
transcoding: {
enabled: boolean
enabledResolutions: number[]
}
}
import: { import: {
videos: { videos: {
http: { http: {

View File

@ -19,6 +19,8 @@ export * from './video-create.model'
export * from './video-file-metadata' export * from './video-file-metadata'
export * from './video-file.model' export * from './video-file.model'
export * from './video-live.model'
export * from './video-privacy.enum' export * from './video-privacy.enum'
export * from './video-query.type' export * from './video-query.type'
export * from './video-rate.type' export * from './video-rate.type'

View File

@ -16,5 +16,5 @@ export interface VideoCreate {
downloadEnabled?: boolean downloadEnabled?: boolean
privacy: VideoPrivacy privacy: VideoPrivacy
scheduleUpdate?: VideoScheduleUpdate scheduleUpdate?: VideoScheduleUpdate
originallyPublishedAt: Date | string originallyPublishedAt?: Date | string
} }

View File

@ -0,0 +1,4 @@
export interface VideoLive {
rtmpUrl: string
streamKey: string
}

View File

@ -1,5 +1,7 @@
export const enum VideoState { export const enum VideoState {
PUBLISHED = 1, PUBLISHED = 1,
TO_TRANSCODE = 2, TO_TRANSCODE = 2,
TO_IMPORT = 3 TO_IMPORT = 3,
WAITING_FOR_LIVE = 4,
LIVE_ENDED = 5
} }

View File

@ -1,6 +1,5 @@
import { VideoPrivacy } from './video-privacy.enum' import { VideoPrivacy } from './video-privacy.enum'
import { VideoScheduleUpdate } from './video-schedule-update.model' import { VideoScheduleUpdate } from './video-schedule-update.model'
export interface VideoUpdate { export interface VideoUpdate {
name?: string name?: string
category?: number category?: number

View File

@ -23,6 +23,8 @@ export interface Video {
isLocal: boolean isLocal: boolean
name: string name: string
isLive: boolean
thumbnailPath: string thumbnailPath: string
thumbnailUrl?: string thumbnailUrl?: string

2137
yarn.lock

File diff suppressed because it is too large Load Diff