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-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">
<a ngbNavLink i18n>Advanced configuration</a>
@ -814,7 +895,7 @@
<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">
<ng-container formGroupName="resolutions">

View file

@ -34,6 +34,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
customConfig: CustomConfig
resolutions: { id: string, label: string, description?: string }[] = []
liveResolutions: { id: string, label: string, description?: string }[] = []
transcodingThreadOptions: { label: string, value: number }[] = []
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 = [
{ value: 0, label: $localize`Auto (via ffmpeg)` },
{ value: 1, label: '1' },
@ -198,6 +201,15 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
enabled: null
}
},
live: {
enabled: null,
transcoding: {
enabled: null,
threads: TRANSCODING_THREADS_VALIDATOR,
resolutions: {}
}
},
autoBlacklist: {
videos: {
ofUsers: {
@ -245,13 +257,24 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
const defaultValues = {
transcoding: {
resolutions: {}
},
live: {
transcoding: {
resolutions: {}
}
}
}
for (const resolution of this.resolutions) {
defaultValues.transcoding.resolutions[resolution.id] = 'false'
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.loadForm()
this.checkTranscodingFields()
@ -268,6 +291,14 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
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 () {
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-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>
<a ngbNavLink i18n>Advanced settings</a>

View file

@ -20,10 +20,11 @@ import {
import { FormReactiveValidationMessages, FormValidatorService, SelectChannelItem } from '@app/shared/shared-forms'
import { InstanceService } from '@app/shared/shared-instance'
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 { I18nPrimengCalendarService } from './i18n-primeng-calendar.service'
import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
import { VideoEditType } from './video-edit.type'
type VideoLanguages = VideoConstant<string> & { group?: string }
@ -40,7 +41,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
@Input() schedulePublicationPossible = true
@Input() videoCaptions: (VideoCaptionEdit & { captionPath?: string })[] = []
@Input() waitTranscodingEnabled = true
@Input() type: 'import-url' | 'import-torrent' | 'upload' | 'update'
@Input() type: VideoEditType
@Input() videoLive: VideoLive
@ViewChild('videoCaptionAddModal', { static: true }) videoCaptionAddModal: VideoCaptionAddModalComponent
@ -124,7 +126,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
previewfile: null,
support: VIDEO_SUPPORT_VALIDATOR,
schedulePublicationAt: VIDEO_SCHEDULE_PUBLICATION_AT_VALIDATOR,
originallyPublishedAt: VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR
originallyPublishedAt: VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR,
liveStreamKey: null
}
this.formValidatorService.updateForm(
@ -320,7 +323,12 @@ export class VideoEditComponent implements OnInit, OnDestroy {
const currentSupport = this.form.value[ 'support' ]
// 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)
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 { LoadingBarService } from '@ngx-loading-bar/core'
import { VideoPrivacy, VideoUpdate } from '@shared/models'
import { hydrateFormFromVideo } from '../shared/video-edit-utils'
import { VideoSend } from './video-send'
@Component({
@ -99,7 +100,7 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Ca
previewUrl: null
}))
this.hydrateFormFromVideo()
hydrateFormFromVideo(this.form, this.video, false)
},
err => {
@ -136,10 +137,5 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Ca
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 { LoadingBarService } from '@ngx-loading-bar/core'
import { VideoPrivacy, VideoUpdate } from '@shared/models'
import { hydrateFormFromVideo } from '../shared/video-edit-utils'
import { VideoSend } from './video-send'
@Component({
@ -109,7 +110,7 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, CanCom
this.videoCaptions = videoCaptions
this.hydrateFormFromVideo()
hydrateFormFromVideo(this.form, this.video, true)
},
err => {
@ -146,31 +147,5 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, CanCom
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
}
const privacy = this.firstStepPrivacyId.toString()
const nsfw = this.serverConfig.instance.isNSFW
const waitTranscoding = 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>
</ng-template>
</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 [ngbNavOutlet]="nav"></div>
</div>
</div>

View file

@ -1,6 +1,8 @@
import { Component, HostListener, OnInit, ViewChild } from '@angular/core'
import { AuthService, AuthUser, CanComponentDeactivate, ServerService } from '@app/core'
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 { VideoImportUrlComponent } from './video-add-components/video-import-url.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('videoImportUrl') videoImportUrl: VideoImportUrlComponent
@ViewChild('videoImportTorrent') videoImportTorrent: VideoImportTorrentComponent
@ViewChild('videoGoLive') videoGoLive: VideoGoLiveComponent
user: AuthUser = null
secondStepType: 'upload' | 'import-url' | 'import-torrent'
secondStepType: VideoEditType
videoName: string
serverConfig: ServerConfig
@ -41,7 +44,7 @@ export class VideoAddComponent implements OnInit, CanComponentDeactivate {
this.user = this.auth.getUser()
}
onFirstStepDone (type: 'upload' | 'import-url' | 'import-torrent', videoName: string) {
onFirstStepDone (type: VideoEditType, videoName: string) {
this.secondStepType = type
this.videoName = videoName
}
@ -62,9 +65,9 @@ export class VideoAddComponent implements OnInit, CanComponentDeactivate {
}
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-torrent') return this.videoImportTorrent.canDeactivate()
if (this.secondStepType === 'go-live') return this.videoGoLive.canDeactivate()
return { canDeactivate: true }
}
@ -77,6 +80,10 @@ export class VideoAddComponent implements OnInit, CanComponentDeactivate {
return this.serverConfig.import.videos.torrent.enabled
}
isVideoLiveEnabled () {
return this.serverConfig.live.enabled
}
isInSecondStep () {
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 { VideoImportTorrentComponent } from './video-add-components/video-import-torrent.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 { VideoAddRoutingModule } from './video-add-routing.module'
import { VideoAddComponent } from './video-add.component'
@ -20,7 +21,8 @@ import { VideoAddComponent } from './video-add.component'
VideoUploadComponent,
VideoImportUrlComponent,
VideoImportTorrentComponent,
DragDropDirective
DragDropDirective,
VideoGoLiveComponent
],
exports: [ ],

View file

@ -11,6 +11,7 @@
[validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels"
[videoCaptions]="videoCaptions" [waitTranscodingEnabled]="waitTranscodingEnabled"
type="update" (pluginFieldsAdded)="hydratePluginFieldsFromVideo()"
[videoLive]="videoLive"
></my-video-edit>
<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 { VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main'
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({
selector: 'my-videos-update',
@ -14,11 +15,12 @@ import { VideoPrivacy } from '@shared/models'
})
export class VideoUpdateComponent extends FormReactive implements OnInit {
video: VideoEdit
userVideoChannels: SelectChannelItem[] = []
videoCaptions: VideoCaptionEdit[] = []
videoLive: VideoLive
isUpdatingVideo = false
userVideoChannels: SelectChannelItem[] = []
schedulePublicationPossible = false
videoCaptions: VideoCaptionEdit[] = []
waitTranscodingEnabled = true
private updateDone = false
@ -40,10 +42,11 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
this.route.data
.pipe(map(data => data.videoData))
.subscribe(({ video, videoChannels, videoCaptions }) => {
.subscribe(({ video, videoChannels, videoCaptions, videoLive }) => {
this.video = new VideoEdit(video)
this.userVideoChannels = videoChannels
this.videoCaptions = videoCaptions
this.videoLive = videoLive
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
setTimeout(() => this.hydrateFormFromVideo())
setTimeout(() => hydrateFormFromVideo(this.form, this.video, true))
},
err => {
@ -133,29 +136,4 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
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 { Injectable } from '@angular/core'
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()
export class VideoUpdateResolver implements Resolve<any> {
constructor (
private videoService: VideoService,
private videoLiveService: VideoLiveService,
private videoChannelService: VideoChannelService,
private videoCaptionService: VideoCaptionService
) {
@ -18,32 +19,38 @@ export class VideoUpdateResolver implements Resolve<any> {
return this.videoService.getVideo({ videoId: uuid })
.pipe(
switchMap(video => {
return forkJoin([
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 }))
switchMap(video => forkJoin(this.buildVideoObservables(video))),
map(([ video, videoChannels, videoCaptions, videoLive ]) => ({ video, videoChannels, videoCaptions, videoLive }))
)
}
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 { HttpClient } from '@angular/common/http'
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 { Notifier } from '@app/core/notification'
import { MarkdownService } from '@app/core/renderer'
@ -192,7 +193,7 @@ export class PluginService implements ClientHook {
: PluginType.THEME
}
getRegisteredVideoFormFields (type: 'import-url' | 'import-torrent' | 'upload' | 'update') {
getRegisteredVideoFormFields (type: VideoEditType) {
return this.formFields.video.filter(f => f.videoFormOptions.type === type)
}

View file

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

View file

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

View file

@ -63,6 +63,24 @@
</td>
</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>
<th i18n class="label" colspan="2">Import</th>
</tr>

View file

@ -23,7 +23,7 @@ import { FeedComponent } from './feeds'
import { LoaderComponent, SmallLoaderComponent } from './loaders'
import { HelpComponent, ListOverflowComponent, TopMenuDropdownComponent } from './misc'
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 { VideoChannelService } from './video-channel'
@ -142,6 +142,7 @@ import { VideoChannelService } from './video-channel'
RedundancyService,
VideoImportService,
VideoOwnershipService,
VideoLiveService,
VideoService,
VideoCaptionService,

View file

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

View file

@ -62,8 +62,11 @@ export class VideoDetails extends Video implements VideoDetailsServerModel {
}
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
thumbnailUrl: string
isLive: boolean
previewPath: string
previewUrl: string
@ -103,6 +105,8 @@ export class Video implements VideoServerModel {
this.state = hash.state
this.description = hash.description
this.isLive = hash.isLive
this.duration = hash.duration
this.durationLabel = durationToString(hash.duration)
@ -113,10 +117,14 @@ export class Video implements VideoServerModel {
this.name = hash.name
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.previewUrl = hash.previewUrl || (absoluteAPIUrl + hash.previewPath)
this.previewUrl = this.previewPath
? hash.previewUrl || (absoluteAPIUrl + hash.previewPath)
: null
this.embedPath = hash.embedPath
this.embedUrl = hash.embedUrl || (getAbsoluteEmbedUrl() + hash.embedPath)

View file

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

View file

@ -1,17 +1,42 @@
import { Segment } from 'p2p-media-loader-core'
import { basename } from 'path'
type SegmentsJSON = { [filename: string]: string | { [byterange: string]: string } }
function segmentValidatorFactory (segmentsSha256Url: string) {
const segmentsJSON = fetchSha256Segments(segmentsSha256Url)
let segmentsJSON = fetchSha256Segments(segmentsSha256Url)
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 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) {
throw new Error(`Unknown segment name ${filename}/${range} in segment validator`)
}
@ -36,7 +61,7 @@ export {
function fetchSha256Segments (url: string) {
return fetch(url)
.then(res => res.json())
.then(res => res.json() as Promise<SegmentsJSON>)
.catch(err => {
console.error('Cannot get sha256 segments', err)
return {}

View file

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

View file

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

View file

@ -243,6 +243,24 @@ transcoding:
hls:
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:
# Add ability for your users to import remote videos (from YouTube, torrent...)
videos:

View file

@ -37,24 +37,24 @@ log:
contact_form:
enabled: true
redundancy:
videos:
check_interval: '1 minute'
strategies:
-
size: '1000MB'
min_lifetime: '10 minutes'
strategy: 'most-views'
-
size: '1000MB'
min_lifetime: '10 minutes'
strategy: 'trending'
-
size: '1000MB'
min_lifetime: '10 minutes'
strategy: 'recently-added'
min_views: 1
#
#redundancy:
# videos:
# check_interval: '1 minute'
# strategies:
# -
# size: '1000MB'
# min_lifetime: '10 minutes'
# strategy: 'most-views'
# -
# size: '1000MB'
# min_lifetime: '10 minutes'
# strategy: 'trending'
# -
# size: '1000MB'
# min_lifetime: '10 minutes'
# strategy: 'recently-added'
# min_views: 1
cache:
previews:
@ -82,6 +82,24 @@ transcoding:
hls:
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:
videos:
http:

View file

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

View file

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

View file

@ -130,7 +130,7 @@ async function run () {
for (const playlist of video.VideoStreamingPlaylists) {
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()
}

View file

@ -98,10 +98,12 @@ import {
staticRouter,
lazyStaticRouter,
servicesRouter,
liveRouter,
pluginsRouter,
webfingerRouter,
trackerRouter,
createWebsocketTrackerServer, botsRouter
createWebsocketTrackerServer,
botsRouter
} from './server/controllers'
import { advertiseDoNotTrack } from './server/middlewares/dnt'
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 { Hooks } from './server/lib/plugins/hooks'
import { PluginManager } from './server/lib/plugins/plugin-manager'
import { LiveManager } from '@server/lib/live-manager'
// ----------- Command line -----------
@ -139,14 +142,14 @@ if (isTestInstance()) {
}
// 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') {
return anonymize(req.ip, 16, 16)
}
return req.ip
})
morgan.token<express.Request>('user-agent', req => {
morgan.token('user-agent', req => {
if (req.get('DNT') === '1') {
return useragent.parse(req.get('user-agent')).family
}
@ -183,6 +186,9 @@ app.use(apiRoute, apiRouter)
// Services (oembed...)
app.use('/services', servicesRouter)
// Live streaming
app.use('/live', liveRouter)
// Plugins & themes
app.use('/', pluginsRouter)
@ -271,6 +277,9 @@ async function startApplication () {
if (cli.plugins) await PluginManager.Instance.registerPluginsAndThemes()
LiveManager.Instance.init()
if (CONFIG.LIVE.ENABLED) LiveManager.Instance.run()
// Make server listening
server.listen(port, hostname, () => {
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: {
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: {
videos: {
@ -232,7 +240,7 @@ async function deleteCustomConfig (req: express.Request, res: express.Response)
const data = customConfig()
return res.json(data).end()
return res.json(data)
}
async function updateCustomConfig (req: express.Request, res: express.Response) {
@ -254,7 +262,7 @@ async function updateCustomConfig (req: express.Request, res: express.Response)
oldCustomConfigAuditKeys
)
return res.json(data).end()
return res.json(data)
}
function getRegisteredThemes () {
@ -268,9 +276,13 @@ function getRegisteredThemes () {
}))
}
function getEnabledResolutions () {
return Object.keys(CONFIG.TRANSCODING.RESOLUTIONS)
.filter(key => CONFIG.TRANSCODING.ENABLED && CONFIG.TRANSCODING.RESOLUTIONS[key] === true)
function getEnabledResolutions (type: 'vod' | 'live') {
const transcoding = type === 'vod'
? CONFIG.TRANSCODING
: CONFIG.LIVE.TRANSCODING
return Object.keys(transcoding.RESOLUTIONS)
.filter(key => transcoding.ENABLED && transcoding.RESOLUTIONS[key] === true)
.map(r => parseInt(r, 10))
}
@ -411,6 +423,21 @@ function customConfig (): CustomConfig {
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: {
videos: {
http: {

View file

@ -63,6 +63,7 @@ import { blacklistRouter } from './blacklist'
import { videoCaptionsRouter } from './captions'
import { videoCommentRouter } from './comment'
import { videoImportsRouter } from './import'
import { liveRouter } from './live'
import { ownershipVideoRouter } from './ownership'
import { rateVideoRouter } from './rate'
import { watchingRouter } from './watching'
@ -96,6 +97,7 @@ videosRouter.use('/', videoCaptionsRouter)
videosRouter.use('/', videoImportsRouter)
videosRouter.use('/', ownershipVideoRouter)
videosRouter.use('/', watchingRouter)
videosRouter.use('/', liveRouter)
videosRouter.get('/categories', listVideoCategories)
videosRouter.get('/licences', listVideoLicences)
@ -304,7 +306,7 @@ async function addVideo (req: express.Request, res: express.Response) {
id: videoCreated.id,
uuid: videoCreated.uuid
}
}).end()
})
}
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 './static'
export * from './lazy-static'
export * from './live'
export * from './webfinger'
export * from './tracker'
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: {
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: {
videos: {

View file

@ -175,6 +175,16 @@ function pageToStartAndCount (page: number, itemsPerPage: number) {
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) {
if (isAbsolute(path)) return path
@ -263,6 +273,7 @@ export {
sha256,
sha1,
mapToJSON,
promisify0,
promisify1,

View file

@ -8,7 +8,8 @@ import {
VIDEO_LICENCES,
VIDEO_PRIVACIES,
VIDEO_RATE_TYPES,
VIDEO_STATES
VIDEO_STATES,
VIDEO_LIVE
} from '../../initializers/constants'
import { exists, isArray, isDateValid, isFileValid } from './misc'
import * as magnetUtil from 'magnet-uri'
@ -77,7 +78,7 @@ function isVideoRatingTypeValid (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[]) {

View file

@ -1,13 +1,13 @@
import * as ffmpeg from 'fluent-ffmpeg'
import { readFile, remove, writeFile } from 'fs-extra'
import { dirname, join } from 'path'
import { VideoFileMetadata } from '@shared/models/videos/video-file-metadata'
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 { processImage } from './image-utils'
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
@ -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 configResolutions = CONFIG.TRANSCODING.RESOLUTIONS
// Put in the order we want to proceed jobs
const resolutions = [
@ -270,14 +273,13 @@ type TranscodeOptions =
function transcode (options: TranscodeOptions) {
return new Promise<void>(async (res, rej) => {
try {
// we set cwd explicitly because ffmpeg appears to create temporary files when trancoding which fails in read-only file systems
let command = ffmpeg(options.inputPath, { niceness: FFMPEG_NICE.TRANSCODING, cwd: CONFIG.STORAGE.TMP_DIR })
let command = getFFmpeg(options.inputPath)
.output(options.outputPath)
if (options.type === 'quick-transcode') {
command = buildQuickTranscodeCommand(command)
} else if (options.type === 'hls') {
command = await buildHLSCommand(command, options)
command = await buildHLSVODCommand(command, options)
} else if (options.type === 'merge-audio') {
command = await buildAudioMergeCommand(command, options)
} else if (options.type === 'only-audio') {
@ -286,11 +288,6 @@ function transcode (options: TranscodeOptions) {
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
.on('error', (err, 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 {
getVideoStreamCodec,
getAudioStreamCodec,
runLiveMuxing,
convertWebPToJPG,
getVideoStreamSize,
getVideoFileResolution,
getMetadataFromFile,
getDurationFromVideoFile,
runLiveTranscoding,
generateImageFromVideoFile,
TranscodeOptions,
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) {
let fps = await getVideoFileFPS(options.inputPath)
if (
@ -438,7 +527,7 @@ function buildQuickTranscodeCommand (command: ffmpeg.FfmpegCommand) {
return command
}
async function buildHLSCommand (command: ffmpeg.FfmpegCommand, options: HLSTranscodeOptions) {
async function buildHLSVODCommand (command: ffmpeg.FfmpegCommand, options: HLSTranscodeOptions) {
const videoPath = getHLSVideoPath(options)
if (options.copyCodecs) command = presetCopy(command)
@ -508,13 +597,10 @@ async function presetH264 (command: ffmpeg.FfmpegCommand, input: string, resolut
let localCommand = command
.format('mp4')
.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')
addDefaultX264Params(localCommand)
const parsedAudio = await audio.get(input)
if (!parsedAudio.audioStream) {
@ -565,3 +651,15 @@ function presetOnlyAudio (command: ffmpeg.FfmpegCommand): ffmpeg.FfmpegCommand {
.audioCodec('copy')
.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') }
}
},
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: {
VIDEOS: {
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: '',
WS: '',
HOSTNAME: '',
PORT: 0
PORT: 0,
RTMP_URL: ''
}
// Sortable columns per schema
@ -264,7 +265,7 @@ const CONSTRAINTS_FIELDS = {
VIEWS: { min: 0 },
LIKES: { min: 0 },
DISLIKES: { min: 0 },
FILE_SIZE: { min: 10 },
FILE_SIZE: { min: -1 },
URL: { min: 3, max: 2000 } // Length
},
VIDEO_PLAYLISTS: {
@ -370,39 +371,41 @@ const VIDEO_LICENCES = {
const VIDEO_LANGUAGES: { [id: string]: string } = {}
const VIDEO_PRIVACIES = {
const VIDEO_PRIVACIES: { [ id in VideoPrivacy ]: string } = {
[VideoPrivacy.PUBLIC]: 'Public',
[VideoPrivacy.UNLISTED]: 'Unlisted',
[VideoPrivacy.PRIVATE]: 'Private',
[VideoPrivacy.INTERNAL]: 'Internal'
}
const VIDEO_STATES = {
const VIDEO_STATES: { [ id in VideoState ]: string } = {
[VideoState.PUBLISHED]: 'Published',
[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.PENDING]: 'Pending',
[VideoImportState.SUCCESS]: 'Success',
[VideoImportState.REJECTED]: 'Rejected'
}
const ABUSE_STATES = {
const ABUSE_STATES: { [ id in AbuseState ]: string } = {
[AbuseState.PENDING]: 'Pending',
[AbuseState.REJECTED]: 'Rejected',
[AbuseState.ACCEPTED]: 'Accepted'
}
const VIDEO_PLAYLIST_PRIVACIES = {
const VIDEO_PLAYLIST_PRIVACIES: { [ id in VideoPlaylistPrivacy ]: string } = {
[VideoPlaylistPrivacy.PUBLIC]: 'Public',
[VideoPlaylistPrivacy.UNLISTED]: 'Unlisted',
[VideoPlaylistPrivacy.PRIVATE]: 'Private'
}
const VIDEO_PLAYLIST_TYPES = {
const VIDEO_PLAYLIST_TYPES: { [ id in VideoPlaylistType ]: string } = {
[VideoPlaylistType.REGULAR]: 'Regular',
[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_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 = {
OVERVIEWS_SAMPLE: 1000 * 3600 * 4, // 4 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 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'
ACTIVITY_PUB.COLLECTION_ITEMS_PER_PAGE = 2
ACTIVITY_PUB.ACTOR_REFRESH_INTERVAL = 10 * 1000 // 10 seconds
ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL = 10 * 1000 // 10 seconds
ACTIVITY_PUB.VIDEO_PLAYLIST_REFRESH_INTERVAL = 10 * 1000 // 10 seconds
ACTIVITY_PUB.ACTOR_REFRESH_INTERVAL = 100 * 10000 // 10 seconds
ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL = 100 * 10000 // 10 seconds
ACTIVITY_PUB.VIDEO_PLAYLIST_REFRESH_INTERVAL = 100 * 10000 // 10 seconds
CONSTRAINTS_FIELDS.ACTORS.AVATAR.FILE_SIZE.max = 100 * 1024 // 100KB
@ -737,6 +752,7 @@ const FILES_CONTENT_HASH = {
export {
WEBSERVER,
API_VERSION,
VIDEO_LIVE,
PEERTUBE_VERSION,
LAZY_STATIC_PATHS,
SEARCH_INDEX,
@ -892,10 +908,14 @@ function buildVideoMimetypeExt () {
function updateWebserverUrls () {
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.SCHEME = CONFIG.WEBSERVER.SCHEME
WEBSERVER.WS = CONFIG.WEBSERVER.WS
WEBSERVER.SCHEME = CONFIG.WEBSERVER.SCHEME
WEBSERVER.HOSTNAME = CONFIG.WEBSERVER.HOSTNAME
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 () {

View file

@ -1,11 +1,11 @@
import { QueryTypes, Transaction } from 'sequelize'
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 { 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 { AccountBlocklistModel } from '../models/account/account-blocklist'
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 { VideoFileModel } from '../models/video/video-file'
import { VideoImportModel } from '../models/video/video-import'
import { VideoLiveModel } from '../models/video/video-live'
import { VideoPlaylistModel } from '../models/video/video-playlist'
import { VideoPlaylistElementModel } from '../models/video/video-playlist-element'
import { VideoShareModel } from '../models/video/video-share'
@ -118,6 +119,7 @@ async function initDatabaseModels (silent: boolean) {
VideoViewModel,
VideoRedundancyModel,
UserVideoHistoryModel,
VideoLiveModel,
AccountBlocklistModel,
ServerBlocklistModel,
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')
}
async function updateSha256Segments (video: MVideoWithFile) {
async function updateSha256VODSegments (video: MVideoWithFile) {
const json: { [filename: string]: { [range: string]: string } } = {}
const playlistDirectory = join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid)
@ -101,6 +101,11 @@ async function updateSha256Segments (video: MVideoWithFile) {
await outputJSON(outputPath, json)
}
async function buildSha256Segment (segmentPath: string) {
const buf = await readFile(segmentPath)
return sha256(buf)
}
function getRangesFromPlaylist (playlistContent: string) {
const ranges: { offset: number, length: number }[] = []
const lines = playlistContent.split('\n')
@ -187,7 +192,8 @@ function downloadPlaylistSegments (playlistUrl: string, destinationDir: string,
export {
updateMasterHLSPlaylist,
updateSha256Segments,
updateSha256VODSegments,
buildSha256Segment,
downloadPlaylistSegments,
updateStreamingPlaylistsInfohashesIfNeeded
}

View file

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

View file

@ -13,13 +13,14 @@ import { copyFile, ensureDir, move, remove, stat } from 'fs-extra'
import { logger } from '../helpers/logger'
import { VideoResolution } from '../../shared/models/videos'
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 { VideoStreamingPlaylistType } from '../../shared/models/videos/video-streaming-playlist.type'
import { CONFIG } from '../initializers/config'
import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoWithAllFiles, MVideoWithFile } from '@server/types/models'
import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
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.
@ -182,7 +183,7 @@ async function generateHlsPlaylist (video: MVideoWithFile, resolution: VideoReso
const [ videoStreamingPlaylist ] = await VideoStreamingPlaylistModel.upsert({
videoId: video.id,
playlistUrl,
segmentsSha256Url: WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsSha256SegmentsStaticPath(video.uuid),
segmentsSha256Url: WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsSha256SegmentsStaticPath(video.uuid, video.isLive),
p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrl, video.VideoFiles),
p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
@ -213,7 +214,7 @@ async function generateHlsPlaylist (video: MVideoWithFile, resolution: VideoReso
video.setHLSPlaylist(videoStreamingPlaylist)
await updateMasterHLSPlaylist(video)
await updateSha256Segments(video)
await updateSha256VODSegments(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
extname: string
@AllowNull(false)
@Is('VideoFileInfohash', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash'))
@AllowNull(true)
@Is('VideoFileInfohash', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash', true))
@Column
infoHash: string

View file

@ -77,6 +77,8 @@ function videoModelToFormattedJSON (video: MVideoFormattable, options?: VideoFor
publishedAt: video.publishedAt,
originallyPublishedAt: video.originallyPublishedAt,
isLive: video.isLive,
account: video.VideoChannel.Account.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))
}
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())
}

View file

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

View file

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

View file

@ -64,6 +64,7 @@ function checkInitialConfig (server: ServerInfo, data: CustomConfig) {
expect(data.user.videoQuota).to.equal(5242880)
expect(data.user.videoQuotaDaily).to.equal(-1)
expect(data.transcoding.enabled).to.be.false
expect(data.transcoding.allowAdditionalExtensions).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.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.torrent.enabled).to.be.true
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.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.torrent.enabled).to.be.false
expect(data.autoBlacklist.videos.ofUsers.enabled).to.be.true
@ -301,6 +322,21 @@ describe('Test config', function () {
enabled: false
}
},
live: {
enabled: true,
transcoding: {
enabled: true,
threads: 4,
resolutions: {
'240p': true,
'360p': true,
'480p': true,
'720p': true,
'1080p': true,
'2160p': true
}
}
},
import: {
videos: {
http: {

View file

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

View file

@ -9,6 +9,7 @@ export * from './video-channels'
export * from './video-comment'
export * from './video-file'
export * from './video-import'
export * from './video-live'
export * from './video-playlist'
export * from './video-playlist-element'
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,
MVideoImmutable,
MVideoPlaylistFull,
MVideoPlaylistFullSummary
MVideoPlaylistFullSummary,
MVideoLive
} from '@server/types/models'
import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token'
import { MPlugin, MServer, MServerBlocklist } from '@server/types/models/server'
@ -68,6 +69,8 @@ declare module 'express' {
onlyVideoWithRights?: MVideoWithRights
videoId?: MVideoIdThumbnail
videoLive?: MVideoLive
videoShare?: MVideoShareActor
videoFile?: MVideoFile

View file

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

View file

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

View file

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

View file

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

View file

@ -16,5 +16,5 @@ export interface VideoCreate {
downloadEnabled?: boolean
privacy: VideoPrivacy
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 {
PUBLISHED = 1,
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 { VideoScheduleUpdate } from './video-schedule-update.model'
export interface VideoUpdate {
name?: string
category?: number

View file

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

2137
yarn.lock

File diff suppressed because it is too large Load diff