Compare commits

...
Sign in to create a new pull request.

5 commits

Author SHA1 Message Date
Chocobozzz
09c16166a4
Check live duration and size 2020-09-25 16:19:35 +02:00
Chocobozzz
e2d9d60383
Add watch messages if live has not started 2020-09-25 10:04:21 +02:00
Chocobozzz
52194a1b8c
Handle live federation 2020-09-17 13:59:02 +02:00
Chocobozzz
9e8a9e65ab
Refactor video creation 2020-09-17 10:00:46 +02:00
Chocobozzz
4ba542d832
Live streaming implementation first step 2020-09-17 09:20:52 +02:00
120 changed files with 4121 additions and 1858 deletions

View file

@ -699,6 +699,111 @@
</ng-template> </ng-template>
</ng-container> </ng-container>
<div 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-container ngProjectAs="description" i18n>
⚠️ Enabling live streaming requires trust in your users and extra moderation work
</ng-container>
<ng-container ngProjectAs="extra">
<div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isLiveEnabled() }">
<my-peertube-checkbox
inputName="liveAllowReplay" formControlName="allowReplay"
i18n-labelText labelText="Allow your users to automatically publish a replay of their live"
>
<ng-container ngProjectAs="description" i18n>
If the user quota is reached, PeerTube will automatically terminate the live streaming
</ng-container>
</my-peertube-checkbox>
</div>
<div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isLiveEnabled() }">
<label i18n for="liveMaxDuration">Max live duration</label>
<div class="peertube-select-container">
<select id="liveMaxDuration" formControlName="maxDuration" class="form-control">
<option *ngFor="let liveMaxDurationOption of liveMaxDurationOptions" [value]="liveMaxDurationOption.value">
{{ liveMaxDurationOption.label }}
</option>
</select>
</div>
</div>
<ng-container 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" i18n>
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>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</div>
</div>
</ng-template>
</div>
<ng-container ngbNavItem="advanced-configuration"> <ng-container ngbNavItem="advanced-configuration">
<a ngbNavLink i18n>Advanced configuration</a> <a ngbNavLink i18n>Advanced configuration</a>
@ -814,7 +919,7 @@
<div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isTranscodingEnabled() }"> <div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isTranscodingEnabled() }">
<label i18n for="transcodingThreads">Resolutions to generate</label> <label i18n>Resolutions to generate</label>
<div class="ml-2 mt-2 d-flex flex-column"> <div class="ml-2 mt-2 d-flex flex-column">
<ng-container formGroupName="resolutions"> <ng-container formGroupName="resolutions">
@ -945,9 +1050,15 @@
<div class="form-row mt-4"> <!-- submit placement block --> <div class="form-row mt-4"> <!-- submit placement block -->
<div class="col-md-7 col-xl-5"></div> <div class="col-md-7 col-xl-5"></div>
<div class="col-md-5 col-xl-5"> <div class="col-md-5 col-xl-5">
<span class="form-error submit-error" i18n *ngIf="!form.valid">It seems like the configuration is invalid. Please search for potential errors in the different tabs.</span> <span class="form-error submit-error" i18n *ngIf="!form.valid">
It seems like the configuration is invalid. Please search for potential errors in the different tabs.
</span>
<input (click)="formValidated()" type="submit" i18n-value value="Update configuration" [disabled]="!form.valid"> <span class="form-error submit-error" i18n *ngIf="!hasLiveAllowReplayConsistentOptions()">
You cannot allow live replay if you don't enable transcoding.
</span>
<input (click)="formValidated()" type="submit" i18n-value value="Update configuration" [disabled]="!form.valid || !hasConsistentOptions()">
</div> </div>
</div> </div>
</form> </form>

View file

@ -34,7 +34,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
customConfig: CustomConfig customConfig: CustomConfig
resolutions: { id: string, label: string, description?: string }[] = [] resolutions: { id: string, label: string, description?: string }[] = []
liveResolutions: { id: string, label: string, description?: string }[] = []
transcodingThreadOptions: { label: string, value: number }[] = [] transcodingThreadOptions: { label: string, value: number }[] = []
liveMaxDurationOptions: { label: string, value: number }[] = []
languageItems: SelectOptionsItem[] = [] languageItems: SelectOptionsItem[] = []
categoryItems: SelectOptionsItem[] = [] categoryItems: SelectOptionsItem[] = []
@ -82,6 +84,8 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
} }
] ]
this.liveResolutions = this.resolutions.filter(r => r.id !== '0p')
this.transcodingThreadOptions = [ this.transcodingThreadOptions = [
{ value: 0, label: $localize`Auto (via ffmpeg)` }, { value: 0, label: $localize`Auto (via ffmpeg)` },
{ value: 1, label: '1' }, { value: 1, label: '1' },
@ -89,6 +93,14 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
{ value: 4, label: '4' }, { value: 4, label: '4' },
{ value: 8, label: '8' } { value: 8, label: '8' }
] ]
this.liveMaxDurationOptions = [
{ value: 0, label: $localize`No limit` },
{ value: 1000 * 3600, label: $localize`1 hour` },
{ value: 1000 * 3600 * 3, label: $localize`3 hours` },
{ value: 1000 * 3600 * 5, label: $localize`5 hours` },
{ value: 1000 * 3600 * 10, label: $localize`10 hours` }
]
} }
get videoQuotaOptions () { get videoQuotaOptions () {
@ -111,7 +123,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
ngOnInit () { ngOnInit () {
this.serverConfig = this.serverService.getTmpConfig() this.serverConfig = this.serverService.getTmpConfig()
this.serverService.getConfig() this.serverService.getConfig()
.subscribe(config => this.serverConfig = config) .subscribe(config => {
this.serverConfig = config
})
const formGroupData: { [key in keyof CustomConfig ]: any } = { const formGroupData: { [key in keyof CustomConfig ]: any } = {
instance: { instance: {
@ -198,6 +212,18 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
enabled: null enabled: null
} }
}, },
live: {
enabled: null,
maxDuration: null,
allowReplay: null,
transcoding: {
enabled: null,
threads: TRANSCODING_THREADS_VALIDATOR,
resolutions: {}
}
},
autoBlacklist: { autoBlacklist: {
videos: { videos: {
ofUsers: { ofUsers: {
@ -243,15 +269,26 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
} }
const defaultValues = { const defaultValues = {
transcoding: {
resolutions: {}
},
live: {
transcoding: { transcoding: {
resolutions: {} resolutions: {}
} }
} }
}
for (const resolution of this.resolutions) { for (const resolution of this.resolutions) {
defaultValues.transcoding.resolutions[resolution.id] = 'false' defaultValues.transcoding.resolutions[resolution.id] = 'false'
formGroupData.transcoding.resolutions[resolution.id] = null formGroupData.transcoding.resolutions[resolution.id] = null
} }
for (const resolution of this.liveResolutions) {
defaultValues.live.transcoding.resolutions[resolution.id] = 'false'
formGroupData.live.transcoding.resolutions[resolution.id] = null
}
this.buildForm(formGroupData) this.buildForm(formGroupData)
this.loadForm() this.loadForm()
this.checkTranscodingFields() this.checkTranscodingFields()
@ -268,6 +305,14 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
return this.form.value['transcoding']['enabled'] === true return this.form.value['transcoding']['enabled'] === true
} }
isLiveEnabled () {
return this.form.value['live']['enabled'] === true
}
isLiveTranscodingEnabled () {
return this.form.value['live']['transcoding']['enabled'] === true
}
isSignupEnabled () { isSignupEnabled () {
return this.form.value['signup']['enabled'] === true return this.form.value['signup']['enabled'] === true
} }
@ -310,6 +355,20 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
} }
} }
hasConsistentOptions () {
if (this.hasLiveAllowReplayConsistentOptions()) return true
return false
}
hasLiveAllowReplayConsistentOptions () {
if (this.isTranscodingEnabled() === false && this.isLiveEnabled() && this.form.value['live']['allowReplay'] === true) {
return false
}
return true
}
private updateForm () { private updateForm () {
this.form.patchValue(this.customConfig) this.form.patchValue(this.customConfig)
} }

View file

@ -32,6 +32,7 @@ export class JobsComponent extends RestTable implements OnInit {
'video-import', 'video-import',
'videos-views', 'videos-views',
'activitypub-refresher', 'activitypub-refresher',
'video-live-ending',
'video-redundancy' 'video-redundancy'
] ]

View file

@ -86,7 +86,7 @@ export class MyAccountNotificationPreferencesComponent implements OnInit {
} }
private savePreferencesImpl () { private savePreferencesImpl () {
this.userNotificationService.updateNotificationSettings(this.user, this.user.notificationSettings) this.userNotificationService.updateNotificationSettings(this.user.notificationSettings)
.subscribe( .subscribe(
() => { () => {
this.notifier.success($localize`Preferences saved`, undefined, 2000) this.notifier.success($localize`Preferences saved`, undefined, 2000)

View file

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

View file

@ -195,6 +195,29 @@
</ng-template> </ng-template>
</ng-container> </ng-container>
<ng-container ngbNavItem *ngIf="liveVideo">
<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="liveVideoRTMPUrl" i18n>Live RTMP Url</label>
<my-input-readonly-copy id="liveVideoRTMPUrl" [value]="liveVideo.rtmpUrl"></my-input-readonly-copy>
</div>
<div class="form-group">
<label for="liveVideoStreamKey" i18n>Live stream key</label>
<my-input-readonly-copy id="liveVideoStreamKey" [value]="liveVideo.streamKey"></my-input-readonly-copy>
</div>
</div>
</div>
</ng-template>
</ng-container>
<ng-container ngbNavItem> <ng-container ngbNavItem>
<a ngbNavLink i18n>Advanced settings</a> <a ngbNavLink i18n>Advanced settings</a>

View file

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

View file

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

View file

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

View file

@ -0,0 +1,129 @@
import { Component, EventEmitter, OnInit, Output } from '@angular/core'
import { Router } from '@angular/router'
import { AuthService, CanComponentDeactivate, Notifier, ServerService } from '@app/core'
import { scrollToTop } from '@app/helpers'
import { FormValidatorService } from '@app/shared/shared-forms'
import { LiveVideoService, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
import { LoadingBarService } from '@ngx-loading-bar/core'
import { LiveVideo, VideoCreate, 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
liveVideo: LiveVideo
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 liveVideoService: LiveVideoService,
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.liveVideoService.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.liveVideoService.getVideoLive(this.videoId)
.subscribe(
liveVideo => {
this.liveVideo = liveVideo
},
err => {
this.firstStepError.emit()
this.notifier.error(err.message)
}
)
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,13 +1,14 @@
import { forkJoin } from 'rxjs' import { forkJoin, of } from 'rxjs'
import { map, switchMap } from 'rxjs/operators' import { map, switchMap } from 'rxjs/operators'
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { ActivatedRouteSnapshot, Resolve } from '@angular/router' import { ActivatedRouteSnapshot, Resolve } from '@angular/router'
import { VideoCaptionService, VideoChannelService, VideoService } from '@app/shared/shared-main' import { VideoCaptionService, VideoChannelService, VideoDetails, LiveVideoService, VideoService } from '@app/shared/shared-main'
@Injectable() @Injectable()
export class VideoUpdateResolver implements Resolve<any> { export class VideoUpdateResolver implements Resolve<any> {
constructor ( constructor (
private videoService: VideoService, private videoService: VideoService,
private liveVideoService: LiveVideoService,
private videoChannelService: VideoChannelService, private videoChannelService: VideoChannelService,
private videoCaptionService: VideoCaptionService private videoCaptionService: VideoCaptionService
) { ) {
@ -18,8 +19,13 @@ export class VideoUpdateResolver implements Resolve<any> {
return this.videoService.getVideo({ videoId: uuid }) return this.videoService.getVideo({ videoId: uuid })
.pipe( .pipe(
switchMap(video => { switchMap(video => forkJoin(this.buildVideoObservables(video))),
return forkJoin([ map(([ video, videoChannels, videoCaptions, videoLive ]) => ({ video, videoChannels, videoCaptions, videoLive }))
)
}
private buildVideoObservables (video: VideoDetails) {
return [
this.videoService this.videoService
.loadCompleteDescription(video.descriptionPath) .loadCompleteDescription(video.descriptionPath)
.pipe(map(description => Object.assign(video, { description }))), .pipe(map(description => Object.assign(video, { description }))),
@ -40,10 +46,11 @@ export class VideoUpdateResolver implements Resolve<any> {
.listCaptions(video.id) .listCaptions(video.id)
.pipe( .pipe(
map(result => result.data) map(result => result.data)
) ),
])
}), video.isLive
map(([ video, videoChannels, videoCaptions ]) => ({ video, videoChannels, videoCaptions })) ? this.liveVideoService.getVideoLive(video.id)
) : of(undefined)
]
} }
} }

View file

@ -29,6 +29,14 @@
This video will be published on {{ video.scheduledUpdate.updateAt | date: 'full' }}. This video will be published on {{ video.scheduledUpdate.updateAt | date: 'full' }}.
</div> </div>
<div i18n class="col-md-12 alert alert-info" *ngIf="isWaitingForLive()">
This live has not started yet.
</div>
<div i18n class="col-md-12 alert alert-info" *ngIf="isLiveEnded()">
This live is finished.
</div>
<div class="col-md-12 alert alert-danger" *ngIf="video?.blacklisted"> <div class="col-md-12 alert alert-danger" *ngIf="video?.blacklisted">
<div class="blocked-label" i18n>This video is blocked.</div> <div class="blocked-label" i18n>This video is blocked.</div>
{{ video.blockedReason }} {{ video.blockedReason }}
@ -109,7 +117,7 @@
</div> </div>
</div> </div>
<ng-container *ngIf="!isUserLoggedIn()"> <ng-container *ngIf="!isUserLoggedIn() && !isLive()">
<button <button
*ngIf="isVideoDownloadable()" class="action-button action-button-save" *ngIf="isVideoDownloadable()" class="action-button action-button-save"
(click)="showDownloadModal()" (keydown.enter)="showDownloadModal()" (click)="showDownloadModal()" (keydown.enter)="showDownloadModal()"

View file

@ -50,6 +50,8 @@ $video-info-margin-left: 44px;
} }
#video-wrapper { #video-wrapper {
$video-height: 66vh;
background-color: #000; background-color: #000;
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -58,6 +60,7 @@ $video-info-margin-left: 44px;
display: flex; display: flex;
justify-content: center; justify-content: center;
flex-grow: 1; flex-grow: 1;
height: $video-height;
} }
.remote-server-down { .remote-server-down {
@ -84,7 +87,7 @@ $video-info-margin-left: 44px;
::ng-deep .video-js { ::ng-deep .video-js {
width: 100%; width: 100%;
max-width: getPlayerWidth(66vh); max-width: getPlayerWidth(66vh);
height: 66vh; height: $video-height;
// VideoJS create an inner video player // VideoJS create an inner video player
video { video {

View file

@ -4,7 +4,17 @@ import { catchError } from 'rxjs/operators'
import { PlatformLocation } from '@angular/common' import { PlatformLocation } from '@angular/common'
import { ChangeDetectorRef, Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core' import { ChangeDetectorRef, Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router' import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, AuthUser, ConfirmService, MarkdownService, Notifier, RestExtractor, ServerService, UserService } from '@app/core' import {
AuthService,
AuthUser,
ConfirmService,
MarkdownService,
Notifier,
PeerTubeSocket,
RestExtractor,
ServerService,
UserService
} from '@app/core'
import { HooksService } from '@app/core/plugins/hooks.service' import { HooksService } from '@app/core/plugins/hooks.service'
import { RedirectService } from '@app/core/routing/redirect.service' import { RedirectService } from '@app/core/routing/redirect.service'
import { isXPercentInViewport, scrollToTop } from '@app/helpers' import { isXPercentInViewport, scrollToTop } from '@app/helpers'
@ -30,6 +40,8 @@ import { environment } from '../../../environments/environment'
import { VideoSupportComponent } from './modal/video-support.component' import { VideoSupportComponent } from './modal/video-support.component'
import { VideoWatchPlaylistComponent } from './video-watch-playlist.component' import { VideoWatchPlaylistComponent } from './video-watch-playlist.component'
type URLOptions = CustomizationOptions & { playerMode: PlayerMode }
@Component({ @Component({
selector: 'my-video-watch', selector: 'my-video-watch',
templateUrl: './video-watch.component.html', templateUrl: './video-watch.component.html',
@ -76,6 +88,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
private paramsSub: Subscription private paramsSub: Subscription
private queryParamsSub: Subscription private queryParamsSub: Subscription
private configSub: Subscription private configSub: Subscription
private liveVideosSub: Subscription
private serverConfig: ServerConfig private serverConfig: ServerConfig
@ -99,6 +112,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
private videoCaptionService: VideoCaptionService, private videoCaptionService: VideoCaptionService,
private hotkeysService: HotkeysService, private hotkeysService: HotkeysService,
private hooks: HooksService, private hooks: HooksService,
private peertubeSocket: PeerTubeSocket,
private location: PlatformLocation, private location: PlatformLocation,
@Inject(LOCALE_ID) private localeId: string @Inject(LOCALE_ID) private localeId: string
) { ) {
@ -165,6 +179,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
if (this.paramsSub) this.paramsSub.unsubscribe() if (this.paramsSub) this.paramsSub.unsubscribe()
if (this.queryParamsSub) this.queryParamsSub.unsubscribe() if (this.queryParamsSub) this.queryParamsSub.unsubscribe()
if (this.configSub) this.configSub.unsubscribe() if (this.configSub) this.configSub.unsubscribe()
if (this.liveVideosSub) this.liveVideosSub.unsubscribe()
// Unbind hotkeys // Unbind hotkeys
this.hotkeysService.remove(this.hotkeys) this.hotkeysService.remove(this.hotkeys)
@ -306,6 +321,18 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
return this.video && this.video.scheduledUpdate !== undefined return this.video && this.video.scheduledUpdate !== undefined
} }
isLive () {
return !!(this.video?.isLive)
}
isWaitingForLive () {
return this.video?.state.id === VideoState.WAITING_FOR_LIVE
}
isLiveEnded () {
return this.video?.state.id === VideoState.LIVE_ENDED
}
isVideoBlur (video: Video) { isVideoBlur (video: Video) {
return video.isVideoNSFWForUser(this.user, this.serverConfig) return video.isVideoNSFWForUser(this.user, this.serverConfig)
} }
@ -470,8 +497,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
private async onVideoFetched ( private async onVideoFetched (
video: VideoDetails, video: VideoDetails,
videoCaptions: VideoCaption[], videoCaptions: VideoCaption[],
urlOptions: CustomizationOptions & { playerMode: PlayerMode } urlOptions: URLOptions
) { ) {
this.subscribeToLiveEventsIfNeeded(this.video, video)
this.video = video this.video = video
this.videoCaptions = videoCaptions this.videoCaptions = videoCaptions
@ -489,6 +518,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
if (res === false) return this.location.back() if (res === false) return this.location.back()
} }
const videoState = this.video.state.id
if (videoState === VideoState.LIVE_ENDED || videoState === VideoState.WAITING_FOR_LIVE) return
// Flush old player if needed // Flush old player if needed
this.flushPlayer() this.flushPlayer()
@ -794,6 +826,29 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
return !this.player.paused() return !this.player.paused()
} }
private async subscribeToLiveEventsIfNeeded (oldVideo: VideoDetails, newVideo: VideoDetails) {
if (!this.liveVideosSub) {
this.liveVideosSub = this.peertubeSocket.getLiveVideosObservable()
.subscribe(({ payload }) => {
if (payload.state !== VideoState.PUBLISHED || this.video.state.id !== VideoState.WAITING_FOR_LIVE) return
const videoUUID = this.video.uuid
// Reset to refetch the video
this.video = undefined
this.loadVideo(videoUUID)
})
}
if (oldVideo && oldVideo.id !== newVideo.id) {
await this.peertubeSocket.unsubscribeLiveVideos(oldVideo.id)
}
if (!newVideo.isLive) return
await this.peertubeSocket.subscribeToLiveVideosSocket(newVideo.id)
}
private initHotkeys () { private initHotkeys () {
this.hotkeys = [ this.hotkeys = [
// These hotkeys are managed by the player // These hotkeys are managed by the player

View file

@ -4,7 +4,7 @@ import { ToastModule } from 'primeng/toast'
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { NgModule, Optional, SkipSelf } from '@angular/core' import { NgModule, Optional, SkipSelf } from '@angular/core'
import { BrowserAnimationsModule } from '@angular/platform-browser/animations' import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
import { UserNotificationSocket } from '@app/core/notification/user-notification-socket.service' import { PeerTubeSocket } from '@app/core/notification/peertube-socket.service'
import { HooksService } from '@app/core/plugins/hooks.service' import { HooksService } from '@app/core/plugins/hooks.service'
import { PluginService } from '@app/core/plugins/plugin.service' import { PluginService } from '@app/core/plugins/plugin.service'
import { UnloggedGuard } from '@app/core/routing/unlogged-guard.service' import { UnloggedGuard } from '@app/core/routing/unlogged-guard.service'
@ -84,7 +84,7 @@ import { LocalStorageService, ScreenService, SessionStorageService } from './wra
RedirectService, RedirectService,
Notifier, Notifier,
MessageService, MessageService,
UserNotificationSocket, PeerTubeSocket,
ServerConfigResolver, ServerConfigResolver,
CanDeactivateGuard CanDeactivateGuard
] ]

View file

@ -1,2 +1,2 @@
export * from './notifier.service' export * from './notifier.service'
export * from './user-notification-socket.service' export * from './peertube-socket.service'

View file

@ -0,0 +1,86 @@
import { Subject } from 'rxjs'
import { Injectable, NgZone } from '@angular/core'
import { LiveVideoEventPayload, LiveVideoEventType, UserNotification as UserNotificationServer } from '@shared/models'
import { environment } from '../../../environments/environment'
import { AuthService } from '../auth'
export type NotificationEvent = 'new' | 'read' | 'read-all'
@Injectable()
export class PeerTubeSocket {
private io: typeof import ('socket.io-client')
private notificationSubject = new Subject<{ type: NotificationEvent, notification?: UserNotificationServer }>()
private liveVideosSubject = new Subject<{ type: LiveVideoEventType, payload: LiveVideoEventPayload }>()
private notificationSocket: SocketIOClient.Socket
private liveVideosSocket: SocketIOClient.Socket
constructor (
private auth: AuthService,
private ngZone: NgZone
) {}
async getMyNotificationsSocket () {
await this.initNotificationSocket()
return this.notificationSubject.asObservable()
}
getLiveVideosObservable () {
return this.liveVideosSubject.asObservable()
}
async subscribeToLiveVideosSocket (videoId: number) {
await this.initLiveVideosSocket()
this.liveVideosSocket.emit('subscribe', { videoId })
}
async unsubscribeLiveVideos (videoId: number) {
if (!this.liveVideosSocket) return
this.liveVideosSocket.emit('unsubscribe', { videoId })
}
dispatchNotificationEvent (type: NotificationEvent, notification?: UserNotificationServer) {
this.notificationSubject.next({ type, notification })
}
private async initNotificationSocket () {
if (this.notificationSocket) return
await this.importIOIfNeeded()
this.ngZone.runOutsideAngular(() => {
this.notificationSocket = this.io(environment.apiUrl + '/user-notifications', {
query: { accessToken: this.auth.getAccessToken() }
})
this.notificationSocket.on('new-notification', (n: UserNotificationServer) => this.dispatchNotificationEvent('new', n))
})
}
private async initLiveVideosSocket () {
if (this.liveVideosSocket) return
await this.importIOIfNeeded()
this.ngZone.runOutsideAngular(() => {
this.liveVideosSocket = this.io(environment.apiUrl + '/live-videos')
const type: LiveVideoEventType = 'state-change'
this.liveVideosSocket.on(type, (payload: LiveVideoEventPayload) => this.dispatchLiveVideoEvent(type, payload))
})
}
private async importIOIfNeeded () {
if (this.io) return
this.io = (await import('socket.io-client') as any).default
}
private dispatchLiveVideoEvent (type: LiveVideoEventType, payload: LiveVideoEventPayload) {
this.liveVideosSubject.next({ type, payload })
}
}

View file

@ -1,44 +0,0 @@
import { Subject } from 'rxjs'
import { Injectable, NgZone } from '@angular/core'
import { UserNotification as UserNotificationServer } from '@shared/models'
import { environment } from '../../../environments/environment'
import { AuthService } from '../auth'
export type NotificationEvent = 'new' | 'read' | 'read-all'
@Injectable()
export class UserNotificationSocket {
private notificationSubject = new Subject<{ type: NotificationEvent, notification?: UserNotificationServer }>()
private socket: SocketIOClient.Socket
constructor (
private auth: AuthService,
private ngZone: NgZone
) {}
dispatch (type: NotificationEvent, notification?: UserNotificationServer) {
this.notificationSubject.next({ type, notification })
}
async getMyNotificationsSocket () {
await this.initSocket()
return this.notificationSubject.asObservable()
}
private async initSocket () {
if (this.socket) return
// FIXME: import('..') returns a struct module, containing a "default" field corresponding to our sanitizeHtml function
const io: typeof import ('socket.io-client') = (await import('socket.io-client') as any).default
this.ngZone.runOutsideAngular(() => {
this.socket = io(environment.apiUrl + '/user-notifications', {
query: { accessToken: this.auth.getAccessToken() }
})
this.socket.on('new-notification', (n: UserNotificationServer) => this.dispatch('new', n))
})
}
}

View file

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

View file

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

View file

@ -2,7 +2,7 @@ import { Subject, Subscription } from 'rxjs'
import { filter } from 'rxjs/operators' import { filter } from 'rxjs/operators'
import { Component, EventEmitter, Input, Output, OnDestroy, OnInit, ViewChild } from '@angular/core' import { Component, EventEmitter, Input, Output, OnDestroy, OnInit, ViewChild } from '@angular/core'
import { NavigationEnd, Router } from '@angular/router' import { NavigationEnd, Router } from '@angular/router'
import { Notifier, User, UserNotificationSocket } from '@app/core' import { Notifier, User, PeerTubeSocket } from '@app/core'
import { UserNotificationService } from '@app/shared/shared-main' import { UserNotificationService } from '@app/shared/shared-main'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap' import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
@ -27,7 +27,7 @@ export class AvatarNotificationComponent implements OnInit, OnDestroy {
constructor ( constructor (
private userNotificationService: UserNotificationService, private userNotificationService: UserNotificationService,
private userNotificationSocket: UserNotificationSocket, private peertubeSocket: PeerTubeSocket,
private notifier: Notifier, private notifier: Notifier,
private router: Router private router: Router
) { ) {
@ -75,7 +75,7 @@ export class AvatarNotificationComponent implements OnInit, OnDestroy {
} }
private async subscribeToNotifications () { private async subscribeToNotifications () {
const obs = await this.userNotificationSocket.getMyNotificationsSocket() const obs = await this.peertubeSocket.getMyNotificationsSocket()
this.notificationSub = obs.subscribe(data => { this.notificationSub = obs.subscribe(data => {
if (data.type === 'new') return this.unreadNotifications++ if (data.type === 'new') return this.unreadNotifications++

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import { catchError, map, tap } from 'rxjs/operators' import { catchError, map, tap } from 'rxjs/operators'
import { HttpClient, HttpParams } from '@angular/common/http' import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { ComponentPaginationLight, RestExtractor, RestService, User, UserNotificationSocket, AuthService } from '@app/core' import { ComponentPaginationLight, RestExtractor, RestService, User, PeerTubeSocket, AuthService } from '@app/core'
import { ResultList, UserNotification as UserNotificationServer, UserNotificationSetting } from '@shared/models' import { ResultList, UserNotification as UserNotificationServer, UserNotificationSetting } from '@shared/models'
import { environment } from '../../../../environments/environment' import { environment } from '../../../../environments/environment'
import { UserNotification } from './user-notification.model' import { UserNotification } from './user-notification.model'
@ -17,7 +17,7 @@ export class UserNotificationService {
private auth: AuthService, private auth: AuthService,
private restExtractor: RestExtractor, private restExtractor: RestExtractor,
private restService: RestService, private restService: RestService,
private userNotificationSocket: UserNotificationSocket private peertubeSocket: PeerTubeSocket
) {} ) {}
listMyNotifications (parameters: { listMyNotifications (parameters: {
@ -57,7 +57,7 @@ export class UserNotificationService {
return this.authHttp.post(url, body, { headers }) return this.authHttp.post(url, body, { headers })
.pipe( .pipe(
map(this.restExtractor.extractDataBool), map(this.restExtractor.extractDataBool),
tap(() => this.userNotificationSocket.dispatch('read')), tap(() => this.peertubeSocket.dispatchNotificationEvent('read')),
catchError(res => this.restExtractor.handleError(res)) catchError(res => this.restExtractor.handleError(res))
) )
} }
@ -69,12 +69,12 @@ export class UserNotificationService {
return this.authHttp.post(url, {}, { headers }) return this.authHttp.post(url, {}, { headers })
.pipe( .pipe(
map(this.restExtractor.extractDataBool), map(this.restExtractor.extractDataBool),
tap(() => this.userNotificationSocket.dispatch('read-all')), tap(() => this.peertubeSocket.dispatchNotificationEvent('read-all')),
catchError(res => this.restExtractor.handleError(res)) catchError(res => this.restExtractor.handleError(res))
) )
} }
updateNotificationSettings (user: User, settings: UserNotificationSetting) { updateNotificationSettings (settings: UserNotificationSetting) {
const url = UserNotificationService.BASE_NOTIFICATION_SETTINGS const url = UserNotificationService.BASE_NOTIFICATION_SETTINGS
return this.authHttp.put(url, settings) return this.authHttp.put(url, settings)

View file

@ -1,3 +1,4 @@
export * from './live-video.service'
export * from './redundancy.service' export * from './redundancy.service'
export * from './video-details.model' export * from './video-details.model'
export * from './video-edit.model' export * from './video-edit.model'

View file

@ -0,0 +1,28 @@
import { catchError } from 'rxjs/operators'
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { RestExtractor } from '@app/core'
import { VideoCreate, LiveVideo } from '@shared/models'
import { environment } from '../../../../environments/environment'
@Injectable()
export class LiveVideoService {
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 } }>(LiveVideoService.BASE_VIDEO_LIVE_URL, video)
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
getVideoLive (videoId: number | string) {
return this.authHttp
.get<LiveVideo>(LiveVideoService.BASE_VIDEO_LIVE_URL + videoId)
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
}

View file

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

View file

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

View file

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

View file

@ -107,7 +107,7 @@
<div class="filters"> <div class="filters">
<div> <div>
<div class="form-group start-at"> <div class="form-group start-at" *ngIf="!video.isLive">
<my-peertube-checkbox <my-peertube-checkbox
inputName="startAt" [(ngModel)]="customizations.startAtCheckbox" inputName="startAt" [(ngModel)]="customizations.startAtCheckbox"
i18n-labelText labelText="Start at" i18n-labelText labelText="Start at"
@ -138,7 +138,7 @@
<div class="advanced-filters collapse-transition" [ngbCollapse]="isAdvancedCustomizationCollapsed"> <div class="advanced-filters collapse-transition" [ngbCollapse]="isAdvancedCustomizationCollapsed">
<div> <div>
<div class="form-group stop-at"> <div class="form-group stop-at" *ngIf="!video.isLive">
<my-peertube-checkbox <my-peertube-checkbox
inputName="stopAt" [(ngModel)]="customizations.stopAtCheckbox" inputName="stopAt" [(ngModel)]="customizations.stopAtCheckbox"
i18n-labelText labelText="Stop at" i18n-labelText labelText="Stop at"
@ -167,7 +167,7 @@
></my-peertube-checkbox> ></my-peertube-checkbox>
</div> </div>
<div class="form-group"> <div class="form-group" *ngIf="!video.isLive">
<my-peertube-checkbox <my-peertube-checkbox
inputName="loop" [(ngModel)]="customizations.loop" inputName="loop" [(ngModel)]="customizations.loop"
i18n-labelText labelText="Loop" i18n-labelText labelText="Loop"

View file

@ -146,7 +146,10 @@ export class VideoActionsDropdownComponent implements OnChanges {
} }
isVideoDownloadable () { isVideoDownloadable () {
return this.video && this.video instanceof VideoDetails && this.video.downloadEnabled return this.video &&
this.video.isLive !== true &&
this.video instanceof VideoDetails &&
this.video.downloadEnabled
} }
canVideoBeDuplicated () { canVideoBeDuplicated () {

View file

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

View file

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

View file

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

View file

@ -243,6 +243,35 @@ transcoding:
hls: hls:
enabled: false enabled: false
live:
enabled: false
# Limit lives duration
# Set null to disable duration limit
max_duration: 5 hours
# Allow your users to save a replay of their live
# PeerTube will transcode segments in a video file
# If the user daily/total quota is reached, PeerTube will stop the live
# /!\ transcoding.enabled (and not live.transcoding.enabled) has to be true to create a replay
allow_replay: true
rtmp:
port: 1935
# Allow to transcode the live streaming in multiple live resolutions
transcoding:
enabled: false
threads: 2
resolutions:
240p: false
360p: false
480p: false
720p: false
1080p: false
2160p: false
import: import:
# Add ability for your users to import remote videos (from YouTube, torrent...) # Add ability for your users to import remote videos (from YouTube, torrent...)
videos: videos:

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View file

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

View file

@ -9,7 +9,7 @@ import { MIMETYPES } from '../../../initializers/constants'
import { sequelizeTypescript } from '../../../initializers/database' import { sequelizeTypescript } from '../../../initializers/database'
import { sendUpdateActor } from '../../../lib/activitypub/send' import { sendUpdateActor } from '../../../lib/activitypub/send'
import { updateActorAvatarFile } from '../../../lib/avatar' import { updateActorAvatarFile } from '../../../lib/avatar'
import { sendVerifyUserEmail } from '../../../lib/user' import { getOriginalVideoFileTotalDailyFromUser, getOriginalVideoFileTotalFromUser, sendVerifyUserEmail } from '../../../lib/user'
import { import {
asyncMiddleware, asyncMiddleware,
asyncRetryTransactionMiddleware, asyncRetryTransactionMiddleware,
@ -133,8 +133,8 @@ async function getUserInformation (req: express.Request, res: express.Response)
async function getUserVideoQuotaUsed (req: express.Request, res: express.Response) { async function getUserVideoQuotaUsed (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.user const user = res.locals.oauth.token.user
const videoQuotaUsed = await UserModel.getOriginalVideoFileTotalFromUser(user) const videoQuotaUsed = await getOriginalVideoFileTotalFromUser(user)
const videoQuotaUsedDaily = await UserModel.getOriginalVideoFileTotalDailyFromUser(user) const videoQuotaUsedDaily = await getOriginalVideoFileTotalDailyFromUser(user)
const data: UserVideoQuota = { const data: UserVideoQuota = {
videoQuotaUsed, videoQuotaUsed,

View file

@ -1,30 +1,10 @@
import * as express from 'express'
import * as magnetUtil from 'magnet-uri'
import { auditLoggerFactory, getAuditIdFromRes, VideoImportAuditView } from '../../../helpers/audit-logger'
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoImportAddValidator } from '../../../middlewares'
import { MIMETYPES } from '../../../initializers/constants'
import { getYoutubeDLInfo, YoutubeDLInfo, getYoutubeDLSubs } from '../../../helpers/youtube-dl'
import { createReqFiles } from '../../../helpers/express-utils'
import { logger } from '../../../helpers/logger'
import { VideoImportCreate, VideoImportState, VideoPrivacy, VideoState } from '../../../../shared'
import { VideoModel } from '../../../models/video/video'
import { VideoCaptionModel } from '../../../models/video/video-caption'
import { moveAndProcessCaptionFile } from '../../../helpers/captions-utils'
import { getVideoActivityPubUrl } from '../../../lib/activitypub/url'
import { TagModel } from '../../../models/video/tag'
import { VideoImportModel } from '../../../models/video/video-import'
import { JobQueue } from '../../../lib/job-queue/job-queue'
import { join } from 'path'
import { isArray } from '../../../helpers/custom-validators/misc'
import * as Bluebird from 'bluebird' import * as Bluebird from 'bluebird'
import * as parseTorrent from 'parse-torrent' import * as express from 'express'
import { getSecureTorrentName } from '../../../helpers/utils'
import { move, readFile } from 'fs-extra' import { move, readFile } from 'fs-extra'
import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist' import * as magnetUtil from 'magnet-uri'
import { CONFIG } from '../../../initializers/config' import * as parseTorrent from 'parse-torrent'
import { sequelizeTypescript } from '../../../initializers/database' import { join } from 'path'
import { createVideoMiniatureFromExisting, createVideoMiniatureFromUrl } from '../../../lib/thumbnail' import { setVideoTags } from '@server/lib/video'
import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type'
import { import {
MChannelAccountDefault, MChannelAccountDefault,
MThumbnail, MThumbnail,
@ -36,6 +16,26 @@ import {
MVideoWithBlacklistLight MVideoWithBlacklistLight
} from '@server/types/models' } from '@server/types/models'
import { MVideoImport, MVideoImportFormattable } from '@server/types/models/video/video-import' import { MVideoImport, MVideoImportFormattable } from '@server/types/models/video/video-import'
import { VideoImportCreate, VideoImportState, VideoPrivacy, VideoState } from '../../../../shared'
import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type'
import { auditLoggerFactory, getAuditIdFromRes, VideoImportAuditView } from '../../../helpers/audit-logger'
import { moveAndProcessCaptionFile } from '../../../helpers/captions-utils'
import { isArray } from '../../../helpers/custom-validators/misc'
import { createReqFiles } from '../../../helpers/express-utils'
import { logger } from '../../../helpers/logger'
import { getSecureTorrentName } from '../../../helpers/utils'
import { getYoutubeDLInfo, getYoutubeDLSubs, YoutubeDLInfo } from '../../../helpers/youtube-dl'
import { CONFIG } from '../../../initializers/config'
import { MIMETYPES } from '../../../initializers/constants'
import { sequelizeTypescript } from '../../../initializers/database'
import { getVideoActivityPubUrl } from '../../../lib/activitypub/url'
import { JobQueue } from '../../../lib/job-queue/job-queue'
import { createVideoMiniatureFromExisting, createVideoMiniatureFromUrl } from '../../../lib/thumbnail'
import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoImportAddValidator } from '../../../middlewares'
import { VideoModel } from '../../../models/video/video'
import { VideoCaptionModel } from '../../../models/video/video-caption'
import { VideoImportModel } from '../../../models/video/video-import'
const auditLogger = auditLoggerFactory('video-imports') const auditLogger = auditLoggerFactory('video-imports')
const videoImportsRouter = express.Router() const videoImportsRouter = express.Router()
@ -260,7 +260,12 @@ async function processThumbnail (req: express.Request, video: VideoModel) {
if (thumbnailField) { if (thumbnailField) {
const thumbnailPhysicalFile = thumbnailField[0] const thumbnailPhysicalFile = thumbnailField[0]
return createVideoMiniatureFromExisting(thumbnailPhysicalFile.path, video, ThumbnailType.MINIATURE, false) return createVideoMiniatureFromExisting({
inputPath: thumbnailPhysicalFile.path,
video,
type: ThumbnailType.MINIATURE,
automaticallyGenerated: false
})
} }
return undefined return undefined
@ -271,7 +276,12 @@ async function processPreview (req: express.Request, video: VideoModel) {
if (previewField) { if (previewField) {
const previewPhysicalFile = previewField[0] const previewPhysicalFile = previewField[0]
return createVideoMiniatureFromExisting(previewPhysicalFile.path, video, ThumbnailType.PREVIEW, false) return createVideoMiniatureFromExisting({
inputPath: previewPhysicalFile.path,
video,
type: ThumbnailType.PREVIEW,
automaticallyGenerated: false
})
} }
return undefined return undefined
@ -325,15 +335,7 @@ function insertIntoDB (parameters: {
transaction: t transaction: t
}) })
// Set tags to the video await setVideoTags({ video: videoCreated, tags, transaction: t })
if (tags) {
const tagInstances = await TagModel.findOrCreateTags(tags, t)
await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
videoCreated.Tags = tagInstances
} else {
videoCreated.Tags = []
}
// Create video import object in database // Create video import object in database
const videoImport = await VideoImportModel.create( const videoImport = await VideoImportModel.create(

View file

@ -6,11 +6,11 @@ import { addOptimizeOrMergeAudioJob } from '@server/helpers/video'
import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
import { changeVideoChannelShare } from '@server/lib/activitypub/share' import { changeVideoChannelShare } from '@server/lib/activitypub/share'
import { getVideoActivityPubUrl } from '@server/lib/activitypub/url' import { getVideoActivityPubUrl } from '@server/lib/activitypub/url'
import { buildLocalVideoFromReq, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
import { getVideoFilePath } from '@server/lib/video-paths' import { getVideoFilePath } from '@server/lib/video-paths'
import { getServerActor } from '@server/models/application/application' import { getServerActor } from '@server/models/application/application'
import { MVideoDetails, MVideoFullLight } from '@server/types/models' import { MVideoDetails, MVideoFullLight } from '@server/types/models'
import { VideoCreate, VideoPrivacy, VideoState, VideoUpdate } from '../../../../shared' import { VideoCreate, VideoState, VideoUpdate } from '../../../../shared'
import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type'
import { VideoFilter } from '../../../../shared/models/videos/video-query.type' import { VideoFilter } from '../../../../shared/models/videos/video-query.type'
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
import { resetSequelizeInstance } from '../../../helpers/database-utils' import { resetSequelizeInstance } from '../../../helpers/database-utils'
@ -34,7 +34,7 @@ import { JobQueue } from '../../../lib/job-queue'
import { Notifier } from '../../../lib/notifier' import { Notifier } from '../../../lib/notifier'
import { Hooks } from '../../../lib/plugins/hooks' import { Hooks } from '../../../lib/plugins/hooks'
import { Redis } from '../../../lib/redis' import { Redis } from '../../../lib/redis'
import { createVideoMiniatureFromExisting, generateVideoMiniature } from '../../../lib/thumbnail' import { generateVideoMiniature } from '../../../lib/thumbnail'
import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist' import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
import { import {
asyncMiddleware, asyncMiddleware,
@ -55,7 +55,6 @@ import {
videosUpdateValidator videosUpdateValidator
} from '../../../middlewares' } from '../../../middlewares'
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
import { TagModel } from '../../../models/video/tag'
import { VideoModel } from '../../../models/video/video' import { VideoModel } from '../../../models/video/video'
import { VideoFileModel } from '../../../models/video/video-file' import { VideoFileModel } from '../../../models/video/video-file'
import { abuseVideoRouter } from './abuse' import { abuseVideoRouter } from './abuse'
@ -63,6 +62,7 @@ import { blacklistRouter } from './blacklist'
import { videoCaptionsRouter } from './captions' import { videoCaptionsRouter } from './captions'
import { videoCommentRouter } from './comment' import { videoCommentRouter } from './comment'
import { videoImportsRouter } from './import' import { videoImportsRouter } from './import'
import { liveRouter } from './live'
import { ownershipVideoRouter } from './ownership' import { ownershipVideoRouter } from './ownership'
import { rateVideoRouter } from './rate' import { rateVideoRouter } from './rate'
import { watchingRouter } from './watching' import { watchingRouter } from './watching'
@ -96,6 +96,7 @@ videosRouter.use('/', videoCaptionsRouter)
videosRouter.use('/', videoImportsRouter) videosRouter.use('/', videoImportsRouter)
videosRouter.use('/', ownershipVideoRouter) videosRouter.use('/', ownershipVideoRouter)
videosRouter.use('/', watchingRouter) videosRouter.use('/', watchingRouter)
videosRouter.use('/', liveRouter)
videosRouter.get('/categories', listVideoCategories) videosRouter.get('/categories', listVideoCategories)
videosRouter.get('/licences', listVideoLicences) videosRouter.get('/licences', listVideoLicences)
@ -184,25 +185,9 @@ async function addVideo (req: express.Request, res: express.Response) {
const videoPhysicalFile = req.files['videofile'][0] const videoPhysicalFile = req.files['videofile'][0]
const videoInfo: VideoCreate = req.body const videoInfo: VideoCreate = req.body
// Prepare data so we don't block the transaction const videoData = buildLocalVideoFromReq(videoInfo, res.locals.videoChannel.id)
const videoData = { videoData.state = CONFIG.TRANSCODING.ENABLED ? VideoState.TO_TRANSCODE : VideoState.PUBLISHED
name: videoInfo.name, videoData.duration = videoPhysicalFile['duration'] // duration was added by a previous middleware
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: CONFIG.TRANSCODING.ENABLED ? VideoState.TO_TRANSCODE : VideoState.PUBLISHED,
nsfw: videoInfo.nsfw || false,
description: videoInfo.description,
support: videoInfo.support,
privacy: videoInfo.privacy || VideoPrivacy.PRIVATE,
duration: videoPhysicalFile['duration'], // duration was added by a previous middleware
channelId: res.locals.videoChannel.id,
originallyPublishedAt: videoInfo.originallyPublishedAt
}
const video = new VideoModel(videoData) as MVideoDetails const video = new VideoModel(videoData) as MVideoDetails
video.url = getVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object video.url = getVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object
@ -228,17 +213,11 @@ async function addVideo (req: express.Request, res: express.Response) {
videoPhysicalFile.filename = getVideoFilePath(video, videoFile) videoPhysicalFile.filename = getVideoFilePath(video, videoFile)
videoPhysicalFile.path = destination videoPhysicalFile.path = destination
// Process thumbnail or create it from the video const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({
const thumbnailField = req.files['thumbnailfile'] video,
const thumbnailModel = thumbnailField files: req.files,
? await createVideoMiniatureFromExisting(thumbnailField[0].path, video, ThumbnailType.MINIATURE, false) fallback: type => generateVideoMiniature(video, videoFile, type)
: await generateVideoMiniature(video, videoFile, ThumbnailType.MINIATURE) })
// Process preview or create it from the video
const previewField = req.files['previewfile']
const previewModel = previewField
? await createVideoMiniatureFromExisting(previewField[0].path, video, ThumbnailType.PREVIEW, false)
: await generateVideoMiniature(video, videoFile, ThumbnailType.PREVIEW)
// Create the torrent file // Create the torrent file
await createTorrentAndSetInfoHash(video, videoFile) await createTorrentAndSetInfoHash(video, videoFile)
@ -259,13 +238,7 @@ async function addVideo (req: express.Request, res: express.Response) {
video.VideoFiles = [ videoFile ] video.VideoFiles = [ videoFile ]
// Create tags await setVideoTags({ video, tags: videoInfo.tags, transaction: t })
if (videoInfo.tags !== undefined) {
const tagInstances = await TagModel.findOrCreateTags(videoInfo.tags, t)
await video.$set('Tags', tagInstances, sequelizeOptions)
video.Tags = tagInstances
}
// Schedule an update in the future? // Schedule an update in the future?
if (videoInfo.scheduleUpdate) { if (videoInfo.scheduleUpdate) {
@ -304,7 +277,7 @@ async function addVideo (req: express.Request, res: express.Response) {
id: videoCreated.id, id: videoCreated.id,
uuid: videoCreated.uuid uuid: videoCreated.uuid
} }
}).end() })
} }
async function updateVideo (req: express.Request, res: express.Response) { async function updateVideo (req: express.Request, res: express.Response) {
@ -316,14 +289,12 @@ async function updateVideo (req: express.Request, res: express.Response) {
const wasConfidentialVideo = videoInstance.isConfidential() const wasConfidentialVideo = videoInstance.isConfidential()
const hadPrivacyForFederation = videoInstance.hasPrivacyForFederation() const hadPrivacyForFederation = videoInstance.hasPrivacyForFederation()
// Process thumbnail or create it from the video const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({
const thumbnailModel = req.files?.['thumbnailfile'] video: videoInstance,
? await createVideoMiniatureFromExisting(req.files['thumbnailfile'][0].path, videoInstance, ThumbnailType.MINIATURE, false) files: req.files,
: undefined fallback: () => Promise.resolve(undefined),
automaticallyGenerated: false
const previewModel = req.files?.['previewfile'] })
? await createVideoMiniatureFromExisting(req.files['previewfile'][0].path, videoInstance, ThumbnailType.PREVIEW, false)
: undefined
try { try {
const videoInstanceUpdated = await sequelizeTypescript.transaction(async t => { const videoInstanceUpdated = await sequelizeTypescript.transaction(async t => {
@ -364,12 +335,12 @@ async function updateVideo (req: express.Request, res: express.Response) {
if (previewModel) await videoInstanceUpdated.addAndSaveThumbnail(previewModel, t) if (previewModel) await videoInstanceUpdated.addAndSaveThumbnail(previewModel, t)
// Video tags update? // Video tags update?
if (videoInfoToUpdate.tags !== undefined) { await setVideoTags({
const tagInstances = await TagModel.findOrCreateTags(videoInfoToUpdate.tags, t) video: videoInstanceUpdated,
tags: videoInfoToUpdate.tags,
await videoInstanceUpdated.$set('Tags', tagInstances, sequelizeOptions) transaction: t,
videoInstanceUpdated.Tags = tagInstances defaultValue: videoInstanceUpdated.Tags
} })
// Video channel update? // Video channel update?
if (res.locals.videoChannel && videoInstanceUpdated.channelId !== res.locals.videoChannel.id) { if (res.locals.videoChannel && videoInstanceUpdated.channelId !== res.locals.videoChannel.id) {

View file

@ -0,0 +1,106 @@
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 { buildLocalVideoFromReq, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
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, VideoState } from '../../../../shared'
import { logger } from '../../../helpers/logger'
import { sequelizeTypescript } from '../../../initializers/database'
import { createVideoMiniatureFromExisting } from '../../../lib/thumbnail'
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares'
import { VideoModel } from '../../../models/video/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 = buildLocalVideoFromReq(videoInfo, res.locals.videoChannel.id)
videoData.isLive = true
videoData.state = VideoState.WAITING_FOR_LIVE
videoData.duration = 0
const video = new VideoModel(videoData) as MVideoDetails
video.url = getVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object
const videoLive = new VideoLiveModel()
videoLive.streamKey = uuidv4()
const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({
video,
files: req.files,
fallback: type => {
return createVideoMiniatureFromExisting({ inputPath: ASSETS_PATH.DEFAULT_LIVE_BACKGROUND, video, type, automaticallyGenerated: 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)
await setVideoTags({ video, tags: videoInfo.tags, transaction: t })
logger.info('Video live %s with uuid %s created.', videoInfo.name, videoCreated.uuid)
return { videoCreated }
})
return res.json({
video: {
id: videoCreated.id,
uuid: videoCreated.uuid
}
})
}

View file

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

View file

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

View file

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

View file

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

View file

@ -62,6 +62,7 @@ function sanitizeAndCheckVideoTorrentObject (video: any) {
if (!isBooleanValid(video.waitTranscoding)) video.waitTranscoding = false if (!isBooleanValid(video.waitTranscoding)) video.waitTranscoding = false
if (!isBooleanValid(video.downloadEnabled)) video.downloadEnabled = true if (!isBooleanValid(video.downloadEnabled)) video.downloadEnabled = true
if (!isBooleanValid(video.commentsEnabled)) video.commentsEnabled = false if (!isBooleanValid(video.commentsEnabled)) video.commentsEnabled = false
if (!isBooleanValid(video.isLiveBroadcast)) video.isLiveBroadcast = false
return isActivityPubUrlValid(video.id) && return isActivityPubUrlValid(video.id) &&
isVideoNameValid(video.name) && isVideoNameValid(video.name) &&

View file

@ -45,6 +45,10 @@ function isBooleanValid (value: any) {
return typeof value === 'boolean' || (typeof value === 'string' && validator.isBoolean(value)) return typeof value === 'boolean' || (typeof value === 'string' && validator.isBoolean(value))
} }
function isIntOrNull (value: any) {
return value === null || validator.isInt('' + value)
}
function toIntOrNull (value: string) { function toIntOrNull (value: string) {
const v = toValueOrNull(value) const v = toValueOrNull(value)
@ -116,6 +120,7 @@ export {
isArrayOf, isArrayOf,
isNotEmptyIntArray, isNotEmptyIntArray,
isArray, isArray,
isIntOrNull,
isIdValid, isIdValid,
isSafePath, isSafePath,
isUUIDValid, isUUIDValid,

View file

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

View file

@ -1,13 +1,13 @@
import * as ffmpeg from 'fluent-ffmpeg' import * as ffmpeg from 'fluent-ffmpeg'
import { readFile, remove, writeFile } from 'fs-extra'
import { dirname, join } from 'path' import { dirname, join } from 'path'
import { VideoFileMetadata } from '@shared/models/videos/video-file-metadata'
import { getMaxBitrate, getTargetBitrate, VideoResolution } from '../../shared/models/videos' import { getMaxBitrate, getTargetBitrate, VideoResolution } from '../../shared/models/videos'
import { FFMPEG_NICE, VIDEO_TRANSCODING_FPS } from '../initializers/constants' import { checkFFmpegEncoders } from '../initializers/checker-before-init'
import { CONFIG } from '../initializers/config'
import { FFMPEG_NICE, VIDEO_LIVE, VIDEO_TRANSCODING_FPS } from '../initializers/constants'
import { processImage } from './image-utils' import { processImage } from './image-utils'
import { logger } from './logger' import { logger } from './logger'
import { checkFFmpegEncoders } from '../initializers/checker-before-init'
import { readFile, remove, writeFile } from 'fs-extra'
import { CONFIG } from '../initializers/config'
import { VideoFileMetadata } from '@shared/models/videos/video-file-metadata'
/** /**
* A toolbox to play with audio * A toolbox to play with audio
@ -74,9 +74,12 @@ namespace audio {
} }
} }
function computeResolutionsToTranscode (videoFileResolution: number) { function computeResolutionsToTranscode (videoFileResolution: number, type: 'vod' | 'live') {
const configResolutions = type === 'vod'
? CONFIG.TRANSCODING.RESOLUTIONS
: CONFIG.LIVE.TRANSCODING.RESOLUTIONS
const resolutionsEnabled: number[] = [] const resolutionsEnabled: number[] = []
const configResolutions = CONFIG.TRANSCODING.RESOLUTIONS
// Put in the order we want to proceed jobs // Put in the order we want to proceed jobs
const resolutions = [ const resolutions = [
@ -270,13 +273,13 @@ type TranscodeOptions =
function transcode (options: TranscodeOptions) { function transcode (options: TranscodeOptions) {
return new Promise<void>(async (res, rej) => { return new Promise<void>(async (res, rej) => {
try { try {
let command = ffmpeg(options.inputPath, { niceness: FFMPEG_NICE.TRANSCODING }) let command = getFFmpeg(options.inputPath)
.output(options.outputPath) .output(options.outputPath)
if (options.type === 'quick-transcode') { if (options.type === 'quick-transcode') {
command = buildQuickTranscodeCommand(command) command = buildQuickTranscodeCommand(command)
} else if (options.type === 'hls') { } else if (options.type === 'hls') {
command = await buildHLSCommand(command, options) command = await buildHLSVODCommand(command, options)
} else if (options.type === 'merge-audio') { } else if (options.type === 'merge-audio') {
command = await buildAudioMergeCommand(command, options) command = await buildAudioMergeCommand(command, options)
} else if (options.type === 'only-audio') { } else if (options.type === 'only-audio') {
@ -285,11 +288,6 @@ function transcode (options: TranscodeOptions) {
command = await buildx264Command(command, options) command = await buildx264Command(command, options)
} }
if (CONFIG.TRANSCODING.THREADS > 0) {
// if we don't set any threads ffmpeg will chose automatically
command = command.outputOption('-threads ' + CONFIG.TRANSCODING.THREADS)
}
command command
.on('error', (err, stdout, stderr) => { .on('error', (err, stdout, stderr) => {
logger.error('Error in transcoding job.', { stdout, stderr }) logger.error('Error in transcoding job.', { stdout, stderr })
@ -355,16 +353,89 @@ function convertWebPToJPG (path: string, destination: string): Promise<void> {
}) })
} }
function runLiveTranscoding (rtmpUrl: string, outPath: string, resolutions: number[], deleteSegments: boolean) {
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, deleteSegments)
command.outputOption('-var_stream_map', varStreamMap.join(' '))
command.run()
return command
}
function runLiveMuxing (rtmpUrl: string, outPath: string, deleteSegments: boolean) {
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, deleteSegments)
command.run()
return command
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export { export {
getVideoStreamCodec, getVideoStreamCodec,
getAudioStreamCodec, getAudioStreamCodec,
runLiveMuxing,
convertWebPToJPG, convertWebPToJPG,
getVideoStreamSize, getVideoStreamSize,
getVideoFileResolution, getVideoFileResolution,
getMetadataFromFile, getMetadataFromFile,
getDurationFromVideoFile, getDurationFromVideoFile,
runLiveTranscoding,
generateImageFromVideoFile, generateImageFromVideoFile,
TranscodeOptions, TranscodeOptions,
TranscodeOptionsType, TranscodeOptionsType,
@ -378,6 +449,29 @@ 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, deleteSegments: boolean) {
command.outputOption('-hls_time ' + VIDEO_LIVE.SEGMENT_TIME)
command.outputOption('-hls_list_size ' + VIDEO_LIVE.SEGMENTS_LIST_SIZE)
if (deleteSegments === true) {
command.outputOption('-hls_flags delete_segments')
}
command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%d.ts')}`)
command.outputOption('-master_pl_name master.m3u8')
command.outputOption(`-f hls`)
command.output(join(outPath, '%v.m3u8'))
}
async function buildx264Command (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) { async function buildx264Command (command: ffmpeg.FfmpegCommand, options: TranscodeOptions) {
let fps = await getVideoFileFPS(options.inputPath) let fps = await getVideoFileFPS(options.inputPath)
if ( if (
@ -437,7 +531,7 @@ function buildQuickTranscodeCommand (command: ffmpeg.FfmpegCommand) {
return command return command
} }
async function buildHLSCommand (command: ffmpeg.FfmpegCommand, options: HLSTranscodeOptions) { async function buildHLSVODCommand (command: ffmpeg.FfmpegCommand, options: HLSTranscodeOptions) {
const videoPath = getHLSVideoPath(options) const videoPath = getHLSVideoPath(options)
if (options.copyCodecs) command = presetCopy(command) if (options.copyCodecs) command = presetCopy(command)
@ -507,13 +601,10 @@ async function presetH264 (command: ffmpeg.FfmpegCommand, input: string, resolut
let localCommand = command let localCommand = command
.format('mp4') .format('mp4')
.videoCodec('libx264') .videoCodec('libx264')
.outputOption('-level 3.1') // 3.1 is the minimal resource allocation for our highest supported resolution
.outputOption('-b_strategy 1') // NOTE: b-strategy 1 - heuristic algorithm, 16 is optimal B-frames for it
.outputOption('-bf 16') // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16
.outputOption('-pix_fmt yuv420p') // allows import of source material with incompatible pixel formats (e.g. MJPEG video)
.outputOption('-map_metadata -1') // strip all metadata
.outputOption('-movflags faststart') .outputOption('-movflags faststart')
addDefaultX264Params(localCommand)
const parsedAudio = await audio.get(input) const parsedAudio = await audio.get(input)
if (!parsedAudio.audioStream) { if (!parsedAudio.audioStream) {
@ -564,3 +655,14 @@ function presetOnlyAudio (command: ffmpeg.FfmpegCommand): ffmpeg.FfmpegCommand {
.audioCodec('copy') .audioCodec('copy')
.noVideo() .noVideo()
} }
function getFFmpeg (input: string) {
const command = ffmpeg(input, { niceness: FFMPEG_NICE.TRANSCODING })
if (CONFIG.TRANSCODING.THREADS > 0) {
// If we don't set any threads ffmpeg will chose automatically
command.outputOption('-threads ' + CONFIG.TRANSCODING.THREADS)
}
return command
}

View file

@ -135,6 +135,13 @@ function checkConfig () {
} }
} }
// Live
if (CONFIG.LIVE.ENABLED === true) {
if (CONFIG.LIVE.ALLOW_REPLAY === true && CONFIG.TRANSCODING.ENABLED === false) {
return 'Live allow replay cannot be enabled if transcoding is not enabled.'
}
}
return null return null
} }

View file

@ -37,8 +37,13 @@ function checkMissedConfig () {
'remote_redundancy.videos.accept_from', 'remote_redundancy.videos.accept_from',
'federation.videos.federate_unlisted', 'federation.videos.federate_unlisted',
'search.remote_uri.users', 'search.remote_uri.anonymous', 'search.search_index.enabled', 'search.search_index.url', 'search.remote_uri.users', 'search.remote_uri.anonymous', 'search.search_index.enabled', 'search.search_index.url',
'search.search_index.disable_local_search', 'search.search_index.is_default_search' 'search.search_index.disable_local_search', 'search.search_index.is_default_search',
'live.enabled', 'live.allow_replay', 'live.max_duration',
'live.transcoding.enabled', 'live.transcoding.threads',
'live.transcoding.resolutions.240p', 'live.transcoding.resolutions.360p', 'live.transcoding.resolutions.480p',
'live.transcoding.resolutions.720p', 'live.transcoding.resolutions.1080p', 'live.transcoding.resolutions.2160p'
] ]
const requiredAlternatives = [ const requiredAlternatives = [
[ // set [ // set
[ 'redis.hostname', 'redis.port' ], // alternative [ 'redis.hostname', 'redis.port' ], // alternative

View file

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

View file

@ -23,7 +23,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const LAST_MIGRATION_VERSION = 530 const LAST_MIGRATION_VERSION = 540
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -50,7 +50,8 @@ const WEBSERVER = {
SCHEME: '', SCHEME: '',
WS: '', WS: '',
HOSTNAME: '', HOSTNAME: '',
PORT: 0 PORT: 0,
RTMP_URL: ''
} }
// Sortable columns per schema // Sortable columns per schema
@ -138,7 +139,8 @@ const JOB_ATTEMPTS: { [id in JobType]: number } = {
'email': 5, 'email': 5,
'videos-views': 1, 'videos-views': 1,
'activitypub-refresher': 1, 'activitypub-refresher': 1,
'video-redundancy': 1 'video-redundancy': 1,
'video-live-ending': 1
} }
const JOB_CONCURRENCY: { [id in JobType]: number } = { const JOB_CONCURRENCY: { [id in JobType]: number } = {
'activitypub-http-broadcast': 1, 'activitypub-http-broadcast': 1,
@ -151,7 +153,8 @@ const JOB_CONCURRENCY: { [id in JobType]: number } = {
'email': 5, 'email': 5,
'videos-views': 1, 'videos-views': 1,
'activitypub-refresher': 1, 'activitypub-refresher': 1,
'video-redundancy': 1 'video-redundancy': 1,
'video-live-ending': 1
} }
const JOB_TTL: { [id in JobType]: number } = { const JOB_TTL: { [id in JobType]: number } = {
'activitypub-http-broadcast': 60000 * 10, // 10 minutes 'activitypub-http-broadcast': 60000 * 10, // 10 minutes
@ -164,7 +167,8 @@ const JOB_TTL: { [id in JobType]: number } = {
'email': 60000 * 10, // 10 minutes 'email': 60000 * 10, // 10 minutes
'videos-views': undefined, // Unlimited 'videos-views': undefined, // Unlimited
'activitypub-refresher': 60000 * 10, // 10 minutes 'activitypub-refresher': 60000 * 10, // 10 minutes
'video-redundancy': 1000 * 3600 * 3 // 3 hours 'video-redundancy': 1000 * 3600 * 3, // 3 hours
'video-live-ending': 1000 * 60 * 10 // 10 minutes
} }
const REPEAT_JOBS: { [ id: string ]: EveryRepeatOptions | CronRepeatOptions } = { const REPEAT_JOBS: { [ id: string ]: EveryRepeatOptions | CronRepeatOptions } = {
'videos-views': { 'videos-views': {
@ -264,7 +268,7 @@ const CONSTRAINTS_FIELDS = {
VIEWS: { min: 0 }, VIEWS: { min: 0 },
LIKES: { min: 0 }, LIKES: { min: 0 },
DISLIKES: { min: 0 }, DISLIKES: { min: 0 },
FILE_SIZE: { min: 10 }, FILE_SIZE: { min: -1 },
URL: { min: 3, max: 2000 } // Length URL: { min: 3, max: 2000 } // Length
}, },
VIDEO_PLAYLISTS: { VIDEO_PLAYLISTS: {
@ -370,39 +374,41 @@ const VIDEO_LICENCES = {
const VIDEO_LANGUAGES: { [id: string]: string } = {} const VIDEO_LANGUAGES: { [id: string]: string } = {}
const VIDEO_PRIVACIES = { const VIDEO_PRIVACIES: { [ id in VideoPrivacy ]: string } = {
[VideoPrivacy.PUBLIC]: 'Public', [VideoPrivacy.PUBLIC]: 'Public',
[VideoPrivacy.UNLISTED]: 'Unlisted', [VideoPrivacy.UNLISTED]: 'Unlisted',
[VideoPrivacy.PRIVATE]: 'Private', [VideoPrivacy.PRIVATE]: 'Private',
[VideoPrivacy.INTERNAL]: 'Internal' [VideoPrivacy.INTERNAL]: 'Internal'
} }
const VIDEO_STATES = { const VIDEO_STATES: { [ id in VideoState ]: string } = {
[VideoState.PUBLISHED]: 'Published', [VideoState.PUBLISHED]: 'Published',
[VideoState.TO_TRANSCODE]: 'To transcode', [VideoState.TO_TRANSCODE]: 'To transcode',
[VideoState.TO_IMPORT]: 'To import' [VideoState.TO_IMPORT]: 'To import',
[VideoState.WAITING_FOR_LIVE]: 'Waiting for livestream',
[VideoState.LIVE_ENDED]: 'Livestream ended'
} }
const VIDEO_IMPORT_STATES = { const VIDEO_IMPORT_STATES: { [ id in VideoImportState ]: string } = {
[VideoImportState.FAILED]: 'Failed', [VideoImportState.FAILED]: 'Failed',
[VideoImportState.PENDING]: 'Pending', [VideoImportState.PENDING]: 'Pending',
[VideoImportState.SUCCESS]: 'Success', [VideoImportState.SUCCESS]: 'Success',
[VideoImportState.REJECTED]: 'Rejected' [VideoImportState.REJECTED]: 'Rejected'
} }
const ABUSE_STATES = { const ABUSE_STATES: { [ id in AbuseState ]: string } = {
[AbuseState.PENDING]: 'Pending', [AbuseState.PENDING]: 'Pending',
[AbuseState.REJECTED]: 'Rejected', [AbuseState.REJECTED]: 'Rejected',
[AbuseState.ACCEPTED]: 'Accepted' [AbuseState.ACCEPTED]: 'Accepted'
} }
const VIDEO_PLAYLIST_PRIVACIES = { const VIDEO_PLAYLIST_PRIVACIES: { [ id in VideoPlaylistPrivacy ]: string } = {
[VideoPlaylistPrivacy.PUBLIC]: 'Public', [VideoPlaylistPrivacy.PUBLIC]: 'Public',
[VideoPlaylistPrivacy.UNLISTED]: 'Unlisted', [VideoPlaylistPrivacy.UNLISTED]: 'Unlisted',
[VideoPlaylistPrivacy.PRIVATE]: 'Private' [VideoPlaylistPrivacy.PRIVATE]: 'Private'
} }
const VIDEO_PLAYLIST_TYPES = { const VIDEO_PLAYLIST_TYPES: { [ id in VideoPlaylistType ]: string } = {
[VideoPlaylistType.REGULAR]: 'Regular', [VideoPlaylistType.REGULAR]: 'Regular',
[VideoPlaylistType.WATCH_LATER]: 'Watch later' [VideoPlaylistType.WATCH_LATER]: 'Watch later'
} }
@ -600,9 +606,24 @@ const LRU_CACHE = {
const HLS_STREAMING_PLAYLIST_DIRECTORY = join(CONFIG.STORAGE.STREAMING_PLAYLISTS_DIR, 'hls') const HLS_STREAMING_PLAYLIST_DIRECTORY = join(CONFIG.STORAGE.STREAMING_PLAYLISTS_DIR, 'hls')
const HLS_REDUNDANCY_DIRECTORY = join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls') const HLS_REDUNDANCY_DIRECTORY = join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls')
const VIDEO_LIVE = {
EXTENSION: '.ts',
CLEANUP_DELAY: 1000 * 60 * 5, // 5 minutes
SEGMENT_TIME: 4, // 4 seconds
SEGMENTS_LIST_SIZE: 15, // 15 maximum segments in live playlist
RTMP: {
CHUNK_SIZE: 60000,
GOP_CACHE: true,
PING: 60,
PING_TIMEOUT: 30,
BASE_PATH: 'live'
}
}
const MEMOIZE_TTL = { const MEMOIZE_TTL = {
OVERVIEWS_SAMPLE: 1000 * 3600 * 4, // 4 hours OVERVIEWS_SAMPLE: 1000 * 3600 * 4, // 4 hours
INFO_HASH_EXISTS: 1000 * 3600 * 12 // 12 hours INFO_HASH_EXISTS: 1000 * 3600 * 12, // 12 hours
LIVE_ABLE_TO_UPLOAD: 1000 * 60 // 1 minute
} }
const MEMOIZE_LENGTH = { const MEMOIZE_LENGTH = {
@ -622,7 +643,8 @@ const REDUNDANCY = {
const ACCEPT_HEADERS = [ 'html', 'application/json' ].concat(ACTIVITY_PUB.POTENTIAL_ACCEPT_HEADERS) const ACCEPT_HEADERS = [ 'html', 'application/json' ].concat(ACTIVITY_PUB.POTENTIAL_ACCEPT_HEADERS)
const ASSETS_PATH = { const ASSETS_PATH = {
DEFAULT_AUDIO_BACKGROUND: join(root(), 'dist', 'server', 'assets', 'default-audio-background.jpg') DEFAULT_AUDIO_BACKGROUND: join(root(), 'dist', 'server', 'assets', 'default-audio-background.jpg'),
DEFAULT_LIVE_BACKGROUND: join(root(), 'dist', 'server', 'assets', 'default-live-background.jpg')
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -688,9 +710,9 @@ if (isTestInstance() === true) {
STATIC_MAX_AGE.SERVER = '0' STATIC_MAX_AGE.SERVER = '0'
ACTIVITY_PUB.COLLECTION_ITEMS_PER_PAGE = 2 ACTIVITY_PUB.COLLECTION_ITEMS_PER_PAGE = 2
ACTIVITY_PUB.ACTOR_REFRESH_INTERVAL = 10 * 1000 // 10 seconds ACTIVITY_PUB.ACTOR_REFRESH_INTERVAL = 100 * 10000 // 10 seconds
ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL = 10 * 1000 // 10 seconds ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL = 100 * 10000 // 10 seconds
ACTIVITY_PUB.VIDEO_PLAYLIST_REFRESH_INTERVAL = 10 * 1000 // 10 seconds ACTIVITY_PUB.VIDEO_PLAYLIST_REFRESH_INTERVAL = 100 * 10000 // 10 seconds
CONSTRAINTS_FIELDS.ACTORS.AVATAR.FILE_SIZE.max = 100 * 1024 // 100KB CONSTRAINTS_FIELDS.ACTORS.AVATAR.FILE_SIZE.max = 100 * 1024 // 100KB
@ -737,6 +759,7 @@ const FILES_CONTENT_HASH = {
export { export {
WEBSERVER, WEBSERVER,
API_VERSION, API_VERSION,
VIDEO_LIVE,
PEERTUBE_VERSION, PEERTUBE_VERSION,
LAZY_STATIC_PATHS, LAZY_STATIC_PATHS,
SEARCH_INDEX, SEARCH_INDEX,
@ -892,10 +915,14 @@ function buildVideoMimetypeExt () {
function updateWebserverUrls () { function updateWebserverUrls () {
WEBSERVER.URL = sanitizeUrl(CONFIG.WEBSERVER.SCHEME + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT) WEBSERVER.URL = sanitizeUrl(CONFIG.WEBSERVER.SCHEME + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT)
WEBSERVER.HOST = sanitizeHost(CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT, REMOTE_SCHEME.HTTP) WEBSERVER.HOST = sanitizeHost(CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT, REMOTE_SCHEME.HTTP)
WEBSERVER.SCHEME = CONFIG.WEBSERVER.SCHEME
WEBSERVER.WS = CONFIG.WEBSERVER.WS WEBSERVER.WS = CONFIG.WEBSERVER.WS
WEBSERVER.SCHEME = CONFIG.WEBSERVER.SCHEME
WEBSERVER.HOSTNAME = CONFIG.WEBSERVER.HOSTNAME WEBSERVER.HOSTNAME = CONFIG.WEBSERVER.HOSTNAME
WEBSERVER.PORT = CONFIG.WEBSERVER.PORT WEBSERVER.PORT = CONFIG.WEBSERVER.PORT
WEBSERVER.PORT = CONFIG.WEBSERVER.PORT
WEBSERVER.RTMP_URL = 'rtmp://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.LIVE.RTMP.PORT + '/' + VIDEO_LIVE.RTMP.BASE_PATH
} }
function updateWebserverConfig () { function updateWebserverConfig () {

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import { isRedundancyAccepted } from '@server/lib/redundancy' import { isRedundancyAccepted } from '@server/lib/redundancy'
import { ActivityCreate, CacheFileObject, VideoTorrentObject } from '../../../../shared' import { ActivityCreate, CacheFileObject, VideoObject } from '../../../../shared'
import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object' import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object'
import { VideoCommentObject } from '../../../../shared/models/activitypub/objects/video-comment-object' import { VideoCommentObject } from '../../../../shared/models/activitypub/objects/video-comment-object'
import { retryTransactionWrapper } from '../../../helpers/database-utils' import { retryTransactionWrapper } from '../../../helpers/database-utils'
@ -52,7 +52,7 @@ export {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function processCreateVideo (activity: ActivityCreate, notify: boolean) { async function processCreateVideo (activity: ActivityCreate, notify: boolean) {
const videoToCreateData = activity.object as VideoTorrentObject const videoToCreateData = activity.object as VideoObject
const syncParam = { likes: false, dislikes: false, shares: false, comments: false, thumbnail: true, refreshVideo: false } const syncParam = { likes: false, dislikes: false, shares: false, comments: false, thumbnail: true, refreshVideo: false }
const { video, created } = await getOrCreateVideoAndAccountAndChannel({ videoObject: videoToCreateData, syncParam }) const { video, created } = await getOrCreateVideoAndAccountAndChannel({ videoObject: videoToCreateData, syncParam })

View file

@ -1,4 +1,4 @@
import { ActivityUpdate, CacheFileObject, VideoTorrentObject } from '../../../../shared/models/activitypub' import { ActivityUpdate, CacheFileObject, VideoObject } from '../../../../shared/models/activitypub'
import { ActivityPubActor } from '../../../../shared/models/activitypub/activitypub-actor' import { ActivityPubActor } from '../../../../shared/models/activitypub/activitypub-actor'
import { resetSequelizeInstance, retryTransactionWrapper } from '../../../helpers/database-utils' import { resetSequelizeInstance, retryTransactionWrapper } from '../../../helpers/database-utils'
import { logger } from '../../../helpers/logger' import { logger } from '../../../helpers/logger'
@ -55,7 +55,7 @@ export {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function processUpdateVideo (actor: MActorSignature, activity: ActivityUpdate) { async function processUpdateVideo (actor: MActorSignature, activity: ActivityUpdate) {
const videoObject = activity.object as VideoTorrentObject const videoObject = activity.object as VideoObject
if (sanitizeAndCheckVideoTorrentObject(videoObject) === false) { if (sanitizeAndCheckVideoTorrentObject(videoObject) === false) {
logger.debug('Video sent by update is not valid.', { videoObject }) logger.debug('Video sent by update is not valid.', { videoObject })

View file

@ -15,7 +15,7 @@ import {
ActivityVideoUrlObject, ActivityVideoUrlObject,
VideoState VideoState
} from '../../../shared/index' } from '../../../shared/index'
import { VideoTorrentObject } from '../../../shared/models/activitypub/objects' import { VideoObject } from '../../../shared/models/activitypub/objects'
import { VideoPrivacy } from '../../../shared/models/videos' import { VideoPrivacy } from '../../../shared/models/videos'
import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type' import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type' import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
@ -38,7 +38,6 @@ import {
} from '../../initializers/constants' } from '../../initializers/constants'
import { sequelizeTypescript } from '../../initializers/database' import { sequelizeTypescript } from '../../initializers/database'
import { AccountVideoRateModel } from '../../models/account/account-video-rate' import { AccountVideoRateModel } from '../../models/account/account-video-rate'
import { TagModel } from '../../models/video/tag'
import { VideoModel } from '../../models/video/video' import { VideoModel } from '../../models/video/video'
import { VideoCaptionModel } from '../../models/video/video-caption' import { VideoCaptionModel } from '../../models/video/video-caption'
import { VideoCommentModel } from '../../models/video/video-comment' import { VideoCommentModel } from '../../models/video/video-comment'
@ -67,7 +66,9 @@ import { FilteredModelAttributes } from '../../types/sequelize'
import { ActorFollowScoreCache } from '../files-cache' import { ActorFollowScoreCache } from '../files-cache'
import { JobQueue } from '../job-queue' import { JobQueue } from '../job-queue'
import { Notifier } from '../notifier' import { Notifier } from '../notifier'
import { PeerTubeSocket } from '../peertube-socket'
import { createPlaceholderThumbnail, createVideoMiniatureFromUrl } from '../thumbnail' import { createPlaceholderThumbnail, createVideoMiniatureFromUrl } from '../thumbnail'
import { setVideoTags } from '../video'
import { autoBlacklistVideoIfNeeded } from '../video-blacklist' import { autoBlacklistVideoIfNeeded } from '../video-blacklist'
import { getOrCreateActorAndServerAndModel } from './actor' import { getOrCreateActorAndServerAndModel } from './actor'
import { crawlCollectionPage } from './crawl' import { crawlCollectionPage } from './crawl'
@ -103,7 +104,7 @@ async function federateVideoIfNeeded (videoArg: MVideoAPWithoutCaption, isNewVid
} }
} }
async function fetchRemoteVideo (videoUrl: string): Promise<{ response: request.RequestResponse, videoObject: VideoTorrentObject }> { async function fetchRemoteVideo (videoUrl: string): Promise<{ response: request.RequestResponse, videoObject: VideoObject }> {
const options = { const options = {
uri: videoUrl, uri: videoUrl,
method: 'GET', method: 'GET',
@ -135,7 +136,7 @@ async function fetchRemoteVideoDescription (video: MVideoAccountLight) {
return body.description ? body.description : '' return body.description ? body.description : ''
} }
function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) { function getOrCreateVideoChannelFromVideoObject (videoObject: VideoObject) {
const channel = videoObject.attributedTo.find(a => a.type === 'Group') const channel = videoObject.attributedTo.find(a => a.type === 'Group')
if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url) if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
@ -154,7 +155,7 @@ type SyncParam = {
thumbnail: boolean thumbnail: boolean
refreshVideo?: boolean refreshVideo?: boolean
} }
async function syncVideoExternalAttributes (video: MVideo, fetchedVideo: VideoTorrentObject, syncParam: SyncParam) { async function syncVideoExternalAttributes (video: MVideo, fetchedVideo: VideoObject, syncParam: SyncParam) {
logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid) logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid)
const jobPayloads: ActivitypubHttpFetcherPayload[] = [] const jobPayloads: ActivitypubHttpFetcherPayload[] = []
@ -293,7 +294,7 @@ async function getOrCreateVideoAndAccountAndChannel (
async function updateVideoFromAP (options: { async function updateVideoFromAP (options: {
video: MVideoAccountLightBlacklistAllFiles video: MVideoAccountLightBlacklistAllFiles
videoObject: VideoTorrentObject videoObject: VideoObject
account: MAccountIdActor account: MAccountIdActor
channel: MChannelDefault channel: MChannelDefault
overrideTo?: string[] overrideTo?: string[]
@ -348,6 +349,7 @@ async function updateVideoFromAP (options: {
video.privacy = videoData.privacy video.privacy = videoData.privacy
video.channelId = videoData.channelId video.channelId = videoData.channelId
video.views = videoData.views video.views = videoData.views
video.isLive = videoData.isLive
const videoUpdated = await video.save(sequelizeOptions) as MVideoFullLight const videoUpdated = await video.save(sequelizeOptions) as MVideoFullLight
@ -409,8 +411,7 @@ async function updateVideoFromAP (options: {
const tags = videoObject.tag const tags = videoObject.tag
.filter(isAPHashTagObject) .filter(isAPHashTagObject)
.map(tag => tag.name) .map(tag => tag.name)
const tagInstances = await TagModel.findOrCreateTags(tags, t) await setVideoTags({ video: videoUpdated, tags, transaction: t, defaultValue: videoUpdated.Tags })
await videoUpdated.$set('Tags', tagInstances, sequelizeOptions)
} }
{ {
@ -435,6 +436,7 @@ async function updateVideoFromAP (options: {
}) })
if (wasPrivateVideo || wasUnlistedVideo) Notifier.Instance.notifyOnNewVideoIfNeeded(videoUpdated) // Notify our users? if (wasPrivateVideo || wasUnlistedVideo) Notifier.Instance.notifyOnNewVideoIfNeeded(videoUpdated) // Notify our users?
if (videoUpdated.isLive) PeerTubeSocket.Instance.sendVideoLiveNewState(video)
logger.info('Remote video with uuid %s updated', videoObject.uuid) logger.info('Remote video with uuid %s updated', videoObject.uuid)
@ -538,7 +540,7 @@ function isAPHashTagObject (url: any): url is ActivityHashTagObject {
return url && url.type === 'Hashtag' return url && url.type === 'Hashtag'
} }
async function createVideo (videoObject: VideoTorrentObject, channel: MChannelAccountLight, waitThumbnail = false) { async function createVideo (videoObject: VideoObject, channel: MChannelAccountLight, waitThumbnail = false) {
logger.debug('Adding remote video %s.', videoObject.id) logger.debug('Adding remote video %s.', videoObject.id)
const videoData = await videoActivityObjectToDBAttributes(channel, videoObject, videoObject.to) const videoData = await videoActivityObjectToDBAttributes(channel, videoObject, videoObject.to)
@ -594,8 +596,7 @@ async function createVideo (videoObject: VideoTorrentObject, channel: MChannelAc
const tags = videoObject.tag const tags = videoObject.tag
.filter(isAPHashTagObject) .filter(isAPHashTagObject)
.map(t => t.name) .map(t => t.name)
const tagInstances = await TagModel.findOrCreateTags(tags, t) await setVideoTags({ video: videoCreated, tags, transaction: t })
await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
// Process captions // Process captions
const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => { const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
@ -604,7 +605,6 @@ async function createVideo (videoObject: VideoTorrentObject, channel: MChannelAc
await Promise.all(videoCaptionsPromises) await Promise.all(videoCaptionsPromises)
videoCreated.VideoFiles = videoFiles videoCreated.VideoFiles = videoFiles
videoCreated.Tags = tagInstances
const autoBlacklisted = await autoBlacklistVideoIfNeeded({ const autoBlacklisted = await autoBlacklistVideoIfNeeded({
video: videoCreated, video: videoCreated,
@ -634,7 +634,7 @@ async function createVideo (videoObject: VideoTorrentObject, channel: MChannelAc
return { autoBlacklisted, videoCreated } return { autoBlacklisted, videoCreated }
} }
function videoActivityObjectToDBAttributes (videoChannel: MChannelId, videoObject: VideoTorrentObject, to: string[] = []) { function videoActivityObjectToDBAttributes (videoChannel: MChannelId, videoObject: VideoObject, to: string[] = []) {
const privacy = to.includes(ACTIVITY_PUB.PUBLIC) const privacy = to.includes(ACTIVITY_PUB.PUBLIC)
? VideoPrivacy.PUBLIC ? VideoPrivacy.PUBLIC
: VideoPrivacy.UNLISTED : VideoPrivacy.UNLISTED
@ -666,6 +666,7 @@ function videoActivityObjectToDBAttributes (videoChannel: MChannelId, videoObjec
commentsEnabled: videoObject.commentsEnabled, commentsEnabled: videoObject.commentsEnabled,
downloadEnabled: videoObject.downloadEnabled, downloadEnabled: videoObject.downloadEnabled,
waitTranscoding: videoObject.waitTranscoding, waitTranscoding: videoObject.waitTranscoding,
isLive: videoObject.isLiveBroadcast,
state: videoObject.state, state: videoObject.state,
channelId: videoChannel.id, channelId: videoChannel.id,
duration: parseInt(duration, 10), duration: parseInt(duration, 10),
@ -734,7 +735,7 @@ function videoFileActivityUrlToDBAttributes (
return attributes return attributes
} }
function streamingPlaylistActivityUrlToDBAttributes (video: MVideoId, videoObject: VideoTorrentObject, videoFiles: MVideoFile[]) { function streamingPlaylistActivityUrlToDBAttributes (video: MVideoId, videoObject: VideoObject, videoFiles: MVideoFile[]) {
const playlistUrls = videoObject.url.filter(u => isAPStreamingPlaylistUrlObject(u)) as ActivityPlaylistUrlObject[] const playlistUrls = videoObject.url.filter(u => isAPStreamingPlaylistUrlObject(u)) as ActivityPlaylistUrlObject[]
if (playlistUrls.length === 0) return [] if (playlistUrls.length === 0) return []
@ -768,7 +769,7 @@ function streamingPlaylistActivityUrlToDBAttributes (video: MVideoId, videoObjec
return attributes return attributes
} }
function getThumbnailFromIcons (videoObject: VideoTorrentObject) { function getThumbnailFromIcons (videoObject: VideoObject) {
let validIcons = videoObject.icon.filter(i => i.width > THUMBNAILS_SIZE.minWidth) let validIcons = videoObject.icon.filter(i => i.width > THUMBNAILS_SIZE.minWidth)
// Fallback if there are not valid icons // Fallback if there are not valid icons
if (validIcons.length === 0) validIcons = videoObject.icon if (validIcons.length === 0) validIcons = videoObject.icon
@ -776,7 +777,7 @@ function getThumbnailFromIcons (videoObject: VideoTorrentObject) {
return minBy(validIcons, 'width') return minBy(validIcons, 'width')
} }
function getPreviewFromIcons (videoObject: VideoTorrentObject) { function getPreviewFromIcons (videoObject: VideoObject) {
const validIcons = videoObject.icon.filter(i => i.width > PREVIEWS_SIZE.minWidth) const validIcons = videoObject.icon.filter(i => i.width > PREVIEWS_SIZE.minWidth)
// FIXME: don't put a fallback here for compatibility with PeerTube <2.2 // FIXME: don't put a fallback here for compatibility with PeerTube <2.2

View file

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

View file

@ -4,6 +4,7 @@ import { extname } from 'path'
import { addOptimizeOrMergeAudioJob } from '@server/helpers/video' import { addOptimizeOrMergeAudioJob } from '@server/helpers/video'
import { isPostImportVideoAccepted } from '@server/lib/moderation' import { isPostImportVideoAccepted } from '@server/lib/moderation'
import { Hooks } from '@server/lib/plugins/hooks' import { Hooks } from '@server/lib/plugins/hooks'
import { isAbleToUploadVideo } from '@server/lib/user'
import { getVideoFilePath } from '@server/lib/video-paths' import { getVideoFilePath } from '@server/lib/video-paths'
import { MVideoImportDefault, MVideoImportDefaultFiles, MVideoImportVideo } from '@server/types/models/video/video-import' import { MVideoImportDefault, MVideoImportDefaultFiles, MVideoImportVideo } from '@server/types/models/video/video-import'
import { import {
@ -108,7 +109,7 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid
// Get information about this video // Get information about this video
const stats = await stat(tempVideoPath) const stats = await stat(tempVideoPath)
const isAble = await videoImport.User.isAbleToUploadVideo({ size: stats.size }) const isAble = await isAbleToUploadVideo(videoImport.User.id, stats.size)
if (isAble === false) { if (isAble === false) {
throw new Error('The user video quota is exceeded with this video to import.') throw new Error('The user video quota is exceeded with this video to import.')
} }

View file

@ -0,0 +1,47 @@
import * as Bull from 'bull'
import { readdir, remove } from 'fs-extra'
import { join } from 'path'
import { getHLSDirectory } from '@server/lib/video-paths'
import { VideoModel } from '@server/models/video/video'
import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
import { VideoLiveEndingPayload } from '@shared/models'
import { logger } from '../../../helpers/logger'
async function processVideoLiveEnding (job: Bull.Job) {
const payload = job.data as VideoLiveEndingPayload
const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoId)
if (!video) {
logger.warn('Video live %d does not exist anymore. Cannot cleanup.', payload.videoId)
return
}
const streamingPlaylist = await VideoStreamingPlaylistModel.loadHLSPlaylistByVideo(video.id)
const hlsDirectory = getHLSDirectory(video, false)
const files = await readdir(hlsDirectory)
for (const filename of files) {
if (
filename.endsWith('.ts') ||
filename.endsWith('.m3u8') ||
filename.endsWith('.mpd') ||
filename.endsWith('.m4s') ||
filename.endsWith('.tmp')
) {
const p = join(hlsDirectory, filename)
remove(p)
.catch(err => logger.error('Cannot remove %s.', p, { err }))
}
}
streamingPlaylist.destroy()
.catch(err => logger.error('Cannot remove live streaming playlist.', { err }))
}
// ---------------------------------------------------------------------------
export {
processVideoLiveEnding
}

View file

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

View file

@ -10,6 +10,7 @@ import {
RefreshPayload, RefreshPayload,
VideoFileImportPayload, VideoFileImportPayload,
VideoImportPayload, VideoImportPayload,
VideoLiveEndingPayload,
VideoRedundancyPayload, VideoRedundancyPayload,
VideoTranscodingPayload VideoTranscodingPayload
} from '../../../shared/models' } from '../../../shared/models'
@ -27,6 +28,7 @@ import { processVideosViews } from './handlers/video-views'
import { refreshAPObject } from './handlers/activitypub-refresher' import { refreshAPObject } from './handlers/activitypub-refresher'
import { processVideoFileImport } from './handlers/video-file-import' import { processVideoFileImport } from './handlers/video-file-import'
import { processVideoRedundancy } from '@server/lib/job-queue/handlers/video-redundancy' import { processVideoRedundancy } from '@server/lib/job-queue/handlers/video-redundancy'
import { processVideoLiveEnding } from './handlers/video-live-ending'
type CreateJobArgument = type CreateJobArgument =
{ type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } | { type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } |
@ -39,8 +41,13 @@ type CreateJobArgument =
{ type: 'video-import', payload: VideoImportPayload } | { type: 'video-import', payload: VideoImportPayload } |
{ type: 'activitypub-refresher', payload: RefreshPayload } | { type: 'activitypub-refresher', payload: RefreshPayload } |
{ type: 'videos-views', payload: {} } | { type: 'videos-views', payload: {} } |
{ type: 'video-live-ending', payload: VideoLiveEndingPayload } |
{ type: 'video-redundancy', payload: VideoRedundancyPayload } { type: 'video-redundancy', payload: VideoRedundancyPayload }
type CreateJobOptions = {
delay?: number
}
const handlers: { [id in JobType]: (job: Bull.Job) => Promise<any> } = { const handlers: { [id in JobType]: (job: Bull.Job) => Promise<any> } = {
'activitypub-http-broadcast': processActivityPubHttpBroadcast, 'activitypub-http-broadcast': processActivityPubHttpBroadcast,
'activitypub-http-unicast': processActivityPubHttpUnicast, 'activitypub-http-unicast': processActivityPubHttpUnicast,
@ -52,6 +59,7 @@ const handlers: { [id in JobType]: (job: Bull.Job) => Promise<any> } = {
'video-import': processVideoImport, 'video-import': processVideoImport,
'videos-views': processVideosViews, 'videos-views': processVideosViews,
'activitypub-refresher': refreshAPObject, 'activitypub-refresher': refreshAPObject,
'video-live-ending': processVideoLiveEnding,
'video-redundancy': processVideoRedundancy 'video-redundancy': processVideoRedundancy
} }
@ -66,7 +74,8 @@ const jobTypes: JobType[] = [
'video-import', 'video-import',
'videos-views', 'videos-views',
'activitypub-refresher', 'activitypub-refresher',
'video-redundancy' 'video-redundancy',
'video-live-ending'
] ]
class JobQueue { class JobQueue {
@ -122,12 +131,12 @@ class JobQueue {
} }
} }
createJob (obj: CreateJobArgument): void { createJob (obj: CreateJobArgument, options: CreateJobOptions = {}): void {
this.createJobWithPromise(obj) this.createJobWithPromise(obj, options)
.catch(err => logger.error('Cannot create job.', { err, obj })) .catch(err => logger.error('Cannot create job.', { err, obj }))
} }
createJobWithPromise (obj: CreateJobArgument) { createJobWithPromise (obj: CreateJobArgument, options: CreateJobOptions = {}) {
const queue = this.queues[obj.type] const queue = this.queues[obj.type]
if (queue === undefined) { if (queue === undefined) {
logger.error('Unknown queue %s: cannot create job.', obj.type) logger.error('Unknown queue %s: cannot create job.', obj.type)
@ -137,7 +146,8 @@ class JobQueue {
const jobArgs: Bull.JobOptions = { const jobArgs: Bull.JobOptions = {
backoff: { delay: 60 * 1000, type: 'exponential' }, backoff: { delay: 60 * 1000, type: 'exponential' },
attempts: JOB_ATTEMPTS[obj.type], attempts: JOB_ATTEMPTS[obj.type],
timeout: JOB_TTL[obj.type] timeout: JOB_TTL[obj.type],
delay: options.delay
} }
return queue.add(obj.payload, jobArgs) return queue.add(obj.payload, jobArgs)

412
server/lib/live-manager.ts Normal file
View file

@ -0,0 +1,412 @@
import { AsyncQueue, queue } from 'async'
import * as chokidar from 'chokidar'
import { FfmpegCommand } from 'fluent-ffmpeg'
import { ensureDir, stat } from 'fs-extra'
import { basename } 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 { MEMOIZE_TTL, P2P_MEDIA_LOADER_PEER_VERSION, VIDEO_LIVE, WEBSERVER } from '@server/initializers/constants'
import { UserModel } from '@server/models/account/user'
import { VideoModel } from '@server/models/video/video'
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, MUser, MUserId, MVideoLive, MVideoLiveVideo } from '@server/types/models'
import { VideoState, VideoStreamingPlaylistType } from '@shared/models'
import { federateVideoIfNeeded } from './activitypub/videos'
import { buildSha256Segment } from './hls'
import { JobQueue } from './job-queue'
import { PeerTubeSocket } from './peertube-socket'
import { isAbleToUploadVideo } from './user'
import { getHLSDirectory } from './video-paths'
import memoizee = require('memoizee')
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 videoSessions = new Map<number, string>()
private readonly segmentsSha256 = new Map<string, Map<string, string>>()
private readonly livesPerUser = new Map<number, { liveId: number, videoId: number, size: number }[]>()
private readonly isAbleToUploadVideoWithCache = memoizee((userId: number) => {
return isAbleToUploadVideo(userId, 1000)
}, { maxAge: MEMOIZE_TTL.LIVE_ABLE_TO_UPLOAD })
private segmentsSha256Queue: AsyncQueue<SegmentSha256QueueParam>
private rtmpServer: any
private constructor () {
}
init () {
const events = this.getContext().nodeEvent
events.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 }))
})
events.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)
}
stopSessionOf (videoId: number) {
const sessionId = this.videoSessions.get(videoId)
if (!sessionId) return
this.abortSession(sessionId)
this.onEndTransmuxing(videoId, true)
.catch(err => logger.error('Cannot end transmuxing of video %d.', videoId, { err }))
}
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
if (video.isBlacklisted()) {
logger.warn('Video is blacklisted. Refusing stream %s.', streamKey)
return this.abortSession(sessionId)
}
this.videoSessions.set(video.id, sessionId)
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 ]
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 startStreamDateTime = new Date().getTime()
const allResolutions = resolutionsEnabled.concat([ originalResolution ])
const user = await UserModel.loadByLiveId(videoLive.id)
if (!this.livesPerUser.has(user.id)) {
this.livesPerUser.set(user.id, [])
}
const currentUserLive = { liveId: videoLive.id, videoId: videoLive.videoId, size: 0 }
const livesOfUser = this.livesPerUser.get(user.id)
livesOfUser.push(currentUserLive)
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 deleteSegments = videoLive.saveReplay === false
const rtmpUrl = 'rtmp://127.0.0.1:' + config.rtmp.port + streamPath
const ffmpegExec = CONFIG.LIVE.TRANSCODING.ENABLED
? runLiveTranscoding(rtmpUrl, outPath, allResolutions, deleteSegments)
: runLiveMuxing(rtmpUrl, outPath, deleteSegments)
logger.info('Running live muxing/transcoding.')
this.transSessions.set(sessionId, ffmpegExec)
const videoUUID = videoLive.Video.uuid
const tsWatcher = chokidar.watch(outPath + '/*.ts')
const updateSegment = segmentPath => this.segmentsSha256Queue.push({ operation: 'update', segmentPath, videoUUID })
const addHandler = segmentPath => {
updateSegment(segmentPath)
if (this.isDurationConstraintValid(startStreamDateTime) !== true) {
this.stopSessionOf(videoLive.videoId)
}
if (videoLive.saveReplay === true) {
stat(segmentPath)
.then(segmentStat => {
currentUserLive.size += segmentStat.size
})
.then(() => this.isQuotaConstraintValid(user, videoLive))
.then(quotaValid => {
if (quotaValid !== true) {
this.stopSessionOf(videoLive.videoId)
}
})
.catch(err => logger.error('Cannot stat %s or check quota of %d.', segmentPath, user.id, { err }))
}
}
const deleteHandler = segmentPath => this.segmentsSha256Queue.push({ operation: 'delete', segmentPath, videoUUID })
tsWatcher.on('add', p => addHandler(p))
tsWatcher.on('change', p => updateSegment(p))
tsWatcher.on('unlink', p => deleteHandler(p))
const masterWatcher = chokidar.watch(outPath + '/master.m3u8')
masterWatcher.on('add', async () => {
try {
const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoLive.videoId)
video.state = VideoState.PUBLISHED
await video.save()
videoLive.Video = video
await federateVideoIfNeeded(video, false)
PeerTubeSocket.Instance.sendVideoLiveNewState(video)
} catch (err) {
logger.error('Cannot federate video %d.', videoLive.videoId, { err })
} finally {
masterWatcher.close()
.catch(err => logger.error('Cannot close master watcher of %s.', outPath, { err }))
}
})
const onFFmpegEnded = () => {
logger.info('RTMP transmuxing for video %s ended. Scheduling cleanup', streamPath)
Promise.all([ tsWatcher.close(), masterWatcher.close() ])
.catch(err => logger.error('Cannot close watchers of %s.', outPath, { err }))
this.onEndTransmuxing(videoLive.Video.id)
.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())
}
getLiveQuotaUsedByUser (userId: number) {
const currentLives = this.livesPerUser.get(userId)
if (!currentLives) return 0
return currentLives.reduce((sum, obj) => sum + obj.size, 0)
}
private async onEndTransmuxing (videoId: number, cleanupNow = false) {
try {
const fullVideo = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId)
if (!fullVideo) return
JobQueue.Instance.createJob({
type: 'video-live-ending',
payload: {
videoId: fullVideo.id
}
}, { delay: cleanupNow ? 0 : VIDEO_LIVE.CLEANUP_DELAY })
// FIXME: use end
fullVideo.state = VideoState.WAITING_FOR_LIVE
await fullVideo.save()
PeerTubeSocket.Instance.sendVideoLiveNewState(fullVideo)
await federateVideoIfNeeded(fullVideo, false)
} catch (err) {
logger.error('Cannot save/federate 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)
}
private isDurationConstraintValid (streamingStartTime: number) {
const maxDuration = CONFIG.LIVE.MAX_DURATION
// No limit
if (maxDuration === null) return true
const now = new Date().getTime()
const max = streamingStartTime + maxDuration
return now <= max
}
private async isQuotaConstraintValid (user: MUserId, live: MVideoLive) {
if (live.saveReplay !== true) return true
return this.isAbleToUploadVideoWithCache(user.id)
}
static get Instance () {
return this.instance || (this.instance = new this())
}
}
// ---------------------------------------------------------------------------
export {
LiveManager
}

View file

@ -18,7 +18,7 @@ import {
MVideoAccountLightBlacklistAllFiles MVideoAccountLightBlacklistAllFiles
} from '@server/types/models' } from '@server/types/models'
import { ActivityCreate } from '../../shared/models/activitypub' import { ActivityCreate } from '../../shared/models/activitypub'
import { VideoTorrentObject } from '../../shared/models/activitypub/objects' import { VideoObject } from '../../shared/models/activitypub/objects'
import { VideoCommentObject } from '../../shared/models/activitypub/objects/video-comment-object' import { VideoCommentObject } from '../../shared/models/activitypub/objects/video-comment-object'
import { VideoCreate, VideoImportCreate } from '../../shared/models/videos' import { VideoCreate, VideoImportCreate } from '../../shared/models/videos'
import { VideoCommentCreate } from '../../shared/models/videos/video-comment.model' import { VideoCommentCreate } from '../../shared/models/videos/video-comment.model'
@ -62,7 +62,7 @@ function isLocalVideoCommentReplyAccepted (_object: {
function isRemoteVideoAccepted (_object: { function isRemoteVideoAccepted (_object: {
activity: ActivityCreate activity: ActivityCreate
videoAP: VideoTorrentObject videoAP: VideoObject
byActor: ActorModel byActor: ActorModel
}): AcceptResult { }): AcceptResult {
return { accepted: true } return { accepted: true }

View file

@ -1,14 +1,18 @@
import * as SocketIO from 'socket.io' import { Socket } from 'dgram'
import { authenticateSocket } from '../middlewares'
import { logger } from '../helpers/logger'
import { Server } from 'http' import { Server } from 'http'
import * as SocketIO from 'socket.io'
import { MVideo } from '@server/types/models'
import { UserNotificationModelForApi } from '@server/types/models/user' import { UserNotificationModelForApi } from '@server/types/models/user'
import { LiveVideoEventPayload, LiveVideoEventType } from '@shared/models'
import { logger } from '../helpers/logger'
import { authenticateSocket } from '../middlewares'
class PeerTubeSocket { class PeerTubeSocket {
private static instance: PeerTubeSocket private static instance: PeerTubeSocket
private userNotificationSockets: { [ userId: number ]: SocketIO.Socket[] } = {} private userNotificationSockets: { [ userId: number ]: SocketIO.Socket[] } = {}
private liveVideosNamespace: SocketIO.Namespace
private constructor () {} private constructor () {}
@ -32,19 +36,37 @@ class PeerTubeSocket {
this.userNotificationSockets[userId] = this.userNotificationSockets[userId].filter(s => s !== socket) this.userNotificationSockets[userId] = this.userNotificationSockets[userId].filter(s => s !== socket)
}) })
}) })
this.liveVideosNamespace = io.of('/live-videos')
.on('connection', socket => {
socket.on('subscribe', ({ videoId }) => socket.join(videoId))
socket.on('unsubscribe', ({ videoId }) => socket.leave(videoId))
})
} }
sendNotification (userId: number, notification: UserNotificationModelForApi) { sendNotification (userId: number, notification: UserNotificationModelForApi) {
const sockets = this.userNotificationSockets[userId] const sockets = this.userNotificationSockets[userId]
if (!sockets) return if (!sockets) return
logger.debug('Sending user notification to user %d.', userId)
const notificationMessage = notification.toFormattedJSON() const notificationMessage = notification.toFormattedJSON()
for (const socket of sockets) { for (const socket of sockets) {
socket.emit('new-notification', notificationMessage) socket.emit('new-notification', notificationMessage)
} }
} }
sendVideoLiveNewState (video: MVideo) {
const data: LiveVideoEventPayload = { state: video.state }
const type: LiveVideoEventType = 'state-change'
logger.debug('Sending video live new state notification of %s.', video.url)
this.liveVideosNamespace
.in(video.id)
.emit(type, data)
}
static get Instance () { static get Instance () {
return this.instance || (this.instance = new this()) return this.instance || (this.instance = new this())
} }

View file

@ -42,15 +42,18 @@ function createVideoMiniatureFromUrl (fileUrl: string, video: MVideoThumbnail, t
return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail, fileUrl }) return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail, fileUrl })
} }
function createVideoMiniatureFromExisting ( function createVideoMiniatureFromExisting (options: {
inputPath: string, inputPath: string
video: MVideoThumbnail, video: MVideoThumbnail
type: ThumbnailType, type: ThumbnailType
automaticallyGenerated: boolean, automaticallyGenerated: boolean
size?: ImageSize size?: ImageSize
) { keepOriginal?: boolean
}) {
const { inputPath, video, type, automaticallyGenerated, size, keepOriginal } = options
const { filename, outputPath, height, width, existingThumbnail } = buildMetadataFromVideo(video, type, size) const { filename, outputPath, height, width, existingThumbnail } = buildMetadataFromVideo(video, type, size)
const thumbnailCreator = () => processImage(inputPath, outputPath, { width, height }) const thumbnailCreator = () => processImage(inputPath, outputPath, { width, height }, keepOriginal)
return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, automaticallyGenerated, existingThumbnail }) return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, automaticallyGenerated, existingThumbnail })
} }

View file

@ -1,20 +1,24 @@
import { v4 as uuidv4 } from 'uuid'
import { ActivityPubActorType } from '../../shared/models/activitypub'
import { SERVER_ACTOR_NAME, WEBSERVER } from '../initializers/constants'
import { AccountModel } from '../models/account/account'
import { buildActorInstance, setAsyncActorKeys } from './activitypub/actor'
import { createLocalVideoChannel } from './video-channel'
import { ActorModel } from '../models/activitypub/actor'
import { UserNotificationSettingModel } from '../models/account/user-notification-setting'
import { UserNotificationSetting, UserNotificationSettingValue } from '../../shared/models/users'
import { createWatchLaterPlaylist } from './video-playlist'
import { sequelizeTypescript } from '../initializers/database'
import { Transaction } from 'sequelize/types' import { Transaction } from 'sequelize/types'
import { Redis } from './redis' import { v4 as uuidv4 } from 'uuid'
import { Emailer } from './emailer' import { UserModel } from '@server/models/account/user'
import { ActivityPubActorType } from '../../shared/models/activitypub'
import { UserNotificationSetting, UserNotificationSettingValue } from '../../shared/models/users'
import { SERVER_ACTOR_NAME, WEBSERVER } from '../initializers/constants'
import { sequelizeTypescript } from '../initializers/database'
import { AccountModel } from '../models/account/account'
import { UserNotificationSettingModel } from '../models/account/user-notification-setting'
import { ActorModel } from '../models/activitypub/actor'
import { MAccountDefault, MActorDefault, MChannelActor } from '../types/models' import { MAccountDefault, MActorDefault, MChannelActor } from '../types/models'
import { MUser, MUserDefault, MUserId } from '../types/models/user' import { MUser, MUserDefault, MUserId } from '../types/models/user'
import { buildActorInstance, setAsyncActorKeys } from './activitypub/actor'
import { getAccountActivityPubUrl } from './activitypub/url' import { getAccountActivityPubUrl } from './activitypub/url'
import { Emailer } from './emailer'
import { LiveManager } from './live-manager'
import { Redis } from './redis'
import { createLocalVideoChannel } from './video-channel'
import { createWatchLaterPlaylist } from './video-playlist'
import memoizee = require('memoizee')
type ChannelNames = { name: string, displayName: string } type ChannelNames = { name: string, displayName: string }
@ -116,13 +120,61 @@ async function sendVerifyUserEmail (user: MUser, isPendingEmail = false) {
await Emailer.Instance.addVerifyEmailJob(username, email, url) await Emailer.Instance.addVerifyEmailJob(username, email, url)
} }
async function getOriginalVideoFileTotalFromUser (user: MUserId) {
// Don't use sequelize because we need to use a sub query
const query = UserModel.generateUserQuotaBaseSQL({
withSelect: true,
whereUserId: '$userId'
})
const base = await UserModel.getTotalRawQuery(query, user.id)
return base + LiveManager.Instance.getLiveQuotaUsedByUser(user.id)
}
// Returns cumulative size of all video files uploaded in the last 24 hours.
async function getOriginalVideoFileTotalDailyFromUser (user: MUserId) {
// Don't use sequelize because we need to use a sub query
const query = UserModel.generateUserQuotaBaseSQL({
withSelect: true,
whereUserId: '$userId',
where: '"video"."createdAt" > now() - interval \'24 hours\''
})
const base = await UserModel.getTotalRawQuery(query, user.id)
return base + LiveManager.Instance.getLiveQuotaUsedByUser(user.id)
}
async function isAbleToUploadVideo (userId: number, size: number) {
const user = await UserModel.loadById(userId)
if (user.videoQuota === -1 && user.videoQuotaDaily === -1) return Promise.resolve(true)
const [ totalBytes, totalBytesDaily ] = await Promise.all([
getOriginalVideoFileTotalFromUser(user.id),
getOriginalVideoFileTotalDailyFromUser(user.id)
])
const uploadedTotal = size + totalBytes
const uploadedDaily = size + totalBytesDaily
if (user.videoQuotaDaily === -1) return uploadedTotal < user.videoQuota
if (user.videoQuota === -1) return uploadedDaily < user.videoQuotaDaily
return uploadedTotal < user.videoQuota && uploadedDaily < user.videoQuotaDaily
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export { export {
getOriginalVideoFileTotalFromUser,
getOriginalVideoFileTotalDailyFromUser,
createApplicationActor, createApplicationActor,
createUserAccountAndChannelAndPlaylist, createUserAccountAndChannelAndPlaylist,
createLocalAccountWithoutKeys, createLocalAccountWithoutKeys,
sendVerifyUserEmail sendVerifyUserEmail,
isAbleToUploadVideo
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -17,6 +17,7 @@ import { sendDeleteVideo } from './activitypub/send'
import { federateVideoIfNeeded } from './activitypub/videos' import { federateVideoIfNeeded } from './activitypub/videos'
import { Notifier } from './notifier' import { Notifier } from './notifier'
import { Hooks } from './plugins/hooks' import { Hooks } from './plugins/hooks'
import { LiveManager } from './live-manager'
async function autoBlacklistVideoIfNeeded (parameters: { async function autoBlacklistVideoIfNeeded (parameters: {
video: MVideoWithBlacklistLight video: MVideoWithBlacklistLight
@ -73,6 +74,10 @@ async function blacklistVideo (videoInstance: MVideoAccountLight, options: Video
await sendDeleteVideo(videoInstance, undefined) await sendDeleteVideo(videoInstance, undefined)
} }
if (videoInstance.isLive) {
LiveManager.Instance.stopSessionOf(videoInstance.id)
}
Notifier.Instance.notifyOnVideoBlacklist(blacklist) Notifier.Instance.notifyOnVideoBlacklist(blacklist)
} }

View file

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

View file

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

87
server/lib/video.ts Normal file
View file

@ -0,0 +1,87 @@
import { Transaction } from 'sequelize/types'
import { TagModel } from '@server/models/video/tag'
import { VideoModel } from '@server/models/video/video'
import { FilteredModelAttributes } from '@server/types'
import { MTag, MThumbnail, MVideoTag, MVideoThumbnail } from '@server/types/models'
import { ThumbnailType, VideoCreate, VideoPrivacy } from '@shared/models'
import { createVideoMiniatureFromExisting } from './thumbnail'
function buildLocalVideoFromReq (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,
nsfw: videoInfo.nsfw || false,
description: videoInfo.description,
support: videoInfo.support,
privacy: videoInfo.privacy || VideoPrivacy.PRIVATE,
channelId: channelId,
originallyPublishedAt: videoInfo.originallyPublishedAt
}
}
async function buildVideoThumbnailsFromReq (options: {
video: MVideoThumbnail
files: { [fieldname: string]: Express.Multer.File[] } | Express.Multer.File[]
fallback: (type: ThumbnailType) => Promise<MThumbnail>
automaticallyGenerated?: boolean
}) {
const { video, files, fallback, automaticallyGenerated } = options
const promises = [
{
type: ThumbnailType.MINIATURE,
fieldName: 'thumbnailfile'
},
{
type: ThumbnailType.PREVIEW,
fieldName: 'previewfile'
}
].map(p => {
const fields = files?.[p.fieldName]
if (fields) {
return createVideoMiniatureFromExisting({
inputPath: fields[0].path,
video,
type: p.type,
automaticallyGenerated: automaticallyGenerated || false
})
}
return fallback(p.type)
})
return Promise.all(promises)
}
async function setVideoTags (options: {
video: MVideoTag
tags: string[]
transaction?: Transaction
defaultValue?: MTag[]
}) {
const { video, tags, transaction, defaultValue } = options
// Set tags to the video
if (tags) {
const tagInstances = await TagModel.findOrCreateTags(tags, transaction)
await video.$set('Tags', tagInstances, { transaction })
video.Tags = tagInstances
} else {
video.Tags = defaultValue || []
}
}
// ---------------------------------------------------------------------------
export {
buildLocalVideoFromReq,
buildVideoThumbnailsFromReq,
setVideoTags
}

View file

@ -1,12 +1,13 @@
import * as express from 'express' import * as express from 'express'
import { body } from 'express-validator' import { body } from 'express-validator'
import { isIntOrNull } from '@server/helpers/custom-validators/misc'
import { isEmailEnabled } from '@server/initializers/config'
import { CustomConfig } from '../../../shared/models/server/custom-config.model'
import { isThemeNameValid } from '../../helpers/custom-validators/plugins'
import { isUserNSFWPolicyValid, isUserVideoQuotaDailyValid, isUserVideoQuotaValid } from '../../helpers/custom-validators/users' import { isUserNSFWPolicyValid, isUserVideoQuotaDailyValid, isUserVideoQuotaValid } from '../../helpers/custom-validators/users'
import { logger } from '../../helpers/logger' import { logger } from '../../helpers/logger'
import { CustomConfig } from '../../../shared/models/server/custom-config.model'
import { areValidationErrors } from './utils'
import { isThemeNameValid } from '../../helpers/custom-validators/plugins'
import { isThemeRegistered } from '../../lib/plugins/theme-utils' import { isThemeRegistered } from '../../lib/plugins/theme-utils'
import { isEmailEnabled } from '@server/initializers/config' import { areValidationErrors } from './utils'
const customConfigUpdateValidator = [ const customConfigUpdateValidator = [
body('instance.name').exists().withMessage('Should have a valid instance name'), body('instance.name').exists().withMessage('Should have a valid instance name'),
@ -43,6 +44,7 @@ const customConfigUpdateValidator = [
body('transcoding.resolutions.480p').isBoolean().withMessage('Should have a valid transcoding 480p resolution enabled boolean'), body('transcoding.resolutions.480p').isBoolean().withMessage('Should have a valid transcoding 480p resolution enabled boolean'),
body('transcoding.resolutions.720p').isBoolean().withMessage('Should have a valid transcoding 720p resolution enabled boolean'), body('transcoding.resolutions.720p').isBoolean().withMessage('Should have a valid transcoding 720p resolution enabled boolean'),
body('transcoding.resolutions.1080p').isBoolean().withMessage('Should have a valid transcoding 1080p resolution enabled boolean'), body('transcoding.resolutions.1080p').isBoolean().withMessage('Should have a valid transcoding 1080p resolution enabled boolean'),
body('transcoding.resolutions.2160p').isBoolean().withMessage('Should have a valid transcoding 2160p resolution enabled boolean'),
body('transcoding.webtorrent.enabled').isBoolean().withMessage('Should have a valid webtorrent transcoding enabled boolean'), body('transcoding.webtorrent.enabled').isBoolean().withMessage('Should have a valid webtorrent transcoding enabled boolean'),
body('transcoding.hls.enabled').isBoolean().withMessage('Should have a valid hls transcoding enabled boolean'), body('transcoding.hls.enabled').isBoolean().withMessage('Should have a valid hls transcoding enabled boolean'),
@ -60,6 +62,18 @@ const customConfigUpdateValidator = [
body('broadcastMessage.level').exists().withMessage('Should have a valid broadcast level'), body('broadcastMessage.level').exists().withMessage('Should have a valid broadcast level'),
body('broadcastMessage.dismissable').isBoolean().withMessage('Should have a valid broadcast dismissable boolean'), body('broadcastMessage.dismissable').isBoolean().withMessage('Should have a valid broadcast dismissable boolean'),
body('live.enabled').isBoolean().withMessage('Should have a valid live enabled boolean'),
body('live.allowReplay').isBoolean().withMessage('Should have a valid live allow replay boolean'),
body('live.maxDuration').custom(isIntOrNull).withMessage('Should have a valid live max duration'),
body('live.transcoding.enabled').isBoolean().withMessage('Should have a valid live transcoding enabled boolean'),
body('live.transcoding.threads').isInt().withMessage('Should have a valid live transcoding threads'),
body('live.transcoding.resolutions.240p').isBoolean().withMessage('Should have a valid transcoding 240p resolution enabled boolean'),
body('live.transcoding.resolutions.360p').isBoolean().withMessage('Should have a valid transcoding 360p resolution enabled boolean'),
body('live.transcoding.resolutions.480p').isBoolean().withMessage('Should have a valid transcoding 480p resolution enabled boolean'),
body('live.transcoding.resolutions.720p').isBoolean().withMessage('Should have a valid transcoding 720p resolution enabled boolean'),
body('live.transcoding.resolutions.1080p').isBoolean().withMessage('Should have a valid transcoding 1080p resolution enabled boolean'),
body('live.transcoding.resolutions.2160p').isBoolean().withMessage('Should have a valid transcoding 2160p resolution enabled boolean'),
body('search.remoteUri.users').isBoolean().withMessage('Should have a remote URI search for users boolean'), body('search.remoteUri.users').isBoolean().withMessage('Should have a remote URI search for users boolean'),
body('search.remoteUri.anonymous').isBoolean().withMessage('Should have a valid remote URI search for anonymous boolean'), body('search.remoteUri.anonymous').isBoolean().withMessage('Should have a valid remote URI search for anonymous boolean'),
body('search.searchIndex.enabled').isBoolean().withMessage('Should have a valid search index enabled boolean'), body('search.searchIndex.enabled').isBoolean().withMessage('Should have a valid search index enabled boolean'),
@ -71,8 +85,9 @@ const customConfigUpdateValidator = [
logger.debug('Checking customConfigUpdateValidator parameters', { parameters: req.body }) logger.debug('Checking customConfigUpdateValidator parameters', { parameters: req.body })
if (areValidationErrors(req, res)) return if (areValidationErrors(req, res)) return
if (!checkInvalidConfigIfEmailDisabled(req.body as CustomConfig, res)) return if (!checkInvalidConfigIfEmailDisabled(req.body, res)) return
if (!checkInvalidTranscodingConfig(req.body as CustomConfig, res)) return if (!checkInvalidTranscodingConfig(req.body, res)) return
if (!checkInvalidLiveConfig(req.body, res)) return
return next() return next()
} }
@ -109,3 +124,16 @@ function checkInvalidTranscodingConfig (customConfig: CustomConfig, res: express
return true return true
} }
function checkInvalidLiveConfig (customConfig: CustomConfig, res: express.Response) {
if (customConfig.live.enabled === false) return true
if (customConfig.live.allowReplay === true && customConfig.transcoding.enabled === false) {
res.status(400)
.send({ error: 'You cannot allow live replay if transcoding is not enabled' })
.end()
return false
}
return true
}

View file

@ -497,7 +497,7 @@ export {
function checkUserIdExist (idArg: number | string, res: express.Response, withStats = false) { function checkUserIdExist (idArg: number | string, res: express.Response, withStats = false) {
const id = parseInt(idArg + '', 10) const id = parseInt(idArg + '', 10)
return checkUserExist(() => UserModel.loadById(id, withStats), res) return checkUserExist(() => UserModel.loadByIdWithChannels(id, withStats), res)
} }
function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) { function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) {

View file

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

View file

@ -1,5 +1,6 @@
import * as express from 'express' import * as express from 'express'
import { body, param, query, ValidationChain } from 'express-validator' import { body, param, query, ValidationChain } from 'express-validator'
import { isAbleToUploadVideo } from '@server/lib/user'
import { getServerActor } from '@server/models/application/application' import { getServerActor } from '@server/models/application/application'
import { MVideoFullLight } from '@server/types/models' import { MVideoFullLight } from '@server/types/models'
import { ServerErrorCode, UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared' import { ServerErrorCode, UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared'
@ -73,7 +74,7 @@ const videosAddValidator = getCommonVideoEditAttributes().concat([
if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req) if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
if (await user.isAbleToUploadVideo(videoFile) === false) { if (await isAbleToUploadVideo(user.id, videoFile.size) === false) {
res.status(403) res.status(403)
.json({ error: 'The user video quota is exceeded with this video.' }) .json({ error: 'The user video quota is exceeded with this video.' })
@ -291,7 +292,7 @@ const videosAcceptChangeOwnershipValidator = [
const user = res.locals.oauth.token.User const user = res.locals.oauth.token.User
const videoChangeOwnership = res.locals.videoChangeOwnership const videoChangeOwnership = res.locals.videoChangeOwnership
const isAble = await user.isAbleToUploadVideo(videoChangeOwnership.Video.getMaxQualityFile()) const isAble = await isAbleToUploadVideo(user.id, videoChangeOwnership.Video.getMaxQualityFile().size)
if (isAble === false) { if (isAble === false) {
res.status(403) res.status(403)
.json({ error: 'The user video quota is exceeded with this video.' }) .json({ error: 'The user video quota is exceeded with this video.' })

View file

@ -23,6 +23,7 @@ import {
} from 'sequelize-typescript' } from 'sequelize-typescript'
import { import {
MMyUserFormattable, MMyUserFormattable,
MUser,
MUserDefault, MUserDefault,
MUserFormattable, MUserFormattable,
MUserId, MUserId,
@ -70,6 +71,7 @@ import { VideoImportModel } from '../video/video-import'
import { VideoPlaylistModel } from '../video/video-playlist' import { VideoPlaylistModel } from '../video/video-playlist'
import { AccountModel } from './account' import { AccountModel } from './account'
import { UserNotificationSettingModel } from './user-notification-setting' import { UserNotificationSettingModel } from './user-notification-setting'
import { VideoLiveModel } from '../video/video-live'
enum ScopeNames { enum ScopeNames {
FOR_ME_API = 'FOR_ME_API', FOR_ME_API = 'FOR_ME_API',
@ -540,7 +542,11 @@ export class UserModel extends Model<UserModel> {
return UserModel.findAll(query) return UserModel.findAll(query)
} }
static loadById (id: number, withStats = false): Bluebird<MUserDefault> { static loadById (id: number): Bluebird<MUser> {
return UserModel.unscoped().findByPk(id)
}
static loadByIdWithChannels (id: number, withStats = false): Bluebird<MUserDefault> {
const scopes = [ const scopes = [
ScopeNames.WITH_VIDEOCHANNELS ScopeNames.WITH_VIDEOCHANNELS
] ]
@ -685,26 +691,85 @@ export class UserModel extends Model<UserModel> {
return UserModel.findOne(query) return UserModel.findOne(query)
} }
static getOriginalVideoFileTotalFromUser (user: MUserId) { static loadByLiveId (liveId: number): Bluebird<MUser> {
// Don't use sequelize because we need to use a sub query const query = {
const query = UserModel.generateUserQuotaBaseSQL({ include: [
withSelect: true, {
whereUserId: '$userId' attributes: [ 'id' ],
}) model: AccountModel.unscoped(),
required: true,
return UserModel.getTotalRawQuery(query, user.id) include: [
{
attributes: [ 'id' ],
model: VideoChannelModel.unscoped(),
required: true,
include: [
{
attributes: [ 'id' ],
model: VideoModel.unscoped(),
required: true,
include: [
{
attributes: [ 'id', 'videoId' ],
model: VideoLiveModel.unscoped(),
required: true,
where: {
id: liveId
}
}
]
}
]
}
]
}
]
} }
// Returns cumulative size of all video files uploaded in the last 24 hours. return UserModel.findOne(query)
static getOriginalVideoFileTotalDailyFromUser (user: MUserId) { }
// Don't use sequelize because we need to use a sub query
const query = UserModel.generateUserQuotaBaseSQL({
withSelect: true,
whereUserId: '$userId',
where: '"video"."createdAt" > now() - interval \'24 hours\''
})
return UserModel.getTotalRawQuery(query, user.id) static generateUserQuotaBaseSQL (options: {
whereUserId: '$userId' | '"UserModel"."id"'
withSelect: boolean
where?: string
}) {
const andWhere = options.where
? 'AND ' + options.where
: ''
const videoChannelJoin = 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' +
`WHERE "account"."userId" = ${options.whereUserId} ${andWhere}`
const webtorrentFiles = 'SELECT "videoFile"."size" AS "size", "video"."id" AS "videoId" FROM "videoFile" ' +
'INNER JOIN "video" ON "videoFile"."videoId" = "video"."id" ' +
videoChannelJoin
const hlsFiles = 'SELECT "videoFile"."size" AS "size", "video"."id" AS "videoId" FROM "videoFile" ' +
'INNER JOIN "videoStreamingPlaylist" ON "videoFile"."videoStreamingPlaylistId" = "videoStreamingPlaylist".id ' +
'INNER JOIN "video" ON "videoStreamingPlaylist"."videoId" = "video"."id" ' +
videoChannelJoin
return 'SELECT COALESCE(SUM("size"), 0) AS "total" ' +
'FROM (' +
`SELECT MAX("t1"."size") AS "size" FROM (${webtorrentFiles} UNION ${hlsFiles}) t1 ` +
'GROUP BY "t1"."videoId"' +
') t2'
}
static getTotalRawQuery (query: string, userId: number) {
const options = {
bind: { userId },
type: QueryTypes.SELECT as QueryTypes.SELECT
}
return UserModel.sequelize.query<{ total: string }>(query, options)
.then(([ { total } ]) => {
if (total === null) return 0
return parseInt(total, 10)
})
} }
static async getStats () { static async getStats () {
@ -874,64 +939,4 @@ export class UserModel extends Model<UserModel> {
return Object.assign(formatted, { specialPlaylists }) return Object.assign(formatted, { specialPlaylists })
} }
async isAbleToUploadVideo (videoFile: { size: number }) {
if (this.videoQuota === -1 && this.videoQuotaDaily === -1) return Promise.resolve(true)
const [ totalBytes, totalBytesDaily ] = await Promise.all([
UserModel.getOriginalVideoFileTotalFromUser(this),
UserModel.getOriginalVideoFileTotalDailyFromUser(this)
])
const uploadedTotal = videoFile.size + totalBytes
const uploadedDaily = videoFile.size + totalBytesDaily
if (this.videoQuotaDaily === -1) return uploadedTotal < this.videoQuota
if (this.videoQuota === -1) return uploadedDaily < this.videoQuotaDaily
return uploadedTotal < this.videoQuota && uploadedDaily < this.videoQuotaDaily
}
private static generateUserQuotaBaseSQL (options: {
whereUserId: '$userId' | '"UserModel"."id"'
withSelect: boolean
where?: string
}) {
const andWhere = options.where
? 'AND ' + options.where
: ''
const videoChannelJoin = 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' +
`WHERE "account"."userId" = ${options.whereUserId} ${andWhere}`
const webtorrentFiles = 'SELECT "videoFile"."size" AS "size", "video"."id" AS "videoId" FROM "videoFile" ' +
'INNER JOIN "video" ON "videoFile"."videoId" = "video"."id" ' +
videoChannelJoin
const hlsFiles = 'SELECT "videoFile"."size" AS "size", "video"."id" AS "videoId" FROM "videoFile" ' +
'INNER JOIN "videoStreamingPlaylist" ON "videoFile"."videoStreamingPlaylistId" = "videoStreamingPlaylist".id ' +
'INNER JOIN "video" ON "videoStreamingPlaylist"."videoId" = "video"."id" ' +
videoChannelJoin
return 'SELECT COALESCE(SUM("size"), 0) AS "total" ' +
'FROM (' +
`SELECT MAX("t1"."size") AS "size" FROM (${webtorrentFiles} UNION ${hlsFiles}) t1 ` +
'GROUP BY "t1"."videoId"' +
') t2'
}
private static getTotalRawQuery (query: string, userId: number) {
const options = {
bind: { userId },
type: QueryTypes.SELECT as QueryTypes.SELECT
}
return UserModel.sequelize.query<{ total: string }>(query, options)
.then(([ { total } ]) => {
if (total === null) return 0
return parseInt(total, 10)
})
}
} }

View file

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

View file

@ -1,6 +1,6 @@
import { Video, VideoDetails } from '../../../shared/models/videos' import { Video, VideoDetails } from '../../../shared/models/videos'
import { VideoModel } from './video' import { VideoModel } from './video'
import { ActivityTagObject, ActivityUrlObject, VideoTorrentObject } from '../../../shared/models/activitypub/objects' import { ActivityTagObject, ActivityUrlObject, VideoObject } from '../../../shared/models/activitypub/objects'
import { MIMETYPES, WEBSERVER } from '../../initializers/constants' import { MIMETYPES, WEBSERVER } from '../../initializers/constants'
import { VideoCaptionModel } from './video-caption' import { VideoCaptionModel } from './video-caption'
import { import {
@ -77,6 +77,8 @@ function videoModelToFormattedJSON (video: MVideoFormattable, options?: VideoFor
publishedAt: video.publishedAt, publishedAt: video.publishedAt,
originallyPublishedAt: video.originallyPublishedAt, originallyPublishedAt: video.originallyPublishedAt,
isLive: video.isLive,
account: video.VideoChannel.Account.toFormattedSummaryJSON(), account: video.VideoChannel.Account.toFormattedSummaryJSON(),
channel: video.VideoChannel.toFormattedSummaryJSON(), channel: video.VideoChannel.toFormattedSummaryJSON(),
@ -260,7 +262,7 @@ function addVideoFilesInAPAcc (
} }
} }
function videoModelToActivityPubObject (video: MVideoAP): VideoTorrentObject { function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
const { baseUrlHttp, baseUrlWs } = video.getBaseUrls() const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
if (!video.Tags) video.Tags = [] if (!video.Tags) video.Tags = []
@ -349,6 +351,7 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoTorrentObject {
views: video.views, views: video.views,
sensitive: video.nsfw, sensitive: video.nsfw,
waitTranscoding: video.waitTranscoding, waitTranscoding: video.waitTranscoding,
isLiveBroadcast: video.isLive,
state: video.state, state: video.state,
commentsEnabled: video.commentsEnabled, commentsEnabled: video.commentsEnabled,
downloadEnabled: video.downloadEnabled, downloadEnabled: video.downloadEnabled,

View file

@ -0,0 +1,104 @@
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 { LiveVideo, VideoState } from '@shared/models'
import { VideoModel } from './video'
import { VideoBlacklistModel } from './video-blacklist'
@DefaultScope(() => ({
include: [
{
model: VideoModel,
required: true,
include: [
{
model: VideoBlacklistModel,
required: false
}
]
}
]
}))
@Table({
tableName: 'videoLive',
indexes: [
{
fields: [ 'videoId' ],
unique: true
}
]
})
export class VideoLiveModel extends Model<VideoLiveModel> {
@AllowNull(true)
@Column(DataType.STRING)
streamKey: string
@AllowNull(false)
@Column
perpetualLive: boolean
@AllowNull(false)
@Column
saveReplay: boolean
@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
},
include: [
{
model: VideoModel.unscoped(),
required: true,
where: {
state: VideoState.WAITING_FOR_LIVE
},
include: [
{
model: VideoBlacklistModel.unscoped(),
required: false
}
]
}
]
}
return VideoLiveModel.findOne<MVideoLiveVideo>(query)
}
static loadByVideoId (videoId: number) {
const query = {
where: {
videoId
}
}
return VideoLiveModel.findOne<MVideoLive>(query)
}
toFormattedJSON (): LiveVideo {
return {
rtmpUrl: WEBSERVER.RTMP_URL,
streamKey: this.streamKey
}
}
}

View file

@ -153,6 +153,17 @@ export class VideoStreamingPlaylistModel extends Model<VideoStreamingPlaylistMod
return VideoStreamingPlaylistModel.findByPk(id, options) return VideoStreamingPlaylistModel.findByPk(id, options)
} }
static loadHLSPlaylistByVideo (videoId: number) {
const options = {
where: {
type: VideoStreamingPlaylistType.HLS,
videoId
}
}
return VideoStreamingPlaylistModel.findOne(options)
}
static getHlsPlaylistFilename (resolution: number) { static getHlsPlaylistFilename (resolution: number) {
return resolution + '.m3u8' return resolution + '.m3u8'
} }
@ -173,7 +184,9 @@ export class VideoStreamingPlaylistModel extends Model<VideoStreamingPlaylistMod
return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsPlaylistFilename(resolution)) return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsPlaylistFilename(resolution))
} }
static getHlsSha256SegmentsStaticPath (videoUUID: string) { static getHlsSha256SegmentsStaticPath (videoUUID: string, isLive: boolean) {
if (isLive) return join('/live', 'segments-sha256', videoUUID)
return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsSha256SegmentsFilename()) return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsSha256SegmentsFilename())
} }

View file

@ -31,7 +31,7 @@ import { getServerActor } from '@server/models/application/application'
import { ModelCache } from '@server/models/model-cache' import { ModelCache } from '@server/models/model-cache'
import { VideoFile } from '@shared/models/videos/video-file.model' import { VideoFile } from '@shared/models/videos/video-file.model'
import { ResultList, UserRight, VideoPrivacy, VideoState } from '../../../shared' import { ResultList, UserRight, VideoPrivacy, VideoState } from '../../../shared'
import { VideoTorrentObject } from '../../../shared/models/activitypub/objects' import { VideoObject } from '../../../shared/models/activitypub/objects'
import { Video, VideoDetails } from '../../../shared/models/videos' import { Video, VideoDetails } from '../../../shared/models/videos'
import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type' import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
import { VideoFilter } from '../../../shared/models/videos/video-query.type' import { VideoFilter } from '../../../shared/models/videos/video-query.type'
@ -127,6 +127,7 @@ import { VideoShareModel } from './video-share'
import { VideoStreamingPlaylistModel } from './video-streaming-playlist' import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
import { VideoTagModel } from './video-tag' import { VideoTagModel } from './video-tag'
import { VideoViewModel } from './video-view' import { VideoViewModel } from './video-view'
import { LiveManager } from '@server/lib/live-manager'
export enum ScopeNames { export enum ScopeNames {
AVAILABLE_FOR_LIST_IDS = 'AVAILABLE_FOR_LIST_IDS', AVAILABLE_FOR_LIST_IDS = 'AVAILABLE_FOR_LIST_IDS',
@ -549,6 +550,11 @@ export class VideoModel extends Model<VideoModel> {
@Column @Column
remote: boolean remote: boolean
@AllowNull(false)
@Default(false)
@Column
isLive: boolean
@AllowNull(false) @AllowNull(false)
@Is('VideoUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url')) @Is('VideoUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
@Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max)) @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
@ -794,6 +800,13 @@ export class VideoModel extends Model<VideoModel> {
return undefined return undefined
} }
@BeforeDestroy
static stopLiveIfNeeded (instance: VideoModel) {
if (!instance.isLive) return
return LiveManager.Instance.stopSessionOf(instance.id)
}
@BeforeDestroy @BeforeDestroy
static invalidateCache (instance: VideoModel) { static invalidateCache (instance: VideoModel) {
ModelCache.Instance.invalidateCache('video', instance.id) ModelCache.Instance.invalidateCache('video', instance.id)
@ -1758,7 +1771,7 @@ export class VideoModel extends Model<VideoModel> {
return videoFilesModelToFormattedJSON(this, baseUrlHttp, baseUrlWs, files) return videoFilesModelToFormattedJSON(this, baseUrlHttp, baseUrlWs, files)
} }
toActivityPubObject (this: MVideoAP): VideoTorrentObject { toActivityPubObject (this: MVideoAP): VideoObject {
return videoModelToActivityPubObject(this) return videoModelToActivityPubObject(this)
} }

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more