]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Check live duration and size
authorChocobozzz <me@florianbigard.com>
Fri, 25 Sep 2020 14:19:35 +0000 (16:19 +0200)
committerChocobozzz <chocobozzz@cpy.re>
Mon, 9 Nov 2020 14:33:04 +0000 (15:33 +0100)
27 files changed:
client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html
client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
client/src/app/core/server/server.service.ts
config/test.yaml
server/controllers/api/config.ts
server/controllers/api/users/me.ts
server/helpers/core-utils.ts
server/helpers/custom-validators/misc.ts
server/helpers/ffmpeg-utils.ts
server/initializers/checker-after-init.ts
server/initializers/checker-before-init.ts
server/initializers/config.ts
server/initializers/constants.ts
server/initializers/migrations/0535-video-live.ts
server/lib/job-queue/handlers/video-import.ts
server/lib/live-manager.ts
server/lib/user.ts
server/middlewares/validators/config.ts
server/middlewares/validators/users.ts
server/middlewares/validators/videos/videos.ts
server/models/account/user.ts
server/models/video/video-live.ts
server/tests/api/check-params/config.ts
server/tests/api/server/config.ts
shared/extra-utils/server/config.ts
shared/models/server/custom-config.model.ts
shared/models/server/server-config.model.ts

index 8000f471fe80e96dee4718f2ba07608c315013f9..2f3202e06d10e3a3984bae5754a7a0962d9aadd2 100644 (file)
       </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>
index de800c87ef06aa6ef00be6be5103a339c157dd92..745238647637bcb44ec2b08cb9cb035991941891 100644 (file)
@@ -36,6 +36,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
   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[] = []
@@ -92,6 +93,14 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
       { value: 4, label: '4' },
       { value: 8, label: '8' }
     ]
+
+    this.liveMaxDurationOptions = [
+      { value: 0, label: $localize`No limit` },
+      { value: 1000 * 3600, label: $localize`1 hour` },
+      { value: 1000 * 3600 * 3, label: $localize`3 hours` },
+      { value: 1000 * 3600 * 5, label: $localize`5 hours` },
+      { value: 1000 * 3600 * 10, label: $localize`10 hours` }
+    ]
   }
 
   get videoQuotaOptions () {
@@ -114,7 +123,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
   ngOnInit () {
     this.serverConfig = this.serverService.getTmpConfig()
     this.serverService.getConfig()
-        .subscribe(config => this.serverConfig = config)
+        .subscribe(config => {
+          this.serverConfig = config
+        })
 
     const formGroupData: { [key in keyof CustomConfig ]: any } = {
       instance: {
@@ -204,6 +215,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit, A
       live: {
         enabled: null,
 
+        maxDuration: null,
+        allowReplay: null,
+
         transcoding: {
           enabled: null,
           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 () {
     this.form.patchValue(this.customConfig)
   }
index bc76bacfcf46f4073b9be9eec755ed9cefe2b1e1..c19c3c12e037fa4ddd68b66ad9e652490841331c 100644 (file)
@@ -76,6 +76,8 @@ export class ServerService {
     },
     live: {
       enabled: false,
+      allowReplay: true,
+      maxDuration: null,
       transcoding: {
         enabled: false,
         enabledResolutions: []
index 865ed540087e1dc3259f1452f33d25c856bc1b7d..b9279b5e68a7e0f6c8e245c0f63b8e7dfdfca5e4 100644 (file)
@@ -89,7 +89,7 @@ live:
     port: 1935
 
   transcoding:
-    enabled: true
+    enabled: false
     threads: 2
 
     resolutions:
index bd100ef9cddf03d46a4ff547071ee0bca3138091..99aabba62e62a94c4b0fe7e051b0bdb59b3bb967 100644 (file)
@@ -118,6 +118,9 @@ async function getConfig (req: express.Request, res: express.Response) {
     live: {
       enabled: CONFIG.LIVE.ENABLED,
 
+      allowReplay: CONFIG.LIVE.ALLOW_REPLAY,
+      maxDuration: CONFIG.LIVE.MAX_DURATION,
+
       transcoding: {
         enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
         enabledResolutions: getEnabledResolutions('live')
@@ -425,6 +428,8 @@ function customConfig (): CustomConfig {
     },
     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,
index ba60a3d2aa2ca8fdf08aa9a936e0ea78e2969527..b490518fcecd61f399d10140ed6e03a8fef653ce 100644 (file)
@@ -9,7 +9,7 @@ import { MIMETYPES } from '../../../initializers/constants'
 import { sequelizeTypescript } from '../../../initializers/database'
 import { sendUpdateActor } from '../../../lib/activitypub/send'
 import { updateActorAvatarFile } from '../../../lib/avatar'
-import { sendVerifyUserEmail } from '../../../lib/user'
+import { getOriginalVideoFileTotalDailyFromUser, getOriginalVideoFileTotalFromUser, sendVerifyUserEmail } from '../../../lib/user'
 import {
   asyncMiddleware,
   asyncRetryTransactionMiddleware,
@@ -133,8 +133,8 @@ async function getUserInformation (req: express.Request, res: express.Response)
 
 async function getUserVideoQuotaUsed (req: express.Request, res: express.Response) {
   const user = res.locals.oauth.token.user
-  const videoQuotaUsed = await UserModel.getOriginalVideoFileTotalFromUser(user)
-  const videoQuotaUsedDaily = await UserModel.getOriginalVideoFileTotalDailyFromUser(user)
+  const videoQuotaUsed = await getOriginalVideoFileTotalFromUser(user)
+  const videoQuotaUsedDaily = await getOriginalVideoFileTotalDailyFromUser(user)
 
   const data: UserVideoQuota = {
     videoQuotaUsed,
index 49eee7c591323f0fca9eb17e0807002d7ff01c0a..e1c15a6ebf1aa08c2c86b22af1448d970aa211f1 100644 (file)
@@ -41,6 +41,7 @@ const timeTable = {
 }
 
 export function parseDurationToMs (duration: number | string): number {
+  if (duration === null) return null
   if (typeof duration === 'number') return duration
 
   if (typeof duration === 'string') {
index cf32201c411a6532e6c002c88282b699657ddee7..61c03f0c978fa947e6494452333fa3496aaade56 100644 (file)
@@ -45,6 +45,10 @@ function isBooleanValid (value: any) {
   return typeof value === 'boolean' || (typeof value === 'string' && validator.isBoolean(value))
 }
 
+function isIntOrNull (value: any) {
+  return value === null || validator.isInt('' + value)
+}
+
 function toIntOrNull (value: string) {
   const v = toValueOrNull(value)
 
@@ -116,6 +120,7 @@ export {
   isArrayOf,
   isNotEmptyIntArray,
   isArray,
+  isIntOrNull,
   isIdValid,
   isSafePath,
   isUUIDValid,
index fac2595f1001559e79d7a5e0212cf177fdca77f4..b25dcaa9095af2c7457875ce8af6b02bcc890dd2 100644 (file)
@@ -5,7 +5,7 @@ import { VideoFileMetadata } from '@shared/models/videos/video-file-metadata'
 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'
 
@@ -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)
   command.inputOption('-fflags nobuffer')
 
@@ -399,7 +399,7 @@ function runLiveTranscoding (rtmpUrl: string, outPath: string, resolutions: numb
     varStreamMap.push(`v:${i},a:${i}`)
   }
 
-  addDefaultLiveHLSParams(command, outPath)
+  addDefaultLiveHLSParams(command, outPath, deleteSegments)
 
   command.outputOption('-var_stream_map', varStreamMap.join(' '))
 
@@ -408,7 +408,7 @@ function runLiveTranscoding (rtmpUrl: string, outPath: string, resolutions: numb
   return command
 }
 
-function runLiveMuxing (rtmpUrl: string, outPath: string) {
+function runLiveMuxing (rtmpUrl: string, outPath: string, deleteSegments: boolean) {
   const command = getFFmpeg(rtmpUrl)
   command.inputOption('-fflags nobuffer')
 
@@ -417,7 +417,7 @@ function runLiveMuxing (rtmpUrl: string, outPath: string) {
   command.outputOption('-map 0:a?')
   command.outputOption('-map 0:v?')
 
-  addDefaultLiveHLSParams(command, outPath)
+  addDefaultLiveHLSParams(command, outPath, deleteSegments)
 
   command.run()
 
@@ -457,10 +457,14 @@ function addDefaultX264Params (command: ffmpeg.FfmpegCommand) {
          .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`)
index b49ab6bca790d1bbab034c95ee2a898838b9d14e..979c97a8b2743024bfe06e36e1c01b0fb2b34b07 100644 (file)
@@ -135,6 +135,13 @@ function checkConfig () {
     }
   }
 
+  // Live
+  if (CONFIG.LIVE.ENABLED === true) {
+    if (CONFIG.LIVE.ALLOW_REPLAY === true && CONFIG.TRANSCODING.ENABLED === false) {
+      return 'Live allow replay cannot be enabled if transcoding is not enabled.'
+    }
+  }
+
   return null
 }
 
index e0819c4aa1c9c44de65ab6db180633de13db723e..d4140e3fa9863f6ff2ff8e409e82e613cf8a6939 100644 (file)
@@ -37,8 +37,13 @@ function checkMissedConfig () {
     'remote_redundancy.videos.accept_from',
     'federation.videos.federate_unlisted',
     'search.remote_uri.users', 'search.remote_uri.anonymous', 'search.search_index.enabled', 'search.search_index.url',
-    'search.search_index.disable_local_search', 'search.search_index.is_default_search'
+    'search.search_index.disable_local_search', 'search.search_index.is_default_search',
+    'live.enabled', 'live.allow_replay', 'live.max_duration',
+    'live.transcoding.enabled', 'live.transcoding.threads',
+    'live.transcoding.resolutions.240p', 'live.transcoding.resolutions.360p', 'live.transcoding.resolutions.480p',
+    'live.transcoding.resolutions.720p', 'live.transcoding.resolutions.1080p', 'live.transcoding.resolutions.2160p'
   ]
+
   const requiredAlternatives = [
     [ // set
       [ 'redis.hostname', 'redis.port' ], // alternative
index 7a8200ed9fce88abc4c0abe2961e378c5cbe86d4..9e892735009f0456b13dab2010d4501dcba0b08f 100644 (file)
@@ -201,6 +201,9 @@ const CONFIG = {
   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') }
     },
index 82d04a94e462dcd61b5e2840c1c42341a22d4869..065012b323be29161b0ce3f52086f4eb0eae76c3 100644 (file)
@@ -608,7 +608,9 @@ const HLS_REDUNDANCY_DIRECTORY = join(CONFIG.STORAGE.REDUNDANCY_DIR, 'hls')
 
 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,
@@ -620,7 +622,8 @@ const VIDEO_LIVE = {
 
 const MEMOIZE_TTL = {
   OVERVIEWS_SAMPLE: 1000 * 3600 * 4, // 4 hours
-  INFO_HASH_EXISTS: 1000 * 3600 * 12 // 12 hours
+  INFO_HASH_EXISTS: 1000 * 3600 * 12, // 12 hours
+  LIVE_ABLE_TO_UPLOAD: 1000 * 60 // 1 minute
 }
 
 const MEMOIZE_LENGTH = {
index 35523efc4adaddcc27555a81974074c045032cce..7501e080b12d094893d65633c9cdab29f758ae0a 100644 (file)
@@ -9,7 +9,7 @@ async function up (utils: {
     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,
index 9b5f2bb2ba32daeb5c74673ba9e1692fad05d776..9210aec547ef3c7a20afe1c5669dfe89ec676533 100644 (file)
@@ -4,6 +4,7 @@ import { extname } from 'path'
 import { addOptimizeOrMergeAudioJob } from '@server/helpers/video'
 import { isPostImportVideoAccepted } from '@server/lib/moderation'
 import { Hooks } from '@server/lib/plugins/hooks'
+import { isAbleToUploadVideo } from '@server/lib/user'
 import { getVideoFilePath } from '@server/lib/video-paths'
 import { MVideoImportDefault, MVideoImportDefaultFiles, MVideoImportVideo } from '@server/types/models/video/video-import'
 import {
@@ -108,7 +109,7 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid
 
     // Get information about this video
     const stats = await stat(tempVideoPath)
-    const isAble = await videoImport.User.isAbleToUploadVideo({ size: stats.size })
+    const isAble = await isAbleToUploadVideo(videoImport.User.id, stats.size)
     if (isAble === false) {
       throw new Error('The user video quota is exceeded with this video to import.')
     }
index 41176d19742b64b5e26a5cefb0323e638dddd963..3ff2434ff4b9e5507d25a41e24c7b55ecefc68c1 100644 (file)
@@ -2,24 +2,27 @@
 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')
@@ -53,6 +56,11 @@ class LiveManager {
   private readonly transSessions = new Map<string, FfmpegCommand>()
   private readonly videoSessions = new Map<number, string>()
   private readonly segmentsSha256 = new Map<string, Map<string, string>>()
+  private readonly livesPerUser = new Map<number, { liveId: number, videoId: number, size: number }[]>()
+
+  private readonly isAbleToUploadVideoWithCache = memoizee((userId: number) => {
+    return isAbleToUploadVideo(userId, 1000)
+  }, { maxAge: MEMOIZE_TTL.LIVE_ABLE_TO_UPLOAD })
 
   private segmentsSha256Queue: AsyncQueue<SegmentSha256QueueParam>
   private rtmpServer: any
@@ -127,7 +135,7 @@ class LiveManager {
 
     this.abortSession(sessionId)
 
-    this.onEndTransmuxing(videoId)
+    this.onEndTransmuxing(videoId, true)
       .catch(err => logger.error('Cannot end transmuxing of video %d.', videoId, { err }))
   }
 
@@ -196,8 +204,18 @@ class LiveManager {
     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]
 
@@ -216,26 +234,47 @@ class LiveManager {
     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')
@@ -280,7 +319,14 @@ class LiveManager {
     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
@@ -290,7 +336,7 @@ class LiveManager {
         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
@@ -337,6 +383,23 @@ class LiveManager {
     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())
   }
index aa14f0b54a1fed23ff1c93616a4d81b42bf0e5b1..d3338f329467b4b6b184715256dac7eb4344cbc6 100644 (file)
@@ -1,20 +1,24 @@
+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 }
 
@@ -116,13 +120,61 @@ async function sendVerifyUserEmail (user: MUser, isPendingEmail = false) {
   await Emailer.Instance.addVerifyEmailJob(username, email, url)
 }
 
+async function getOriginalVideoFileTotalFromUser (user: MUserId) {
+  // Don't use sequelize because we need to use a sub query
+  const query = UserModel.generateUserQuotaBaseSQL({
+    withSelect: true,
+    whereUserId: '$userId'
+  })
+
+  const base = await UserModel.getTotalRawQuery(query, user.id)
+
+  return base + LiveManager.Instance.getLiveQuotaUsedByUser(user.id)
+}
+
+// Returns cumulative size of all video files uploaded in the last 24 hours.
+async function getOriginalVideoFileTotalDailyFromUser (user: MUserId) {
+  // Don't use sequelize because we need to use a sub query
+  const query = UserModel.generateUserQuotaBaseSQL({
+    withSelect: true,
+    whereUserId: '$userId',
+    where: '"video"."createdAt" > now() - interval \'24 hours\''
+  })
+
+  const base = await UserModel.getTotalRawQuery(query, user.id)
+
+  return base + LiveManager.Instance.getLiveQuotaUsedByUser(user.id)
+}
+
+async function isAbleToUploadVideo (userId: number, size: number) {
+  const user = await UserModel.loadById(userId)
+
+  if (user.videoQuota === -1 && user.videoQuotaDaily === -1) return Promise.resolve(true)
+
+  const [ totalBytes, totalBytesDaily ] = await Promise.all([
+    getOriginalVideoFileTotalFromUser(user.id),
+    getOriginalVideoFileTotalDailyFromUser(user.id)
+  ])
+
+  const uploadedTotal = size + totalBytes
+  const uploadedDaily = size + totalBytesDaily
+
+  if (user.videoQuotaDaily === -1) return uploadedTotal < user.videoQuota
+  if (user.videoQuota === -1) return uploadedDaily < user.videoQuotaDaily
+
+  return uploadedTotal < user.videoQuota && uploadedDaily < user.videoQuotaDaily
+}
+
 // ---------------------------------------------------------------------------
 
 export {
+  getOriginalVideoFileTotalFromUser,
+  getOriginalVideoFileTotalDailyFromUser,
   createApplicationActor,
   createUserAccountAndChannelAndPlaylist,
   createLocalAccountWithoutKeys,
-  sendVerifyUserEmail
+  sendVerifyUserEmail,
+  isAbleToUploadVideo
 }
 
 // ---------------------------------------------------------------------------
index d3669f6bef91938a7f3696f34b638dab31c48834..41a6ae4f9aa856c1f60e1b8b899007d6b1ec2d56 100644 (file)
@@ -1,12 +1,13 @@
 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'),
@@ -43,6 +44,7 @@ const customConfigUpdateValidator = [
   body('transcoding.resolutions.480p').isBoolean().withMessage('Should have a valid transcoding 480p resolution enabled boolean'),
   body('transcoding.resolutions.720p').isBoolean().withMessage('Should have a valid transcoding 720p resolution enabled boolean'),
   body('transcoding.resolutions.1080p').isBoolean().withMessage('Should have a valid transcoding 1080p resolution enabled boolean'),
+  body('transcoding.resolutions.2160p').isBoolean().withMessage('Should have a valid transcoding 2160p resolution enabled boolean'),
 
   body('transcoding.webtorrent.enabled').isBoolean().withMessage('Should have a valid webtorrent transcoding enabled boolean'),
   body('transcoding.hls.enabled').isBoolean().withMessage('Should have a valid hls transcoding enabled boolean'),
@@ -60,6 +62,18 @@ const customConfigUpdateValidator = [
   body('broadcastMessage.level').exists().withMessage('Should have a valid broadcast level'),
   body('broadcastMessage.dismissable').isBoolean().withMessage('Should have a valid broadcast dismissable boolean'),
 
+  body('live.enabled').isBoolean().withMessage('Should have a valid live enabled boolean'),
+  body('live.allowReplay').isBoolean().withMessage('Should have a valid live allow replay boolean'),
+  body('live.maxDuration').custom(isIntOrNull).withMessage('Should have a valid live max duration'),
+  body('live.transcoding.enabled').isBoolean().withMessage('Should have a valid live transcoding enabled boolean'),
+  body('live.transcoding.threads').isInt().withMessage('Should have a valid live transcoding threads'),
+  body('live.transcoding.resolutions.240p').isBoolean().withMessage('Should have a valid transcoding 240p resolution enabled boolean'),
+  body('live.transcoding.resolutions.360p').isBoolean().withMessage('Should have a valid transcoding 360p resolution enabled boolean'),
+  body('live.transcoding.resolutions.480p').isBoolean().withMessage('Should have a valid transcoding 480p resolution enabled boolean'),
+  body('live.transcoding.resolutions.720p').isBoolean().withMessage('Should have a valid transcoding 720p resolution enabled boolean'),
+  body('live.transcoding.resolutions.1080p').isBoolean().withMessage('Should have a valid transcoding 1080p resolution enabled boolean'),
+  body('live.transcoding.resolutions.2160p').isBoolean().withMessage('Should have a valid transcoding 2160p resolution enabled boolean'),
+
   body('search.remoteUri.users').isBoolean().withMessage('Should have a remote URI search for users boolean'),
   body('search.remoteUri.anonymous').isBoolean().withMessage('Should have a valid remote URI search for anonymous boolean'),
   body('search.searchIndex.enabled').isBoolean().withMessage('Should have a valid search index enabled boolean'),
@@ -71,8 +85,9 @@ const customConfigUpdateValidator = [
     logger.debug('Checking customConfigUpdateValidator parameters', { parameters: req.body })
 
     if (areValidationErrors(req, res)) return
-    if (!checkInvalidConfigIfEmailDisabled(req.body as CustomConfig, res)) return
-    if (!checkInvalidTranscodingConfig(req.body as CustomConfig, res)) return
+    if (!checkInvalidConfigIfEmailDisabled(req.body, res)) return
+    if (!checkInvalidTranscodingConfig(req.body, res)) return
+    if (!checkInvalidLiveConfig(req.body, res)) return
 
     return next()
   }
@@ -109,3 +124,16 @@ function checkInvalidTranscodingConfig (customConfig: CustomConfig, res: express
 
   return true
 }
+
+function checkInvalidLiveConfig (customConfig: CustomConfig, res: express.Response) {
+  if (customConfig.live.enabled === false) return true
+
+  if (customConfig.live.allowReplay === true && customConfig.transcoding.enabled === false) {
+    res.status(400)
+       .send({ error: 'You cannot allow live replay if transcoding is not enabled' })
+       .end()
+    return false
+  }
+
+  return true
+}
index 76ecff8845bcfbf262a9a794508e8f96b17575de..452c7fb930d2af67ccb8bd549e8836794957277a 100644 (file)
@@ -497,7 +497,7 @@ export {
 
 function checkUserIdExist (idArg: number | string, res: express.Response, withStats = false) {
   const id = parseInt(idArg + '', 10)
-  return checkUserExist(() => UserModel.loadById(id, withStats), res)
+  return checkUserExist(() => UserModel.loadByIdWithChannels(id, withStats), res)
 }
 
 function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) {
index b022b2c2379e3713c7b5f7991d33bb6272f85468..ff90e347ae0b44d0d78819641decabb5c1ebbb8b 100644 (file)
@@ -1,5 +1,6 @@
 import * as express from 'express'
 import { body, param, query, ValidationChain } from 'express-validator'
+import { isAbleToUploadVideo } from '@server/lib/user'
 import { getServerActor } from '@server/models/application/application'
 import { MVideoFullLight } from '@server/types/models'
 import { ServerErrorCode, UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared'
@@ -73,7 +74,7 @@ const videosAddValidator = getCommonVideoEditAttributes().concat([
 
     if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
 
-    if (await user.isAbleToUploadVideo(videoFile) === false) {
+    if (await isAbleToUploadVideo(user.id, videoFile.size) === false) {
       res.status(403)
          .json({ error: 'The user video quota is exceeded with this video.' })
 
@@ -291,7 +292,7 @@ const videosAcceptChangeOwnershipValidator = [
 
     const user = res.locals.oauth.token.User
     const videoChangeOwnership = res.locals.videoChangeOwnership
-    const isAble = await user.isAbleToUploadVideo(videoChangeOwnership.Video.getMaxQualityFile())
+    const isAble = await isAbleToUploadVideo(user.id, videoChangeOwnership.Video.getMaxQualityFile().size)
     if (isAble === false) {
       res.status(403)
         .json({ error: 'The user video quota is exceeded with this video.' })
index 22e6715b46ab9ec41c20656dda35655cfec383dd..e850d1e6df311c4a45b1a054b73e33faa4cbe920 100644 (file)
@@ -23,6 +23,7 @@ import {
 } from 'sequelize-typescript'
 import {
   MMyUserFormattable,
+  MUser,
   MUserDefault,
   MUserFormattable,
   MUserId,
@@ -70,6 +71,7 @@ import { VideoImportModel } from '../video/video-import'
 import { VideoPlaylistModel } from '../video/video-playlist'
 import { AccountModel } from './account'
 import { UserNotificationSettingModel } from './user-notification-setting'
+import { VideoLiveModel } from '../video/video-live'
 
 enum ScopeNames {
   FOR_ME_API = 'FOR_ME_API',
@@ -540,7 +542,11 @@ export class UserModel extends Model<UserModel> {
     return UserModel.findAll(query)
   }
 
-  static loadById (id: number, withStats = false): Bluebird<MUserDefault> {
+  static loadById (id: number): Bluebird<MUser> {
+    return UserModel.unscoped().findByPk(id)
+  }
+
+  static loadByIdWithChannels (id: number, withStats = false): Bluebird<MUserDefault> {
     const scopes = [
       ScopeNames.WITH_VIDEOCHANNELS
     ]
@@ -685,26 +691,85 @@ export class UserModel extends Model<UserModel> {
     return UserModel.findOne(query)
   }
 
-  static getOriginalVideoFileTotalFromUser (user: MUserId) {
-    // Don't use sequelize because we need to use a sub query
-    const query = UserModel.generateUserQuotaBaseSQL({
-      withSelect: true,
-      whereUserId: '$userId'
-    })
+  static loadByLiveId (liveId: number): Bluebird<MUser> {
+    const query = {
+      include: [
+        {
+          attributes: [ 'id' ],
+          model: AccountModel.unscoped(),
+          required: true,
+          include: [
+            {
+              attributes: [ 'id' ],
+              model: VideoChannelModel.unscoped(),
+              required: true,
+              include: [
+                {
+                  attributes: [ 'id' ],
+                  model: VideoModel.unscoped(),
+                  required: true,
+                  include: [
+                    {
+                      attributes: [ 'id', 'videoId' ],
+                      model: VideoLiveModel.unscoped(),
+                      required: true,
+                      where: {
+                        id: liveId
+                      }
+                    }
+                  ]
+                }
+              ]
+            }
+          ]
+        }
+      ]
+    }
+
+    return UserModel.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 () {
@@ -874,64 +939,4 @@ export class UserModel extends Model<UserModel> {
 
     return Object.assign(formatted, { specialPlaylists })
   }
-
-  async isAbleToUploadVideo (videoFile: { size: number }) {
-    if (this.videoQuota === -1 && this.videoQuotaDaily === -1) return Promise.resolve(true)
-
-    const [ totalBytes, totalBytesDaily ] = await Promise.all([
-      UserModel.getOriginalVideoFileTotalFromUser(this),
-      UserModel.getOriginalVideoFileTotalDailyFromUser(this)
-    ])
-
-    const uploadedTotal = videoFile.size + totalBytes
-    const uploadedDaily = videoFile.size + totalBytesDaily
-
-    if (this.videoQuotaDaily === -1) return uploadedTotal < this.videoQuota
-    if (this.videoQuota === -1) return uploadedDaily < this.videoQuotaDaily
-
-    return uploadedTotal < this.videoQuota && uploadedDaily < this.videoQuotaDaily
-  }
-
-  private static generateUserQuotaBaseSQL (options: {
-    whereUserId: '$userId' | '"UserModel"."id"'
-    withSelect: boolean
-    where?: string
-  }) {
-    const andWhere = options.where
-      ? 'AND ' + options.where
-      : ''
-
-    const videoChannelJoin = 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
-      'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' +
-      `WHERE "account"."userId" = ${options.whereUserId} ${andWhere}`
-
-    const webtorrentFiles = 'SELECT "videoFile"."size" AS "size", "video"."id" AS "videoId" FROM "videoFile" ' +
-      'INNER JOIN "video" ON "videoFile"."videoId" = "video"."id" ' +
-      videoChannelJoin
-
-    const hlsFiles = 'SELECT "videoFile"."size" AS "size", "video"."id" AS "videoId" FROM "videoFile" ' +
-      'INNER JOIN "videoStreamingPlaylist" ON "videoFile"."videoStreamingPlaylistId" = "videoStreamingPlaylist".id ' +
-      'INNER JOIN "video" ON "videoStreamingPlaylist"."videoId" = "video"."id" ' +
-      videoChannelJoin
-
-    return 'SELECT COALESCE(SUM("size"), 0) AS "total" ' +
-      'FROM (' +
-        `SELECT MAX("t1"."size") AS "size" FROM (${webtorrentFiles} UNION ${hlsFiles}) t1 ` +
-        'GROUP BY "t1"."videoId"' +
-      ') t2'
-  }
-
-  private static getTotalRawQuery (query: string, userId: number) {
-    const options = {
-      bind: { userId },
-      type: QueryTypes.SELECT as QueryTypes.SELECT
-    }
-
-    return UserModel.sequelize.query<{ total: string }>(query, options)
-                    .then(([ { total } ]) => {
-                      if (total === null) return 0
-
-                      return parseInt(total, 10)
-                    })
-  }
 }
index 8608bc84ca9f654ff2b262904ef4072ff177b15d..a1dd80d3c74bfe0d9cb6e5ce12f221ed2ad707e7 100644 (file)
@@ -30,10 +30,18 @@ import { VideoBlacklistModel } from './video-blacklist'
 })
 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
 
index 35cb333ef39ced2e038c25e4274eeaee6ec00330..2882ceb7c26c909f2f3bd1cb82351ff042f0a151 100644 (file)
@@ -103,6 +103,9 @@ describe('Test config API validators', function () {
     live: {
       enabled: true,
 
+      allowReplay: false,
+      maxDuration: null,
+
       transcoding: {
         enabled: true,
         threads: 4,
index a46e179c22d2d00362688e091c6a416dfbd5ecaf..a7f03536274cfd8788c1c7a05999d61a82c055ad 100644 (file)
@@ -79,6 +79,8 @@ function checkInitialConfig (server: ServerInfo, data: CustomConfig) {
   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
@@ -162,6 +164,8 @@ function checkUpdatedConfig (data: CustomConfig) {
   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
@@ -324,6 +328,8 @@ describe('Test config', function () {
       },
       live: {
         enabled: true,
+        allowReplay: false,
+        maxDuration: 5000,
         transcoding: {
           enabled: true,
           threads: 4,
index 3606976bd0716f018ded05684fb98ca48a03f81a..bb7e23d54a33d5a84716dc7984d8c2e232c9ee6a 100644 (file)
@@ -128,6 +128,8 @@ function updateCustomSubConfig (url: string, token: string, newConfig: DeepParti
     },
     live: {
       enabled: true,
+      allowReplay: false,
+      maxDuration: null,
       transcoding: {
         enabled: true,
         threads: 4,
index e609d1a337c6508f02424ab4ff9194cb76fdb0ff..11b2ef2ebbd21b9639bbfce7218263b9b910e5d5 100644 (file)
@@ -98,6 +98,9 @@ export interface CustomConfig {
   live: {
     enabled: boolean
 
+    allowReplay: boolean
+    maxDuration: number
+
     transcoding: {
       enabled: boolean
       threads: number
index 77694a6273c615f5990a608dfb28b64cde7f3e3a..1563d848e81d98f649bdf18e6858dfa51476324c 100644 (file)
@@ -101,6 +101,9 @@ export interface ServerConfig {
   live: {
     enabled: boolean
 
+    maxDuration: number
+    allowReplay: boolean
+
     transcoding: {
       enabled: boolean