Compare commits
5 commits
develop
...
feature/li
Author | SHA1 | Date | |
---|---|---|---|
![]() |
09c16166a4 | ||
![]() |
e2d9d60383 | ||
![]() |
52194a1b8c | ||
![]() |
9e8a9e65ab | ||
![]() |
4ba542d832 |
120 changed files with 4121 additions and 1858 deletions
|
@ -699,6 +699,111 @@
|
|||
</ng-template>
|
||||
</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">
|
||||
<a ngbNavLink i18n>Advanced configuration</a>
|
||||
|
||||
|
@ -814,7 +919,7 @@
|
|||
|
||||
<div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isTranscodingEnabled() }">
|
||||
|
||||
<label i18n for="transcodingThreads">Resolutions to generate</label>
|
||||
<label i18n>Resolutions to generate</label>
|
||||
|
||||
<div class="ml-2 mt-2 d-flex flex-column">
|
||||
<ng-container formGroupName="resolutions">
|
||||
|
@ -945,9 +1050,15 @@
|
|||
<div class="form-row mt-4"> <!-- submit placement block -->
|
||||
<div class="col-md-7 col-xl-5"></div>
|
||||
<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>
|
||||
</form>
|
||||
|
|
|
@ -34,7 +34,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
|
|||
customConfig: CustomConfig
|
||||
|
||||
resolutions: { id: string, label: string, description?: string }[] = []
|
||||
liveResolutions: { id: string, label: string, description?: string }[] = []
|
||||
transcodingThreadOptions: { label: string, value: number }[] = []
|
||||
liveMaxDurationOptions: { label: string, value: number }[] = []
|
||||
|
||||
languageItems: 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 = [
|
||||
{ value: 0, label: $localize`Auto (via ffmpeg)` },
|
||||
{ value: 1, label: '1' },
|
||||
|
@ -89,6 +93,14 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
|
|||
{ value: 4, label: '4' },
|
||||
{ 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 () {
|
||||
|
@ -111,7 +123,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
|
|||
ngOnInit () {
|
||||
this.serverConfig = this.serverService.getTmpConfig()
|
||||
this.serverService.getConfig()
|
||||
.subscribe(config => this.serverConfig = config)
|
||||
.subscribe(config => {
|
||||
this.serverConfig = config
|
||||
})
|
||||
|
||||
const formGroupData: { [key in keyof CustomConfig ]: any } = {
|
||||
instance: {
|
||||
|
@ -198,6 +212,18 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
|
|||
enabled: null
|
||||
}
|
||||
},
|
||||
live: {
|
||||
enabled: null,
|
||||
|
||||
maxDuration: null,
|
||||
allowReplay: null,
|
||||
|
||||
transcoding: {
|
||||
enabled: null,
|
||||
threads: TRANSCODING_THREADS_VALIDATOR,
|
||||
resolutions: {}
|
||||
}
|
||||
},
|
||||
autoBlacklist: {
|
||||
videos: {
|
||||
ofUsers: {
|
||||
|
@ -245,13 +271,24 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
|
|||
const defaultValues = {
|
||||
transcoding: {
|
||||
resolutions: {}
|
||||
},
|
||||
live: {
|
||||
transcoding: {
|
||||
resolutions: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const resolution of this.resolutions) {
|
||||
defaultValues.transcoding.resolutions[resolution.id] = 'false'
|
||||
formGroupData.transcoding.resolutions[resolution.id] = null
|
||||
}
|
||||
|
||||
for (const resolution of this.liveResolutions) {
|
||||
defaultValues.live.transcoding.resolutions[resolution.id] = 'false'
|
||||
formGroupData.live.transcoding.resolutions[resolution.id] = null
|
||||
}
|
||||
|
||||
this.buildForm(formGroupData)
|
||||
this.loadForm()
|
||||
this.checkTranscodingFields()
|
||||
|
@ -268,6 +305,14 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
|
|||
return this.form.value['transcoding']['enabled'] === true
|
||||
}
|
||||
|
||||
isLiveEnabled () {
|
||||
return this.form.value['live']['enabled'] === true
|
||||
}
|
||||
|
||||
isLiveTranscodingEnabled () {
|
||||
return this.form.value['live']['transcoding']['enabled'] === true
|
||||
}
|
||||
|
||||
isSignupEnabled () {
|
||||
return this.form.value['signup']['enabled'] === true
|
||||
}
|
||||
|
@ -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 () {
|
||||
this.form.patchValue(this.customConfig)
|
||||
}
|
||||
|
|
|
@ -32,6 +32,7 @@ export class JobsComponent extends RestTable implements OnInit {
|
|||
'video-import',
|
||||
'videos-views',
|
||||
'activitypub-refresher',
|
||||
'video-live-ending',
|
||||
'video-redundancy'
|
||||
]
|
||||
|
||||
|
|
|
@ -86,7 +86,7 @@ export class MyAccountNotificationPreferencesComponent implements OnInit {
|
|||
}
|
||||
|
||||
private savePreferencesImpl () {
|
||||
this.userNotificationService.updateNotificationSettings(this.user, this.user.notificationSettings)
|
||||
this.userNotificationService.updateNotificationSettings(this.user.notificationSettings)
|
||||
.subscribe(
|
||||
() => {
|
||||
this.notifier.success($localize`Preferences saved`, undefined, 2000)
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
import { FormGroup } from '@angular/forms'
|
||||
import { VideoEdit } from '@app/shared/shared-main'
|
||||
|
||||
function hydrateFormFromVideo (formGroup: FormGroup, video: VideoEdit, thumbnailFiles: boolean) {
|
||||
formGroup.patchValue(video.toFormPatch())
|
||||
|
||||
if (thumbnailFiles === false) return
|
||||
|
||||
const objects = [
|
||||
{
|
||||
url: 'thumbnailUrl',
|
||||
name: 'thumbnailfile'
|
||||
},
|
||||
{
|
||||
url: 'previewUrl',
|
||||
name: 'previewfile'
|
||||
}
|
||||
]
|
||||
|
||||
for (const obj of objects) {
|
||||
if (!video[obj.url]) continue
|
||||
|
||||
fetch(video[obj.url])
|
||||
.then(response => response.blob())
|
||||
.then(data => {
|
||||
formGroup.patchValue({
|
||||
[ obj.name ]: data
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
hydrateFormFromVideo
|
||||
}
|
|
@ -195,6 +195,29 @@
|
|||
</ng-template>
|
||||
</ng-container>
|
||||
|
||||
<ng-container ngbNavItem *ngIf="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>
|
||||
<a ngbNavLink i18n>Advanced settings</a>
|
||||
|
||||
|
|
|
@ -20,10 +20,11 @@ import {
|
|||
import { FormReactiveValidationMessages, FormValidatorService, SelectChannelItem } from '@app/shared/shared-forms'
|
||||
import { InstanceService } from '@app/shared/shared-instance'
|
||||
import { VideoCaptionEdit, VideoEdit, VideoService } from '@app/shared/shared-main'
|
||||
import { ServerConfig, VideoConstant, VideoPrivacy } from '@shared/models'
|
||||
import { ServerConfig, VideoConstant, LiveVideo, VideoPrivacy } from '@shared/models'
|
||||
import { RegisterClientFormFieldOptions, RegisterClientVideoFieldOptions } from '@shared/models/plugins/register-client-form-field.model'
|
||||
import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service'
|
||||
import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
|
||||
import { VideoEditType } from './video-edit.type'
|
||||
|
||||
type VideoLanguages = VideoConstant<string> & { group?: string }
|
||||
|
||||
|
@ -40,7 +41,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
|
|||
@Input() schedulePublicationPossible = true
|
||||
@Input() videoCaptions: (VideoCaptionEdit & { captionPath?: string })[] = []
|
||||
@Input() waitTranscodingEnabled = true
|
||||
@Input() type: 'import-url' | 'import-torrent' | 'upload' | 'update'
|
||||
@Input() type: VideoEditType
|
||||
@Input() liveVideo: LiveVideo
|
||||
|
||||
@ViewChild('videoCaptionAddModal', { static: true }) videoCaptionAddModal: VideoCaptionAddModalComponent
|
||||
|
||||
|
@ -124,7 +126,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
|
|||
previewfile: null,
|
||||
support: VIDEO_SUPPORT_VALIDATOR,
|
||||
schedulePublicationAt: VIDEO_SCHEDULE_PUBLICATION_AT_VALIDATOR,
|
||||
originallyPublishedAt: VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR
|
||||
originallyPublishedAt: VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR,
|
||||
liveStreamKey: null
|
||||
}
|
||||
|
||||
this.formValidatorService.updateForm(
|
||||
|
@ -320,7 +323,12 @@ export class VideoEditComponent implements OnInit, OnDestroy {
|
|||
const currentSupport = this.form.value[ 'support' ]
|
||||
|
||||
// First time we set the channel?
|
||||
if (isNaN(oldChannelId) && !currentSupport) return this.updateSupportField(newChannel.support)
|
||||
if (isNaN(oldChannelId)) {
|
||||
// Fill support if it's empty
|
||||
if (!currentSupport) this.updateSupportField(newChannel.support)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const oldChannel = this.userVideoChannels.find(c => c.id === oldChannelId)
|
||||
if (!newChannel || !oldChannel) {
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export type VideoEditType = 'update' | 'upload' | 'import-url' | 'import-torrent' | 'go-live'
|
|
@ -0,0 +1,47 @@
|
|||
<div *ngIf="!isInUpdateForm" class="upload-video-container">
|
||||
<div class="first-step-block">
|
||||
<my-global-icon class="upload-icon" iconName="upload" aria-hidden="true"></my-global-icon>
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="first-step-channel">Channel</label>
|
||||
<my-select-channel
|
||||
labelForId="first-step-channel" [items]="userVideoChannels" [(ngModel)]="firstStepChannelId"
|
||||
></my-select-channel>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="first-step-privacy">Privacy</label>
|
||||
<my-select-options
|
||||
labelForId="first-step-privacy" [items]="videoPrivacies" [(ngModel)]="firstStepPrivacyId"
|
||||
></my-select-options>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="button" i18n-value value="Go Live" (click)="goLive()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="error" class="alert alert-danger">
|
||||
<div i18n>Sorry, but something went wrong</div>
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Hidden because we want to load the component -->
|
||||
<form [hidden]="!isInUpdateForm" novalidate [formGroup]="form">
|
||||
<my-video-edit
|
||||
[form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [schedulePublicationPossible]="false"
|
||||
[validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels" [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>
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
|
@ -6,6 +6,7 @@ import { FormValidatorService } from '@app/shared/shared-forms'
|
|||
import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main'
|
||||
import { LoadingBarService } from '@ngx-loading-bar/core'
|
||||
import { VideoPrivacy, VideoUpdate } from '@shared/models'
|
||||
import { hydrateFormFromVideo } from '../shared/video-edit-utils'
|
||||
import { VideoSend } from './video-send'
|
||||
|
||||
@Component({
|
||||
|
@ -99,7 +100,7 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Ca
|
|||
previewUrl: null
|
||||
}))
|
||||
|
||||
this.hydrateFormFromVideo()
|
||||
hydrateFormFromVideo(this.form, this.video, false)
|
||||
},
|
||||
|
||||
err => {
|
||||
|
@ -136,10 +137,5 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Ca
|
|||
console.error(err)
|
||||
}
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
private hydrateFormFromVideo () {
|
||||
this.form.patchValue(this.video.toFormPatch())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import { FormValidatorService } from '@app/shared/shared-forms'
|
|||
import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main'
|
||||
import { LoadingBarService } from '@ngx-loading-bar/core'
|
||||
import { VideoPrivacy, VideoUpdate } from '@shared/models'
|
||||
import { hydrateFormFromVideo } from '../shared/video-edit-utils'
|
||||
import { VideoSend } from './video-send'
|
||||
|
||||
@Component({
|
||||
|
@ -109,7 +110,7 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, CanCom
|
|||
|
||||
this.videoCaptions = videoCaptions
|
||||
|
||||
this.hydrateFormFromVideo()
|
||||
hydrateFormFromVideo(this.form, this.video, true)
|
||||
},
|
||||
|
||||
err => {
|
||||
|
@ -146,31 +147,5 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, CanCom
|
|||
console.error(err)
|
||||
}
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
private hydrateFormFromVideo () {
|
||||
this.form.patchValue(this.video.toFormPatch())
|
||||
|
||||
const objects = [
|
||||
{
|
||||
url: 'thumbnailUrl',
|
||||
name: 'thumbnailfile'
|
||||
},
|
||||
{
|
||||
url: 'previewUrl',
|
||||
name: 'previewfile'
|
||||
}
|
||||
]
|
||||
|
||||
for (const obj of objects) {
|
||||
fetch(this.video[obj.url])
|
||||
.then(response => response.blob())
|
||||
.then(data => {
|
||||
this.form.patchValue({
|
||||
[ obj.name ]: data
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -157,7 +157,6 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
|
|||
this.waitTranscodingEnabled = false
|
||||
}
|
||||
|
||||
const privacy = this.firstStepPrivacyId.toString()
|
||||
const nsfw = this.serverConfig.instance.isNSFW
|
||||
const waitTranscoding = true
|
||||
const commentsEnabled = true
|
||||
|
|
|
@ -50,7 +50,17 @@
|
|||
<my-video-import-torrent #videoImportTorrent (firstStepDone)="onFirstStepDone('import-torrent', $event)" (firstStepError)="onError()"></my-video-import-torrent>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
|
||||
<ng-container ngbNavItem *ngIf="isVideoLiveEnabled()">
|
||||
<a ngbNavLink>
|
||||
<span i18n>Go live</span>
|
||||
</a>
|
||||
|
||||
<ng-template ngbNavContent>
|
||||
<my-video-go-live #videoGoLive (firstStepDone)="onFirstStepDone('go-live', $event)" (firstStepError)="onError()"></my-video-go-live>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div [ngbNavOutlet]="nav"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import { Component, HostListener, OnInit, ViewChild } from '@angular/core'
|
||||
import { AuthService, AuthUser, CanComponentDeactivate, ServerService } from '@app/core'
|
||||
import { ServerConfig } from '@shared/models'
|
||||
import { VideoEditType } from './shared/video-edit.type'
|
||||
import { VideoGoLiveComponent } from './video-add-components/video-go-live.component'
|
||||
import { VideoImportTorrentComponent } from './video-add-components/video-import-torrent.component'
|
||||
import { VideoImportUrlComponent } from './video-add-components/video-import-url.component'
|
||||
import { VideoUploadComponent } from './video-add-components/video-upload.component'
|
||||
|
@ -14,10 +16,11 @@ export class VideoAddComponent implements OnInit, CanComponentDeactivate {
|
|||
@ViewChild('videoUpload') videoUpload: VideoUploadComponent
|
||||
@ViewChild('videoImportUrl') videoImportUrl: VideoImportUrlComponent
|
||||
@ViewChild('videoImportTorrent') videoImportTorrent: VideoImportTorrentComponent
|
||||
@ViewChild('videoGoLive') videoGoLive: VideoGoLiveComponent
|
||||
|
||||
user: AuthUser = null
|
||||
|
||||
secondStepType: 'upload' | 'import-url' | 'import-torrent'
|
||||
secondStepType: VideoEditType
|
||||
videoName: string
|
||||
serverConfig: ServerConfig
|
||||
|
||||
|
@ -41,7 +44,7 @@ export class VideoAddComponent implements OnInit, CanComponentDeactivate {
|
|||
this.user = this.auth.getUser()
|
||||
}
|
||||
|
||||
onFirstStepDone (type: 'upload' | 'import-url' | 'import-torrent', videoName: string) {
|
||||
onFirstStepDone (type: VideoEditType, videoName: string) {
|
||||
this.secondStepType = type
|
||||
this.videoName = videoName
|
||||
}
|
||||
|
@ -62,9 +65,9 @@ export class VideoAddComponent implements OnInit, CanComponentDeactivate {
|
|||
}
|
||||
|
||||
canDeactivate (): { canDeactivate: boolean, text?: string} {
|
||||
if (this.secondStepType === 'upload') return this.videoUpload.canDeactivate()
|
||||
if (this.secondStepType === 'import-url') return this.videoImportUrl.canDeactivate()
|
||||
if (this.secondStepType === 'import-torrent') return this.videoImportTorrent.canDeactivate()
|
||||
if (this.secondStepType === 'go-live') return this.videoGoLive.canDeactivate()
|
||||
|
||||
return { canDeactivate: true }
|
||||
}
|
||||
|
@ -77,6 +80,10 @@ export class VideoAddComponent implements OnInit, CanComponentDeactivate {
|
|||
return this.serverConfig.import.videos.torrent.enabled
|
||||
}
|
||||
|
||||
isVideoLiveEnabled () {
|
||||
return this.serverConfig.live.enabled
|
||||
}
|
||||
|
||||
isInSecondStep () {
|
||||
return !!this.secondStepType
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import { VideoEditModule } from './shared/video-edit.module'
|
|||
import { DragDropDirective } from './video-add-components/drag-drop.directive'
|
||||
import { VideoImportTorrentComponent } from './video-add-components/video-import-torrent.component'
|
||||
import { VideoImportUrlComponent } from './video-add-components/video-import-url.component'
|
||||
import { VideoGoLiveComponent } from './video-add-components/video-go-live.component'
|
||||
import { VideoUploadComponent } from './video-add-components/video-upload.component'
|
||||
import { VideoAddRoutingModule } from './video-add-routing.module'
|
||||
import { VideoAddComponent } from './video-add.component'
|
||||
|
@ -20,7 +21,8 @@ import { VideoAddComponent } from './video-add.component'
|
|||
VideoUploadComponent,
|
||||
VideoImportUrlComponent,
|
||||
VideoImportTorrentComponent,
|
||||
DragDropDirective
|
||||
DragDropDirective,
|
||||
VideoGoLiveComponent
|
||||
],
|
||||
|
||||
exports: [ ],
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
[validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels"
|
||||
[videoCaptions]="videoCaptions" [waitTranscodingEnabled]="waitTranscodingEnabled"
|
||||
type="update" (pluginFieldsAdded)="hydratePluginFieldsFromVideo()"
|
||||
[liveVideo]="liveVideo"
|
||||
></my-video-edit>
|
||||
|
||||
<div class="submit-container">
|
||||
|
|
|
@ -5,7 +5,8 @@ import { Notifier } from '@app/core'
|
|||
import { FormReactive, FormValidatorService, SelectChannelItem } from '@app/shared/shared-forms'
|
||||
import { VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main'
|
||||
import { LoadingBarService } from '@ngx-loading-bar/core'
|
||||
import { VideoPrivacy } from '@shared/models'
|
||||
import { LiveVideo, VideoPrivacy } from '@shared/models'
|
||||
import { hydrateFormFromVideo } from './shared/video-edit-utils'
|
||||
|
||||
@Component({
|
||||
selector: 'my-videos-update',
|
||||
|
@ -14,11 +15,12 @@ import { VideoPrivacy } from '@shared/models'
|
|||
})
|
||||
export class VideoUpdateComponent extends FormReactive implements OnInit {
|
||||
video: VideoEdit
|
||||
userVideoChannels: SelectChannelItem[] = []
|
||||
videoCaptions: VideoCaptionEdit[] = []
|
||||
liveVideo: LiveVideo
|
||||
|
||||
isUpdatingVideo = false
|
||||
userVideoChannels: SelectChannelItem[] = []
|
||||
schedulePublicationPossible = false
|
||||
videoCaptions: VideoCaptionEdit[] = []
|
||||
waitTranscodingEnabled = true
|
||||
|
||||
private updateDone = false
|
||||
|
@ -40,10 +42,11 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
|
|||
|
||||
this.route.data
|
||||
.pipe(map(data => data.videoData))
|
||||
.subscribe(({ video, videoChannels, videoCaptions }) => {
|
||||
.subscribe(({ video, videoChannels, videoCaptions, liveVideo }) => {
|
||||
this.video = new VideoEdit(video)
|
||||
this.userVideoChannels = videoChannels
|
||||
this.videoCaptions = videoCaptions
|
||||
this.liveVideo = liveVideo
|
||||
|
||||
this.schedulePublicationPossible = this.video.privacy === VideoPrivacy.PRIVATE
|
||||
|
||||
|
@ -53,7 +56,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
|
|||
}
|
||||
|
||||
// FIXME: Angular does not detect the change inside this subscription, so use the patched setTimeout
|
||||
setTimeout(() => this.hydrateFormFromVideo())
|
||||
setTimeout(() => hydrateFormFromVideo(this.form, this.video, true))
|
||||
},
|
||||
|
||||
err => {
|
||||
|
@ -133,29 +136,4 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
|
|||
pluginData: this.video.pluginData
|
||||
})
|
||||
}
|
||||
|
||||
private hydrateFormFromVideo () {
|
||||
this.form.patchValue(this.video.toFormPatch())
|
||||
|
||||
const objects = [
|
||||
{
|
||||
url: 'thumbnailUrl',
|
||||
name: 'thumbnailfile'
|
||||
},
|
||||
{
|
||||
url: 'previewUrl',
|
||||
name: 'previewfile'
|
||||
}
|
||||
]
|
||||
|
||||
for (const obj of objects) {
|
||||
fetch(this.video[obj.url])
|
||||
.then(response => response.blob())
|
||||
.then(data => {
|
||||
this.form.patchValue({
|
||||
[ obj.name ]: data
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
import { forkJoin } from 'rxjs'
|
||||
import { forkJoin, of } from 'rxjs'
|
||||
import { map, switchMap } from 'rxjs/operators'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { ActivatedRouteSnapshot, Resolve } from '@angular/router'
|
||||
import { VideoCaptionService, VideoChannelService, VideoService } from '@app/shared/shared-main'
|
||||
import { VideoCaptionService, VideoChannelService, VideoDetails, LiveVideoService, VideoService } from '@app/shared/shared-main'
|
||||
|
||||
@Injectable()
|
||||
export class VideoUpdateResolver implements Resolve<any> {
|
||||
constructor (
|
||||
private videoService: VideoService,
|
||||
private liveVideoService: LiveVideoService,
|
||||
private videoChannelService: VideoChannelService,
|
||||
private videoCaptionService: VideoCaptionService
|
||||
) {
|
||||
|
@ -18,32 +19,38 @@ export class VideoUpdateResolver implements Resolve<any> {
|
|||
|
||||
return this.videoService.getVideo({ videoId: uuid })
|
||||
.pipe(
|
||||
switchMap(video => {
|
||||
return forkJoin([
|
||||
this.videoService
|
||||
.loadCompleteDescription(video.descriptionPath)
|
||||
.pipe(map(description => Object.assign(video, { description }))),
|
||||
|
||||
this.videoChannelService
|
||||
.listAccountVideoChannels(video.account)
|
||||
.pipe(
|
||||
map(result => result.data),
|
||||
map(videoChannels => videoChannels.map(c => ({
|
||||
id: c.id,
|
||||
label: c.displayName,
|
||||
support: c.support,
|
||||
avatarPath: c.avatar?.path
|
||||
})))
|
||||
),
|
||||
|
||||
this.videoCaptionService
|
||||
.listCaptions(video.id)
|
||||
.pipe(
|
||||
map(result => result.data)
|
||||
)
|
||||
])
|
||||
}),
|
||||
map(([ video, videoChannels, videoCaptions ]) => ({ video, videoChannels, videoCaptions }))
|
||||
switchMap(video => forkJoin(this.buildVideoObservables(video))),
|
||||
map(([ video, videoChannels, videoCaptions, videoLive ]) => ({ video, videoChannels, videoCaptions, videoLive }))
|
||||
)
|
||||
}
|
||||
|
||||
private buildVideoObservables (video: VideoDetails) {
|
||||
return [
|
||||
this.videoService
|
||||
.loadCompleteDescription(video.descriptionPath)
|
||||
.pipe(map(description => Object.assign(video, { description }))),
|
||||
|
||||
this.videoChannelService
|
||||
.listAccountVideoChannels(video.account)
|
||||
.pipe(
|
||||
map(result => result.data),
|
||||
map(videoChannels => videoChannels.map(c => ({
|
||||
id: c.id,
|
||||
label: c.displayName,
|
||||
support: c.support,
|
||||
avatarPath: c.avatar?.path
|
||||
})))
|
||||
),
|
||||
|
||||
this.videoCaptionService
|
||||
.listCaptions(video.id)
|
||||
.pipe(
|
||||
map(result => result.data)
|
||||
),
|
||||
|
||||
video.isLive
|
||||
? this.liveVideoService.getVideoLive(video.id)
|
||||
: of(undefined)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,6 +29,14 @@
|
|||
This video will be published on {{ video.scheduledUpdate.updateAt | date: 'full' }}.
|
||||
</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="blocked-label" i18n>This video is blocked.</div>
|
||||
{{ video.blockedReason }}
|
||||
|
@ -109,7 +117,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="!isUserLoggedIn()">
|
||||
<ng-container *ngIf="!isUserLoggedIn() && !isLive()">
|
||||
<button
|
||||
*ngIf="isVideoDownloadable()" class="action-button action-button-save"
|
||||
(click)="showDownloadModal()" (keydown.enter)="showDownloadModal()"
|
||||
|
|
|
@ -50,6 +50,8 @@ $video-info-margin-left: 44px;
|
|||
}
|
||||
|
||||
#video-wrapper {
|
||||
$video-height: 66vh;
|
||||
|
||||
background-color: #000;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
@ -58,6 +60,7 @@ $video-info-margin-left: 44px;
|
|||
display: flex;
|
||||
justify-content: center;
|
||||
flex-grow: 1;
|
||||
height: $video-height;
|
||||
}
|
||||
|
||||
.remote-server-down {
|
||||
|
@ -84,7 +87,7 @@ $video-info-margin-left: 44px;
|
|||
::ng-deep .video-js {
|
||||
width: 100%;
|
||||
max-width: getPlayerWidth(66vh);
|
||||
height: 66vh;
|
||||
height: $video-height;
|
||||
|
||||
// VideoJS create an inner video player
|
||||
video {
|
||||
|
|
|
@ -4,7 +4,17 @@ import { catchError } from 'rxjs/operators'
|
|||
import { PlatformLocation } from '@angular/common'
|
||||
import { ChangeDetectorRef, Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'
|
||||
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 { RedirectService } from '@app/core/routing/redirect.service'
|
||||
import { isXPercentInViewport, scrollToTop } from '@app/helpers'
|
||||
|
@ -30,6 +40,8 @@ import { environment } from '../../../environments/environment'
|
|||
import { VideoSupportComponent } from './modal/video-support.component'
|
||||
import { VideoWatchPlaylistComponent } from './video-watch-playlist.component'
|
||||
|
||||
type URLOptions = CustomizationOptions & { playerMode: PlayerMode }
|
||||
|
||||
@Component({
|
||||
selector: 'my-video-watch',
|
||||
templateUrl: './video-watch.component.html',
|
||||
|
@ -76,6 +88,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
private paramsSub: Subscription
|
||||
private queryParamsSub: Subscription
|
||||
private configSub: Subscription
|
||||
private liveVideosSub: Subscription
|
||||
|
||||
private serverConfig: ServerConfig
|
||||
|
||||
|
@ -99,6 +112,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
private videoCaptionService: VideoCaptionService,
|
||||
private hotkeysService: HotkeysService,
|
||||
private hooks: HooksService,
|
||||
private peertubeSocket: PeerTubeSocket,
|
||||
private location: PlatformLocation,
|
||||
@Inject(LOCALE_ID) private localeId: string
|
||||
) {
|
||||
|
@ -165,6 +179,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
if (this.paramsSub) this.paramsSub.unsubscribe()
|
||||
if (this.queryParamsSub) this.queryParamsSub.unsubscribe()
|
||||
if (this.configSub) this.configSub.unsubscribe()
|
||||
if (this.liveVideosSub) this.liveVideosSub.unsubscribe()
|
||||
|
||||
// Unbind hotkeys
|
||||
this.hotkeysService.remove(this.hotkeys)
|
||||
|
@ -306,6 +321,18 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
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) {
|
||||
return video.isVideoNSFWForUser(this.user, this.serverConfig)
|
||||
}
|
||||
|
@ -470,8 +497,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
private async onVideoFetched (
|
||||
video: VideoDetails,
|
||||
videoCaptions: VideoCaption[],
|
||||
urlOptions: CustomizationOptions & { playerMode: PlayerMode }
|
||||
urlOptions: URLOptions
|
||||
) {
|
||||
this.subscribeToLiveEventsIfNeeded(this.video, video)
|
||||
|
||||
this.video = video
|
||||
this.videoCaptions = videoCaptions
|
||||
|
||||
|
@ -489,6 +518,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
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
|
||||
this.flushPlayer()
|
||||
|
||||
|
@ -794,6 +826,29 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
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 () {
|
||||
this.hotkeys = [
|
||||
// These hotkeys are managed by the player
|
||||
|
|
|
@ -4,7 +4,7 @@ import { ToastModule } from 'primeng/toast'
|
|||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule, Optional, SkipSelf } from '@angular/core'
|
||||
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 { PluginService } from '@app/core/plugins/plugin.service'
|
||||
import { UnloggedGuard } from '@app/core/routing/unlogged-guard.service'
|
||||
|
@ -84,7 +84,7 @@ import { LocalStorageService, ScreenService, SessionStorageService } from './wra
|
|||
RedirectService,
|
||||
Notifier,
|
||||
MessageService,
|
||||
UserNotificationSocket,
|
||||
PeerTubeSocket,
|
||||
ServerConfigResolver,
|
||||
CanDeactivateGuard
|
||||
]
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
export * from './notifier.service'
|
||||
export * from './user-notification-socket.service'
|
||||
export * from './peertube-socket.service'
|
||||
|
|
86
client/src/app/core/notification/peertube-socket.service.ts
Normal file
86
client/src/app/core/notification/peertube-socket.service.ts
Normal 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 })
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
}
|
|
@ -2,6 +2,7 @@ import { Observable, of, ReplaySubject } from 'rxjs'
|
|||
import { catchError, first, map, shareReplay } from 'rxjs/operators'
|
||||
import { HttpClient } from '@angular/common/http'
|
||||
import { Inject, Injectable, LOCALE_ID, NgZone } from '@angular/core'
|
||||
import { VideoEditType } from '@app/+videos/+video-edit/shared/video-edit.type'
|
||||
import { AuthService } from '@app/core/auth'
|
||||
import { Notifier } from '@app/core/notification'
|
||||
import { MarkdownService } from '@app/core/renderer'
|
||||
|
@ -192,7 +193,7 @@ export class PluginService implements ClientHook {
|
|||
: PluginType.THEME
|
||||
}
|
||||
|
||||
getRegisteredVideoFormFields (type: 'import-url' | 'import-torrent' | 'upload' | 'update') {
|
||||
getRegisteredVideoFormFields (type: VideoEditType) {
|
||||
return this.formFields.video.filter(f => f.videoFormOptions.type === type)
|
||||
}
|
||||
|
||||
|
|
|
@ -74,6 +74,15 @@ export class ServerService {
|
|||
enabled: true
|
||||
}
|
||||
},
|
||||
live: {
|
||||
enabled: false,
|
||||
allowReplay: true,
|
||||
maxDuration: null,
|
||||
transcoding: {
|
||||
enabled: false,
|
||||
enabledResolutions: []
|
||||
}
|
||||
},
|
||||
avatar: {
|
||||
file: {
|
||||
size: { max: 0 },
|
||||
|
|
|
@ -2,7 +2,7 @@ import { Subject, Subscription } from 'rxjs'
|
|||
import { filter } from 'rxjs/operators'
|
||||
import { Component, EventEmitter, Input, Output, OnDestroy, OnInit, ViewChild } from '@angular/core'
|
||||
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 { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
|
||||
|
||||
|
@ -27,7 +27,7 @@ export class AvatarNotificationComponent implements OnInit, OnDestroy {
|
|||
|
||||
constructor (
|
||||
private userNotificationService: UserNotificationService,
|
||||
private userNotificationSocket: UserNotificationSocket,
|
||||
private peertubeSocket: PeerTubeSocket,
|
||||
private notifier: Notifier,
|
||||
private router: Router
|
||||
) {
|
||||
|
@ -75,7 +75,7 @@ export class AvatarNotificationComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
private async subscribeToNotifications () {
|
||||
const obs = await this.userNotificationSocket.getMyNotificationsSocket()
|
||||
const obs = await this.peertubeSocket.getMyNotificationsSocket()
|
||||
|
||||
this.notificationSub = obs.subscribe(data => {
|
||||
if (data.type === 'new') return this.unreadNotifications++
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<div class="input-group input-group-sm">
|
||||
<input #urlInput (click)="urlInput.select()" type="text" class="form-control readonly" readonly [value]="value" />
|
||||
<input [id]="id" #urlInput (click)="urlInput.select()" type="text" class="form-control readonly" readonly [value]="value" />
|
||||
|
||||
<div class="input-group-append">
|
||||
<button [cdkCopyToClipboard]="urlInput.value" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary">
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { Component, Input } from '@angular/core'
|
||||
import { Notifier } from '@app/core'
|
||||
import { FormGroup } from '@angular/forms'
|
||||
|
||||
@Component({
|
||||
selector: 'my-input-readonly-copy',
|
||||
|
@ -7,6 +8,7 @@ import { Notifier } from '@app/core'
|
|||
styleUrls: [ './input-readonly-copy.component.scss' ]
|
||||
})
|
||||
export class InputReadonlyCopyComponent {
|
||||
@Input() id: string
|
||||
@Input() value = ''
|
||||
|
||||
constructor (private notifier: Notifier) { }
|
||||
|
|
|
@ -63,6 +63,24 @@
|
|||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th i18n class="label" colspan="2">Live streaming</th>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th i18n class="sub-label" scope="row">Live streaming enabled</th>
|
||||
<td>
|
||||
<my-feature-boolean [value]="serverConfig.live.enabled"></my-feature-boolean>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th i18n class="sub-label" scope="row">Transcode live video in multiple resolutions</th>
|
||||
<td>
|
||||
<my-feature-boolean [value]="serverConfig.live.transcoding.enabled && serverConfig.live.transcoding.enabledResolutions.length > 1"></my-feature-boolean>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th i18n class="label" colspan="2">Import</th>
|
||||
</tr>
|
||||
|
|
|
@ -23,7 +23,7 @@ import { FeedComponent } from './feeds'
|
|||
import { LoaderComponent, SmallLoaderComponent } from './loaders'
|
||||
import { HelpComponent, ListOverflowComponent, TopMenuDropdownComponent } from './misc'
|
||||
import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users'
|
||||
import { RedundancyService, VideoImportService, VideoOwnershipService, VideoService } from './video'
|
||||
import { RedundancyService, VideoImportService, VideoOwnershipService, VideoService, LiveVideoService } from './video'
|
||||
import { VideoCaptionService } from './video-caption'
|
||||
import { VideoChannelService } from './video-channel'
|
||||
|
||||
|
@ -142,6 +142,7 @@ import { VideoChannelService } from './video-channel'
|
|||
RedundancyService,
|
||||
VideoImportService,
|
||||
VideoOwnershipService,
|
||||
LiveVideoService,
|
||||
VideoService,
|
||||
|
||||
VideoCaptionService,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { catchError, map, tap } from 'rxjs/operators'
|
||||
import { HttpClient, HttpParams } from '@angular/common/http'
|
||||
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 { environment } from '../../../../environments/environment'
|
||||
import { UserNotification } from './user-notification.model'
|
||||
|
@ -17,7 +17,7 @@ export class UserNotificationService {
|
|||
private auth: AuthService,
|
||||
private restExtractor: RestExtractor,
|
||||
private restService: RestService,
|
||||
private userNotificationSocket: UserNotificationSocket
|
||||
private peertubeSocket: PeerTubeSocket
|
||||
) {}
|
||||
|
||||
listMyNotifications (parameters: {
|
||||
|
@ -57,7 +57,7 @@ export class UserNotificationService {
|
|||
return this.authHttp.post(url, body, { headers })
|
||||
.pipe(
|
||||
map(this.restExtractor.extractDataBool),
|
||||
tap(() => this.userNotificationSocket.dispatch('read')),
|
||||
tap(() => this.peertubeSocket.dispatchNotificationEvent('read')),
|
||||
catchError(res => this.restExtractor.handleError(res))
|
||||
)
|
||||
}
|
||||
|
@ -69,12 +69,12 @@ export class UserNotificationService {
|
|||
return this.authHttp.post(url, {}, { headers })
|
||||
.pipe(
|
||||
map(this.restExtractor.extractDataBool),
|
||||
tap(() => this.userNotificationSocket.dispatch('read-all')),
|
||||
tap(() => this.peertubeSocket.dispatchNotificationEvent('read-all')),
|
||||
catchError(res => this.restExtractor.handleError(res))
|
||||
)
|
||||
}
|
||||
|
||||
updateNotificationSettings (user: User, settings: UserNotificationSetting) {
|
||||
updateNotificationSettings (settings: UserNotificationSetting) {
|
||||
const url = UserNotificationService.BASE_NOTIFICATION_SETTINGS
|
||||
|
||||
return this.authHttp.put(url, settings)
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export * from './live-video.service'
|
||||
export * from './redundancy.service'
|
||||
export * from './video-details.model'
|
||||
export * from './video-edit.model'
|
||||
|
|
|
@ -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)))
|
||||
}
|
||||
}
|
|
@ -62,8 +62,11 @@ export class VideoDetails extends Video implements VideoDetailsServerModel {
|
|||
}
|
||||
|
||||
getFiles () {
|
||||
if (this.files.length === 0) return this.getHlsPlaylist().files
|
||||
if (this.files.length !== 0) return this.files
|
||||
|
||||
return this.files
|
||||
const hls = this.getHlsPlaylist()
|
||||
if (hls) return hls.files
|
||||
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,6 +40,8 @@ export class Video implements VideoServerModel {
|
|||
thumbnailPath: string
|
||||
thumbnailUrl: string
|
||||
|
||||
isLive: boolean
|
||||
|
||||
previewPath: string
|
||||
previewUrl: string
|
||||
|
||||
|
@ -103,6 +105,8 @@ export class Video implements VideoServerModel {
|
|||
this.state = hash.state
|
||||
this.description = hash.description
|
||||
|
||||
this.isLive = hash.isLive
|
||||
|
||||
this.duration = hash.duration
|
||||
this.durationLabel = durationToString(hash.duration)
|
||||
|
||||
|
@ -113,10 +117,14 @@ export class Video implements VideoServerModel {
|
|||
this.name = hash.name
|
||||
|
||||
this.thumbnailPath = hash.thumbnailPath
|
||||
this.thumbnailUrl = hash.thumbnailUrl || (absoluteAPIUrl + hash.thumbnailPath)
|
||||
this.thumbnailUrl = this.thumbnailPath
|
||||
? hash.thumbnailUrl || (absoluteAPIUrl + hash.thumbnailPath)
|
||||
: null
|
||||
|
||||
this.previewPath = hash.previewPath
|
||||
this.previewUrl = hash.previewUrl || (absoluteAPIUrl + hash.previewPath)
|
||||
this.previewUrl = this.previewPath
|
||||
? hash.previewUrl || (absoluteAPIUrl + hash.previewPath)
|
||||
: null
|
||||
|
||||
this.embedPath = hash.embedPath
|
||||
this.embedUrl = hash.embedUrl || (getAbsoluteEmbedUrl() + hash.embedPath)
|
||||
|
|
|
@ -18,7 +18,8 @@ import {
|
|||
VideoFilter,
|
||||
VideoPrivacy,
|
||||
VideoSortField,
|
||||
VideoUpdate
|
||||
VideoUpdate,
|
||||
VideoCreate
|
||||
} from '@shared/models'
|
||||
import { environment } from '../../../../environments/environment'
|
||||
import { Account } from '../account/account.model'
|
||||
|
|
|
@ -107,7 +107,7 @@
|
|||
|
||||
<div class="filters">
|
||||
<div>
|
||||
<div class="form-group start-at">
|
||||
<div class="form-group start-at" *ngIf="!video.isLive">
|
||||
<my-peertube-checkbox
|
||||
inputName="startAt" [(ngModel)]="customizations.startAtCheckbox"
|
||||
i18n-labelText labelText="Start at"
|
||||
|
@ -138,7 +138,7 @@
|
|||
|
||||
<div class="advanced-filters collapse-transition" [ngbCollapse]="isAdvancedCustomizationCollapsed">
|
||||
<div>
|
||||
<div class="form-group stop-at">
|
||||
<div class="form-group stop-at" *ngIf="!video.isLive">
|
||||
<my-peertube-checkbox
|
||||
inputName="stopAt" [(ngModel)]="customizations.stopAtCheckbox"
|
||||
i18n-labelText labelText="Stop at"
|
||||
|
@ -167,7 +167,7 @@
|
|||
></my-peertube-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-group" *ngIf="!video.isLive">
|
||||
<my-peertube-checkbox
|
||||
inputName="loop" [(ngModel)]="customizations.loop"
|
||||
i18n-labelText labelText="Loop"
|
||||
|
|
|
@ -146,7 +146,10 @@ export class VideoActionsDropdownComponent implements OnChanges {
|
|||
}
|
||||
|
||||
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 () {
|
||||
|
|
|
@ -1,17 +1,42 @@
|
|||
import { Segment } from 'p2p-media-loader-core'
|
||||
import { basename } from 'path'
|
||||
|
||||
type SegmentsJSON = { [filename: string]: string | { [byterange: string]: string } }
|
||||
|
||||
function segmentValidatorFactory (segmentsSha256Url: string) {
|
||||
const segmentsJSON = fetchSha256Segments(segmentsSha256Url)
|
||||
let segmentsJSON = fetchSha256Segments(segmentsSha256Url)
|
||||
const regex = /bytes=(\d+)-(\d+)/
|
||||
|
||||
return async function segmentValidator (segment: Segment) {
|
||||
return async function segmentValidator (segment: Segment, canRefetchSegmentHashes = true) {
|
||||
const filename = basename(segment.url)
|
||||
const captured = regex.exec(segment.range)
|
||||
|
||||
const range = captured[1] + '-' + captured[2]
|
||||
const segmentValue = (await segmentsJSON)[filename]
|
||||
|
||||
if (!segmentValue && !canRefetchSegmentHashes) {
|
||||
throw new Error(`Unknown segment name ${filename} in segment validator`)
|
||||
}
|
||||
|
||||
if (!segmentValue) {
|
||||
console.log('Refetching sha segments.')
|
||||
|
||||
// Refetch
|
||||
segmentsJSON = fetchSha256Segments(segmentsSha256Url)
|
||||
segmentValidator(segment, false)
|
||||
return
|
||||
}
|
||||
|
||||
let hashShouldBe: string
|
||||
let range = ''
|
||||
|
||||
if (typeof segmentValue === 'string') {
|
||||
hashShouldBe = segmentValue
|
||||
} else {
|
||||
const captured = regex.exec(segment.range)
|
||||
range = captured[1] + '-' + captured[2]
|
||||
|
||||
hashShouldBe = segmentValue[range]
|
||||
}
|
||||
|
||||
const hashShouldBe = (await segmentsJSON)[filename][range]
|
||||
if (hashShouldBe === undefined) {
|
||||
throw new Error(`Unknown segment name ${filename}/${range} in segment validator`)
|
||||
}
|
||||
|
@ -36,7 +61,7 @@ export {
|
|||
|
||||
function fetchSha256Segments (url: string) {
|
||||
return fetch(url)
|
||||
.then(res => res.json())
|
||||
.then(res => res.json() as Promise<SegmentsJSON>)
|
||||
.catch(err => {
|
||||
console.error('Cannot get sha256 segments', err)
|
||||
return {}
|
||||
|
|
|
@ -325,7 +325,7 @@ export class PeertubePlayerManager {
|
|||
trackerAnnounce,
|
||||
segmentValidator: segmentValidatorFactory(options.p2pMediaLoader.segmentsSha256Url),
|
||||
rtcConfig: getRtcConfig(),
|
||||
requiredSegmentsPriority: 5,
|
||||
requiredSegmentsPriority: 1,
|
||||
segmentUrlBuilder: segmentUrlBuilderFactory(redundancyUrlManager),
|
||||
useP2P: getStoredP2PEnabled(),
|
||||
consumeOnly
|
||||
|
@ -353,7 +353,7 @@ export class PeertubePlayerManager {
|
|||
hlsjsConfig: {
|
||||
capLevelToPlayerSize: true,
|
||||
autoStartLoad: false,
|
||||
liveSyncDurationCount: 7,
|
||||
liveSyncDurationCount: 5,
|
||||
loader: new p2pMediaLoaderModule.Engine(p2pMediaLoaderConfig).createLoaderClass()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -556,9 +556,9 @@ export class PeerTubeEmbed {
|
|||
|
||||
Object.assign(options, {
|
||||
p2pMediaLoader: {
|
||||
playlistUrl: hlsPlaylist.playlistUrl,
|
||||
playlistUrl: 'http://localhost:9000/live/toto/master.m3u8',
|
||||
segmentsSha256Url: hlsPlaylist.segmentsSha256Url,
|
||||
redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl),
|
||||
redundancyBaseUrls: [],
|
||||
trackerAnnounce: videoInfo.trackerUrls,
|
||||
videoFiles: hlsPlaylist.files
|
||||
} as P2PMediaLoaderOptions
|
||||
|
|
|
@ -243,6 +243,35 @@ transcoding:
|
|||
hls:
|
||||
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:
|
||||
# Add ability for your users to import remote videos (from YouTube, torrent...)
|
||||
videos:
|
||||
|
|
|
@ -37,24 +37,24 @@ log:
|
|||
|
||||
contact_form:
|
||||
enabled: true
|
||||
|
||||
redundancy:
|
||||
videos:
|
||||
check_interval: '1 minute'
|
||||
strategies:
|
||||
-
|
||||
size: '1000MB'
|
||||
min_lifetime: '10 minutes'
|
||||
strategy: 'most-views'
|
||||
-
|
||||
size: '1000MB'
|
||||
min_lifetime: '10 minutes'
|
||||
strategy: 'trending'
|
||||
-
|
||||
size: '1000MB'
|
||||
min_lifetime: '10 minutes'
|
||||
strategy: 'recently-added'
|
||||
min_views: 1
|
||||
#
|
||||
#redundancy:
|
||||
# videos:
|
||||
# check_interval: '1 minute'
|
||||
# strategies:
|
||||
# -
|
||||
# size: '1000MB'
|
||||
# min_lifetime: '10 minutes'
|
||||
# strategy: 'most-views'
|
||||
# -
|
||||
# size: '1000MB'
|
||||
# min_lifetime: '10 minutes'
|
||||
# strategy: 'trending'
|
||||
# -
|
||||
# size: '1000MB'
|
||||
# min_lifetime: '10 minutes'
|
||||
# strategy: 'recently-added'
|
||||
# min_views: 1
|
||||
|
||||
cache:
|
||||
previews:
|
||||
|
@ -82,6 +82,24 @@ transcoding:
|
|||
hls:
|
||||
enabled: true
|
||||
|
||||
live:
|
||||
enabled: false
|
||||
|
||||
rtmp:
|
||||
port: 1935
|
||||
|
||||
transcoding:
|
||||
enabled: false
|
||||
threads: 2
|
||||
|
||||
resolutions:
|
||||
240p: false
|
||||
360p: false
|
||||
480p: false
|
||||
720p: false
|
||||
1080p: false
|
||||
2160p: false
|
||||
|
||||
import:
|
||||
videos:
|
||||
http:
|
||||
|
|
|
@ -92,6 +92,7 @@
|
|||
"body-parser": "^1.12.4",
|
||||
"bull": "^3.4.2",
|
||||
"bytes": "^3.0.0",
|
||||
"chokidar": "^3.4.2",
|
||||
"commander": "^6.0.0",
|
||||
"config": "^3.0.0",
|
||||
"cookie-parser": "^1.4.3",
|
||||
|
@ -121,6 +122,7 @@
|
|||
"memoizee": "^0.4.14",
|
||||
"morgan": "^1.5.3",
|
||||
"multer": "^1.1.0",
|
||||
"node-media-server": "^2.1.4",
|
||||
"nodemailer": "^6.0.0",
|
||||
"oauth2-server": "3.1.0-beta.1",
|
||||
"parse-torrent": "^7.0.0",
|
||||
|
|
|
@ -43,7 +43,7 @@ async function run () {
|
|||
if (program.generateHls) {
|
||||
const resolutionsEnabled = program.resolution
|
||||
? [ program.resolution ]
|
||||
: computeResolutionsToTranscode(videoFileResolution).concat([ videoFileResolution ])
|
||||
: computeResolutionsToTranscode(videoFileResolution, 'vod').concat([ videoFileResolution ])
|
||||
|
||||
for (const resolution of resolutionsEnabled) {
|
||||
dataInput.push({
|
||||
|
|
|
@ -130,7 +130,7 @@ async function run () {
|
|||
|
||||
for (const playlist of video.VideoStreamingPlaylists) {
|
||||
playlist.playlistUrl = WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsMasterPlaylistStaticPath(video.uuid)
|
||||
playlist.segmentsSha256Url = WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsSha256SegmentsStaticPath(video.uuid)
|
||||
playlist.segmentsSha256Url = WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsSha256SegmentsStaticPath(video.uuid, video.isLive)
|
||||
|
||||
await playlist.save()
|
||||
}
|
||||
|
|
15
server.ts
15
server.ts
|
@ -98,10 +98,12 @@ import {
|
|||
staticRouter,
|
||||
lazyStaticRouter,
|
||||
servicesRouter,
|
||||
liveRouter,
|
||||
pluginsRouter,
|
||||
webfingerRouter,
|
||||
trackerRouter,
|
||||
createWebsocketTrackerServer, botsRouter
|
||||
createWebsocketTrackerServer,
|
||||
botsRouter
|
||||
} from './server/controllers'
|
||||
import { advertiseDoNotTrack } from './server/middlewares/dnt'
|
||||
import { Redis } from './server/lib/redis'
|
||||
|
@ -119,6 +121,7 @@ import { updateStreamingPlaylistsInfohashesIfNeeded } from './server/lib/hls'
|
|||
import { PluginsCheckScheduler } from './server/lib/schedulers/plugins-check-scheduler'
|
||||
import { Hooks } from './server/lib/plugins/hooks'
|
||||
import { PluginManager } from './server/lib/plugins/plugin-manager'
|
||||
import { LiveManager } from '@server/lib/live-manager'
|
||||
|
||||
// ----------- Command line -----------
|
||||
|
||||
|
@ -139,14 +142,14 @@ if (isTestInstance()) {
|
|||
}
|
||||
|
||||
// For the logger
|
||||
morgan.token<express.Request>('remote-addr', req => {
|
||||
morgan.token('remote-addr', req => {
|
||||
if (CONFIG.LOG.ANONYMIZE_IP === true || req.get('DNT') === '1') {
|
||||
return anonymize(req.ip, 16, 16)
|
||||
}
|
||||
|
||||
return req.ip
|
||||
})
|
||||
morgan.token<express.Request>('user-agent', req => {
|
||||
morgan.token('user-agent', req => {
|
||||
if (req.get('DNT') === '1') {
|
||||
return useragent.parse(req.get('user-agent')).family
|
||||
}
|
||||
|
@ -183,6 +186,9 @@ app.use(apiRoute, apiRouter)
|
|||
// Services (oembed...)
|
||||
app.use('/services', servicesRouter)
|
||||
|
||||
// Live streaming
|
||||
app.use('/live', liveRouter)
|
||||
|
||||
// Plugins & themes
|
||||
app.use('/', pluginsRouter)
|
||||
|
||||
|
@ -271,6 +277,9 @@ async function startApplication () {
|
|||
|
||||
if (cli.plugins) await PluginManager.Instance.registerPluginsAndThemes()
|
||||
|
||||
LiveManager.Instance.init()
|
||||
if (CONFIG.LIVE.ENABLED) LiveManager.Instance.run()
|
||||
|
||||
// Make server listening
|
||||
server.listen(port, hostname, () => {
|
||||
logger.info('Server listening on %s:%d', hostname, port)
|
||||
|
|
BIN
server/assets/default-live-background.jpg
Normal file
BIN
server/assets/default-live-background.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 91 KiB |
|
@ -113,7 +113,18 @@ async function getConfig (req: express.Request, res: express.Response) {
|
|||
webtorrent: {
|
||||
enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED
|
||||
},
|
||||
enabledResolutions: getEnabledResolutions()
|
||||
enabledResolutions: getEnabledResolutions('vod')
|
||||
},
|
||||
live: {
|
||||
enabled: CONFIG.LIVE.ENABLED,
|
||||
|
||||
allowReplay: CONFIG.LIVE.ALLOW_REPLAY,
|
||||
maxDuration: CONFIG.LIVE.MAX_DURATION,
|
||||
|
||||
transcoding: {
|
||||
enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
|
||||
enabledResolutions: getEnabledResolutions('live')
|
||||
}
|
||||
},
|
||||
import: {
|
||||
videos: {
|
||||
|
@ -232,7 +243,7 @@ async function deleteCustomConfig (req: express.Request, res: express.Response)
|
|||
|
||||
const data = customConfig()
|
||||
|
||||
return res.json(data).end()
|
||||
return res.json(data)
|
||||
}
|
||||
|
||||
async function updateCustomConfig (req: express.Request, res: express.Response) {
|
||||
|
@ -254,7 +265,7 @@ async function updateCustomConfig (req: express.Request, res: express.Response)
|
|||
oldCustomConfigAuditKeys
|
||||
)
|
||||
|
||||
return res.json(data).end()
|
||||
return res.json(data)
|
||||
}
|
||||
|
||||
function getRegisteredThemes () {
|
||||
|
@ -268,9 +279,13 @@ function getRegisteredThemes () {
|
|||
}))
|
||||
}
|
||||
|
||||
function getEnabledResolutions () {
|
||||
return Object.keys(CONFIG.TRANSCODING.RESOLUTIONS)
|
||||
.filter(key => CONFIG.TRANSCODING.ENABLED && CONFIG.TRANSCODING.RESOLUTIONS[key] === true)
|
||||
function getEnabledResolutions (type: 'vod' | 'live') {
|
||||
const transcoding = type === 'vod'
|
||||
? CONFIG.TRANSCODING
|
||||
: CONFIG.LIVE.TRANSCODING
|
||||
|
||||
return Object.keys(transcoding.RESOLUTIONS)
|
||||
.filter(key => transcoding.ENABLED && transcoding.RESOLUTIONS[key] === true)
|
||||
.map(r => parseInt(r, 10))
|
||||
}
|
||||
|
||||
|
@ -411,6 +426,23 @@ function customConfig (): CustomConfig {
|
|||
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: {
|
||||
videos: {
|
||||
http: {
|
||||
|
|
|
@ -9,7 +9,7 @@ import { MIMETYPES } from '../../../initializers/constants'
|
|||
import { sequelizeTypescript } from '../../../initializers/database'
|
||||
import { sendUpdateActor } from '../../../lib/activitypub/send'
|
||||
import { updateActorAvatarFile } from '../../../lib/avatar'
|
||||
import { sendVerifyUserEmail } from '../../../lib/user'
|
||||
import { getOriginalVideoFileTotalDailyFromUser, getOriginalVideoFileTotalFromUser, sendVerifyUserEmail } from '../../../lib/user'
|
||||
import {
|
||||
asyncMiddleware,
|
||||
asyncRetryTransactionMiddleware,
|
||||
|
@ -133,8 +133,8 @@ async function getUserInformation (req: express.Request, res: express.Response)
|
|||
|
||||
async function getUserVideoQuotaUsed (req: express.Request, res: express.Response) {
|
||||
const user = res.locals.oauth.token.user
|
||||
const videoQuotaUsed = await UserModel.getOriginalVideoFileTotalFromUser(user)
|
||||
const videoQuotaUsedDaily = await UserModel.getOriginalVideoFileTotalDailyFromUser(user)
|
||||
const videoQuotaUsed = await getOriginalVideoFileTotalFromUser(user)
|
||||
const videoQuotaUsedDaily = await getOriginalVideoFileTotalDailyFromUser(user)
|
||||
|
||||
const data: UserVideoQuota = {
|
||||
videoQuotaUsed,
|
||||
|
|
|
@ -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 parseTorrent from 'parse-torrent'
|
||||
import { getSecureTorrentName } from '../../../helpers/utils'
|
||||
import * as express from 'express'
|
||||
import { move, readFile } from 'fs-extra'
|
||||
import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
|
||||
import { CONFIG } from '../../../initializers/config'
|
||||
import { sequelizeTypescript } from '../../../initializers/database'
|
||||
import { createVideoMiniatureFromExisting, createVideoMiniatureFromUrl } from '../../../lib/thumbnail'
|
||||
import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type'
|
||||
import * as magnetUtil from 'magnet-uri'
|
||||
import * as parseTorrent from 'parse-torrent'
|
||||
import { join } from 'path'
|
||||
import { setVideoTags } from '@server/lib/video'
|
||||
import {
|
||||
MChannelAccountDefault,
|
||||
MThumbnail,
|
||||
|
@ -36,6 +16,26 @@ import {
|
|||
MVideoWithBlacklistLight
|
||||
} from '@server/types/models'
|
||||
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 videoImportsRouter = express.Router()
|
||||
|
@ -260,7 +260,12 @@ async function processThumbnail (req: express.Request, video: VideoModel) {
|
|||
if (thumbnailField) {
|
||||
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
|
||||
|
@ -271,7 +276,12 @@ async function processPreview (req: express.Request, video: VideoModel) {
|
|||
if (previewField) {
|
||||
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
|
||||
|
@ -325,15 +335,7 @@ function insertIntoDB (parameters: {
|
|||
transaction: t
|
||||
})
|
||||
|
||||
// Set tags to the video
|
||||
if (tags) {
|
||||
const tagInstances = await TagModel.findOrCreateTags(tags, t)
|
||||
|
||||
await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
|
||||
videoCreated.Tags = tagInstances
|
||||
} else {
|
||||
videoCreated.Tags = []
|
||||
}
|
||||
await setVideoTags({ video: videoCreated, tags, transaction: t })
|
||||
|
||||
// Create video import object in database
|
||||
const videoImport = await VideoImportModel.create(
|
||||
|
|
|
@ -6,11 +6,11 @@ import { addOptimizeOrMergeAudioJob } from '@server/helpers/video'
|
|||
import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
|
||||
import { changeVideoChannelShare } from '@server/lib/activitypub/share'
|
||||
import { getVideoActivityPubUrl } from '@server/lib/activitypub/url'
|
||||
import { buildLocalVideoFromReq, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
|
||||
import { getVideoFilePath } from '@server/lib/video-paths'
|
||||
import { getServerActor } from '@server/models/application/application'
|
||||
import { MVideoDetails, MVideoFullLight } from '@server/types/models'
|
||||
import { VideoCreate, VideoPrivacy, VideoState, VideoUpdate } from '../../../../shared'
|
||||
import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type'
|
||||
import { VideoCreate, VideoState, VideoUpdate } from '../../../../shared'
|
||||
import { VideoFilter } from '../../../../shared/models/videos/video-query.type'
|
||||
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
|
||||
import { resetSequelizeInstance } from '../../../helpers/database-utils'
|
||||
|
@ -34,7 +34,7 @@ import { JobQueue } from '../../../lib/job-queue'
|
|||
import { Notifier } from '../../../lib/notifier'
|
||||
import { Hooks } from '../../../lib/plugins/hooks'
|
||||
import { Redis } from '../../../lib/redis'
|
||||
import { createVideoMiniatureFromExisting, generateVideoMiniature } from '../../../lib/thumbnail'
|
||||
import { generateVideoMiniature } from '../../../lib/thumbnail'
|
||||
import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
|
||||
import {
|
||||
asyncMiddleware,
|
||||
|
@ -55,7 +55,6 @@ import {
|
|||
videosUpdateValidator
|
||||
} from '../../../middlewares'
|
||||
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
|
||||
import { TagModel } from '../../../models/video/tag'
|
||||
import { VideoModel } from '../../../models/video/video'
|
||||
import { VideoFileModel } from '../../../models/video/video-file'
|
||||
import { abuseVideoRouter } from './abuse'
|
||||
|
@ -63,6 +62,7 @@ import { blacklistRouter } from './blacklist'
|
|||
import { videoCaptionsRouter } from './captions'
|
||||
import { videoCommentRouter } from './comment'
|
||||
import { videoImportsRouter } from './import'
|
||||
import { liveRouter } from './live'
|
||||
import { ownershipVideoRouter } from './ownership'
|
||||
import { rateVideoRouter } from './rate'
|
||||
import { watchingRouter } from './watching'
|
||||
|
@ -96,6 +96,7 @@ videosRouter.use('/', videoCaptionsRouter)
|
|||
videosRouter.use('/', videoImportsRouter)
|
||||
videosRouter.use('/', ownershipVideoRouter)
|
||||
videosRouter.use('/', watchingRouter)
|
||||
videosRouter.use('/', liveRouter)
|
||||
|
||||
videosRouter.get('/categories', listVideoCategories)
|
||||
videosRouter.get('/licences', listVideoLicences)
|
||||
|
@ -184,25 +185,9 @@ async function addVideo (req: express.Request, res: express.Response) {
|
|||
const videoPhysicalFile = req.files['videofile'][0]
|
||||
const videoInfo: VideoCreate = req.body
|
||||
|
||||
// Prepare data so we don't block the transaction
|
||||
const videoData = {
|
||||
name: videoInfo.name,
|
||||
remote: false,
|
||||
category: videoInfo.category,
|
||||
licence: videoInfo.licence,
|
||||
language: videoInfo.language,
|
||||
commentsEnabled: videoInfo.commentsEnabled !== false, // If the value is not "false", the default is "true"
|
||||
downloadEnabled: videoInfo.downloadEnabled !== false,
|
||||
waitTranscoding: videoInfo.waitTranscoding || false,
|
||||
state: 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 videoData = buildLocalVideoFromReq(videoInfo, res.locals.videoChannel.id)
|
||||
videoData.state = CONFIG.TRANSCODING.ENABLED ? VideoState.TO_TRANSCODE : VideoState.PUBLISHED
|
||||
videoData.duration = videoPhysicalFile['duration'] // duration was added by a previous middleware
|
||||
|
||||
const video = new VideoModel(videoData) as MVideoDetails
|
||||
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.path = destination
|
||||
|
||||
// Process thumbnail or create it from the video
|
||||
const thumbnailField = req.files['thumbnailfile']
|
||||
const thumbnailModel = thumbnailField
|
||||
? await createVideoMiniatureFromExisting(thumbnailField[0].path, video, ThumbnailType.MINIATURE, false)
|
||||
: 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)
|
||||
const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({
|
||||
video,
|
||||
files: req.files,
|
||||
fallback: type => generateVideoMiniature(video, videoFile, type)
|
||||
})
|
||||
|
||||
// Create the torrent file
|
||||
await createTorrentAndSetInfoHash(video, videoFile)
|
||||
|
@ -259,13 +238,7 @@ async function addVideo (req: express.Request, res: express.Response) {
|
|||
|
||||
video.VideoFiles = [ videoFile ]
|
||||
|
||||
// Create tags
|
||||
if (videoInfo.tags !== undefined) {
|
||||
const tagInstances = await TagModel.findOrCreateTags(videoInfo.tags, t)
|
||||
|
||||
await video.$set('Tags', tagInstances, sequelizeOptions)
|
||||
video.Tags = tagInstances
|
||||
}
|
||||
await setVideoTags({ video, tags: videoInfo.tags, transaction: t })
|
||||
|
||||
// Schedule an update in the future?
|
||||
if (videoInfo.scheduleUpdate) {
|
||||
|
@ -304,7 +277,7 @@ async function addVideo (req: express.Request, res: express.Response) {
|
|||
id: videoCreated.id,
|
||||
uuid: videoCreated.uuid
|
||||
}
|
||||
}).end()
|
||||
})
|
||||
}
|
||||
|
||||
async function updateVideo (req: express.Request, res: express.Response) {
|
||||
|
@ -316,14 +289,12 @@ async function updateVideo (req: express.Request, res: express.Response) {
|
|||
const wasConfidentialVideo = videoInstance.isConfidential()
|
||||
const hadPrivacyForFederation = videoInstance.hasPrivacyForFederation()
|
||||
|
||||
// Process thumbnail or create it from the video
|
||||
const thumbnailModel = req.files?.['thumbnailfile']
|
||||
? await createVideoMiniatureFromExisting(req.files['thumbnailfile'][0].path, videoInstance, ThumbnailType.MINIATURE, false)
|
||||
: undefined
|
||||
|
||||
const previewModel = req.files?.['previewfile']
|
||||
? await createVideoMiniatureFromExisting(req.files['previewfile'][0].path, videoInstance, ThumbnailType.PREVIEW, false)
|
||||
: undefined
|
||||
const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({
|
||||
video: videoInstance,
|
||||
files: req.files,
|
||||
fallback: () => Promise.resolve(undefined),
|
||||
automaticallyGenerated: false
|
||||
})
|
||||
|
||||
try {
|
||||
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)
|
||||
|
||||
// Video tags update?
|
||||
if (videoInfoToUpdate.tags !== undefined) {
|
||||
const tagInstances = await TagModel.findOrCreateTags(videoInfoToUpdate.tags, t)
|
||||
|
||||
await videoInstanceUpdated.$set('Tags', tagInstances, sequelizeOptions)
|
||||
videoInstanceUpdated.Tags = tagInstances
|
||||
}
|
||||
await setVideoTags({
|
||||
video: videoInstanceUpdated,
|
||||
tags: videoInfoToUpdate.tags,
|
||||
transaction: t,
|
||||
defaultValue: videoInstanceUpdated.Tags
|
||||
})
|
||||
|
||||
// Video channel update?
|
||||
if (res.locals.videoChannel && videoInstanceUpdated.channelId !== res.locals.videoChannel.id) {
|
||||
|
|
106
server/controllers/api/videos/live.ts
Normal file
106
server/controllers/api/videos/live.ts
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
|
@ -5,6 +5,7 @@ export * from './feeds'
|
|||
export * from './services'
|
||||
export * from './static'
|
||||
export * from './lazy-static'
|
||||
export * from './live'
|
||||
export * from './webfinger'
|
||||
export * from './tracker'
|
||||
export * from './bots'
|
||||
|
|
29
server/controllers/live.ts
Normal file
29
server/controllers/live.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import * as express from 'express'
|
||||
import { mapToJSON } from '@server/helpers/core-utils'
|
||||
import { LiveManager } from '@server/lib/live-manager'
|
||||
|
||||
const liveRouter = express.Router()
|
||||
|
||||
liveRouter.use('/segments-sha256/:videoUUID',
|
||||
getSegmentsSha256
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
liveRouter
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getSegmentsSha256 (req: express.Request, res: express.Response) {
|
||||
const videoUUID = req.params.videoUUID
|
||||
|
||||
const result = LiveManager.Instance.getSegmentsSha256(videoUUID)
|
||||
|
||||
if (!result) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
return res.json(mapToJSON(result))
|
||||
}
|
|
@ -260,7 +260,14 @@ async function generateNodeinfo (req: express.Request, res: express.Response) {
|
|||
webtorrent: {
|
||||
enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED
|
||||
},
|
||||
enabledResolutions: getEnabledResolutions()
|
||||
enabledResolutions: getEnabledResolutions('vod')
|
||||
},
|
||||
live: {
|
||||
enabled: CONFIG.LIVE.ENABLED,
|
||||
transcoding: {
|
||||
enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
|
||||
enabledResolutions: getEnabledResolutions('live')
|
||||
}
|
||||
},
|
||||
import: {
|
||||
videos: {
|
||||
|
|
|
@ -41,6 +41,7 @@ const timeTable = {
|
|||
}
|
||||
|
||||
export function parseDurationToMs (duration: number | string): number {
|
||||
if (duration === null) return null
|
||||
if (typeof duration === 'number') return duration
|
||||
|
||||
if (typeof duration === 'string') {
|
||||
|
@ -175,6 +176,16 @@ function pageToStartAndCount (page: number, itemsPerPage: number) {
|
|||
return { start, count: itemsPerPage }
|
||||
}
|
||||
|
||||
function mapToJSON (map: Map<any, any>) {
|
||||
const obj: any = {}
|
||||
|
||||
for (const [ k, v ] of map) {
|
||||
obj[k] = v
|
||||
}
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
function buildPath (path: string) {
|
||||
if (isAbsolute(path)) return path
|
||||
|
||||
|
@ -263,6 +274,7 @@ export {
|
|||
|
||||
sha256,
|
||||
sha1,
|
||||
mapToJSON,
|
||||
|
||||
promisify0,
|
||||
promisify1,
|
||||
|
|
|
@ -62,6 +62,7 @@ function sanitizeAndCheckVideoTorrentObject (video: any) {
|
|||
if (!isBooleanValid(video.waitTranscoding)) video.waitTranscoding = false
|
||||
if (!isBooleanValid(video.downloadEnabled)) video.downloadEnabled = true
|
||||
if (!isBooleanValid(video.commentsEnabled)) video.commentsEnabled = false
|
||||
if (!isBooleanValid(video.isLiveBroadcast)) video.isLiveBroadcast = false
|
||||
|
||||
return isActivityPubUrlValid(video.id) &&
|
||||
isVideoNameValid(video.name) &&
|
||||
|
|
|
@ -45,6 +45,10 @@ function isBooleanValid (value: any) {
|
|||
return typeof value === 'boolean' || (typeof value === 'string' && validator.isBoolean(value))
|
||||
}
|
||||
|
||||
function isIntOrNull (value: any) {
|
||||
return value === null || validator.isInt('' + value)
|
||||
}
|
||||
|
||||
function toIntOrNull (value: string) {
|
||||
const v = toValueOrNull(value)
|
||||
|
||||
|
@ -116,6 +120,7 @@ export {
|
|||
isArrayOf,
|
||||
isNotEmptyIntArray,
|
||||
isArray,
|
||||
isIntOrNull,
|
||||
isIdValid,
|
||||
isSafePath,
|
||||
isUUIDValid,
|
||||
|
|
|
@ -8,7 +8,8 @@ import {
|
|||
VIDEO_LICENCES,
|
||||
VIDEO_PRIVACIES,
|
||||
VIDEO_RATE_TYPES,
|
||||
VIDEO_STATES
|
||||
VIDEO_STATES,
|
||||
VIDEO_LIVE
|
||||
} from '../../initializers/constants'
|
||||
import { exists, isArray, isDateValid, isFileValid } from './misc'
|
||||
import * as magnetUtil from 'magnet-uri'
|
||||
|
@ -77,7 +78,7 @@ function isVideoRatingTypeValid (value: string) {
|
|||
}
|
||||
|
||||
function isVideoFileExtnameValid (value: string) {
|
||||
return exists(value) && MIMETYPES.VIDEO.EXT_MIMETYPE[value] !== undefined
|
||||
return exists(value) && (value === VIDEO_LIVE.EXTENSION || MIMETYPES.VIDEO.EXT_MIMETYPE[value] !== undefined)
|
||||
}
|
||||
|
||||
function isVideoFile (files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[]) {
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import * as ffmpeg from 'fluent-ffmpeg'
|
||||
import { readFile, remove, writeFile } from 'fs-extra'
|
||||
import { dirname, join } from 'path'
|
||||
import { VideoFileMetadata } from '@shared/models/videos/video-file-metadata'
|
||||
import { getMaxBitrate, getTargetBitrate, VideoResolution } from '../../shared/models/videos'
|
||||
import { 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 { logger } from './logger'
|
||||
import { checkFFmpegEncoders } from '../initializers/checker-before-init'
|
||||
import { readFile, remove, writeFile } from 'fs-extra'
|
||||
import { CONFIG } from '../initializers/config'
|
||||
import { VideoFileMetadata } from '@shared/models/videos/video-file-metadata'
|
||||
|
||||
/**
|
||||
* A toolbox to play with audio
|
||||
|
@ -74,9 +74,12 @@ namespace audio {
|
|||
}
|
||||
}
|
||||
|
||||
function computeResolutionsToTranscode (videoFileResolution: number) {
|
||||
function computeResolutionsToTranscode (videoFileResolution: number, type: 'vod' | 'live') {
|
||||
const configResolutions = type === 'vod'
|
||||
? CONFIG.TRANSCODING.RESOLUTIONS
|
||||
: CONFIG.LIVE.TRANSCODING.RESOLUTIONS
|
||||
|
||||
const resolutionsEnabled: number[] = []
|
||||
const configResolutions = CONFIG.TRANSCODING.RESOLUTIONS
|
||||
|
||||
// Put in the order we want to proceed jobs
|
||||
const resolutions = [
|
||||
|
@ -270,13 +273,13 @@ type TranscodeOptions =
|
|||
function transcode (options: TranscodeOptions) {
|
||||
return new Promise<void>(async (res, rej) => {
|
||||
try {
|
||||
let command = ffmpeg(options.inputPath, { niceness: FFMPEG_NICE.TRANSCODING })
|
||||
let command = getFFmpeg(options.inputPath)
|
||||
.output(options.outputPath)
|
||||
|
||||
if (options.type === 'quick-transcode') {
|
||||
command = buildQuickTranscodeCommand(command)
|
||||
} else if (options.type === 'hls') {
|
||||
command = await buildHLSCommand(command, options)
|
||||
command = await buildHLSVODCommand(command, options)
|
||||
} else if (options.type === 'merge-audio') {
|
||||
command = await buildAudioMergeCommand(command, options)
|
||||
} else if (options.type === 'only-audio') {
|
||||
|
@ -285,11 +288,6 @@ function transcode (options: TranscodeOptions) {
|
|||
command = await buildx264Command(command, options)
|
||||
}
|
||||
|
||||
if (CONFIG.TRANSCODING.THREADS > 0) {
|
||||
// if we don't set any threads ffmpeg will chose automatically
|
||||
command = command.outputOption('-threads ' + CONFIG.TRANSCODING.THREADS)
|
||||
}
|
||||
|
||||
command
|
||||
.on('error', (err, stdout, stderr) => {
|
||||
logger.error('Error in transcoding job.', { stdout, stderr })
|
||||
|
@ -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 {
|
||||
getVideoStreamCodec,
|
||||
getAudioStreamCodec,
|
||||
runLiveMuxing,
|
||||
convertWebPToJPG,
|
||||
getVideoStreamSize,
|
||||
getVideoFileResolution,
|
||||
getMetadataFromFile,
|
||||
getDurationFromVideoFile,
|
||||
runLiveTranscoding,
|
||||
generateImageFromVideoFile,
|
||||
TranscodeOptions,
|
||||
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) {
|
||||
let fps = await getVideoFileFPS(options.inputPath)
|
||||
if (
|
||||
|
@ -437,7 +531,7 @@ function buildQuickTranscodeCommand (command: ffmpeg.FfmpegCommand) {
|
|||
return command
|
||||
}
|
||||
|
||||
async function buildHLSCommand (command: ffmpeg.FfmpegCommand, options: HLSTranscodeOptions) {
|
||||
async function buildHLSVODCommand (command: ffmpeg.FfmpegCommand, options: HLSTranscodeOptions) {
|
||||
const videoPath = getHLSVideoPath(options)
|
||||
|
||||
if (options.copyCodecs) command = presetCopy(command)
|
||||
|
@ -507,13 +601,10 @@ async function presetH264 (command: ffmpeg.FfmpegCommand, input: string, resolut
|
|||
let localCommand = command
|
||||
.format('mp4')
|
||||
.videoCodec('libx264')
|
||||
.outputOption('-level 3.1') // 3.1 is the minimal resource allocation for our highest supported resolution
|
||||
.outputOption('-b_strategy 1') // NOTE: b-strategy 1 - heuristic algorithm, 16 is optimal B-frames for it
|
||||
.outputOption('-bf 16') // NOTE: Why 16: https://github.com/Chocobozzz/PeerTube/pull/774. b-strategy 2 -> B-frames<16
|
||||
.outputOption('-pix_fmt yuv420p') // allows import of source material with incompatible pixel formats (e.g. MJPEG video)
|
||||
.outputOption('-map_metadata -1') // strip all metadata
|
||||
.outputOption('-movflags faststart')
|
||||
|
||||
addDefaultX264Params(localCommand)
|
||||
|
||||
const parsedAudio = await audio.get(input)
|
||||
|
||||
if (!parsedAudio.audioStream) {
|
||||
|
@ -564,3 +655,14 @@ function presetOnlyAudio (command: ffmpeg.FfmpegCommand): ffmpeg.FfmpegCommand {
|
|||
.audioCodec('copy')
|
||||
.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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -37,8 +37,13 @@ function checkMissedConfig () {
|
|||
'remote_redundancy.videos.accept_from',
|
||||
'federation.videos.federate_unlisted',
|
||||
'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 = [
|
||||
[ // set
|
||||
[ 'redis.hostname', 'redis.port' ], // alternative
|
||||
|
|
|
@ -198,6 +198,30 @@ const CONFIG = {
|
|||
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: {
|
||||
VIDEOS: {
|
||||
HTTP: {
|
||||
|
|
|
@ -23,7 +23,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
|
|||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LAST_MIGRATION_VERSION = 530
|
||||
const LAST_MIGRATION_VERSION = 540
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
@ -50,7 +50,8 @@ const WEBSERVER = {
|
|||
SCHEME: '',
|
||||
WS: '',
|
||||
HOSTNAME: '',
|
||||
PORT: 0
|
||||
PORT: 0,
|
||||
RTMP_URL: ''
|
||||
}
|
||||
|
||||
// Sortable columns per schema
|
||||
|
@ -138,7 +139,8 @@ const JOB_ATTEMPTS: { [id in JobType]: number } = {
|
|||
'email': 5,
|
||||
'videos-views': 1,
|
||||
'activitypub-refresher': 1,
|
||||
'video-redundancy': 1
|
||||
'video-redundancy': 1,
|
||||
'video-live-ending': 1
|
||||
}
|
||||
const JOB_CONCURRENCY: { [id in JobType]: number } = {
|
||||
'activitypub-http-broadcast': 1,
|
||||
|
@ -151,7 +153,8 @@ const JOB_CONCURRENCY: { [id in JobType]: number } = {
|
|||
'email': 5,
|
||||
'videos-views': 1,
|
||||
'activitypub-refresher': 1,
|
||||
'video-redundancy': 1
|
||||
'video-redundancy': 1,
|
||||
'video-live-ending': 1
|
||||
}
|
||||
const JOB_TTL: { [id in JobType]: number } = {
|
||||
'activitypub-http-broadcast': 60000 * 10, // 10 minutes
|
||||
|
@ -164,7 +167,8 @@ const JOB_TTL: { [id in JobType]: number } = {
|
|||
'email': 60000 * 10, // 10 minutes
|
||||
'videos-views': undefined, // Unlimited
|
||||
'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 } = {
|
||||
'videos-views': {
|
||||
|
@ -264,7 +268,7 @@ const CONSTRAINTS_FIELDS = {
|
|||
VIEWS: { min: 0 },
|
||||
LIKES: { min: 0 },
|
||||
DISLIKES: { min: 0 },
|
||||
FILE_SIZE: { min: 10 },
|
||||
FILE_SIZE: { min: -1 },
|
||||
URL: { min: 3, max: 2000 } // Length
|
||||
},
|
||||
VIDEO_PLAYLISTS: {
|
||||
|
@ -370,39 +374,41 @@ const VIDEO_LICENCES = {
|
|||
|
||||
const VIDEO_LANGUAGES: { [id: string]: string } = {}
|
||||
|
||||
const VIDEO_PRIVACIES = {
|
||||
const VIDEO_PRIVACIES: { [ id in VideoPrivacy ]: string } = {
|
||||
[VideoPrivacy.PUBLIC]: 'Public',
|
||||
[VideoPrivacy.UNLISTED]: 'Unlisted',
|
||||
[VideoPrivacy.PRIVATE]: 'Private',
|
||||
[VideoPrivacy.INTERNAL]: 'Internal'
|
||||
}
|
||||
|
||||
const VIDEO_STATES = {
|
||||
const VIDEO_STATES: { [ id in VideoState ]: string } = {
|
||||
[VideoState.PUBLISHED]: 'Published',
|
||||
[VideoState.TO_TRANSCODE]: 'To transcode',
|
||||
[VideoState.TO_IMPORT]: 'To import'
|
||||
[VideoState.TO_IMPORT]: 'To import',
|
||||
[VideoState.WAITING_FOR_LIVE]: 'Waiting for livestream',
|
||||
[VideoState.LIVE_ENDED]: 'Livestream ended'
|
||||
}
|
||||
|
||||
const VIDEO_IMPORT_STATES = {
|
||||
const VIDEO_IMPORT_STATES: { [ id in VideoImportState ]: string } = {
|
||||
[VideoImportState.FAILED]: 'Failed',
|
||||
[VideoImportState.PENDING]: 'Pending',
|
||||
[VideoImportState.SUCCESS]: 'Success',
|
||||
[VideoImportState.REJECTED]: 'Rejected'
|
||||
}
|
||||
|
||||
const ABUSE_STATES = {
|
||||
const ABUSE_STATES: { [ id in AbuseState ]: string } = {
|
||||
[AbuseState.PENDING]: 'Pending',
|
||||
[AbuseState.REJECTED]: 'Rejected',
|
||||
[AbuseState.ACCEPTED]: 'Accepted'
|
||||
}
|
||||
|
||||
const VIDEO_PLAYLIST_PRIVACIES = {
|
||||
const VIDEO_PLAYLIST_PRIVACIES: { [ id in VideoPlaylistPrivacy ]: string } = {
|
||||
[VideoPlaylistPrivacy.PUBLIC]: 'Public',
|
||||
[VideoPlaylistPrivacy.UNLISTED]: 'Unlisted',
|
||||
[VideoPlaylistPrivacy.PRIVATE]: 'Private'
|
||||
}
|
||||
|
||||
const VIDEO_PLAYLIST_TYPES = {
|
||||
const VIDEO_PLAYLIST_TYPES: { [ id in VideoPlaylistType ]: string } = {
|
||||
[VideoPlaylistType.REGULAR]: 'Regular',
|
||||
[VideoPlaylistType.WATCH_LATER]: 'Watch later'
|
||||
}
|
||||
|
@ -600,9 +606,24 @@ const LRU_CACHE = {
|
|||
const HLS_STREAMING_PLAYLIST_DIRECTORY = join(CONFIG.STORAGE.STREAMING_PLAYLISTS_DIR, 'hls')
|
||||
const HLS_REDUNDANCY_DIRECTORY = join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls')
|
||||
|
||||
const VIDEO_LIVE = {
|
||||
EXTENSION: '.ts',
|
||||
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 = {
|
||||
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 = {
|
||||
|
@ -622,7 +643,8 @@ const REDUNDANCY = {
|
|||
const ACCEPT_HEADERS = [ 'html', 'application/json' ].concat(ACTIVITY_PUB.POTENTIAL_ACCEPT_HEADERS)
|
||||
|
||||
const ASSETS_PATH = {
|
||||
DEFAULT_AUDIO_BACKGROUND: join(root(), 'dist', 'server', 'assets', 'default-audio-background.jpg')
|
||||
DEFAULT_AUDIO_BACKGROUND: join(root(), 'dist', 'server', 'assets', 'default-audio-background.jpg'),
|
||||
DEFAULT_LIVE_BACKGROUND: join(root(), 'dist', 'server', 'assets', 'default-live-background.jpg')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
@ -688,9 +710,9 @@ if (isTestInstance() === true) {
|
|||
STATIC_MAX_AGE.SERVER = '0'
|
||||
|
||||
ACTIVITY_PUB.COLLECTION_ITEMS_PER_PAGE = 2
|
||||
ACTIVITY_PUB.ACTOR_REFRESH_INTERVAL = 10 * 1000 // 10 seconds
|
||||
ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL = 10 * 1000 // 10 seconds
|
||||
ACTIVITY_PUB.VIDEO_PLAYLIST_REFRESH_INTERVAL = 10 * 1000 // 10 seconds
|
||||
ACTIVITY_PUB.ACTOR_REFRESH_INTERVAL = 100 * 10000 // 10 seconds
|
||||
ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL = 100 * 10000 // 10 seconds
|
||||
ACTIVITY_PUB.VIDEO_PLAYLIST_REFRESH_INTERVAL = 100 * 10000 // 10 seconds
|
||||
|
||||
CONSTRAINTS_FIELDS.ACTORS.AVATAR.FILE_SIZE.max = 100 * 1024 // 100KB
|
||||
|
||||
|
@ -737,6 +759,7 @@ const FILES_CONTENT_HASH = {
|
|||
export {
|
||||
WEBSERVER,
|
||||
API_VERSION,
|
||||
VIDEO_LIVE,
|
||||
PEERTUBE_VERSION,
|
||||
LAZY_STATIC_PATHS,
|
||||
SEARCH_INDEX,
|
||||
|
@ -892,10 +915,14 @@ function buildVideoMimetypeExt () {
|
|||
function updateWebserverUrls () {
|
||||
WEBSERVER.URL = sanitizeUrl(CONFIG.WEBSERVER.SCHEME + '://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT)
|
||||
WEBSERVER.HOST = sanitizeHost(CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.WEBSERVER.PORT, REMOTE_SCHEME.HTTP)
|
||||
WEBSERVER.SCHEME = CONFIG.WEBSERVER.SCHEME
|
||||
WEBSERVER.WS = CONFIG.WEBSERVER.WS
|
||||
|
||||
WEBSERVER.SCHEME = CONFIG.WEBSERVER.SCHEME
|
||||
WEBSERVER.HOSTNAME = CONFIG.WEBSERVER.HOSTNAME
|
||||
WEBSERVER.PORT = CONFIG.WEBSERVER.PORT
|
||||
WEBSERVER.PORT = CONFIG.WEBSERVER.PORT
|
||||
|
||||
WEBSERVER.RTMP_URL = 'rtmp://' + CONFIG.WEBSERVER.HOSTNAME + ':' + CONFIG.LIVE.RTMP.PORT + '/' + VIDEO_LIVE.RTMP.BASE_PATH
|
||||
}
|
||||
|
||||
function updateWebserverConfig () {
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { QueryTypes, Transaction } from 'sequelize'
|
||||
import { Sequelize as SequelizeTypescript } from 'sequelize-typescript'
|
||||
import { AbuseModel } from '@server/models/abuse/abuse'
|
||||
import { AbuseMessageModel } from '@server/models/abuse/abuse-message'
|
||||
import { VideoAbuseModel } from '@server/models/abuse/video-abuse'
|
||||
import { VideoCommentAbuseModel } from '@server/models/abuse/video-comment-abuse'
|
||||
import { isTestInstance } from '../helpers/core-utils'
|
||||
import { logger } from '../helpers/logger'
|
||||
import { AbuseModel } from '../models/abuse/abuse'
|
||||
import { AbuseMessageModel } from '../models/abuse/abuse-message'
|
||||
import { VideoAbuseModel } from '../models/abuse/video-abuse'
|
||||
import { VideoCommentAbuseModel } from '../models/abuse/video-comment-abuse'
|
||||
import { AccountModel } from '../models/account/account'
|
||||
import { AccountBlocklistModel } from '../models/account/account-blocklist'
|
||||
import { AccountVideoRateModel } from '../models/account/account-video-rate'
|
||||
|
@ -34,6 +34,7 @@ import { VideoChannelModel } from '../models/video/video-channel'
|
|||
import { VideoCommentModel } from '../models/video/video-comment'
|
||||
import { VideoFileModel } from '../models/video/video-file'
|
||||
import { VideoImportModel } from '../models/video/video-import'
|
||||
import { VideoLiveModel } from '../models/video/video-live'
|
||||
import { VideoPlaylistModel } from '../models/video/video-playlist'
|
||||
import { VideoPlaylistElementModel } from '../models/video/video-playlist-element'
|
||||
import { VideoShareModel } from '../models/video/video-share'
|
||||
|
@ -118,6 +119,7 @@ async function initDatabaseModels (silent: boolean) {
|
|||
VideoViewModel,
|
||||
VideoRedundancyModel,
|
||||
UserVideoHistoryModel,
|
||||
VideoLiveModel,
|
||||
AccountBlocklistModel,
|
||||
ServerBlocklistModel,
|
||||
UserNotificationModel,
|
||||
|
|
39
server/initializers/migrations/0535-video-live.ts
Normal file
39
server/initializers/migrations/0535-video-live.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import * as Sequelize from 'sequelize'
|
||||
|
||||
async function up (utils: {
|
||||
transaction: Sequelize.Transaction
|
||||
queryInterface: Sequelize.QueryInterface
|
||||
sequelize: Sequelize.Sequelize
|
||||
}): Promise<void> {
|
||||
{
|
||||
const query = `
|
||||
CREATE TABLE IF NOT EXISTS "videoLive" (
|
||||
"id" SERIAL ,
|
||||
"streamKey" VARCHAR(255),
|
||||
"videoId" INTEGER NOT NULL REFERENCES "video" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
`
|
||||
|
||||
await utils.sequelize.query(query)
|
||||
}
|
||||
|
||||
{
|
||||
await utils.queryInterface.addColumn('video', 'isLive', {
|
||||
type: Sequelize.BOOLEAN,
|
||||
defaultValue: false,
|
||||
allowNull: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function down (options) {
|
||||
throw new Error('Not implemented.')
|
||||
}
|
||||
|
||||
export {
|
||||
up,
|
||||
down
|
||||
}
|
26
server/initializers/migrations/0540-video-file-infohash.ts
Normal file
26
server/initializers/migrations/0540-video-file-infohash.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import * as Sequelize from 'sequelize'
|
||||
|
||||
async function up (utils: {
|
||||
transaction: Sequelize.Transaction
|
||||
queryInterface: Sequelize.QueryInterface
|
||||
sequelize: Sequelize.Sequelize
|
||||
}): Promise<void> {
|
||||
{
|
||||
const data = {
|
||||
type: Sequelize.STRING,
|
||||
defaultValue: null,
|
||||
allowNull: true
|
||||
}
|
||||
|
||||
await utils.queryInterface.changeColumn('videoFile', 'infoHash', data)
|
||||
}
|
||||
}
|
||||
|
||||
function down (options) {
|
||||
throw new Error('Not implemented.')
|
||||
}
|
||||
|
||||
export {
|
||||
up,
|
||||
down
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
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 { VideoCommentObject } from '../../../../shared/models/activitypub/objects/video-comment-object'
|
||||
import { retryTransactionWrapper } from '../../../helpers/database-utils'
|
||||
|
@ -52,7 +52,7 @@ export {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 { video, created } = await getOrCreateVideoAndAccountAndChannel({ videoObject: videoToCreateData, syncParam })
|
||||
|
|
|
@ -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 { resetSequelizeInstance, retryTransactionWrapper } from '../../../helpers/database-utils'
|
||||
import { logger } from '../../../helpers/logger'
|
||||
|
@ -55,7 +55,7 @@ export {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processUpdateVideo (actor: MActorSignature, activity: ActivityUpdate) {
|
||||
const videoObject = activity.object as VideoTorrentObject
|
||||
const videoObject = activity.object as VideoObject
|
||||
|
||||
if (sanitizeAndCheckVideoTorrentObject(videoObject) === false) {
|
||||
logger.debug('Video sent by update is not valid.', { videoObject })
|
||||
|
|
|
@ -15,7 +15,7 @@ import {
|
|||
ActivityVideoUrlObject,
|
||||
VideoState
|
||||
} from '../../../shared/index'
|
||||
import { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
|
||||
import { VideoObject } from '../../../shared/models/activitypub/objects'
|
||||
import { VideoPrivacy } from '../../../shared/models/videos'
|
||||
import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
|
||||
import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
|
||||
|
@ -38,7 +38,6 @@ import {
|
|||
} from '../../initializers/constants'
|
||||
import { sequelizeTypescript } from '../../initializers/database'
|
||||
import { AccountVideoRateModel } from '../../models/account/account-video-rate'
|
||||
import { TagModel } from '../../models/video/tag'
|
||||
import { VideoModel } from '../../models/video/video'
|
||||
import { VideoCaptionModel } from '../../models/video/video-caption'
|
||||
import { VideoCommentModel } from '../../models/video/video-comment'
|
||||
|
@ -67,7 +66,9 @@ import { FilteredModelAttributes } from '../../types/sequelize'
|
|||
import { ActorFollowScoreCache } from '../files-cache'
|
||||
import { JobQueue } from '../job-queue'
|
||||
import { Notifier } from '../notifier'
|
||||
import { PeerTubeSocket } from '../peertube-socket'
|
||||
import { createPlaceholderThumbnail, createVideoMiniatureFromUrl } from '../thumbnail'
|
||||
import { setVideoTags } from '../video'
|
||||
import { autoBlacklistVideoIfNeeded } from '../video-blacklist'
|
||||
import { getOrCreateActorAndServerAndModel } from './actor'
|
||||
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 = {
|
||||
uri: videoUrl,
|
||||
method: 'GET',
|
||||
|
@ -135,7 +136,7 @@ async function fetchRemoteVideoDescription (video: MVideoAccountLight) {
|
|||
return body.description ? body.description : ''
|
||||
}
|
||||
|
||||
function getOrCreateVideoChannelFromVideoObject (videoObject: VideoTorrentObject) {
|
||||
function getOrCreateVideoChannelFromVideoObject (videoObject: VideoObject) {
|
||||
const channel = videoObject.attributedTo.find(a => a.type === 'Group')
|
||||
if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
|
||||
|
||||
|
@ -154,7 +155,7 @@ type SyncParam = {
|
|||
thumbnail: 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)
|
||||
|
||||
const jobPayloads: ActivitypubHttpFetcherPayload[] = []
|
||||
|
@ -293,7 +294,7 @@ async function getOrCreateVideoAndAccountAndChannel (
|
|||
|
||||
async function updateVideoFromAP (options: {
|
||||
video: MVideoAccountLightBlacklistAllFiles
|
||||
videoObject: VideoTorrentObject
|
||||
videoObject: VideoObject
|
||||
account: MAccountIdActor
|
||||
channel: MChannelDefault
|
||||
overrideTo?: string[]
|
||||
|
@ -348,6 +349,7 @@ async function updateVideoFromAP (options: {
|
|||
video.privacy = videoData.privacy
|
||||
video.channelId = videoData.channelId
|
||||
video.views = videoData.views
|
||||
video.isLive = videoData.isLive
|
||||
|
||||
const videoUpdated = await video.save(sequelizeOptions) as MVideoFullLight
|
||||
|
||||
|
@ -409,8 +411,7 @@ async function updateVideoFromAP (options: {
|
|||
const tags = videoObject.tag
|
||||
.filter(isAPHashTagObject)
|
||||
.map(tag => tag.name)
|
||||
const tagInstances = await TagModel.findOrCreateTags(tags, t)
|
||||
await videoUpdated.$set('Tags', tagInstances, sequelizeOptions)
|
||||
await setVideoTags({ video: videoUpdated, tags, transaction: t, defaultValue: videoUpdated.Tags })
|
||||
}
|
||||
|
||||
{
|
||||
|
@ -435,6 +436,7 @@ async function updateVideoFromAP (options: {
|
|||
})
|
||||
|
||||
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)
|
||||
|
||||
|
@ -538,7 +540,7 @@ function isAPHashTagObject (url: any): url is ActivityHashTagObject {
|
|||
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)
|
||||
|
||||
const videoData = await videoActivityObjectToDBAttributes(channel, videoObject, videoObject.to)
|
||||
|
@ -594,8 +596,7 @@ async function createVideo (videoObject: VideoTorrentObject, channel: MChannelAc
|
|||
const tags = videoObject.tag
|
||||
.filter(isAPHashTagObject)
|
||||
.map(t => t.name)
|
||||
const tagInstances = await TagModel.findOrCreateTags(tags, t)
|
||||
await videoCreated.$set('Tags', tagInstances, sequelizeOptions)
|
||||
await setVideoTags({ video: videoCreated, tags, transaction: t })
|
||||
|
||||
// Process captions
|
||||
const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
|
||||
|
@ -604,7 +605,6 @@ async function createVideo (videoObject: VideoTorrentObject, channel: MChannelAc
|
|||
await Promise.all(videoCaptionsPromises)
|
||||
|
||||
videoCreated.VideoFiles = videoFiles
|
||||
videoCreated.Tags = tagInstances
|
||||
|
||||
const autoBlacklisted = await autoBlacklistVideoIfNeeded({
|
||||
video: videoCreated,
|
||||
|
@ -634,7 +634,7 @@ async function createVideo (videoObject: VideoTorrentObject, channel: MChannelAc
|
|||
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)
|
||||
? VideoPrivacy.PUBLIC
|
||||
: VideoPrivacy.UNLISTED
|
||||
|
@ -666,6 +666,7 @@ function videoActivityObjectToDBAttributes (videoChannel: MChannelId, videoObjec
|
|||
commentsEnabled: videoObject.commentsEnabled,
|
||||
downloadEnabled: videoObject.downloadEnabled,
|
||||
waitTranscoding: videoObject.waitTranscoding,
|
||||
isLive: videoObject.isLiveBroadcast,
|
||||
state: videoObject.state,
|
||||
channelId: videoChannel.id,
|
||||
duration: parseInt(duration, 10),
|
||||
|
@ -734,7 +735,7 @@ function videoFileActivityUrlToDBAttributes (
|
|||
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[]
|
||||
if (playlistUrls.length === 0) return []
|
||||
|
||||
|
@ -768,7 +769,7 @@ function streamingPlaylistActivityUrlToDBAttributes (video: MVideoId, videoObjec
|
|||
return attributes
|
||||
}
|
||||
|
||||
function getThumbnailFromIcons (videoObject: VideoTorrentObject) {
|
||||
function getThumbnailFromIcons (videoObject: VideoObject) {
|
||||
let validIcons = videoObject.icon.filter(i => i.width > THUMBNAILS_SIZE.minWidth)
|
||||
// Fallback if there are not valid icons
|
||||
if (validIcons.length === 0) validIcons = videoObject.icon
|
||||
|
@ -776,7 +777,7 @@ function getThumbnailFromIcons (videoObject: VideoTorrentObject) {
|
|||
return minBy(validIcons, 'width')
|
||||
}
|
||||
|
||||
function getPreviewFromIcons (videoObject: VideoTorrentObject) {
|
||||
function getPreviewFromIcons (videoObject: VideoObject) {
|
||||
const validIcons = videoObject.icon.filter(i => i.width > PREVIEWS_SIZE.minWidth)
|
||||
|
||||
// FIXME: don't put a fallback here for compatibility with PeerTube <2.2
|
||||
|
|
|
@ -65,7 +65,7 @@ async function updateMasterHLSPlaylist (video: MVideoWithFile) {
|
|||
await writeFile(masterPlaylistPath, masterPlaylists.join('\n') + '\n')
|
||||
}
|
||||
|
||||
async function updateSha256Segments (video: MVideoWithFile) {
|
||||
async function updateSha256VODSegments (video: MVideoWithFile) {
|
||||
const json: { [filename: string]: { [range: string]: string } } = {}
|
||||
|
||||
const playlistDirectory = join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid)
|
||||
|
@ -101,6 +101,11 @@ async function updateSha256Segments (video: MVideoWithFile) {
|
|||
await outputJSON(outputPath, json)
|
||||
}
|
||||
|
||||
async function buildSha256Segment (segmentPath: string) {
|
||||
const buf = await readFile(segmentPath)
|
||||
return sha256(buf)
|
||||
}
|
||||
|
||||
function getRangesFromPlaylist (playlistContent: string) {
|
||||
const ranges: { offset: number, length: number }[] = []
|
||||
const lines = playlistContent.split('\n')
|
||||
|
@ -187,7 +192,8 @@ function downloadPlaylistSegments (playlistUrl: string, destinationDir: string,
|
|||
|
||||
export {
|
||||
updateMasterHLSPlaylist,
|
||||
updateSha256Segments,
|
||||
updateSha256VODSegments,
|
||||
buildSha256Segment,
|
||||
downloadPlaylistSegments,
|
||||
updateStreamingPlaylistsInfohashesIfNeeded
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import { extname } from 'path'
|
|||
import { addOptimizeOrMergeAudioJob } from '@server/helpers/video'
|
||||
import { isPostImportVideoAccepted } from '@server/lib/moderation'
|
||||
import { Hooks } from '@server/lib/plugins/hooks'
|
||||
import { isAbleToUploadVideo } from '@server/lib/user'
|
||||
import { getVideoFilePath } from '@server/lib/video-paths'
|
||||
import { MVideoImportDefault, MVideoImportDefaultFiles, MVideoImportVideo } from '@server/types/models/video/video-import'
|
||||
import {
|
||||
|
@ -108,7 +109,7 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid
|
|||
|
||||
// Get information about this video
|
||||
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) {
|
||||
throw new Error('The user video quota is exceeded with this video to import.')
|
||||
}
|
||||
|
|
47
server/lib/job-queue/handlers/video-live-ending.ts
Normal file
47
server/lib/job-queue/handlers/video-live-ending.ts
Normal 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
|
||||
}
|
|
@ -84,7 +84,7 @@ async function onVideoFileOptimizerSuccess (videoArg: MVideoWithFile, payload: O
|
|||
if (!videoDatabase) return undefined
|
||||
|
||||
// Create transcoding jobs if there are enabled resolutions
|
||||
const resolutionsEnabled = computeResolutionsToTranscode(videoFileResolution)
|
||||
const resolutionsEnabled = computeResolutionsToTranscode(videoFileResolution, 'vod')
|
||||
logger.info(
|
||||
'Resolutions computed for video %s and origin file resolution of %d.', videoDatabase.uuid, videoFileResolution,
|
||||
{ resolutions: resolutionsEnabled }
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
RefreshPayload,
|
||||
VideoFileImportPayload,
|
||||
VideoImportPayload,
|
||||
VideoLiveEndingPayload,
|
||||
VideoRedundancyPayload,
|
||||
VideoTranscodingPayload
|
||||
} from '../../../shared/models'
|
||||
|
@ -27,6 +28,7 @@ import { processVideosViews } from './handlers/video-views'
|
|||
import { refreshAPObject } from './handlers/activitypub-refresher'
|
||||
import { processVideoFileImport } from './handlers/video-file-import'
|
||||
import { processVideoRedundancy } from '@server/lib/job-queue/handlers/video-redundancy'
|
||||
import { processVideoLiveEnding } from './handlers/video-live-ending'
|
||||
|
||||
type CreateJobArgument =
|
||||
{ type: 'activitypub-http-broadcast', payload: ActivitypubHttpBroadcastPayload } |
|
||||
|
@ -39,8 +41,13 @@ type CreateJobArgument =
|
|||
{ type: 'video-import', payload: VideoImportPayload } |
|
||||
{ type: 'activitypub-refresher', payload: RefreshPayload } |
|
||||
{ type: 'videos-views', payload: {} } |
|
||||
{ type: 'video-live-ending', payload: VideoLiveEndingPayload } |
|
||||
{ type: 'video-redundancy', payload: VideoRedundancyPayload }
|
||||
|
||||
type CreateJobOptions = {
|
||||
delay?: number
|
||||
}
|
||||
|
||||
const handlers: { [id in JobType]: (job: Bull.Job) => Promise<any> } = {
|
||||
'activitypub-http-broadcast': processActivityPubHttpBroadcast,
|
||||
'activitypub-http-unicast': processActivityPubHttpUnicast,
|
||||
|
@ -52,6 +59,7 @@ const handlers: { [id in JobType]: (job: Bull.Job) => Promise<any> } = {
|
|||
'video-import': processVideoImport,
|
||||
'videos-views': processVideosViews,
|
||||
'activitypub-refresher': refreshAPObject,
|
||||
'video-live-ending': processVideoLiveEnding,
|
||||
'video-redundancy': processVideoRedundancy
|
||||
}
|
||||
|
||||
|
@ -66,7 +74,8 @@ const jobTypes: JobType[] = [
|
|||
'video-import',
|
||||
'videos-views',
|
||||
'activitypub-refresher',
|
||||
'video-redundancy'
|
||||
'video-redundancy',
|
||||
'video-live-ending'
|
||||
]
|
||||
|
||||
class JobQueue {
|
||||
|
@ -122,12 +131,12 @@ class JobQueue {
|
|||
}
|
||||
}
|
||||
|
||||
createJob (obj: CreateJobArgument): void {
|
||||
this.createJobWithPromise(obj)
|
||||
createJob (obj: CreateJobArgument, options: CreateJobOptions = {}): void {
|
||||
this.createJobWithPromise(obj, options)
|
||||
.catch(err => logger.error('Cannot create job.', { err, obj }))
|
||||
}
|
||||
|
||||
createJobWithPromise (obj: CreateJobArgument) {
|
||||
createJobWithPromise (obj: CreateJobArgument, options: CreateJobOptions = {}) {
|
||||
const queue = this.queues[obj.type]
|
||||
if (queue === undefined) {
|
||||
logger.error('Unknown queue %s: cannot create job.', obj.type)
|
||||
|
@ -137,7 +146,8 @@ class JobQueue {
|
|||
const jobArgs: Bull.JobOptions = {
|
||||
backoff: { delay: 60 * 1000, type: 'exponential' },
|
||||
attempts: JOB_ATTEMPTS[obj.type],
|
||||
timeout: JOB_TTL[obj.type]
|
||||
timeout: JOB_TTL[obj.type],
|
||||
delay: options.delay
|
||||
}
|
||||
|
||||
return queue.add(obj.payload, jobArgs)
|
||||
|
|
412
server/lib/live-manager.ts
Normal file
412
server/lib/live-manager.ts
Normal 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
|
||||
}
|
|
@ -18,7 +18,7 @@ import {
|
|||
MVideoAccountLightBlacklistAllFiles
|
||||
} from '@server/types/models'
|
||||
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 { VideoCreate, VideoImportCreate } from '../../shared/models/videos'
|
||||
import { VideoCommentCreate } from '../../shared/models/videos/video-comment.model'
|
||||
|
@ -62,7 +62,7 @@ function isLocalVideoCommentReplyAccepted (_object: {
|
|||
|
||||
function isRemoteVideoAccepted (_object: {
|
||||
activity: ActivityCreate
|
||||
videoAP: VideoTorrentObject
|
||||
videoAP: VideoObject
|
||||
byActor: ActorModel
|
||||
}): AcceptResult {
|
||||
return { accepted: true }
|
||||
|
|
|
@ -1,14 +1,18 @@
|
|||
import * as SocketIO from 'socket.io'
|
||||
import { authenticateSocket } from '../middlewares'
|
||||
import { logger } from '../helpers/logger'
|
||||
import { Socket } from 'dgram'
|
||||
import { Server } from 'http'
|
||||
import * as SocketIO from 'socket.io'
|
||||
import { MVideo } from '@server/types/models'
|
||||
import { UserNotificationModelForApi } from '@server/types/models/user'
|
||||
import { LiveVideoEventPayload, LiveVideoEventType } from '@shared/models'
|
||||
import { logger } from '../helpers/logger'
|
||||
import { authenticateSocket } from '../middlewares'
|
||||
|
||||
class PeerTubeSocket {
|
||||
|
||||
private static instance: PeerTubeSocket
|
||||
|
||||
private userNotificationSockets: { [ userId: number ]: SocketIO.Socket[] } = {}
|
||||
private liveVideosNamespace: SocketIO.Namespace
|
||||
|
||||
private constructor () {}
|
||||
|
||||
|
@ -32,19 +36,37 @@ class PeerTubeSocket {
|
|||
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) {
|
||||
const sockets = this.userNotificationSockets[userId]
|
||||
|
||||
if (!sockets) return
|
||||
|
||||
logger.debug('Sending user notification to user %d.', userId)
|
||||
|
||||
const notificationMessage = notification.toFormattedJSON()
|
||||
for (const socket of sockets) {
|
||||
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 () {
|
||||
return this.instance || (this.instance = new this())
|
||||
}
|
||||
|
|
|
@ -42,15 +42,18 @@ function createVideoMiniatureFromUrl (fileUrl: string, video: MVideoThumbnail, t
|
|||
return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail, fileUrl })
|
||||
}
|
||||
|
||||
function createVideoMiniatureFromExisting (
|
||||
inputPath: string,
|
||||
video: MVideoThumbnail,
|
||||
type: ThumbnailType,
|
||||
automaticallyGenerated: boolean,
|
||||
function createVideoMiniatureFromExisting (options: {
|
||||
inputPath: string
|
||||
video: MVideoThumbnail
|
||||
type: ThumbnailType
|
||||
automaticallyGenerated: boolean
|
||||
size?: ImageSize
|
||||
) {
|
||||
keepOriginal?: boolean
|
||||
}) {
|
||||
const { inputPath, video, type, automaticallyGenerated, size, keepOriginal } = options
|
||||
|
||||
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 })
|
||||
}
|
||||
|
|
|
@ -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 { Redis } from './redis'
|
||||
import { Emailer } from './emailer'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
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 { MUser, MUserDefault, MUserId } from '../types/models/user'
|
||||
import { buildActorInstance, setAsyncActorKeys } from './activitypub/actor'
|
||||
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 }
|
||||
|
||||
|
@ -116,13 +120,61 @@ async function sendVerifyUserEmail (user: MUser, isPendingEmail = false) {
|
|||
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 {
|
||||
getOriginalVideoFileTotalFromUser,
|
||||
getOriginalVideoFileTotalDailyFromUser,
|
||||
createApplicationActor,
|
||||
createUserAccountAndChannelAndPlaylist,
|
||||
createLocalAccountWithoutKeys,
|
||||
sendVerifyUserEmail
|
||||
sendVerifyUserEmail,
|
||||
isAbleToUploadVideo
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
@ -17,6 +17,7 @@ import { sendDeleteVideo } from './activitypub/send'
|
|||
import { federateVideoIfNeeded } from './activitypub/videos'
|
||||
import { Notifier } from './notifier'
|
||||
import { Hooks } from './plugins/hooks'
|
||||
import { LiveManager } from './live-manager'
|
||||
|
||||
async function autoBlacklistVideoIfNeeded (parameters: {
|
||||
video: MVideoWithBlacklistLight
|
||||
|
@ -73,6 +74,10 @@ async function blacklistVideo (videoInstance: MVideoAccountLight, options: Video
|
|||
await sendDeleteVideo(videoInstance, undefined)
|
||||
}
|
||||
|
||||
if (videoInstance.isLive) {
|
||||
LiveManager.Instance.stopSessionOf(videoInstance.id)
|
||||
}
|
||||
|
||||
Notifier.Instance.notifyOnVideoBlacklist(blacklist)
|
||||
}
|
||||
|
||||
|
|
|
@ -27,7 +27,8 @@ function generateWebTorrentVideoName (uuid: string, resolution: number, extname:
|
|||
function getVideoFilePath (videoOrPlaylist: MVideo | MStreamingPlaylistVideo, videoFile: MVideoFile, isRedundancy = false) {
|
||||
if (isStreamingPlaylist(videoOrPlaylist)) {
|
||||
const video = extractVideo(videoOrPlaylist)
|
||||
return join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid, getVideoFilename(videoOrPlaylist, videoFile))
|
||||
|
||||
return join(getHLSDirectory(video), getVideoFilename(videoOrPlaylist, videoFile))
|
||||
}
|
||||
|
||||
const baseDir = isRedundancy ? CONFIG.STORAGE.REDUNDANCY_DIR : CONFIG.STORAGE.VIDEOS_DIR
|
||||
|
|
|
@ -13,13 +13,14 @@ import { copyFile, ensureDir, move, remove, stat } from 'fs-extra'
|
|||
import { logger } from '../helpers/logger'
|
||||
import { VideoResolution } from '../../shared/models/videos'
|
||||
import { VideoFileModel } from '../models/video/video-file'
|
||||
import { updateMasterHLSPlaylist, updateSha256Segments } from './hls'
|
||||
import { updateMasterHLSPlaylist, updateSha256VODSegments } from './hls'
|
||||
import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
|
||||
import { VideoStreamingPlaylistType } from '../../shared/models/videos/video-streaming-playlist.type'
|
||||
import { CONFIG } from '../initializers/config'
|
||||
import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoWithAllFiles, MVideoWithFile } from '@server/types/models'
|
||||
import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
|
||||
import { generateVideoStreamingPlaylistName, getVideoFilename, getVideoFilePath } from './video-paths'
|
||||
import { spawn } from 'child_process'
|
||||
|
||||
/**
|
||||
* Optimize the original video file and replace it. The resolution is not changed.
|
||||
|
@ -182,7 +183,7 @@ async function generateHlsPlaylist (video: MVideoWithFile, resolution: VideoReso
|
|||
const [ videoStreamingPlaylist ] = await VideoStreamingPlaylistModel.upsert({
|
||||
videoId: video.id,
|
||||
playlistUrl,
|
||||
segmentsSha256Url: WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsSha256SegmentsStaticPath(video.uuid),
|
||||
segmentsSha256Url: WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsSha256SegmentsStaticPath(video.uuid, video.isLive),
|
||||
p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrl, video.VideoFiles),
|
||||
p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
|
||||
|
||||
|
@ -213,7 +214,7 @@ async function generateHlsPlaylist (video: MVideoWithFile, resolution: VideoReso
|
|||
video.setHLSPlaylist(videoStreamingPlaylist)
|
||||
|
||||
await updateMasterHLSPlaylist(video)
|
||||
await updateSha256Segments(video)
|
||||
await updateSha256VODSegments(video)
|
||||
|
||||
return video
|
||||
}
|
||||
|
|
87
server/lib/video.ts
Normal file
87
server/lib/video.ts
Normal 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
|
||||
}
|
|
@ -1,12 +1,13 @@
|
|||
import * as express from 'express'
|
||||
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 { 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 { isEmailEnabled } from '@server/initializers/config'
|
||||
import { areValidationErrors } from './utils'
|
||||
|
||||
const customConfigUpdateValidator = [
|
||||
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.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.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.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.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.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'),
|
||||
|
@ -71,8 +85,9 @@ const customConfigUpdateValidator = [
|
|||
logger.debug('Checking customConfigUpdateValidator parameters', { parameters: req.body })
|
||||
|
||||
if (areValidationErrors(req, res)) return
|
||||
if (!checkInvalidConfigIfEmailDisabled(req.body as CustomConfig, res)) return
|
||||
if (!checkInvalidTranscodingConfig(req.body as CustomConfig, res)) return
|
||||
if (!checkInvalidConfigIfEmailDisabled(req.body, res)) return
|
||||
if (!checkInvalidTranscodingConfig(req.body, res)) return
|
||||
if (!checkInvalidLiveConfig(req.body, res)) return
|
||||
|
||||
return next()
|
||||
}
|
||||
|
@ -109,3 +124,16 @@ function checkInvalidTranscodingConfig (customConfig: CustomConfig, res: express
|
|||
|
||||
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
|
||||
}
|
||||
|
|
|
@ -497,7 +497,7 @@ export {
|
|||
|
||||
function checkUserIdExist (idArg: number | string, res: express.Response, withStats = false) {
|
||||
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) {
|
||||
|
|
66
server/middlewares/validators/videos/video-live.ts
Normal file
66
server/middlewares/validators/videos/video-live.ts
Normal file
|
@ -0,0 +1,66 @@
|
|||
import * as express from 'express'
|
||||
import { body, param } from 'express-validator'
|
||||
import { checkUserCanManageVideo, doesVideoChannelOfAccountExist, doesVideoExist } from '@server/helpers/middlewares/videos'
|
||||
import { UserRight } from '@shared/models'
|
||||
import { isIdOrUUIDValid, isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc'
|
||||
import { isVideoNameValid } from '../../../helpers/custom-validators/videos'
|
||||
import { cleanUpReqFiles } from '../../../helpers/express-utils'
|
||||
import { logger } from '../../../helpers/logger'
|
||||
import { CONFIG } from '../../../initializers/config'
|
||||
import { areValidationErrors } from '../utils'
|
||||
import { getCommonVideoEditAttributes } from './videos'
|
||||
import { VideoLiveModel } from '@server/models/video/video-live'
|
||||
|
||||
const videoLiveGetValidator = [
|
||||
param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),
|
||||
|
||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.debug('Checking videoLiveGetValidator parameters', { parameters: req.body })
|
||||
|
||||
if (areValidationErrors(req, res)) return
|
||||
if (!await doesVideoExist(req.params.videoId, res, 'all')) return
|
||||
|
||||
// Check if the user who did the request is able to update the video
|
||||
const user = res.locals.oauth.token.User
|
||||
if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return
|
||||
|
||||
const videoLive = await VideoLiveModel.loadByVideoId(res.locals.videoAll.id)
|
||||
if (!videoLive) return res.sendStatus(404)
|
||||
|
||||
res.locals.videoLive = videoLive
|
||||
|
||||
return next()
|
||||
}
|
||||
]
|
||||
|
||||
const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
|
||||
body('channelId')
|
||||
.customSanitizer(toIntOrNull)
|
||||
.custom(isIdValid).withMessage('Should have correct video channel id'),
|
||||
|
||||
body('name')
|
||||
.custom(isVideoNameValid).withMessage('Should have a valid name'),
|
||||
|
||||
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.debug('Checking videoLiveAddValidator parameters', { parameters: req.body })
|
||||
|
||||
if (CONFIG.LIVE.ENABLED !== true) {
|
||||
return res.status(403)
|
||||
.json({ error: 'Live is not enabled on this instance' })
|
||||
}
|
||||
|
||||
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
|
||||
|
||||
const user = res.locals.oauth.token.User
|
||||
if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
|
||||
|
||||
return next()
|
||||
}
|
||||
])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
videoLiveAddValidator,
|
||||
videoLiveGetValidator
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import * as express from 'express'
|
||||
import { body, param, query, ValidationChain } from 'express-validator'
|
||||
import { isAbleToUploadVideo } from '@server/lib/user'
|
||||
import { getServerActor } from '@server/models/application/application'
|
||||
import { MVideoFullLight } from '@server/types/models'
|
||||
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 user.isAbleToUploadVideo(videoFile) === false) {
|
||||
if (await isAbleToUploadVideo(user.id, videoFile.size) === false) {
|
||||
res.status(403)
|
||||
.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 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) {
|
||||
res.status(403)
|
||||
.json({ error: 'The user video quota is exceeded with this video.' })
|
||||
|
|
|
@ -23,6 +23,7 @@ import {
|
|||
} from 'sequelize-typescript'
|
||||
import {
|
||||
MMyUserFormattable,
|
||||
MUser,
|
||||
MUserDefault,
|
||||
MUserFormattable,
|
||||
MUserId,
|
||||
|
@ -70,6 +71,7 @@ import { VideoImportModel } from '../video/video-import'
|
|||
import { VideoPlaylistModel } from '../video/video-playlist'
|
||||
import { AccountModel } from './account'
|
||||
import { UserNotificationSettingModel } from './user-notification-setting'
|
||||
import { VideoLiveModel } from '../video/video-live'
|
||||
|
||||
enum ScopeNames {
|
||||
FOR_ME_API = 'FOR_ME_API',
|
||||
|
@ -540,7 +542,11 @@ export class UserModel extends Model<UserModel> {
|
|||
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 = [
|
||||
ScopeNames.WITH_VIDEOCHANNELS
|
||||
]
|
||||
|
@ -685,26 +691,85 @@ export class UserModel extends Model<UserModel> {
|
|||
return UserModel.findOne(query)
|
||||
}
|
||||
|
||||
static getOriginalVideoFileTotalFromUser (user: MUserId) {
|
||||
// Don't use sequelize because we need to use a sub query
|
||||
const query = UserModel.generateUserQuotaBaseSQL({
|
||||
withSelect: true,
|
||||
whereUserId: '$userId'
|
||||
})
|
||||
static loadByLiveId (liveId: number): Bluebird<MUser> {
|
||||
const query = {
|
||||
include: [
|
||||
{
|
||||
attributes: [ 'id' ],
|
||||
model: AccountModel.unscoped(),
|
||||
required: true,
|
||||
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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return UserModel.getTotalRawQuery(query, user.id)
|
||||
return UserModel.findOne(query)
|
||||
}
|
||||
|
||||
// Returns cumulative size of all video files uploaded in the last 24 hours.
|
||||
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\''
|
||||
})
|
||||
static generateUserQuotaBaseSQL (options: {
|
||||
whereUserId: '$userId' | '"UserModel"."id"'
|
||||
withSelect: boolean
|
||||
where?: string
|
||||
}) {
|
||||
const andWhere = options.where
|
||||
? 'AND ' + options.where
|
||||
: ''
|
||||
|
||||
return UserModel.getTotalRawQuery(query, user.id)
|
||||
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 () {
|
||||
|
@ -874,64 +939,4 @@ export class UserModel extends Model<UserModel> {
|
|||
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -123,8 +123,8 @@ export class VideoFileModel extends Model<VideoFileModel> {
|
|||
@Column
|
||||
extname: string
|
||||
|
||||
@AllowNull(false)
|
||||
@Is('VideoFileInfohash', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash'))
|
||||
@AllowNull(true)
|
||||
@Is('VideoFileInfohash', value => throwIfNotValid(value, isVideoFileInfoHashValid, 'info hash', true))
|
||||
@Column
|
||||
infoHash: string
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Video, VideoDetails } from '../../../shared/models/videos'
|
||||
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 { VideoCaptionModel } from './video-caption'
|
||||
import {
|
||||
|
@ -77,6 +77,8 @@ function videoModelToFormattedJSON (video: MVideoFormattable, options?: VideoFor
|
|||
publishedAt: video.publishedAt,
|
||||
originallyPublishedAt: video.originallyPublishedAt,
|
||||
|
||||
isLive: video.isLive,
|
||||
|
||||
account: video.VideoChannel.Account.toFormattedSummaryJSON(),
|
||||
channel: video.VideoChannel.toFormattedSummaryJSON(),
|
||||
|
||||
|
@ -260,7 +262,7 @@ function addVideoFilesInAPAcc (
|
|||
}
|
||||
}
|
||||
|
||||
function videoModelToActivityPubObject (video: MVideoAP): VideoTorrentObject {
|
||||
function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
|
||||
const { baseUrlHttp, baseUrlWs } = video.getBaseUrls()
|
||||
if (!video.Tags) video.Tags = []
|
||||
|
||||
|
@ -349,6 +351,7 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoTorrentObject {
|
|||
views: video.views,
|
||||
sensitive: video.nsfw,
|
||||
waitTranscoding: video.waitTranscoding,
|
||||
isLiveBroadcast: video.isLive,
|
||||
state: video.state,
|
||||
commentsEnabled: video.commentsEnabled,
|
||||
downloadEnabled: video.downloadEnabled,
|
||||
|
|
104
server/models/video/video-live.ts
Normal file
104
server/models/video/video-live.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -153,6 +153,17 @@ export class VideoStreamingPlaylistModel extends Model<VideoStreamingPlaylistMod
|
|||
return VideoStreamingPlaylistModel.findByPk(id, options)
|
||||
}
|
||||
|
||||
static loadHLSPlaylistByVideo (videoId: number) {
|
||||
const options = {
|
||||
where: {
|
||||
type: VideoStreamingPlaylistType.HLS,
|
||||
videoId
|
||||
}
|
||||
}
|
||||
|
||||
return VideoStreamingPlaylistModel.findOne(options)
|
||||
}
|
||||
|
||||
static getHlsPlaylistFilename (resolution: number) {
|
||||
return resolution + '.m3u8'
|
||||
}
|
||||
|
@ -173,7 +184,9 @@ export class VideoStreamingPlaylistModel extends Model<VideoStreamingPlaylistMod
|
|||
return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsPlaylistFilename(resolution))
|
||||
}
|
||||
|
||||
static getHlsSha256SegmentsStaticPath (videoUUID: string) {
|
||||
static getHlsSha256SegmentsStaticPath (videoUUID: string, isLive: boolean) {
|
||||
if (isLive) return join('/live', 'segments-sha256', videoUUID)
|
||||
|
||||
return join(STATIC_PATHS.STREAMING_PLAYLISTS.HLS, videoUUID, VideoStreamingPlaylistModel.getHlsSha256SegmentsFilename())
|
||||
}
|
||||
|
||||
|
|
|
@ -31,7 +31,7 @@ import { getServerActor } from '@server/models/application/application'
|
|||
import { ModelCache } from '@server/models/model-cache'
|
||||
import { VideoFile } from '@shared/models/videos/video-file.model'
|
||||
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 { ThumbnailType } from '../../../shared/models/videos/thumbnail.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 { VideoTagModel } from './video-tag'
|
||||
import { VideoViewModel } from './video-view'
|
||||
import { LiveManager } from '@server/lib/live-manager'
|
||||
|
||||
export enum ScopeNames {
|
||||
AVAILABLE_FOR_LIST_IDS = 'AVAILABLE_FOR_LIST_IDS',
|
||||
|
@ -549,6 +550,11 @@ export class VideoModel extends Model<VideoModel> {
|
|||
@Column
|
||||
remote: boolean
|
||||
|
||||
@AllowNull(false)
|
||||
@Default(false)
|
||||
@Column
|
||||
isLive: boolean
|
||||
|
||||
@AllowNull(false)
|
||||
@Is('VideoUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
|
||||
@Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
|
||||
|
@ -794,6 +800,13 @@ export class VideoModel extends Model<VideoModel> {
|
|||
return undefined
|
||||
}
|
||||
|
||||
@BeforeDestroy
|
||||
static stopLiveIfNeeded (instance: VideoModel) {
|
||||
if (!instance.isLive) return
|
||||
|
||||
return LiveManager.Instance.stopSessionOf(instance.id)
|
||||
}
|
||||
|
||||
@BeforeDestroy
|
||||
static invalidateCache (instance: VideoModel) {
|
||||
ModelCache.Instance.invalidateCache('video', instance.id)
|
||||
|
@ -1758,7 +1771,7 @@ export class VideoModel extends Model<VideoModel> {
|
|||
return videoFilesModelToFormattedJSON(this, baseUrlHttp, baseUrlWs, files)
|
||||
}
|
||||
|
||||
toActivityPubObject (this: MVideoAP): VideoTorrentObject {
|
||||
toActivityPubObject (this: MVideoAP): VideoObject {
|
||||
return videoModelToActivityPubObject(this)
|
||||
}
|
||||
|
||||
|
|
|
@ -100,6 +100,25 @@ describe('Test config API validators', function () {
|
|||
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: {
|
||||
videos: {
|
||||
http: {
|
||||
|
|
|
@ -64,6 +64,7 @@ function checkInitialConfig (server: ServerInfo, data: CustomConfig) {
|
|||
|
||||
expect(data.user.videoQuota).to.equal(5242880)
|
||||
expect(data.user.videoQuotaDaily).to.equal(-1)
|
||||
|
||||
expect(data.transcoding.enabled).to.be.false
|
||||
expect(data.transcoding.allowAdditionalExtensions).to.be.false
|
||||
expect(data.transcoding.allowAudioFiles).to.be.false
|
||||
|
@ -77,6 +78,18 @@ function checkInitialConfig (server: ServerInfo, data: CustomConfig) {
|
|||
expect(data.transcoding.webtorrent.enabled).to.be.true
|
||||
expect(data.transcoding.hls.enabled).to.be.true
|
||||
|
||||
expect(data.live.enabled).to.be.false
|
||||
expect(data.live.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.torrent.enabled).to.be.true
|
||||
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.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.torrent.enabled).to.be.false
|
||||
expect(data.autoBlacklist.videos.ofUsers.enabled).to.be.true
|
||||
|
@ -301,6 +326,23 @@ describe('Test config', function () {
|
|||
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: {
|
||||
videos: {
|
||||
http: {
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue