mirror of
https://github.com/Chocobozzz/PeerTube.git
synced 2024-08-17 17:30:32 +03:00
Live streaming implementation first step
This commit is contained in:
parent
110d463fec
commit
c6c0fa6cd8
@ -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">
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
@ -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>
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -0,0 +1 @@
|
||||
export type VideoEditType = 'update' | 'upload' | 'import-url' | 'import-torrent' | 'go-live'
|
@ -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>
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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: [ ],
|
||||
|
@ -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">
|
||||
|
@ -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
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -74,6 +74,13 @@ export class ServerService {
|
||||
enabled: true
|
||||
}
|
||||
},
|
||||
live: {
|
||||
enabled: false,
|
||||
transcoding: {
|
||||
enabled: false,
|
||||
enabledResolutions: []
|
||||
}
|
||||
},
|
||||
avatar: {
|
||||
file: {
|
||||
size: { max: 0 },
|
||||
|
@ -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">
|
||||
|
@ -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) { }
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
|
@ -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'
|
||||
|
@ -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 []
|
||||
}
|
||||
}
|
||||
|
@ -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)))
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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'
|
||||
|
@ -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 {}
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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:
|
||||
|
@ -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",
|
||||
|
@ -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({
|
||||
|
@ -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()
|
||||
}
|
||||
|
15
server.ts
15
server.ts
@ -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)
|
||||
|
BIN
server/assets/default-live-background.jpg
Normal file
BIN
server/assets/default-live-background.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 91 KiB |
@ -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: {
|
||||
|
@ -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) {
|
||||
|
116
server/controllers/api/videos/live.ts
Normal file
116
server/controllers/api/videos/live.ts
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
@ -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'
|
||||
|
29
server/controllers/live.ts
Normal file
29
server/controllers/live.ts
Normal 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))
|
||||
}
|
@ -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: {
|
||||
|
@ -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,
|
||||
|
@ -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[]) {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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: {
|
||||
|
@ -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 () {
|
||||
|
@ -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,
|
||||
|
39
server/initializers/migrations/0535-video-live.ts
Normal file
39
server/initializers/migrations/0535-video-live.ts
Normal 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
|
||||
}
|
26
server/initializers/migrations/0540-video-file-infohash.ts
Normal file
26
server/initializers/migrations/0540-video-file-infohash.ts
Normal 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
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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
310
server/lib/live-manager.ts
Normal 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
|
||||
}
|
@ -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
|
||||
|
@ -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
31
server/lib/video.ts
Normal 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
|
||||
}
|
66
server/middlewares/validators/videos/video-live.ts
Normal file
66
server/middlewares/validators/videos/video-live.ts
Normal 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
|
||||
}
|
@ -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
|
||||
|
||||
|
@ -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(),
|
||||
|
||||
|
74
server/models/video/video-live.ts
Normal file
74
server/models/video/video-live.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
@ -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())
|
||||
}
|
||||
|
||||
|
@ -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))
|
||||
|
@ -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: {
|
||||
|
@ -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: {
|
||||
|
@ -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',
|
||||
|
@ -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'
|
||||
|
15
server/types/models/video/video-live.ts
Normal file
15
server/types/models/video/video-live.ts
Normal 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>
|
5
server/typings/express/index.d.ts
vendored
5
server/typings/express/index.d.ts
vendored
@ -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
|
||||
|
@ -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: {
|
||||
|
@ -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: {
|
||||
|
@ -98,6 +98,16 @@ export interface ServerConfig {
|
||||
enabledResolutions: number[]
|
||||
}
|
||||
|
||||
live: {
|
||||
enabled: boolean
|
||||
|
||||
transcoding: {
|
||||
enabled: boolean
|
||||
|
||||
enabledResolutions: number[]
|
||||
}
|
||||
}
|
||||
|
||||
import: {
|
||||
videos: {
|
||||
http: {
|
||||
|
@ -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'
|
||||
|
@ -16,5 +16,5 @@ export interface VideoCreate {
|
||||
downloadEnabled?: boolean
|
||||
privacy: VideoPrivacy
|
||||
scheduleUpdate?: VideoScheduleUpdate
|
||||
originallyPublishedAt: Date | string
|
||||
originallyPublishedAt?: Date | string
|
||||
}
|
||||
|
4
shared/models/videos/video-live.model.ts
Normal file
4
shared/models/videos/video-live.model.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface VideoLive {
|
||||
rtmpUrl: string
|
||||
streamKey: string
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { VideoPrivacy } from './video-privacy.enum'
|
||||
import { VideoScheduleUpdate } from './video-schedule-update.model'
|
||||
|
||||
export interface VideoUpdate {
|
||||
name?: string
|
||||
category?: number
|
||||
|
@ -23,6 +23,8 @@ export interface Video {
|
||||
isLocal: boolean
|
||||
name: string
|
||||
|
||||
isLive: boolean
|
||||
|
||||
thumbnailPath: string
|
||||
thumbnailUrl?: string
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user