</ng-template>
</ng-container>
- <ng-container ngbNavItem="live">
+ <div ngbNavItem="live">
<a ngbNavLink i18n>Live streaming</a>
<ng-template ngbNavContent>
<ng-container i18n>Allow live streaming</ng-container>
</ng-template>
- <ng-template ptTemplate="help">
- <ng-container i18n>Enabling live streaming requires trust in your users and extra moderation work</ng-container>
- </ng-template>
+ <ng-container ngProjectAs="description" i18n>
+ ⚠️ Enabling live streaming requires trust in your users and extra moderation work
+ </ng-container>
- <ng-container ngProjectAs="extra" formGroupName="transcoding">
+ <ng-container ngProjectAs="extra">
<div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isLiveEnabled() }">
<my-peertube-checkbox
- inputName="liveTranscodingEnabled" formControlName="enabled"
- i18n-labelText labelText="Enable live transcoding"
+ inputName="liveAllowReplay" formControlName="allowReplay"
+ i18n-labelText labelText="Allow your users to automatically publish a replay of their live"
>
- <ng-container ngProjectAs="description">
- Requires a lot of CPU!
+ <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() || !isLiveTranscodingEnabled() }">
- <label i18n for="liveTranscodingThreads">Live transcoding threads</label>
+ <div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isLiveEnabled() }">
+ <label i18n for="liveMaxDuration">Max live duration</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 }}
+ <select id="liveMaxDuration" formControlName="maxDuration" class="form-control">
+ <option *ngFor="let liveMaxDurationOption of liveMaxDurationOptions" [value]="liveMaxDurationOption.value">
+ {{ liveMaxDurationOption.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>
+ <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>
+
+ <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>
</div>
</ng-template>
- </ng-container>
+ </div>
<ng-container ngbNavItem="advanced-configuration">
<a ngbNavLink i18n>Advanced configuration</a>
<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>
+
+ <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">
+ <input (click)="formValidated()" type="submit" i18n-value value="Update configuration" [disabled]="!form.valid || !hasConsistentOptions()">
</div>
</div>
</form>
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[] = []
{ 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 () {
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: {
live: {
enabled: null,
+ maxDuration: null,
+ allowReplay: null,
+
transcoding: {
enabled: null,
threads: TRANSCODING_THREADS_VALIDATOR,
}
}
+ 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)
}
},
live: {
enabled: false,
+ allowReplay: true,
+ maxDuration: null,
transcoding: {
enabled: false,
enabledResolutions: []
port: 1935
transcoding:
- enabled: true
+ enabled: false
threads: 2
resolutions:
live: {
enabled: CONFIG.LIVE.ENABLED,
+ allowReplay: CONFIG.LIVE.ALLOW_REPLAY,
+ maxDuration: CONFIG.LIVE.MAX_DURATION,
+
transcoding: {
enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
enabledResolutions: getEnabledResolutions('live')
},
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,
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,
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,
}
export function parseDurationToMs (duration: number | string): number {
+ if (duration === null) return null
if (typeof duration === 'number') return duration
if (typeof duration === 'string') {
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)
isArrayOf,
isNotEmptyIntArray,
isArray,
+ isIntOrNull,
isIdValid,
isSafePath,
isUUIDValid,
import { getMaxBitrate, getTargetBitrate, VideoResolution } from '../../shared/models/videos'
import { checkFFmpegEncoders } from '../initializers/checker-before-init'
import { CONFIG } from '../initializers/config'
-import { FFMPEG_NICE, VIDEO_TRANSCODING_FPS } from '../initializers/constants'
+import { FFMPEG_NICE, VIDEO_LIVE, VIDEO_TRANSCODING_FPS } from '../initializers/constants'
import { processImage } from './image-utils'
import { logger } from './logger'
})
}
-function runLiveTranscoding (rtmpUrl: string, outPath: string, resolutions: number[]) {
+function runLiveTranscoding (rtmpUrl: string, outPath: string, resolutions: number[], deleteSegments: boolean) {
const command = getFFmpeg(rtmpUrl)
command.inputOption('-fflags nobuffer')
varStreamMap.push(`v:${i},a:${i}`)
}
- addDefaultLiveHLSParams(command, outPath)
+ addDefaultLiveHLSParams(command, outPath, deleteSegments)
command.outputOption('-var_stream_map', varStreamMap.join(' '))
return command
}
-function runLiveMuxing (rtmpUrl: string, outPath: string) {
+function runLiveMuxing (rtmpUrl: string, outPath: string, deleteSegments: boolean) {
const command = getFFmpeg(rtmpUrl)
command.inputOption('-fflags nobuffer')
command.outputOption('-map 0:a?')
command.outputOption('-map 0:v?')
- addDefaultLiveHLSParams(command, outPath)
+ addDefaultLiveHLSParams(command, outPath, deleteSegments)
command.run()
.outputOption('-map_metadata -1') // strip all metadata
}
-function addDefaultLiveHLSParams (command: ffmpeg.FfmpegCommand, outPath: string) {
- command.outputOption('-hls_time 4')
- command.outputOption('-hls_list_size 15')
- command.outputOption('-hls_flags delete_segments')
+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`)
}
}
+ // 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
}
'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
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') }
},
const VIDEO_LIVE = {
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: {
CHUNK_SIZE: 60000,
GOP_CACHE: true,
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 = {
const query = `
CREATE TABLE IF NOT EXISTS "videoLive" (
"id" SERIAL ,
- "streamKey" VARCHAR(255) NOT NULL,
+ "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,
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 {
// 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.')
}
import { AsyncQueue, queue } from 'async'
import * as chokidar from 'chokidar'
import { FfmpegCommand } from 'fluent-ffmpeg'
-import { ensureDir } from 'fs-extra'
+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 { 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 { 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, MVideoLiveVideo } from '@server/types/models'
+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')
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
this.abortSession(sessionId)
- this.onEndTransmuxing(videoId)
+ this.onEndTransmuxing(videoId, true)
.catch(err => logger.error('Cannot end transmuxing of video %d.', videoId, { err }))
}
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]
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)
- : runLiveMuxing(rtmpUrl, outPath)
+ ? 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 updateHandler = segmentPath => {
- this.segmentsSha256Queue.push({ operation: 'update', segmentPath, videoUUID })
+ 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 => updateHandler(p))
- tsWatcher.on('change', p => updateHandler(p))
+ 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')
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 {
const fullVideo = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId)
if (!fullVideo) return
payload: {
videoId: fullVideo.id
}
- }, { delay: VIDEO_LIVE.CLEANUP_DELAY })
+ }, { delay: cleanupNow ? 0 : VIDEO_LIVE.CLEANUP_DELAY })
// FIXME: use end
fullVideo.state = VideoState.WAITING_FOR_LIVE
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())
}
+import { Transaction } from 'sequelize/types'
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 { 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 { 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 }
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
}
// ---------------------------------------------------------------------------
import * as express from 'express'
import { body } from 'express-validator'
-import { isUserNSFWPolicyValid, isUserVideoQuotaDailyValid, isUserVideoQuotaValid } from '../../helpers/custom-validators/users'
-import { logger } from '../../helpers/logger'
+import { isIntOrNull } from '@server/helpers/custom-validators/misc'
+import { isEmailEnabled } from '@server/initializers/config'
import { CustomConfig } from '../../../shared/models/server/custom-config.model'
-import { areValidationErrors } from './utils'
import { isThemeNameValid } from '../../helpers/custom-validators/plugins'
+import { isUserNSFWPolicyValid, isUserVideoQuotaDailyValid, isUserVideoQuotaValid } from '../../helpers/custom-validators/users'
+import { logger } from '../../helpers/logger'
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'),
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'),
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'),
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()
}
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
+}
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) {
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'
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.' })
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.' })
} from 'sequelize-typescript'
import {
MMyUserFormattable,
+ MUser,
MUserDefault,
MUserFormattable,
MUserId,
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',
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
]
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.findOne(query)
+ }
+
+ 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 UserModel.getTotalRawQuery(query, user.id)
+ 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'
}
- // 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 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 UserModel.getTotalRawQuery(query, user.id)
+ return parseInt(total, 10)
+ })
}
static async getStats () {
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)
- })
- }
}
})
export class VideoLiveModel extends Model<VideoLiveModel> {
- @AllowNull(false)
+ @AllowNull(true)
@Column(DataType.STRING)
streamKey: string
+ @AllowNull(false)
+ @Column
+ perpetualLive: boolean
+
+ @AllowNull(false)
+ @Column
+ saveReplay: boolean
+
@CreatedAt
createdAt: Date
live: {
enabled: true,
+ allowReplay: false,
+ maxDuration: null,
+
transcoding: {
enabled: true,
threads: 4,
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.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
},
live: {
enabled: true,
+ allowReplay: false,
+ maxDuration: 5000,
transcoding: {
enabled: true,
threads: 4,
},
live: {
enabled: true,
+ allowReplay: false,
+ maxDuration: null,
transcoding: {
enabled: true,
threads: 4,
live: {
enabled: boolean
+ allowReplay: boolean
+ maxDuration: number
+
transcoding: {
enabled: boolean
threads: number
live: {
enabled: boolean
+ maxDuration: number
+ allowReplay: boolean
+
transcoding: {
enabled: boolean