Check live duration and size
This commit is contained in:
parent
a5cf76afa3
commit
fb7194043d
|
@ -699,7 +699,7 @@
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container ngbNavItem="live">
|
<div ngbNavItem="live">
|
||||||
<a ngbNavLink i18n>Live streaming</a>
|
<a ngbNavLink i18n>Live streaming</a>
|
||||||
|
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
|
@ -722,54 +722,78 @@
|
||||||
<ng-container i18n>Allow live streaming</ng-container>
|
<ng-container i18n>Allow live streaming</ng-container>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<ng-template ptTemplate="help">
|
<ng-container ngProjectAs="description" i18n>
|
||||||
<ng-container i18n>Enabling live streaming requires trust in your users and extra moderation work</ng-container>
|
⚠️ Enabling live streaming requires trust in your users and extra moderation work
|
||||||
</ng-template>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container ngProjectAs="extra" formGroupName="transcoding">
|
<ng-container ngProjectAs="extra">
|
||||||
|
|
||||||
<div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isLiveEnabled() }">
|
<div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isLiveEnabled() }">
|
||||||
<my-peertube-checkbox
|
<my-peertube-checkbox
|
||||||
inputName="liveTranscodingEnabled" formControlName="enabled"
|
inputName="liveAllowReplay" formControlName="allowReplay"
|
||||||
i18n-labelText labelText="Enable live transcoding"
|
i18n-labelText labelText="Allow your users to automatically publish a replay of their live"
|
||||||
>
|
>
|
||||||
<ng-container ngProjectAs="description">
|
<ng-container ngProjectAs="description" i18n>
|
||||||
Requires a lot of CPU!
|
If the user quota is reached, PeerTube will automatically terminate the live streaming
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</my-peertube-checkbox>
|
</my-peertube-checkbox>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isLiveEnabled() || !isLiveTranscodingEnabled() }">
|
<div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isLiveEnabled() }">
|
||||||
<label i18n for="liveTranscodingThreads">Live transcoding threads</label>
|
<label i18n for="liveMaxDuration">Max live duration</label>
|
||||||
<div class="peertube-select-container">
|
<div class="peertube-select-container">
|
||||||
<select id="liveTranscodingThreads" formControlName="threads" class="form-control">
|
<select id="liveMaxDuration" formControlName="maxDuration" class="form-control">
|
||||||
<option *ngFor="let transcodingThreadOption of transcodingThreadOptions" [value]="transcodingThreadOption.value">
|
<option *ngFor="let liveMaxDurationOption of liveMaxDurationOptions" [value]="liveMaxDurationOption.value">
|
||||||
{{ transcodingThreadOption.label }}
|
{{ liveMaxDurationOption.label }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="formErrors.live.transcoding.threads" class="form-error">{{ formErrors.live.transcoding.threads }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isLiveEnabled() || !isLiveTranscodingEnabled() }">
|
<ng-container formGroupName="transcoding">
|
||||||
|
|
||||||
<label i18n for="liveTranscodingThreads">Live resolutions to generate</label>
|
<div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isLiveEnabled() }">
|
||||||
|
<my-peertube-checkbox
|
||||||
<div class="ml-2 mt-2 d-flex flex-column">
|
inputName="liveTranscodingEnabled" formControlName="enabled"
|
||||||
<ng-container formGroupName="resolutions">
|
i18n-labelText labelText="Enable live transcoding"
|
||||||
<div class="form-group" *ngFor="let resolution of liveResolutions">
|
>
|
||||||
<my-peertube-checkbox
|
<ng-container ngProjectAs="description" i18n>
|
||||||
[inputName]="getResolutionKey(resolution.id)" [formControlName]="resolution.id"
|
Requires a lot of CPU!
|
||||||
labelText="{{resolution.label}}"
|
</ng-container>
|
||||||
>
|
</my-peertube-checkbox>
|
||||||
<ng-template *ngIf="resolution.description" ptTemplate="help">
|
|
||||||
<div [innerHTML]="resolution.description"></div>
|
|
||||||
</ng-template>
|
|
||||||
</my-peertube-checkbox>
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</ng-container>
|
||||||
</my-peertube-checkbox>
|
</my-peertube-checkbox>
|
||||||
</div>
|
</div>
|
||||||
|
@ -778,7 +802,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</ng-container>
|
</div>
|
||||||
|
|
||||||
<ng-container ngbNavItem="advanced-configuration">
|
<ng-container ngbNavItem="advanced-configuration">
|
||||||
<a ngbNavLink i18n>Advanced configuration</a>
|
<a ngbNavLink i18n>Advanced configuration</a>
|
||||||
|
@ -1026,9 +1050,15 @@
|
||||||
<div class="form-row mt-4"> <!-- submit placement block -->
|
<div class="form-row mt-4"> <!-- submit placement block -->
|
||||||
<div class="col-md-7 col-xl-5"></div>
|
<div class="col-md-7 col-xl-5"></div>
|
||||||
<div class="col-md-5 col-xl-5">
|
<div class="col-md-5 col-xl-5">
|
||||||
<span class="form-error submit-error" i18n *ngIf="!form.valid">It seems like the configuration is invalid. Please search for potential errors in the different tabs.</span>
|
<span class="form-error submit-error" i18n *ngIf="!form.valid">
|
||||||
|
It seems like the configuration is invalid. Please search for potential errors in the different tabs.
|
||||||
|
</span>
|
||||||
|
|
||||||
<input (click)="formValidated()" type="submit" i18n-value value="Update configuration" [disabled]="!form.valid">
|
<span class="form-error submit-error" i18n *ngIf="!hasLiveAllowReplayConsistentOptions()">
|
||||||
|
You cannot allow live replay if you don't enable transcoding.
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<input (click)="formValidated()" type="submit" i18n-value value="Update configuration" [disabled]="!form.valid || !hasConsistentOptions()">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -36,6 +36,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
|
||||||
resolutions: { id: string, label: string, description?: string }[] = []
|
resolutions: { id: string, label: string, description?: string }[] = []
|
||||||
liveResolutions: { id: string, label: string, description?: string }[] = []
|
liveResolutions: { id: string, label: string, description?: string }[] = []
|
||||||
transcodingThreadOptions: { label: string, value: number }[] = []
|
transcodingThreadOptions: { label: string, value: number }[] = []
|
||||||
|
liveMaxDurationOptions: { label: string, value: number }[] = []
|
||||||
|
|
||||||
languageItems: SelectOptionsItem[] = []
|
languageItems: SelectOptionsItem[] = []
|
||||||
categoryItems: SelectOptionsItem[] = []
|
categoryItems: SelectOptionsItem[] = []
|
||||||
|
@ -92,6 +93,14 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
|
||||||
{ value: 4, label: '4' },
|
{ value: 4, label: '4' },
|
||||||
{ value: 8, label: '8' }
|
{ value: 8, label: '8' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
this.liveMaxDurationOptions = [
|
||||||
|
{ value: 0, label: $localize`No limit` },
|
||||||
|
{ value: 1000 * 3600, label: $localize`1 hour` },
|
||||||
|
{ value: 1000 * 3600 * 3, label: $localize`3 hours` },
|
||||||
|
{ value: 1000 * 3600 * 5, label: $localize`5 hours` },
|
||||||
|
{ value: 1000 * 3600 * 10, label: $localize`10 hours` }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
get videoQuotaOptions () {
|
get videoQuotaOptions () {
|
||||||
|
@ -114,7 +123,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
|
||||||
ngOnInit () {
|
ngOnInit () {
|
||||||
this.serverConfig = this.serverService.getTmpConfig()
|
this.serverConfig = this.serverService.getTmpConfig()
|
||||||
this.serverService.getConfig()
|
this.serverService.getConfig()
|
||||||
.subscribe(config => this.serverConfig = config)
|
.subscribe(config => {
|
||||||
|
this.serverConfig = config
|
||||||
|
})
|
||||||
|
|
||||||
const formGroupData: { [key in keyof CustomConfig ]: any } = {
|
const formGroupData: { [key in keyof CustomConfig ]: any } = {
|
||||||
instance: {
|
instance: {
|
||||||
|
@ -204,6 +215,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
|
||||||
live: {
|
live: {
|
||||||
enabled: null,
|
enabled: null,
|
||||||
|
|
||||||
|
maxDuration: null,
|
||||||
|
allowReplay: null,
|
||||||
|
|
||||||
transcoding: {
|
transcoding: {
|
||||||
enabled: null,
|
enabled: null,
|
||||||
threads: TRANSCODING_THREADS_VALIDATOR,
|
threads: TRANSCODING_THREADS_VALIDATOR,
|
||||||
|
@ -341,6 +355,20 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasConsistentOptions () {
|
||||||
|
if (this.hasLiveAllowReplayConsistentOptions()) return true
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
hasLiveAllowReplayConsistentOptions () {
|
||||||
|
if (this.isTranscodingEnabled() === false && this.isLiveEnabled() && this.form.value['live']['allowReplay'] === true) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
private updateForm () {
|
private updateForm () {
|
||||||
this.form.patchValue(this.customConfig)
|
this.form.patchValue(this.customConfig)
|
||||||
}
|
}
|
||||||
|
|
|
@ -76,6 +76,8 @@ export class ServerService {
|
||||||
},
|
},
|
||||||
live: {
|
live: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
allowReplay: true,
|
||||||
|
maxDuration: null,
|
||||||
transcoding: {
|
transcoding: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
enabledResolutions: []
|
enabledResolutions: []
|
||||||
|
|
|
@ -89,7 +89,7 @@ live:
|
||||||
port: 1935
|
port: 1935
|
||||||
|
|
||||||
transcoding:
|
transcoding:
|
||||||
enabled: true
|
enabled: false
|
||||||
threads: 2
|
threads: 2
|
||||||
|
|
||||||
resolutions:
|
resolutions:
|
||||||
|
|
|
@ -118,6 +118,9 @@ async function getConfig (req: express.Request, res: express.Response) {
|
||||||
live: {
|
live: {
|
||||||
enabled: CONFIG.LIVE.ENABLED,
|
enabled: CONFIG.LIVE.ENABLED,
|
||||||
|
|
||||||
|
allowReplay: CONFIG.LIVE.ALLOW_REPLAY,
|
||||||
|
maxDuration: CONFIG.LIVE.MAX_DURATION,
|
||||||
|
|
||||||
transcoding: {
|
transcoding: {
|
||||||
enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
|
enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
|
||||||
enabledResolutions: getEnabledResolutions('live')
|
enabledResolutions: getEnabledResolutions('live')
|
||||||
|
@ -425,6 +428,8 @@ function customConfig (): CustomConfig {
|
||||||
},
|
},
|
||||||
live: {
|
live: {
|
||||||
enabled: CONFIG.LIVE.ENABLED,
|
enabled: CONFIG.LIVE.ENABLED,
|
||||||
|
allowReplay: CONFIG.LIVE.ALLOW_REPLAY,
|
||||||
|
maxDuration: CONFIG.LIVE.MAX_DURATION,
|
||||||
transcoding: {
|
transcoding: {
|
||||||
enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
|
enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
|
||||||
threads: CONFIG.LIVE.TRANSCODING.THREADS,
|
threads: CONFIG.LIVE.TRANSCODING.THREADS,
|
||||||
|
|
|
@ -9,7 +9,7 @@ import { MIMETYPES } from '../../../initializers/constants'
|
||||||
import { sequelizeTypescript } from '../../../initializers/database'
|
import { sequelizeTypescript } from '../../../initializers/database'
|
||||||
import { sendUpdateActor } from '../../../lib/activitypub/send'
|
import { sendUpdateActor } from '../../../lib/activitypub/send'
|
||||||
import { updateActorAvatarFile } from '../../../lib/avatar'
|
import { updateActorAvatarFile } from '../../../lib/avatar'
|
||||||
import { sendVerifyUserEmail } from '../../../lib/user'
|
import { getOriginalVideoFileTotalDailyFromUser, getOriginalVideoFileTotalFromUser, sendVerifyUserEmail } from '../../../lib/user'
|
||||||
import {
|
import {
|
||||||
asyncMiddleware,
|
asyncMiddleware,
|
||||||
asyncRetryTransactionMiddleware,
|
asyncRetryTransactionMiddleware,
|
||||||
|
@ -133,8 +133,8 @@ async function getUserInformation (req: express.Request, res: express.Response)
|
||||||
|
|
||||||
async function getUserVideoQuotaUsed (req: express.Request, res: express.Response) {
|
async function getUserVideoQuotaUsed (req: express.Request, res: express.Response) {
|
||||||
const user = res.locals.oauth.token.user
|
const user = res.locals.oauth.token.user
|
||||||
const videoQuotaUsed = await UserModel.getOriginalVideoFileTotalFromUser(user)
|
const videoQuotaUsed = await getOriginalVideoFileTotalFromUser(user)
|
||||||
const videoQuotaUsedDaily = await UserModel.getOriginalVideoFileTotalDailyFromUser(user)
|
const videoQuotaUsedDaily = await getOriginalVideoFileTotalDailyFromUser(user)
|
||||||
|
|
||||||
const data: UserVideoQuota = {
|
const data: UserVideoQuota = {
|
||||||
videoQuotaUsed,
|
videoQuotaUsed,
|
||||||
|
|
|
@ -41,6 +41,7 @@ const timeTable = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseDurationToMs (duration: number | string): number {
|
export function parseDurationToMs (duration: number | string): number {
|
||||||
|
if (duration === null) return null
|
||||||
if (typeof duration === 'number') return duration
|
if (typeof duration === 'number') return duration
|
||||||
|
|
||||||
if (typeof duration === 'string') {
|
if (typeof duration === 'string') {
|
||||||
|
|
|
@ -45,6 +45,10 @@ function isBooleanValid (value: any) {
|
||||||
return typeof value === 'boolean' || (typeof value === 'string' && validator.isBoolean(value))
|
return typeof value === 'boolean' || (typeof value === 'string' && validator.isBoolean(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isIntOrNull (value: any) {
|
||||||
|
return value === null || validator.isInt('' + value)
|
||||||
|
}
|
||||||
|
|
||||||
function toIntOrNull (value: string) {
|
function toIntOrNull (value: string) {
|
||||||
const v = toValueOrNull(value)
|
const v = toValueOrNull(value)
|
||||||
|
|
||||||
|
@ -116,6 +120,7 @@ export {
|
||||||
isArrayOf,
|
isArrayOf,
|
||||||
isNotEmptyIntArray,
|
isNotEmptyIntArray,
|
||||||
isArray,
|
isArray,
|
||||||
|
isIntOrNull,
|
||||||
isIdValid,
|
isIdValid,
|
||||||
isSafePath,
|
isSafePath,
|
||||||
isUUIDValid,
|
isUUIDValid,
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { VideoFileMetadata } from '@shared/models/videos/video-file-metadata'
|
||||||
import { getMaxBitrate, getTargetBitrate, VideoResolution } from '../../shared/models/videos'
|
import { getMaxBitrate, getTargetBitrate, VideoResolution } from '../../shared/models/videos'
|
||||||
import { checkFFmpegEncoders } from '../initializers/checker-before-init'
|
import { checkFFmpegEncoders } from '../initializers/checker-before-init'
|
||||||
import { CONFIG } from '../initializers/config'
|
import { CONFIG } from '../initializers/config'
|
||||||
import { FFMPEG_NICE, VIDEO_TRANSCODING_FPS } from '../initializers/constants'
|
import { FFMPEG_NICE, VIDEO_LIVE, VIDEO_TRANSCODING_FPS } from '../initializers/constants'
|
||||||
import { processImage } from './image-utils'
|
import { processImage } from './image-utils'
|
||||||
import { logger } from './logger'
|
import { logger } from './logger'
|
||||||
|
|
||||||
|
@ -353,7 +353,7 @@ function convertWebPToJPG (path: string, destination: string): Promise<void> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function runLiveTranscoding (rtmpUrl: string, outPath: string, resolutions: number[]) {
|
function runLiveTranscoding (rtmpUrl: string, outPath: string, resolutions: number[], deleteSegments: boolean) {
|
||||||
const command = getFFmpeg(rtmpUrl)
|
const command = getFFmpeg(rtmpUrl)
|
||||||
command.inputOption('-fflags nobuffer')
|
command.inputOption('-fflags nobuffer')
|
||||||
|
|
||||||
|
@ -399,7 +399,7 @@ function runLiveTranscoding (rtmpUrl: string, outPath: string, resolutions: numb
|
||||||
varStreamMap.push(`v:${i},a:${i}`)
|
varStreamMap.push(`v:${i},a:${i}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
addDefaultLiveHLSParams(command, outPath)
|
addDefaultLiveHLSParams(command, outPath, deleteSegments)
|
||||||
|
|
||||||
command.outputOption('-var_stream_map', varStreamMap.join(' '))
|
command.outputOption('-var_stream_map', varStreamMap.join(' '))
|
||||||
|
|
||||||
|
@ -408,7 +408,7 @@ function runLiveTranscoding (rtmpUrl: string, outPath: string, resolutions: numb
|
||||||
return command
|
return command
|
||||||
}
|
}
|
||||||
|
|
||||||
function runLiveMuxing (rtmpUrl: string, outPath: string) {
|
function runLiveMuxing (rtmpUrl: string, outPath: string, deleteSegments: boolean) {
|
||||||
const command = getFFmpeg(rtmpUrl)
|
const command = getFFmpeg(rtmpUrl)
|
||||||
command.inputOption('-fflags nobuffer')
|
command.inputOption('-fflags nobuffer')
|
||||||
|
|
||||||
|
@ -417,7 +417,7 @@ function runLiveMuxing (rtmpUrl: string, outPath: string) {
|
||||||
command.outputOption('-map 0:a?')
|
command.outputOption('-map 0:a?')
|
||||||
command.outputOption('-map 0:v?')
|
command.outputOption('-map 0:v?')
|
||||||
|
|
||||||
addDefaultLiveHLSParams(command, outPath)
|
addDefaultLiveHLSParams(command, outPath, deleteSegments)
|
||||||
|
|
||||||
command.run()
|
command.run()
|
||||||
|
|
||||||
|
@ -457,10 +457,14 @@ function addDefaultX264Params (command: ffmpeg.FfmpegCommand) {
|
||||||
.outputOption('-map_metadata -1') // strip all metadata
|
.outputOption('-map_metadata -1') // strip all metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
function addDefaultLiveHLSParams (command: ffmpeg.FfmpegCommand, outPath: string) {
|
function addDefaultLiveHLSParams (command: ffmpeg.FfmpegCommand, outPath: string, deleteSegments: boolean) {
|
||||||
command.outputOption('-hls_time 4')
|
command.outputOption('-hls_time ' + VIDEO_LIVE.SEGMENT_TIME)
|
||||||
command.outputOption('-hls_list_size 15')
|
command.outputOption('-hls_list_size ' + VIDEO_LIVE.SEGMENTS_LIST_SIZE)
|
||||||
command.outputOption('-hls_flags delete_segments')
|
|
||||||
|
if (deleteSegments === true) {
|
||||||
|
command.outputOption('-hls_flags delete_segments')
|
||||||
|
}
|
||||||
|
|
||||||
command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%d.ts')}`)
|
command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%d.ts')}`)
|
||||||
command.outputOption('-master_pl_name master.m3u8')
|
command.outputOption('-master_pl_name master.m3u8')
|
||||||
command.outputOption(`-f hls`)
|
command.outputOption(`-f hls`)
|
||||||
|
|
|
@ -135,6 +135,13 @@ function checkConfig () {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Live
|
||||||
|
if (CONFIG.LIVE.ENABLED === true) {
|
||||||
|
if (CONFIG.LIVE.ALLOW_REPLAY === true && CONFIG.TRANSCODING.ENABLED === false) {
|
||||||
|
return 'Live allow replay cannot be enabled if transcoding is not enabled.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -37,8 +37,13 @@ function checkMissedConfig () {
|
||||||
'remote_redundancy.videos.accept_from',
|
'remote_redundancy.videos.accept_from',
|
||||||
'federation.videos.federate_unlisted',
|
'federation.videos.federate_unlisted',
|
||||||
'search.remote_uri.users', 'search.remote_uri.anonymous', 'search.search_index.enabled', 'search.search_index.url',
|
'search.remote_uri.users', 'search.remote_uri.anonymous', 'search.search_index.enabled', 'search.search_index.url',
|
||||||
'search.search_index.disable_local_search', 'search.search_index.is_default_search'
|
'search.search_index.disable_local_search', 'search.search_index.is_default_search',
|
||||||
|
'live.enabled', 'live.allow_replay', 'live.max_duration',
|
||||||
|
'live.transcoding.enabled', 'live.transcoding.threads',
|
||||||
|
'live.transcoding.resolutions.240p', 'live.transcoding.resolutions.360p', 'live.transcoding.resolutions.480p',
|
||||||
|
'live.transcoding.resolutions.720p', 'live.transcoding.resolutions.1080p', 'live.transcoding.resolutions.2160p'
|
||||||
]
|
]
|
||||||
|
|
||||||
const requiredAlternatives = [
|
const requiredAlternatives = [
|
||||||
[ // set
|
[ // set
|
||||||
[ 'redis.hostname', 'redis.port' ], // alternative
|
[ 'redis.hostname', 'redis.port' ], // alternative
|
||||||
|
|
|
@ -201,6 +201,9 @@ const CONFIG = {
|
||||||
LIVE: {
|
LIVE: {
|
||||||
get ENABLED () { return config.get<boolean>('live.enabled') },
|
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: {
|
RTMP: {
|
||||||
get PORT () { return config.get<number>('live.rtmp.port') }
|
get PORT () { return config.get<number>('live.rtmp.port') }
|
||||||
},
|
},
|
||||||
|
|
|
@ -608,7 +608,9 @@ const HLS_REDUNDANCY_DIRECTORY = join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls')
|
||||||
|
|
||||||
const VIDEO_LIVE = {
|
const VIDEO_LIVE = {
|
||||||
EXTENSION: '.ts',
|
EXTENSION: '.ts',
|
||||||
CLEANUP_DELAY: 1000 * 60 * 5, // 5 mintues
|
CLEANUP_DELAY: 1000 * 60 * 5, // 5 minutes
|
||||||
|
SEGMENT_TIME: 4, // 4 seconds
|
||||||
|
SEGMENTS_LIST_SIZE: 15, // 15 maximum segments in live playlist
|
||||||
RTMP: {
|
RTMP: {
|
||||||
CHUNK_SIZE: 60000,
|
CHUNK_SIZE: 60000,
|
||||||
GOP_CACHE: true,
|
GOP_CACHE: true,
|
||||||
|
@ -620,7 +622,8 @@ const VIDEO_LIVE = {
|
||||||
|
|
||||||
const MEMOIZE_TTL = {
|
const MEMOIZE_TTL = {
|
||||||
OVERVIEWS_SAMPLE: 1000 * 3600 * 4, // 4 hours
|
OVERVIEWS_SAMPLE: 1000 * 3600 * 4, // 4 hours
|
||||||
INFO_HASH_EXISTS: 1000 * 3600 * 12 // 12 hours
|
INFO_HASH_EXISTS: 1000 * 3600 * 12, // 12 hours
|
||||||
|
LIVE_ABLE_TO_UPLOAD: 1000 * 60 // 1 minute
|
||||||
}
|
}
|
||||||
|
|
||||||
const MEMOIZE_LENGTH = {
|
const MEMOIZE_LENGTH = {
|
||||||
|
|
|
@ -9,7 +9,7 @@ async function up (utils: {
|
||||||
const query = `
|
const query = `
|
||||||
CREATE TABLE IF NOT EXISTS "videoLive" (
|
CREATE TABLE IF NOT EXISTS "videoLive" (
|
||||||
"id" SERIAL ,
|
"id" SERIAL ,
|
||||||
"streamKey" VARCHAR(255) NOT NULL,
|
"streamKey" VARCHAR(255),
|
||||||
"videoId" INTEGER NOT NULL REFERENCES "video" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
"videoId" INTEGER NOT NULL REFERENCES "video" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL,
|
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
|
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { extname } from 'path'
|
||||||
import { addOptimizeOrMergeAudioJob } from '@server/helpers/video'
|
import { addOptimizeOrMergeAudioJob } from '@server/helpers/video'
|
||||||
import { isPostImportVideoAccepted } from '@server/lib/moderation'
|
import { isPostImportVideoAccepted } from '@server/lib/moderation'
|
||||||
import { Hooks } from '@server/lib/plugins/hooks'
|
import { Hooks } from '@server/lib/plugins/hooks'
|
||||||
|
import { isAbleToUploadVideo } from '@server/lib/user'
|
||||||
import { getVideoFilePath } from '@server/lib/video-paths'
|
import { getVideoFilePath } from '@server/lib/video-paths'
|
||||||
import { MVideoImportDefault, MVideoImportDefaultFiles, MVideoImportVideo } from '@server/types/models/video/video-import'
|
import { MVideoImportDefault, MVideoImportDefaultFiles, MVideoImportVideo } from '@server/types/models/video/video-import'
|
||||||
import {
|
import {
|
||||||
|
@ -108,7 +109,7 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid
|
||||||
|
|
||||||
// Get information about this video
|
// Get information about this video
|
||||||
const stats = await stat(tempVideoPath)
|
const stats = await stat(tempVideoPath)
|
||||||
const isAble = await videoImport.User.isAbleToUploadVideo({ size: stats.size })
|
const isAble = await isAbleToUploadVideo(videoImport.User.id, stats.size)
|
||||||
if (isAble === false) {
|
if (isAble === false) {
|
||||||
throw new Error('The user video quota is exceeded with this video to import.')
|
throw new Error('The user video quota is exceeded with this video to import.')
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,24 +2,27 @@
|
||||||
import { AsyncQueue, queue } from 'async'
|
import { AsyncQueue, queue } from 'async'
|
||||||
import * as chokidar from 'chokidar'
|
import * as chokidar from 'chokidar'
|
||||||
import { FfmpegCommand } from 'fluent-ffmpeg'
|
import { FfmpegCommand } from 'fluent-ffmpeg'
|
||||||
import { ensureDir } from 'fs-extra'
|
import { ensureDir, stat } from 'fs-extra'
|
||||||
import { basename } from 'path'
|
import { basename } from 'path'
|
||||||
import { computeResolutionsToTranscode, runLiveMuxing, runLiveTranscoding } from '@server/helpers/ffmpeg-utils'
|
import { computeResolutionsToTranscode, runLiveMuxing, runLiveTranscoding } from '@server/helpers/ffmpeg-utils'
|
||||||
import { logger } from '@server/helpers/logger'
|
import { logger } from '@server/helpers/logger'
|
||||||
import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config'
|
import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config'
|
||||||
import { P2P_MEDIA_LOADER_PEER_VERSION, VIDEO_LIVE, WEBSERVER } from '@server/initializers/constants'
|
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 { VideoModel } from '@server/models/video/video'
|
||||||
import { VideoFileModel } from '@server/models/video/video-file'
|
import { VideoFileModel } from '@server/models/video/video-file'
|
||||||
import { VideoLiveModel } from '@server/models/video/video-live'
|
import { VideoLiveModel } from '@server/models/video/video-live'
|
||||||
import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
|
import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
|
||||||
import { MStreamingPlaylist, MVideoLiveVideo } from '@server/types/models'
|
import { MStreamingPlaylist, MUser, MUserId, MVideoLive, MVideoLiveVideo } from '@server/types/models'
|
||||||
import { VideoState, VideoStreamingPlaylistType } from '@shared/models'
|
import { VideoState, VideoStreamingPlaylistType } from '@shared/models'
|
||||||
import { federateVideoIfNeeded } from './activitypub/videos'
|
import { federateVideoIfNeeded } from './activitypub/videos'
|
||||||
import { buildSha256Segment } from './hls'
|
import { buildSha256Segment } from './hls'
|
||||||
import { JobQueue } from './job-queue'
|
import { JobQueue } from './job-queue'
|
||||||
import { PeerTubeSocket } from './peertube-socket'
|
import { PeerTubeSocket } from './peertube-socket'
|
||||||
|
import { isAbleToUploadVideo } from './user'
|
||||||
import { getHLSDirectory } from './video-paths'
|
import { getHLSDirectory } from './video-paths'
|
||||||
|
|
||||||
|
import memoizee = require('memoizee')
|
||||||
const NodeRtmpServer = require('node-media-server/node_rtmp_server')
|
const NodeRtmpServer = require('node-media-server/node_rtmp_server')
|
||||||
const context = require('node-media-server/node_core_ctx')
|
const context = require('node-media-server/node_core_ctx')
|
||||||
const nodeMediaServerLogger = require('node-media-server/node_core_logger')
|
const nodeMediaServerLogger = require('node-media-server/node_core_logger')
|
||||||
|
@ -53,6 +56,11 @@ class LiveManager {
|
||||||
private readonly transSessions = new Map<string, FfmpegCommand>()
|
private readonly transSessions = new Map<string, FfmpegCommand>()
|
||||||
private readonly videoSessions = new Map<number, string>()
|
private readonly videoSessions = new Map<number, string>()
|
||||||
private readonly segmentsSha256 = new Map<string, Map<string, 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 segmentsSha256Queue: AsyncQueue<SegmentSha256QueueParam>
|
||||||
private rtmpServer: any
|
private rtmpServer: any
|
||||||
|
@ -127,7 +135,7 @@ class LiveManager {
|
||||||
|
|
||||||
this.abortSession(sessionId)
|
this.abortSession(sessionId)
|
||||||
|
|
||||||
this.onEndTransmuxing(videoId)
|
this.onEndTransmuxing(videoId, true)
|
||||||
.catch(err => logger.error('Cannot end transmuxing of video %d.', videoId, { err }))
|
.catch(err => logger.error('Cannot end transmuxing of video %d.', videoId, { err }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -196,8 +204,18 @@ class LiveManager {
|
||||||
originalResolution: number
|
originalResolution: number
|
||||||
}) {
|
}) {
|
||||||
const { sessionId, videoLive, playlist, streamPath, resolutionsEnabled, originalResolution } = options
|
const { sessionId, videoLive, playlist, streamPath, resolutionsEnabled, originalResolution } = options
|
||||||
|
const startStreamDateTime = new Date().getTime()
|
||||||
const allResolutions = resolutionsEnabled.concat([ originalResolution ])
|
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++) {
|
for (let i = 0; i < allResolutions.length; i++) {
|
||||||
const resolution = allResolutions[i]
|
const resolution = allResolutions[i]
|
||||||
|
|
||||||
|
@ -216,26 +234,47 @@ class LiveManager {
|
||||||
const outPath = getHLSDirectory(videoLive.Video)
|
const outPath = getHLSDirectory(videoLive.Video)
|
||||||
await ensureDir(outPath)
|
await ensureDir(outPath)
|
||||||
|
|
||||||
|
const deleteSegments = videoLive.saveReplay === false
|
||||||
|
|
||||||
const rtmpUrl = 'rtmp://127.0.0.1:' + config.rtmp.port + streamPath
|
const rtmpUrl = 'rtmp://127.0.0.1:' + config.rtmp.port + streamPath
|
||||||
const ffmpegExec = CONFIG.LIVE.TRANSCODING.ENABLED
|
const ffmpegExec = CONFIG.LIVE.TRANSCODING.ENABLED
|
||||||
? runLiveTranscoding(rtmpUrl, outPath, allResolutions)
|
? runLiveTranscoding(rtmpUrl, outPath, allResolutions, deleteSegments)
|
||||||
: runLiveMuxing(rtmpUrl, outPath)
|
: runLiveMuxing(rtmpUrl, outPath, deleteSegments)
|
||||||
|
|
||||||
logger.info('Running live muxing/transcoding.')
|
logger.info('Running live muxing/transcoding.')
|
||||||
|
|
||||||
this.transSessions.set(sessionId, ffmpegExec)
|
this.transSessions.set(sessionId, ffmpegExec)
|
||||||
|
|
||||||
const videoUUID = videoLive.Video.uuid
|
const videoUUID = videoLive.Video.uuid
|
||||||
const tsWatcher = chokidar.watch(outPath + '/*.ts')
|
const tsWatcher = chokidar.watch(outPath + '/*.ts')
|
||||||
|
|
||||||
const updateHandler = segmentPath => {
|
const updateSegment = segmentPath => this.segmentsSha256Queue.push({ operation: 'update', segmentPath, videoUUID })
|
||||||
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 })
|
const deleteHandler = segmentPath => this.segmentsSha256Queue.push({ operation: 'delete', segmentPath, videoUUID })
|
||||||
|
|
||||||
tsWatcher.on('add', p => updateHandler(p))
|
tsWatcher.on('add', p => addHandler(p))
|
||||||
tsWatcher.on('change', p => updateHandler(p))
|
tsWatcher.on('change', p => updateSegment(p))
|
||||||
tsWatcher.on('unlink', p => deleteHandler(p))
|
tsWatcher.on('unlink', p => deleteHandler(p))
|
||||||
|
|
||||||
const masterWatcher = chokidar.watch(outPath + '/master.m3u8')
|
const masterWatcher = chokidar.watch(outPath + '/master.m3u8')
|
||||||
|
@ -280,7 +319,14 @@ class LiveManager {
|
||||||
ffmpegExec.on('end', () => onFFmpegEnded())
|
ffmpegExec.on('end', () => onFFmpegEnded())
|
||||||
}
|
}
|
||||||
|
|
||||||
private async onEndTransmuxing (videoId: number) {
|
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 {
|
try {
|
||||||
const fullVideo = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId)
|
const fullVideo = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId)
|
||||||
if (!fullVideo) return
|
if (!fullVideo) return
|
||||||
|
@ -290,7 +336,7 @@ class LiveManager {
|
||||||
payload: {
|
payload: {
|
||||||
videoId: fullVideo.id
|
videoId: fullVideo.id
|
||||||
}
|
}
|
||||||
}, { delay: VIDEO_LIVE.CLEANUP_DELAY })
|
}, { delay: cleanupNow ? 0 : VIDEO_LIVE.CLEANUP_DELAY })
|
||||||
|
|
||||||
// FIXME: use end
|
// FIXME: use end
|
||||||
fullVideo.state = VideoState.WAITING_FOR_LIVE
|
fullVideo.state = VideoState.WAITING_FOR_LIVE
|
||||||
|
@ -337,6 +383,23 @@ class LiveManager {
|
||||||
filesMap.delete(segmentName)
|
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 () {
|
static get Instance () {
|
||||||
return this.instance || (this.instance = new this())
|
return this.instance || (this.instance = new this())
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,20 +1,24 @@
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
|
||||||
import { ActivityPubActorType } from '../../shared/models/activitypub'
|
|
||||||
import { SERVER_ACTOR_NAME, WEBSERVER } from '../initializers/constants'
|
|
||||||
import { AccountModel } from '../models/account/account'
|
|
||||||
import { buildActorInstance, setAsyncActorKeys } from './activitypub/actor'
|
|
||||||
import { createLocalVideoChannel } from './video-channel'
|
|
||||||
import { ActorModel } from '../models/activitypub/actor'
|
|
||||||
import { UserNotificationSettingModel } from '../models/account/user-notification-setting'
|
|
||||||
import { UserNotificationSetting, UserNotificationSettingValue } from '../../shared/models/users'
|
|
||||||
import { createWatchLaterPlaylist } from './video-playlist'
|
|
||||||
import { sequelizeTypescript } from '../initializers/database'
|
|
||||||
import { Transaction } from 'sequelize/types'
|
import { Transaction } from 'sequelize/types'
|
||||||
import { Redis } from './redis'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
import { Emailer } from './emailer'
|
import { UserModel } from '@server/models/account/user'
|
||||||
|
import { ActivityPubActorType } from '../../shared/models/activitypub'
|
||||||
|
import { UserNotificationSetting, UserNotificationSettingValue } from '../../shared/models/users'
|
||||||
|
import { SERVER_ACTOR_NAME, WEBSERVER } from '../initializers/constants'
|
||||||
|
import { sequelizeTypescript } from '../initializers/database'
|
||||||
|
import { AccountModel } from '../models/account/account'
|
||||||
|
import { UserNotificationSettingModel } from '../models/account/user-notification-setting'
|
||||||
|
import { ActorModel } from '../models/activitypub/actor'
|
||||||
import { MAccountDefault, MActorDefault, MChannelActor } from '../types/models'
|
import { MAccountDefault, MActorDefault, MChannelActor } from '../types/models'
|
||||||
import { MUser, MUserDefault, MUserId } from '../types/models/user'
|
import { MUser, MUserDefault, MUserId } from '../types/models/user'
|
||||||
|
import { buildActorInstance, setAsyncActorKeys } from './activitypub/actor'
|
||||||
import { getAccountActivityPubUrl } from './activitypub/url'
|
import { getAccountActivityPubUrl } from './activitypub/url'
|
||||||
|
import { Emailer } from './emailer'
|
||||||
|
import { LiveManager } from './live-manager'
|
||||||
|
import { Redis } from './redis'
|
||||||
|
import { createLocalVideoChannel } from './video-channel'
|
||||||
|
import { createWatchLaterPlaylist } from './video-playlist'
|
||||||
|
|
||||||
|
import memoizee = require('memoizee')
|
||||||
|
|
||||||
type ChannelNames = { name: string, displayName: string }
|
type ChannelNames = { name: string, displayName: string }
|
||||||
|
|
||||||
|
@ -116,13 +120,61 @@ async function sendVerifyUserEmail (user: MUser, isPendingEmail = false) {
|
||||||
await Emailer.Instance.addVerifyEmailJob(username, email, url)
|
await Emailer.Instance.addVerifyEmailJob(username, email, url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getOriginalVideoFileTotalFromUser (user: MUserId) {
|
||||||
|
// Don't use sequelize because we need to use a sub query
|
||||||
|
const query = UserModel.generateUserQuotaBaseSQL({
|
||||||
|
withSelect: true,
|
||||||
|
whereUserId: '$userId'
|
||||||
|
})
|
||||||
|
|
||||||
|
const base = await UserModel.getTotalRawQuery(query, user.id)
|
||||||
|
|
||||||
|
return base + LiveManager.Instance.getLiveQuotaUsedByUser(user.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns cumulative size of all video files uploaded in the last 24 hours.
|
||||||
|
async function getOriginalVideoFileTotalDailyFromUser (user: MUserId) {
|
||||||
|
// Don't use sequelize because we need to use a sub query
|
||||||
|
const query = UserModel.generateUserQuotaBaseSQL({
|
||||||
|
withSelect: true,
|
||||||
|
whereUserId: '$userId',
|
||||||
|
where: '"video"."createdAt" > now() - interval \'24 hours\''
|
||||||
|
})
|
||||||
|
|
||||||
|
const base = await UserModel.getTotalRawQuery(query, user.id)
|
||||||
|
|
||||||
|
return base + LiveManager.Instance.getLiveQuotaUsedByUser(user.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isAbleToUploadVideo (userId: number, size: number) {
|
||||||
|
const user = await UserModel.loadById(userId)
|
||||||
|
|
||||||
|
if (user.videoQuota === -1 && user.videoQuotaDaily === -1) return Promise.resolve(true)
|
||||||
|
|
||||||
|
const [ totalBytes, totalBytesDaily ] = await Promise.all([
|
||||||
|
getOriginalVideoFileTotalFromUser(user.id),
|
||||||
|
getOriginalVideoFileTotalDailyFromUser(user.id)
|
||||||
|
])
|
||||||
|
|
||||||
|
const uploadedTotal = size + totalBytes
|
||||||
|
const uploadedDaily = size + totalBytesDaily
|
||||||
|
|
||||||
|
if (user.videoQuotaDaily === -1) return uploadedTotal < user.videoQuota
|
||||||
|
if (user.videoQuota === -1) return uploadedDaily < user.videoQuotaDaily
|
||||||
|
|
||||||
|
return uploadedTotal < user.videoQuota && uploadedDaily < user.videoQuotaDaily
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
getOriginalVideoFileTotalFromUser,
|
||||||
|
getOriginalVideoFileTotalDailyFromUser,
|
||||||
createApplicationActor,
|
createApplicationActor,
|
||||||
createUserAccountAndChannelAndPlaylist,
|
createUserAccountAndChannelAndPlaylist,
|
||||||
createLocalAccountWithoutKeys,
|
createLocalAccountWithoutKeys,
|
||||||
sendVerifyUserEmail
|
sendVerifyUserEmail,
|
||||||
|
isAbleToUploadVideo
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
import * as express from 'express'
|
import * as express from 'express'
|
||||||
import { body } from 'express-validator'
|
import { body } from 'express-validator'
|
||||||
|
import { isIntOrNull } from '@server/helpers/custom-validators/misc'
|
||||||
|
import { isEmailEnabled } from '@server/initializers/config'
|
||||||
|
import { CustomConfig } from '../../../shared/models/server/custom-config.model'
|
||||||
|
import { isThemeNameValid } from '../../helpers/custom-validators/plugins'
|
||||||
import { isUserNSFWPolicyValid, isUserVideoQuotaDailyValid, isUserVideoQuotaValid } from '../../helpers/custom-validators/users'
|
import { isUserNSFWPolicyValid, isUserVideoQuotaDailyValid, isUserVideoQuotaValid } from '../../helpers/custom-validators/users'
|
||||||
import { logger } from '../../helpers/logger'
|
import { logger } from '../../helpers/logger'
|
||||||
import { CustomConfig } from '../../../shared/models/server/custom-config.model'
|
|
||||||
import { areValidationErrors } from './utils'
|
|
||||||
import { isThemeNameValid } from '../../helpers/custom-validators/plugins'
|
|
||||||
import { isThemeRegistered } from '../../lib/plugins/theme-utils'
|
import { isThemeRegistered } from '../../lib/plugins/theme-utils'
|
||||||
import { isEmailEnabled } from '@server/initializers/config'
|
import { areValidationErrors } from './utils'
|
||||||
|
|
||||||
const customConfigUpdateValidator = [
|
const customConfigUpdateValidator = [
|
||||||
body('instance.name').exists().withMessage('Should have a valid instance name'),
|
body('instance.name').exists().withMessage('Should have a valid instance name'),
|
||||||
|
@ -43,6 +44,7 @@ const customConfigUpdateValidator = [
|
||||||
body('transcoding.resolutions.480p').isBoolean().withMessage('Should have a valid transcoding 480p resolution enabled boolean'),
|
body('transcoding.resolutions.480p').isBoolean().withMessage('Should have a valid transcoding 480p resolution enabled boolean'),
|
||||||
body('transcoding.resolutions.720p').isBoolean().withMessage('Should have a valid transcoding 720p resolution enabled boolean'),
|
body('transcoding.resolutions.720p').isBoolean().withMessage('Should have a valid transcoding 720p resolution enabled boolean'),
|
||||||
body('transcoding.resolutions.1080p').isBoolean().withMessage('Should have a valid transcoding 1080p resolution enabled boolean'),
|
body('transcoding.resolutions.1080p').isBoolean().withMessage('Should have a valid transcoding 1080p resolution enabled boolean'),
|
||||||
|
body('transcoding.resolutions.2160p').isBoolean().withMessage('Should have a valid transcoding 2160p resolution enabled boolean'),
|
||||||
|
|
||||||
body('transcoding.webtorrent.enabled').isBoolean().withMessage('Should have a valid webtorrent transcoding enabled boolean'),
|
body('transcoding.webtorrent.enabled').isBoolean().withMessage('Should have a valid webtorrent transcoding enabled boolean'),
|
||||||
body('transcoding.hls.enabled').isBoolean().withMessage('Should have a valid hls transcoding enabled boolean'),
|
body('transcoding.hls.enabled').isBoolean().withMessage('Should have a valid hls transcoding enabled boolean'),
|
||||||
|
@ -60,6 +62,18 @@ const customConfigUpdateValidator = [
|
||||||
body('broadcastMessage.level').exists().withMessage('Should have a valid broadcast level'),
|
body('broadcastMessage.level').exists().withMessage('Should have a valid broadcast level'),
|
||||||
body('broadcastMessage.dismissable').isBoolean().withMessage('Should have a valid broadcast dismissable boolean'),
|
body('broadcastMessage.dismissable').isBoolean().withMessage('Should have a valid broadcast dismissable boolean'),
|
||||||
|
|
||||||
|
body('live.enabled').isBoolean().withMessage('Should have a valid live enabled boolean'),
|
||||||
|
body('live.allowReplay').isBoolean().withMessage('Should have a valid live allow replay boolean'),
|
||||||
|
body('live.maxDuration').custom(isIntOrNull).withMessage('Should have a valid live max duration'),
|
||||||
|
body('live.transcoding.enabled').isBoolean().withMessage('Should have a valid live transcoding enabled boolean'),
|
||||||
|
body('live.transcoding.threads').isInt().withMessage('Should have a valid live transcoding threads'),
|
||||||
|
body('live.transcoding.resolutions.240p').isBoolean().withMessage('Should have a valid transcoding 240p resolution enabled boolean'),
|
||||||
|
body('live.transcoding.resolutions.360p').isBoolean().withMessage('Should have a valid transcoding 360p resolution enabled boolean'),
|
||||||
|
body('live.transcoding.resolutions.480p').isBoolean().withMessage('Should have a valid transcoding 480p resolution enabled boolean'),
|
||||||
|
body('live.transcoding.resolutions.720p').isBoolean().withMessage('Should have a valid transcoding 720p resolution enabled boolean'),
|
||||||
|
body('live.transcoding.resolutions.1080p').isBoolean().withMessage('Should have a valid transcoding 1080p resolution enabled boolean'),
|
||||||
|
body('live.transcoding.resolutions.2160p').isBoolean().withMessage('Should have a valid transcoding 2160p resolution enabled boolean'),
|
||||||
|
|
||||||
body('search.remoteUri.users').isBoolean().withMessage('Should have a remote URI search for users boolean'),
|
body('search.remoteUri.users').isBoolean().withMessage('Should have a remote URI search for users boolean'),
|
||||||
body('search.remoteUri.anonymous').isBoolean().withMessage('Should have a valid remote URI search for anonymous boolean'),
|
body('search.remoteUri.anonymous').isBoolean().withMessage('Should have a valid remote URI search for anonymous boolean'),
|
||||||
body('search.searchIndex.enabled').isBoolean().withMessage('Should have a valid search index enabled boolean'),
|
body('search.searchIndex.enabled').isBoolean().withMessage('Should have a valid search index enabled boolean'),
|
||||||
|
@ -71,8 +85,9 @@ const customConfigUpdateValidator = [
|
||||||
logger.debug('Checking customConfigUpdateValidator parameters', { parameters: req.body })
|
logger.debug('Checking customConfigUpdateValidator parameters', { parameters: req.body })
|
||||||
|
|
||||||
if (areValidationErrors(req, res)) return
|
if (areValidationErrors(req, res)) return
|
||||||
if (!checkInvalidConfigIfEmailDisabled(req.body as CustomConfig, res)) return
|
if (!checkInvalidConfigIfEmailDisabled(req.body, res)) return
|
||||||
if (!checkInvalidTranscodingConfig(req.body as CustomConfig, res)) return
|
if (!checkInvalidTranscodingConfig(req.body, res)) return
|
||||||
|
if (!checkInvalidLiveConfig(req.body, res)) return
|
||||||
|
|
||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
|
@ -109,3 +124,16 @@ function checkInvalidTranscodingConfig (customConfig: CustomConfig, res: express
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkInvalidLiveConfig (customConfig: CustomConfig, res: express.Response) {
|
||||||
|
if (customConfig.live.enabled === false) return true
|
||||||
|
|
||||||
|
if (customConfig.live.allowReplay === true && customConfig.transcoding.enabled === false) {
|
||||||
|
res.status(400)
|
||||||
|
.send({ error: 'You cannot allow live replay if transcoding is not enabled' })
|
||||||
|
.end()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
|
@ -497,7 +497,7 @@ export {
|
||||||
|
|
||||||
function checkUserIdExist (idArg: number | string, res: express.Response, withStats = false) {
|
function checkUserIdExist (idArg: number | string, res: express.Response, withStats = false) {
|
||||||
const id = parseInt(idArg + '', 10)
|
const id = parseInt(idArg + '', 10)
|
||||||
return checkUserExist(() => UserModel.loadById(id, withStats), res)
|
return checkUserExist(() => UserModel.loadByIdWithChannels(id, withStats), res)
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) {
|
function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) {
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import * as express from 'express'
|
import * as express from 'express'
|
||||||
import { body, param, query, ValidationChain } from 'express-validator'
|
import { body, param, query, ValidationChain } from 'express-validator'
|
||||||
|
import { isAbleToUploadVideo } from '@server/lib/user'
|
||||||
import { getServerActor } from '@server/models/application/application'
|
import { getServerActor } from '@server/models/application/application'
|
||||||
import { MVideoFullLight } from '@server/types/models'
|
import { MVideoFullLight } from '@server/types/models'
|
||||||
import { ServerErrorCode, UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared'
|
import { ServerErrorCode, UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared'
|
||||||
|
@ -73,7 +74,7 @@ const videosAddValidator = getCommonVideoEditAttributes().concat([
|
||||||
|
|
||||||
if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
|
if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
|
||||||
|
|
||||||
if (await user.isAbleToUploadVideo(videoFile) === false) {
|
if (await isAbleToUploadVideo(user.id, videoFile.size) === false) {
|
||||||
res.status(403)
|
res.status(403)
|
||||||
.json({ error: 'The user video quota is exceeded with this video.' })
|
.json({ error: 'The user video quota is exceeded with this video.' })
|
||||||
|
|
||||||
|
@ -291,7 +292,7 @@ const videosAcceptChangeOwnershipValidator = [
|
||||||
|
|
||||||
const user = res.locals.oauth.token.User
|
const user = res.locals.oauth.token.User
|
||||||
const videoChangeOwnership = res.locals.videoChangeOwnership
|
const videoChangeOwnership = res.locals.videoChangeOwnership
|
||||||
const isAble = await user.isAbleToUploadVideo(videoChangeOwnership.Video.getMaxQualityFile())
|
const isAble = await isAbleToUploadVideo(user.id, videoChangeOwnership.Video.getMaxQualityFile().size)
|
||||||
if (isAble === false) {
|
if (isAble === false) {
|
||||||
res.status(403)
|
res.status(403)
|
||||||
.json({ error: 'The user video quota is exceeded with this video.' })
|
.json({ error: 'The user video quota is exceeded with this video.' })
|
||||||
|
|
|
@ -23,6 +23,7 @@ import {
|
||||||
} from 'sequelize-typescript'
|
} from 'sequelize-typescript'
|
||||||
import {
|
import {
|
||||||
MMyUserFormattable,
|
MMyUserFormattable,
|
||||||
|
MUser,
|
||||||
MUserDefault,
|
MUserDefault,
|
||||||
MUserFormattable,
|
MUserFormattable,
|
||||||
MUserId,
|
MUserId,
|
||||||
|
@ -70,6 +71,7 @@ import { VideoImportModel } from '../video/video-import'
|
||||||
import { VideoPlaylistModel } from '../video/video-playlist'
|
import { VideoPlaylistModel } from '../video/video-playlist'
|
||||||
import { AccountModel } from './account'
|
import { AccountModel } from './account'
|
||||||
import { UserNotificationSettingModel } from './user-notification-setting'
|
import { UserNotificationSettingModel } from './user-notification-setting'
|
||||||
|
import { VideoLiveModel } from '../video/video-live'
|
||||||
|
|
||||||
enum ScopeNames {
|
enum ScopeNames {
|
||||||
FOR_ME_API = 'FOR_ME_API',
|
FOR_ME_API = 'FOR_ME_API',
|
||||||
|
@ -540,7 +542,11 @@ export class UserModel extends Model<UserModel> {
|
||||||
return UserModel.findAll(query)
|
return UserModel.findAll(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
static loadById (id: number, withStats = false): Bluebird<MUserDefault> {
|
static loadById (id: number): Bluebird<MUser> {
|
||||||
|
return UserModel.unscoped().findByPk(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
static loadByIdWithChannels (id: number, withStats = false): Bluebird<MUserDefault> {
|
||||||
const scopes = [
|
const scopes = [
|
||||||
ScopeNames.WITH_VIDEOCHANNELS
|
ScopeNames.WITH_VIDEOCHANNELS
|
||||||
]
|
]
|
||||||
|
@ -685,26 +691,85 @@ export class UserModel extends Model<UserModel> {
|
||||||
return UserModel.findOne(query)
|
return UserModel.findOne(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
static getOriginalVideoFileTotalFromUser (user: MUserId) {
|
static loadByLiveId (liveId: number): Bluebird<MUser> {
|
||||||
// Don't use sequelize because we need to use a sub query
|
const query = {
|
||||||
const query = UserModel.generateUserQuotaBaseSQL({
|
include: [
|
||||||
withSelect: true,
|
{
|
||||||
whereUserId: '$userId'
|
attributes: [ 'id' ],
|
||||||
})
|
model: AccountModel.unscoped(),
|
||||||
|
required: true,
|
||||||
|
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 generateUserQuotaBaseSQL (options: {
|
||||||
static getOriginalVideoFileTotalDailyFromUser (user: MUserId) {
|
whereUserId: '$userId' | '"UserModel"."id"'
|
||||||
// Don't use sequelize because we need to use a sub query
|
withSelect: boolean
|
||||||
const query = UserModel.generateUserQuotaBaseSQL({
|
where?: string
|
||||||
withSelect: true,
|
}) {
|
||||||
whereUserId: '$userId',
|
const andWhere = options.where
|
||||||
where: '"video"."createdAt" > now() - interval \'24 hours\''
|
? '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 () {
|
static async getStats () {
|
||||||
|
@ -874,64 +939,4 @@ export class UserModel extends Model<UserModel> {
|
||||||
|
|
||||||
return Object.assign(formatted, { specialPlaylists })
|
return Object.assign(formatted, { specialPlaylists })
|
||||||
}
|
}
|
||||||
|
|
||||||
async isAbleToUploadVideo (videoFile: { size: number }) {
|
|
||||||
if (this.videoQuota === -1 && this.videoQuotaDaily === -1) return Promise.resolve(true)
|
|
||||||
|
|
||||||
const [ totalBytes, totalBytesDaily ] = await Promise.all([
|
|
||||||
UserModel.getOriginalVideoFileTotalFromUser(this),
|
|
||||||
UserModel.getOriginalVideoFileTotalDailyFromUser(this)
|
|
||||||
])
|
|
||||||
|
|
||||||
const uploadedTotal = videoFile.size + totalBytes
|
|
||||||
const uploadedDaily = videoFile.size + totalBytesDaily
|
|
||||||
|
|
||||||
if (this.videoQuotaDaily === -1) return uploadedTotal < this.videoQuota
|
|
||||||
if (this.videoQuota === -1) return uploadedDaily < this.videoQuotaDaily
|
|
||||||
|
|
||||||
return uploadedTotal < this.videoQuota && uploadedDaily < this.videoQuotaDaily
|
|
||||||
}
|
|
||||||
|
|
||||||
private static generateUserQuotaBaseSQL (options: {
|
|
||||||
whereUserId: '$userId' | '"UserModel"."id"'
|
|
||||||
withSelect: boolean
|
|
||||||
where?: string
|
|
||||||
}) {
|
|
||||||
const andWhere = options.where
|
|
||||||
? 'AND ' + options.where
|
|
||||||
: ''
|
|
||||||
|
|
||||||
const videoChannelJoin = 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
|
|
||||||
'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' +
|
|
||||||
`WHERE "account"."userId" = ${options.whereUserId} ${andWhere}`
|
|
||||||
|
|
||||||
const webtorrentFiles = 'SELECT "videoFile"."size" AS "size", "video"."id" AS "videoId" FROM "videoFile" ' +
|
|
||||||
'INNER JOIN "video" ON "videoFile"."videoId" = "video"."id" ' +
|
|
||||||
videoChannelJoin
|
|
||||||
|
|
||||||
const hlsFiles = 'SELECT "videoFile"."size" AS "size", "video"."id" AS "videoId" FROM "videoFile" ' +
|
|
||||||
'INNER JOIN "videoStreamingPlaylist" ON "videoFile"."videoStreamingPlaylistId" = "videoStreamingPlaylist".id ' +
|
|
||||||
'INNER JOIN "video" ON "videoStreamingPlaylist"."videoId" = "video"."id" ' +
|
|
||||||
videoChannelJoin
|
|
||||||
|
|
||||||
return 'SELECT COALESCE(SUM("size"), 0) AS "total" ' +
|
|
||||||
'FROM (' +
|
|
||||||
`SELECT MAX("t1"."size") AS "size" FROM (${webtorrentFiles} UNION ${hlsFiles}) t1 ` +
|
|
||||||
'GROUP BY "t1"."videoId"' +
|
|
||||||
') t2'
|
|
||||||
}
|
|
||||||
|
|
||||||
private static getTotalRawQuery (query: string, userId: number) {
|
|
||||||
const options = {
|
|
||||||
bind: { userId },
|
|
||||||
type: QueryTypes.SELECT as QueryTypes.SELECT
|
|
||||||
}
|
|
||||||
|
|
||||||
return UserModel.sequelize.query<{ total: string }>(query, options)
|
|
||||||
.then(([ { total } ]) => {
|
|
||||||
if (total === null) return 0
|
|
||||||
|
|
||||||
return parseInt(total, 10)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,10 +30,18 @@ import { VideoBlacklistModel } from './video-blacklist'
|
||||||
})
|
})
|
||||||
export class VideoLiveModel extends Model<VideoLiveModel> {
|
export class VideoLiveModel extends Model<VideoLiveModel> {
|
||||||
|
|
||||||
@AllowNull(false)
|
@AllowNull(true)
|
||||||
@Column(DataType.STRING)
|
@Column(DataType.STRING)
|
||||||
streamKey: string
|
streamKey: string
|
||||||
|
|
||||||
|
@AllowNull(false)
|
||||||
|
@Column
|
||||||
|
perpetualLive: boolean
|
||||||
|
|
||||||
|
@AllowNull(false)
|
||||||
|
@Column
|
||||||
|
saveReplay: boolean
|
||||||
|
|
||||||
@CreatedAt
|
@CreatedAt
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
|
|
||||||
|
|
|
@ -103,6 +103,9 @@ describe('Test config API validators', function () {
|
||||||
live: {
|
live: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|
||||||
|
allowReplay: false,
|
||||||
|
maxDuration: null,
|
||||||
|
|
||||||
transcoding: {
|
transcoding: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
threads: 4,
|
threads: 4,
|
||||||
|
|
|
@ -79,6 +79,8 @@ function checkInitialConfig (server: ServerInfo, data: CustomConfig) {
|
||||||
expect(data.transcoding.hls.enabled).to.be.true
|
expect(data.transcoding.hls.enabled).to.be.true
|
||||||
|
|
||||||
expect(data.live.enabled).to.be.false
|
expect(data.live.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.enabled).to.be.false
|
||||||
expect(data.live.transcoding.threads).to.equal(2)
|
expect(data.live.transcoding.threads).to.equal(2)
|
||||||
expect(data.live.transcoding.resolutions['240p']).to.be.false
|
expect(data.live.transcoding.resolutions['240p']).to.be.false
|
||||||
|
@ -162,6 +164,8 @@ function checkUpdatedConfig (data: CustomConfig) {
|
||||||
expect(data.transcoding.webtorrent.enabled).to.be.true
|
expect(data.transcoding.webtorrent.enabled).to.be.true
|
||||||
|
|
||||||
expect(data.live.enabled).to.be.true
|
expect(data.live.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.enabled).to.be.true
|
||||||
expect(data.live.transcoding.threads).to.equal(4)
|
expect(data.live.transcoding.threads).to.equal(4)
|
||||||
expect(data.live.transcoding.resolutions['240p']).to.be.true
|
expect(data.live.transcoding.resolutions['240p']).to.be.true
|
||||||
|
@ -324,6 +328,8 @@ describe('Test config', function () {
|
||||||
},
|
},
|
||||||
live: {
|
live: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
allowReplay: false,
|
||||||
|
maxDuration: 5000,
|
||||||
transcoding: {
|
transcoding: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
threads: 4,
|
threads: 4,
|
||||||
|
|
|
@ -128,6 +128,8 @@ function updateCustomSubConfig (url: string, token: string, newConfig: DeepParti
|
||||||
},
|
},
|
||||||
live: {
|
live: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
allowReplay: false,
|
||||||
|
maxDuration: null,
|
||||||
transcoding: {
|
transcoding: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
threads: 4,
|
threads: 4,
|
||||||
|
|
|
@ -98,6 +98,9 @@ export interface CustomConfig {
|
||||||
live: {
|
live: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
|
||||||
|
allowReplay: boolean
|
||||||
|
maxDuration: number
|
||||||
|
|
||||||
transcoding: {
|
transcoding: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
threads: number
|
threads: number
|
||||||
|
|
|
@ -101,6 +101,9 @@ export interface ServerConfig {
|
||||||
live: {
|
live: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
|
||||||
|
maxDuration: number
|
||||||
|
allowReplay: boolean
|
||||||
|
|
||||||
transcoding: {
|
transcoding: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user