Compare commits

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

5 commits

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

View file

@ -699,6 +699,111 @@
</ng-template>
</ng-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>

View file

@ -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)
}

View file

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

View file

@ -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)

View file

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

View file

@ -195,6 +195,29 @@
</ng-template>
</ng-container>
<ng-container ngbNavItem *ngIf="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>

View file

@ -20,10 +20,11 @@ import {
import { FormReactiveValidationMessages, FormValidatorService, SelectChannelItem } from '@app/shared/shared-forms'
import { InstanceService } from '@app/shared/shared-instance'
import { VideoCaptionEdit, VideoEdit, VideoService } from '@app/shared/shared-main'
import { ServerConfig, VideoConstant, VideoPrivacy } from '@shared/models'
import { ServerConfig, VideoConstant, 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) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,7 +5,8 @@ import { Notifier } from '@app/core'
import { FormReactive, FormValidatorService, SelectChannelItem } from '@app/shared/shared-forms'
import { VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main'
import { LoadingBarService } from '@ngx-loading-bar/core'
import { VideoPrivacy } from '@shared/models'
import { 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
})
})
}
}
}

View file

@ -1,13 +1,14 @@
import { forkJoin } from 'rxjs'
import { forkJoin, of } from 'rxjs'
import { map, switchMap } from 'rxjs/operators'
import { Injectable } from '@angular/core'
import { ActivatedRouteSnapshot, Resolve } from '@angular/router'
import { VideoCaptionService, VideoChannelService, VideoService } from '@app/shared/shared-main'
import { VideoCaptionService, VideoChannelService, VideoDetails, 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)
]
}
}

View file

@ -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()"

View file

@ -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 {

View file

@ -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

View file

@ -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
]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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++

View file

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

View file

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

View file

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

View file

@ -23,7 +23,7 @@ import { FeedComponent } from './feeds'
import { LoaderComponent, SmallLoaderComponent } from './loaders'
import { HelpComponent, ListOverflowComponent, TopMenuDropdownComponent } from './misc'
import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users'
import { RedundancyService, VideoImportService, VideoOwnershipService, VideoService } from './video'
import { RedundancyService, VideoImportService, VideoOwnershipService, VideoService, 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,

View file

@ -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)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"

View file

@ -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 () {

View file

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

View file

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

View file

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

View file

@ -243,6 +243,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:

View file

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

View file

@ -92,6 +92,7 @@
"body-parser": "^1.12.4",
"bull": "^3.4.2",
"bytes": "^3.0.0",
"chokidar": "^3.4.2",
"commander": "^6.0.0",
"config": "^3.0.0",
"cookie-parser": "^1.4.3",
@ -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",

View file

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View file

@ -113,7 +113,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: {

View file

@ -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,

View file

@ -1,30 +1,10 @@
import * as express from 'express'
import * as magnetUtil from 'magnet-uri'
import { auditLoggerFactory, getAuditIdFromRes, VideoImportAuditView } from '../../../helpers/audit-logger'
import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoImportAddValidator } from '../../../middlewares'
import { MIMETYPES } from '../../../initializers/constants'
import { getYoutubeDLInfo, YoutubeDLInfo, getYoutubeDLSubs } from '../../../helpers/youtube-dl'
import { createReqFiles } from '../../../helpers/express-utils'
import { logger } from '../../../helpers/logger'
import { VideoImportCreate, VideoImportState, VideoPrivacy, VideoState } from '../../../../shared'
import { VideoModel } from '../../../models/video/video'
import { VideoCaptionModel } from '../../../models/video/video-caption'
import { moveAndProcessCaptionFile } from '../../../helpers/captions-utils'
import { getVideoActivityPubUrl } from '../../../lib/activitypub/url'
import { TagModel } from '../../../models/video/tag'
import { VideoImportModel } from '../../../models/video/video-import'
import { JobQueue } from '../../../lib/job-queue/job-queue'
import { join } from 'path'
import { isArray } from '../../../helpers/custom-validators/misc'
import * as Bluebird from 'bluebird'
import * as 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(

View file

@ -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) {

View file

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

View file

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

View file

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

View file

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

View file

@ -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,

View file

@ -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) &&

View file

@ -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,

View file

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

View file

@ -1,13 +1,13 @@
import * as ffmpeg from 'fluent-ffmpeg'
import { readFile, remove, writeFile } from 'fs-extra'
import { dirname, join } from 'path'
import { VideoFileMetadata } from '@shared/models/videos/video-file-metadata'
import { getMaxBitrate, getTargetBitrate, VideoResolution } from '../../shared/models/videos'
import { 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
}

View file

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

View file

@ -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

View file

@ -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: {

View file

@ -23,7 +23,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
// ---------------------------------------------------------------------------
const LAST_MIGRATION_VERSION = 530
const LAST_MIGRATION_VERSION = 540
// ---------------------------------------------------------------------------
@ -50,7 +50,8 @@ const WEBSERVER = {
SCHEME: '',
WS: '',
HOSTNAME: '',
PORT: 0
PORT: 0,
RTMP_URL: ''
}
// Sortable columns per schema
@ -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 () {

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import { isRedundancyAccepted } from '@server/lib/redundancy'
import { 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 })

View file

@ -1,4 +1,4 @@
import { ActivityUpdate, CacheFileObject, VideoTorrentObject } from '../../../../shared/models/activitypub'
import { ActivityUpdate, CacheFileObject, VideoObject } from '../../../../shared/models/activitypub'
import { ActivityPubActor } from '../../../../shared/models/activitypub/activitypub-actor'
import { 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 })

View file

@ -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

View file

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

View file

@ -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.')
}

View file

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

View file

@ -84,7 +84,7 @@ async function onVideoFileOptimizerSuccess (videoArg: MVideoWithFile, payload: O
if (!videoDatabase) return undefined
// 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 }

View file

@ -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
View file

@ -0,0 +1,412 @@
import { AsyncQueue, queue } from 'async'
import * as chokidar from 'chokidar'
import { FfmpegCommand } from 'fluent-ffmpeg'
import { ensureDir, stat } from 'fs-extra'
import { basename } from 'path'
import { computeResolutionsToTranscode, runLiveMuxing, runLiveTranscoding } from '@server/helpers/ffmpeg-utils'
import { logger } from '@server/helpers/logger'
import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config'
import { MEMOIZE_TTL, P2P_MEDIA_LOADER_PEER_VERSION, VIDEO_LIVE, WEBSERVER } from '@server/initializers/constants'
import { UserModel } from '@server/models/account/user'
import { VideoModel } from '@server/models/video/video'
import { VideoFileModel } from '@server/models/video/video-file'
import { VideoLiveModel } from '@server/models/video/video-live'
import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
import { MStreamingPlaylist, MUser, MUserId, MVideoLive, MVideoLiveVideo } from '@server/types/models'
import { VideoState, VideoStreamingPlaylistType } from '@shared/models'
import { federateVideoIfNeeded } from './activitypub/videos'
import { buildSha256Segment } from './hls'
import { JobQueue } from './job-queue'
import { PeerTubeSocket } from './peertube-socket'
import { isAbleToUploadVideo } from './user'
import { getHLSDirectory } from './video-paths'
import memoizee = require('memoizee')
const NodeRtmpServer = require('node-media-server/node_rtmp_server')
const context = require('node-media-server/node_core_ctx')
const nodeMediaServerLogger = require('node-media-server/node_core_logger')
// Disable node media server logs
nodeMediaServerLogger.setLogType(0)
const config = {
rtmp: {
port: CONFIG.LIVE.RTMP.PORT,
chunk_size: VIDEO_LIVE.RTMP.CHUNK_SIZE,
gop_cache: VIDEO_LIVE.RTMP.GOP_CACHE,
ping: VIDEO_LIVE.RTMP.PING,
ping_timeout: VIDEO_LIVE.RTMP.PING_TIMEOUT
},
transcoding: {
ffmpeg: 'ffmpeg'
}
}
type SegmentSha256QueueParam = {
operation: 'update' | 'delete'
videoUUID: string
segmentPath: string
}
class LiveManager {
private static instance: LiveManager
private readonly transSessions = new Map<string, FfmpegCommand>()
private readonly videoSessions = new Map<number, string>()
private readonly segmentsSha256 = new Map<string, Map<string, string>>()
private readonly livesPerUser = new Map<number, { liveId: number, videoId: number, size: number }[]>()
private readonly isAbleToUploadVideoWithCache = memoizee((userId: number) => {
return isAbleToUploadVideo(userId, 1000)
}, { maxAge: MEMOIZE_TTL.LIVE_ABLE_TO_UPLOAD })
private segmentsSha256Queue: AsyncQueue<SegmentSha256QueueParam>
private rtmpServer: any
private constructor () {
}
init () {
const events = this.getContext().nodeEvent
events.on('postPublish', (sessionId: string, streamPath: string) => {
logger.debug('RTMP received stream', { id: sessionId, streamPath })
const splittedPath = streamPath.split('/')
if (splittedPath.length !== 3 || splittedPath[1] !== VIDEO_LIVE.RTMP.BASE_PATH) {
logger.warn('Live path is incorrect.', { streamPath })
return this.abortSession(sessionId)
}
this.handleSession(sessionId, streamPath, splittedPath[2])
.catch(err => logger.error('Cannot handle sessions.', { err }))
})
events.on('donePublish', sessionId => {
this.abortSession(sessionId)
})
this.segmentsSha256Queue = queue<SegmentSha256QueueParam, Error>((options, cb) => {
const promise = options.operation === 'update'
? this.addSegmentSha(options)
: Promise.resolve(this.removeSegmentSha(options))
promise.then(() => cb())
.catch(err => {
logger.error('Cannot update/remove sha segment %s.', options.segmentPath, { err })
cb()
})
})
registerConfigChangedHandler(() => {
if (!this.rtmpServer && CONFIG.LIVE.ENABLED === true) {
this.run()
return
}
if (this.rtmpServer && CONFIG.LIVE.ENABLED === false) {
this.stop()
}
})
}
run () {
logger.info('Running RTMP server.')
this.rtmpServer = new NodeRtmpServer(config)
this.rtmpServer.run()
}
stop () {
logger.info('Stopping RTMP server.')
this.rtmpServer.stop()
this.rtmpServer = undefined
}
getSegmentsSha256 (videoUUID: string) {
return this.segmentsSha256.get(videoUUID)
}
stopSessionOf (videoId: number) {
const sessionId = this.videoSessions.get(videoId)
if (!sessionId) return
this.abortSession(sessionId)
this.onEndTransmuxing(videoId, true)
.catch(err => logger.error('Cannot end transmuxing of video %d.', videoId, { err }))
}
private getContext () {
return context
}
private abortSession (id: string) {
const session = this.getContext().sessions.get(id)
if (session) session.stop()
const transSession = this.transSessions.get(id)
if (transSession) transSession.kill('SIGKILL')
}
private async handleSession (sessionId: string, streamPath: string, streamKey: string) {
const videoLive = await VideoLiveModel.loadByStreamKey(streamKey)
if (!videoLive) {
logger.warn('Unknown live video with stream key %s.', streamKey)
return this.abortSession(sessionId)
}
const video = videoLive.Video
if (video.isBlacklisted()) {
logger.warn('Video is blacklisted. Refusing stream %s.', streamKey)
return this.abortSession(sessionId)
}
this.videoSessions.set(video.id, sessionId)
const playlistUrl = WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsMasterPlaylistStaticPath(video.uuid)
const session = this.getContext().sessions.get(sessionId)
const resolutionsEnabled = CONFIG.LIVE.TRANSCODING.ENABLED
? computeResolutionsToTranscode(session.videoHeight, 'live')
: []
logger.info('Will mux/transcode live video of original resolution %d.', session.videoHeight, { resolutionsEnabled })
const [ videoStreamingPlaylist ] = await VideoStreamingPlaylistModel.upsert({
videoId: video.id,
playlistUrl,
segmentsSha256Url: WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsSha256SegmentsStaticPath(video.uuid, video.isLive),
p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrl, resolutionsEnabled),
p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
type: VideoStreamingPlaylistType.HLS
}, { returning: true }) as [ MStreamingPlaylist, boolean ]
return this.runMuxing({
sessionId,
videoLive,
playlist: videoStreamingPlaylist,
streamPath,
originalResolution: session.videoHeight,
resolutionsEnabled
})
}
private async runMuxing (options: {
sessionId: string
videoLive: MVideoLiveVideo
playlist: MStreamingPlaylist
streamPath: string
resolutionsEnabled: number[]
originalResolution: number
}) {
const { sessionId, videoLive, playlist, streamPath, resolutionsEnabled, originalResolution } = options
const startStreamDateTime = new Date().getTime()
const allResolutions = resolutionsEnabled.concat([ originalResolution ])
const user = await UserModel.loadByLiveId(videoLive.id)
if (!this.livesPerUser.has(user.id)) {
this.livesPerUser.set(user.id, [])
}
const currentUserLive = { liveId: videoLive.id, videoId: videoLive.videoId, size: 0 }
const livesOfUser = this.livesPerUser.get(user.id)
livesOfUser.push(currentUserLive)
for (let i = 0; i < allResolutions.length; i++) {
const resolution = allResolutions[i]
VideoFileModel.upsert({
resolution,
size: -1,
extname: '.ts',
infoHash: null,
fps: -1,
videoStreamingPlaylistId: playlist.id
}).catch(err => {
logger.error('Cannot create file for live streaming.', { err })
})
}
const outPath = getHLSDirectory(videoLive.Video)
await ensureDir(outPath)
const deleteSegments = videoLive.saveReplay === false
const rtmpUrl = 'rtmp://127.0.0.1:' + config.rtmp.port + streamPath
const ffmpegExec = CONFIG.LIVE.TRANSCODING.ENABLED
? runLiveTranscoding(rtmpUrl, outPath, allResolutions, deleteSegments)
: runLiveMuxing(rtmpUrl, outPath, deleteSegments)
logger.info('Running live muxing/transcoding.')
this.transSessions.set(sessionId, ffmpegExec)
const videoUUID = videoLive.Video.uuid
const tsWatcher = chokidar.watch(outPath + '/*.ts')
const updateSegment = segmentPath => this.segmentsSha256Queue.push({ operation: 'update', segmentPath, videoUUID })
const addHandler = segmentPath => {
updateSegment(segmentPath)
if (this.isDurationConstraintValid(startStreamDateTime) !== true) {
this.stopSessionOf(videoLive.videoId)
}
if (videoLive.saveReplay === true) {
stat(segmentPath)
.then(segmentStat => {
currentUserLive.size += segmentStat.size
})
.then(() => this.isQuotaConstraintValid(user, videoLive))
.then(quotaValid => {
if (quotaValid !== true) {
this.stopSessionOf(videoLive.videoId)
}
})
.catch(err => logger.error('Cannot stat %s or check quota of %d.', segmentPath, user.id, { err }))
}
}
const deleteHandler = segmentPath => this.segmentsSha256Queue.push({ operation: 'delete', segmentPath, videoUUID })
tsWatcher.on('add', p => addHandler(p))
tsWatcher.on('change', p => updateSegment(p))
tsWatcher.on('unlink', p => deleteHandler(p))
const masterWatcher = chokidar.watch(outPath + '/master.m3u8')
masterWatcher.on('add', async () => {
try {
const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoLive.videoId)
video.state = VideoState.PUBLISHED
await video.save()
videoLive.Video = video
await federateVideoIfNeeded(video, false)
PeerTubeSocket.Instance.sendVideoLiveNewState(video)
} catch (err) {
logger.error('Cannot federate video %d.', videoLive.videoId, { err })
} finally {
masterWatcher.close()
.catch(err => logger.error('Cannot close master watcher of %s.', outPath, { err }))
}
})
const onFFmpegEnded = () => {
logger.info('RTMP transmuxing for video %s ended. Scheduling cleanup', streamPath)
Promise.all([ tsWatcher.close(), masterWatcher.close() ])
.catch(err => logger.error('Cannot close watchers of %s.', outPath, { err }))
this.onEndTransmuxing(videoLive.Video.id)
.catch(err => logger.error('Error in closed transmuxing.', { err }))
}
ffmpegExec.on('error', (err, stdout, stderr) => {
onFFmpegEnded()
// Don't care that we killed the ffmpeg process
if (err?.message?.includes('SIGKILL')) return
logger.error('Live transcoding error.', { err, stdout, stderr })
})
ffmpegExec.on('end', () => onFFmpegEnded())
}
getLiveQuotaUsedByUser (userId: number) {
const currentLives = this.livesPerUser.get(userId)
if (!currentLives) return 0
return currentLives.reduce((sum, obj) => sum + obj.size, 0)
}
private async onEndTransmuxing (videoId: number, cleanupNow = false) {
try {
const fullVideo = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId)
if (!fullVideo) return
JobQueue.Instance.createJob({
type: 'video-live-ending',
payload: {
videoId: fullVideo.id
}
}, { delay: cleanupNow ? 0 : VIDEO_LIVE.CLEANUP_DELAY })
// FIXME: use end
fullVideo.state = VideoState.WAITING_FOR_LIVE
await fullVideo.save()
PeerTubeSocket.Instance.sendVideoLiveNewState(fullVideo)
await federateVideoIfNeeded(fullVideo, false)
} catch (err) {
logger.error('Cannot save/federate new video state of live streaming.', { err })
}
}
private async addSegmentSha (options: SegmentSha256QueueParam) {
const segmentName = basename(options.segmentPath)
logger.debug('Updating live sha segment %s.', options.segmentPath)
const shaResult = await buildSha256Segment(options.segmentPath)
if (!this.segmentsSha256.has(options.videoUUID)) {
this.segmentsSha256.set(options.videoUUID, new Map())
}
const filesMap = this.segmentsSha256.get(options.videoUUID)
filesMap.set(segmentName, shaResult)
}
private removeSegmentSha (options: SegmentSha256QueueParam) {
const segmentName = basename(options.segmentPath)
logger.debug('Removing live sha segment %s.', options.segmentPath)
const filesMap = this.segmentsSha256.get(options.videoUUID)
if (!filesMap) {
logger.warn('Unknown files map to remove sha for %s.', options.videoUUID)
return
}
if (!filesMap.has(segmentName)) {
logger.warn('Unknown segment in files map for video %s and segment %s.', options.videoUUID, options.segmentPath)
return
}
filesMap.delete(segmentName)
}
private isDurationConstraintValid (streamingStartTime: number) {
const maxDuration = CONFIG.LIVE.MAX_DURATION
// No limit
if (maxDuration === null) return true
const now = new Date().getTime()
const max = streamingStartTime + maxDuration
return now <= max
}
private async isQuotaConstraintValid (user: MUserId, live: MVideoLive) {
if (live.saveReplay !== true) return true
return this.isAbleToUploadVideoWithCache(user.id)
}
static get Instance () {
return this.instance || (this.instance = new this())
}
}
// ---------------------------------------------------------------------------
export {
LiveManager
}

View file

@ -18,7 +18,7 @@ import {
MVideoAccountLightBlacklistAllFiles
} 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 }

View file

@ -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())
}

View file

@ -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 })
}

View file

@ -1,20 +1,24 @@
import { v4 as uuidv4 } from 'uuid'
import { ActivityPubActorType } from '../../shared/models/activitypub'
import { SERVER_ACTOR_NAME, WEBSERVER } from '../initializers/constants'
import { AccountModel } from '../models/account/account'
import { buildActorInstance, setAsyncActorKeys } from './activitypub/actor'
import { createLocalVideoChannel } from './video-channel'
import { ActorModel } from '../models/activitypub/actor'
import { UserNotificationSettingModel } from '../models/account/user-notification-setting'
import { UserNotificationSetting, UserNotificationSettingValue } from '../../shared/models/users'
import { createWatchLaterPlaylist } from './video-playlist'
import { sequelizeTypescript } from '../initializers/database'
import { Transaction } from 'sequelize/types'
import { 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
}
// ---------------------------------------------------------------------------

View file

@ -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)
}

View file

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

View file

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

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

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

View file

@ -1,12 +1,13 @@
import * as express from 'express'
import { 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
}

View file

@ -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) {

View file

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

View file

@ -1,5 +1,6 @@
import * as express from 'express'
import { 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.' })

View file

@ -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)
})
}
}

View file

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

View file

@ -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,

View file

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

View file

@ -153,6 +153,17 @@ export class VideoStreamingPlaylistModel extends Model<VideoStreamingPlaylistMod
return VideoStreamingPlaylistModel.findByPk(id, options)
}
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())
}

View file

@ -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)
}

View file

@ -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: {

View file

@ -64,6 +64,7 @@ function checkInitialConfig (server: ServerInfo, data: CustomConfig) {
expect(data.user.videoQuota).to.equal(5242880)
expect(data.user.videoQuotaDaily).to.equal(-1)
expect(data.transcoding.enabled).to.be.false
expect(data.transcoding.allowAdditionalExtensions).to.be.false
expect(data.transcoding.allowAudioFiles).to.be.false
@ -77,6 +78,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