]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Add ability to save live replay
authorChocobozzz <me@florianbigard.com>
Mon, 26 Oct 2020 15:44:23 +0000 (16:44 +0100)
committerChocobozzz <chocobozzz@cpy.re>
Mon, 9 Nov 2020 14:33:04 +0000 (15:33 +0100)
28 files changed:
client/src/app/+videos/+video-edit/shared/video-edit.component.html
client/src/app/+videos/+video-edit/shared/video-edit.component.ts
client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.html
client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts
client/src/app/+videos/+video-edit/video-add.component.html
client/src/app/+videos/+video-edit/video-update.component.ts
client/src/app/+videos/+video-edit/video-update.resolver.ts
client/src/app/+videos/+video-watch/video-duration-formatter.pipe.ts [deleted file]
client/src/app/+videos/+video-watch/video-watch.component.html
client/src/app/+videos/+video-watch/video-watch.module.ts
client/src/app/shared/shared-main/angular/duration-formatter.pipe.ts [new file with mode: 0644]
client/src/app/shared/shared-main/angular/index.ts
client/src/app/shared/shared-main/shared-main.module.ts
client/src/app/shared/shared-main/video/live-video.service.ts
server/controllers/api/videos/live.ts
server/helpers/ffmpeg-utils.ts
server/helpers/image-utils.ts
server/lib/hls.ts
server/lib/job-queue/handlers/video-live-ending.ts
server/lib/job-queue/handlers/video-transcoding.ts
server/lib/live-manager.ts
server/lib/video-transcoding.ts
server/middlewares/validators/videos/video-live.ts
server/models/video/video-live.ts
shared/models/videos/live/index.ts
shared/models/videos/live/live-video-create.model.ts [new file with mode: 0644]
shared/models/videos/live/live-video-update.model.ts [new file with mode: 0644]
shared/models/videos/live/live-video.model.ts

index 0802e906dbfc86259a57ca0192da69e325337f72..d9e09c453eea58883a70f7c3d8a3ebf79171d23c 100644 (file)
       </ng-template>
     </ng-container>
 
-    <ng-container ngbNavItem>
+    <ng-container ngbNavItem *ngIf="!liveVideo">
       <a ngbNavLink i18n>Captions</a>
 
       <ng-template ngbNavContent>
               <label for="liveVideoStreamKey" i18n>Live stream key</label>
               <my-input-readonly-copy id="liveVideoStreamKey" [value]="liveVideo.streamKey"></my-input-readonly-copy>
             </div>
+
+            <div class="form-group" *ngIf="isSaveReplayEnabled()">
+              <my-peertube-checkbox inputName="liveVideoSaveReplay" formControlName="saveReplay">
+                <ng-template ptTemplate="label">
+                  <ng-container i18n>Automatically publish a replay when your live ends</ng-container>
+                </ng-template>
+
+                <ng-container ngProjectAs="description">
+                  <span i18n>⚠️ If you enable this option, your live will be terminated if you exceed your video quota</span>
+                </ng-container>
+              </my-peertube-checkbox>
+            </div>
           </div>
         </div>
       </ng-template>
index 304bf7ed0286db89bfd993ff71367cc107f07ab0..26d871e59307484b985916b4aac786bd703804c4 100644 (file)
@@ -127,7 +127,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
       support: VIDEO_SUPPORT_VALIDATOR,
       schedulePublicationAt: VIDEO_SCHEDULE_PUBLICATION_AT_VALIDATOR,
       originallyPublishedAt: VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR,
-      liveStreamKey: null
+      liveStreamKey: null,
+      saveReplay: null
     }
 
     this.formValidatorService.updateForm(
@@ -239,6 +240,10 @@ export class VideoEditComponent implements OnInit, OnDestroy {
     this.videoCaptionAddModal.show()
   }
 
+  isSaveReplayEnabled () {
+    return this.serverConfig.live.allowReplay
+  }
+
   private sortVideoCaptions () {
     this.videoCaptions.sort((v1, v2) => {
       if (v1.language.label < v2.language.label) return -1
index 8fae4044ac25cd7eab988935456d5006e54f0462..5657827a92acbf770069aa464836fce228743444 100644 (file)
   {{ error }}
 </div>
 
+<div class="alert alert-info" i18n *ngIf="isInUpdateForm && getMaxLiveDuration()">
+  Max live duration is {{ getMaxLiveDuration() | myDurationFormatter }}.
+  If your live reaches this limit, it will be automatically terminated.
+</div>
+
 <!-- Hidden because we want to load the component -->
 <form [hidden]="!isInUpdateForm" novalidate [formGroup]="form">
   <my-video-edit
index 0a9efc693818acecd6ad1b808b3d51cbed220452..9868c37d2bb12616400d89505862d11d58cd5b9c 100644 (file)
@@ -1,4 +1,5 @@
 
+import { forkJoin } from 'rxjs'
 import { Component, EventEmitter, OnInit, Output } from '@angular/core'
 import { Router } from '@angular/router'
 import { AuthService, CanComponentDeactivate, Notifier, ServerService } from '@app/core'
@@ -6,7 +7,7 @@ import { scrollToTop } from '@app/helpers'
 import { FormValidatorService } from '@app/shared/shared-forms'
 import { LiveVideoService, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
 import { LoadingBarService } from '@ngx-loading-bar/core'
-import { LiveVideo, VideoCreate, VideoPrivacy } from '@shared/models'
+import { LiveVideo, LiveVideoCreate, LiveVideoUpdate, VideoPrivacy } from '@shared/models'
 import { VideoSend } from './video-send'
 
 @Component({
@@ -53,7 +54,7 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, CanCompon
   }
 
   goLive () {
-    const video: VideoCreate = {
+    const video: LiveVideoCreate = {
       name: 'Live',
       privacy: VideoPrivacy.PRIVATE,
       nsfw: this.serverConfig.instance.isNSFW,
@@ -95,22 +96,32 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, CanCompon
     video.id = this.videoId
     video.uuid = this.videoUUID
 
+    const liveVideoUpdate: LiveVideoUpdate = {
+      saveReplay: this.form.value.saveReplay
+    }
+
     // Update the video
-    this.updateVideoAndCaptions(video)
-        .subscribe(
-          () => {
-            this.notifier.success($localize`Live published.`)
+    forkJoin([
+      this.updateVideoAndCaptions(video),
 
-            this.router.navigate([ '/videos/watch', video.uuid ])
-          },
+      this.liveVideoService.updateLive(this.videoId, liveVideoUpdate)
+    ]).subscribe(
+      () => {
+        this.notifier.success($localize`Live published.`)
+
+        this.router.navigate(['/videos/watch', video.uuid])
+      },
 
-          err => {
-            this.error = err.message
-            scrollToTop()
-            console.error(err)
-          }
-        )
+      err => {
+        this.error = err.message
+        scrollToTop()
+        console.error(err)
+      }
+    )
+  }
 
+  getMaxLiveDuration () {
+    return this.serverConfig.live.maxDuration / 1000
   }
 
   private fetchVideoLive () {
index bf2cc9c83f10bd70e093ee70b842137064edb224..dc8c2f21da0e288f86603b5075e9766074a6ef5d 100644 (file)
@@ -13,7 +13,7 @@
     Instead, <a routerLink="/admin/users">create a dedicated account</a> to upload your videos.
   </div>
 
-  <my-user-quota *ngIf="!isInSecondStep()" [user]="user" [userInformationLoaded]="userInformationLoaded"></my-user-quota>
+  <my-user-quota *ngIf="!isInSecondStep() || secondStepType === 'go-live'" [user]="user" [userInformationLoaded]="userInformationLoaded"></my-user-quota>
 
   <div class="title-page title-page-single" *ngIf="isInSecondStep()">
     <ng-container *ngIf="secondStepType === 'import-url' || secondStepType === 'import-torrent'" i18n>Import {{ videoName }}</ng-container>
index ec1305a33c8df558c7b8e95f57a266a3daadcd54..7126ad05be5007fe85f040acd4af3aca9155ecd9 100644 (file)
@@ -3,10 +3,11 @@ import { Component, HostListener, OnInit } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
 import { Notifier } from '@app/core'
 import { FormReactive, FormValidatorService, SelectChannelItem } from '@app/shared/shared-forms'
-import { VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main'
+import { LiveVideoService, VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main'
 import { LoadingBarService } from '@ngx-loading-bar/core'
-import { LiveVideo, VideoPrivacy } from '@shared/models'
+import { LiveVideo, LiveVideoUpdate, VideoPrivacy } from '@shared/models'
 import { hydrateFormFromVideo } from './shared/video-edit-utils'
+import { of } from 'rxjs'
 
 @Component({
   selector: 'my-videos-update',
@@ -32,7 +33,8 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
     private notifier: Notifier,
     private videoService: VideoService,
     private loadingBar: LoadingBarService,
-    private videoCaptionService: VideoCaptionService
+    private videoCaptionService: VideoCaptionService,
+    private liveVideoService: LiveVideoService
     ) {
     super()
   }
@@ -56,7 +58,15 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
           }
 
           // FIXME: Angular does not detect the change inside this subscription, so use the patched setTimeout
-          setTimeout(() => hydrateFormFromVideo(this.form, this.video, true))
+          setTimeout(() => {
+            hydrateFormFromVideo(this.form, this.video, true)
+
+            if (this.liveVideo) {
+              this.form.patchValue({
+                saveReplay: this.liveVideo.saveReplay
+              })
+            }
+          })
         },
 
         err => {
@@ -102,6 +112,10 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
 
     this.video.patch(this.form.value)
 
+    const liveVideoUpdate: LiveVideoUpdate = {
+      saveReplay: this.form.value.saveReplay
+    }
+
     this.loadingBar.useRef().start()
     this.isUpdatingVideo = true
 
@@ -109,7 +123,13 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
     this.videoService.updateVideo(this.video)
         .pipe(
           // Then update captions
-          switchMap(() => this.videoCaptionService.updateCaptions(this.video.id, this.videoCaptions))
+          switchMap(() => this.videoCaptionService.updateCaptions(this.video.id, this.videoCaptions)),
+
+          switchMap(() => {
+            if (!this.liveVideo) return of(undefined)
+
+            return this.liveVideoService.updateLive(this.video.id, liveVideoUpdate)
+          })
         )
         .subscribe(
           () => {
index b7ec22dd57d95d023f0621bff54c4da78c653ab5..5388a64b0899288d7b19cdeff9ce7dcf86d89fb5 100644 (file)
@@ -20,7 +20,7 @@ export class VideoUpdateResolver implements Resolve<any> {
     return this.videoService.getVideo({ videoId: uuid })
                .pipe(
                  switchMap(video => forkJoin(this.buildVideoObservables(video))),
-                 map(([ video, videoChannels, videoCaptions, videoLive ]) => ({ video, videoChannels, videoCaptions, videoLive }))
+                 map(([ video, videoChannels, videoCaptions, liveVideo ]) => ({ video, videoChannels, videoCaptions, liveVideo }))
                )
   }
 
diff --git a/client/src/app/+videos/+video-watch/video-duration-formatter.pipe.ts b/client/src/app/+videos/+video-watch/video-duration-formatter.pipe.ts
deleted file mode 100644 (file)
index 19b34f9..0000000
+++ /dev/null
@@ -1,23 +0,0 @@
-import { Pipe, PipeTransform } from '@angular/core'
-
-@Pipe({
-  name: 'myVideoDurationFormatter'
-})
-export class VideoDurationPipe implements PipeTransform {
-
-  transform (value: number): string {
-    const hours = Math.floor(value / 3600)
-    const minutes = Math.floor((value % 3600) / 60)
-    const seconds = value % 60
-
-    if (hours > 0) {
-      return $localize`${hours} h ${minutes} min ${seconds} sec`
-    }
-
-    if (minutes > 0) {
-      return $localize`${minutes} min ${seconds} sec`
-    }
-
-    return $localize`${seconds} sec`
-  }
-}
index 13242a2bcaa1c29118c70ee62608a22e09b27819..bc1c302de5927214768ac7753d9365b9227bc6c4 100644 (file)
 
         <div class="video-attribute">
           <span i18n class="video-attribute-label">Duration</span>
-          <span class="video-attribute-value">{{ video.duration | myVideoDurationFormatter }}</span>
+          <span class="video-attribute-value">{{ video.duration | myDurationFormatter }}</span>
         </div>
       </div>
 
index 612bbccc4488e3eb26199ea2544ed4544b1721fd..21aa33b84691aed67c710605f6eed36ec7940da1 100644 (file)
@@ -15,7 +15,6 @@ import { VideoCommentsComponent } from './comment/video-comments.component'
 import { VideoSupportComponent } from './modal/video-support.component'
 import { RecommendationsModule } from './recommendations/recommendations.module'
 import { TimestampRouteTransformerDirective } from './timestamp-route-transformer.directive'
-import { VideoDurationPipe } from './video-duration-formatter.pipe'
 import { VideoWatchPlaylistComponent } from './video-watch-playlist.component'
 import { VideoWatchRoutingModule } from './video-watch-routing.module'
 import { VideoWatchComponent } from './video-watch.component'
@@ -46,7 +45,6 @@ import { VideoWatchComponent } from './video-watch.component'
     VideoCommentComponent,
 
     TimestampRouteTransformerDirective,
-    VideoDurationPipe,
     TimestampRouteTransformerDirective
   ],
 
diff --git a/client/src/app/shared/shared-main/angular/duration-formatter.pipe.ts b/client/src/app/shared/shared-main/angular/duration-formatter.pipe.ts
new file mode 100644 (file)
index 0000000..29ff864
--- /dev/null
@@ -0,0 +1,32 @@
+import { Pipe, PipeTransform } from '@angular/core'
+
+@Pipe({
+  name: 'myDurationFormatter'
+})
+export class DurationFormatterPipe implements PipeTransform {
+
+  transform (value: number): string {
+    const hours = Math.floor(value / 3600)
+    const minutes = Math.floor((value % 3600) / 60)
+    const seconds = value % 60
+
+    if (hours > 0) {
+      let result = $localize`${hours}h`
+
+      if (minutes !== 0) result += ' ' + $localize`${minutes}min`
+      if (seconds !== 0) result += ' ' + $localize`${seconds}sec`
+
+      return result
+    }
+
+    if (minutes > 0) {
+      let result = $localize`${minutes}min`
+
+      if (seconds !== 0) result += ' ' + `${seconds}sec`
+
+      return result
+    }
+
+    return $localize`${seconds} sec`
+  }
+}
index 9ba8151360d1af797b8e5ff2147132db84d63d28..29f8b3650586c36170fa571b2f38b2f2ecd7871c 100644 (file)
@@ -1,4 +1,5 @@
 export * from './bytes.pipe'
+export * from './duration-formatter.pipe'
 export * from './from-now.pipe'
 export * from './infinite-scroller.directive'
 export * from './number-formatter.pipe'
index 0580872f46ecf4460eda1e179d09d2a5ca3261ec..3816cab19b9eff9836254e93e64959b1a80c3d4d 100644 (file)
@@ -15,7 +15,14 @@ import {
 } from '@ng-bootstrap/ng-bootstrap'
 import { SharedGlobalIconModule } from '../shared-icons'
 import { AccountService, ActorAvatarInfoComponent, AvatarComponent } from './account'
-import { FromNowPipe, InfiniteScrollerDirective, NumberFormatterPipe, PeerTubeTemplateDirective, BytesPipe } from './angular'
+import {
+  BytesPipe,
+  DurationFormatterPipe,
+  FromNowPipe,
+  InfiniteScrollerDirective,
+  NumberFormatterPipe,
+  PeerTubeTemplateDirective
+} from './angular'
 import { AUTH_INTERCEPTOR_PROVIDER } from './auth'
 import { ActionDropdownComponent, ButtonComponent, DeleteButtonComponent, EditButtonComponent } from './buttons'
 import { DateToggleComponent } from './date'
@@ -23,7 +30,7 @@ import { FeedComponent } from './feeds'
 import { LoaderComponent, SmallLoaderComponent } from './loaders'
 import { HelpComponent, ListOverflowComponent, TopMenuDropdownComponent } from './misc'
 import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users'
-import { RedundancyService, VideoImportService, VideoOwnershipService, VideoService, LiveVideoService } from './video'
+import { LiveVideoService, RedundancyService, VideoImportService, VideoOwnershipService, VideoService } from './video'
 import { VideoCaptionService } from './video-caption'
 import { VideoChannelService } from './video-channel'
 
@@ -56,6 +63,8 @@ import { VideoChannelService } from './video-channel'
     FromNowPipe,
     NumberFormatterPipe,
     BytesPipe,
+    DurationFormatterPipe,
+
     InfiniteScrollerDirective,
     PeerTubeTemplateDirective,
 
@@ -103,6 +112,7 @@ import { VideoChannelService } from './video-channel'
     FromNowPipe,
     BytesPipe,
     NumberFormatterPipe,
+    DurationFormatterPipe,
 
     InfiniteScrollerDirective,
     PeerTubeTemplateDirective,
index 2cd1c66a5f7acd2763e9f11749198d5932427e6c..093d65e83910b18d3c77a88fc33622950ca908b9 100644 (file)
@@ -2,7 +2,7 @@ import { catchError } from 'rxjs/operators'
 import { HttpClient } from '@angular/common/http'
 import { Injectable } from '@angular/core'
 import { RestExtractor } from '@app/core'
-import { VideoCreate, LiveVideo } from '@shared/models'
+import { LiveVideo, LiveVideoCreate, LiveVideoUpdate } from '@shared/models'
 import { environment } from '../../../../environments/environment'
 
 @Injectable()
@@ -14,7 +14,7 @@ export class LiveVideoService {
     private restExtractor: RestExtractor
   ) {}
 
-  goLive (video: VideoCreate) {
+  goLive (video: LiveVideoCreate) {
     return this.authHttp
                .post<{ video: { id: number, uuid: string } }>(LiveVideoService.BASE_VIDEO_LIVE_URL, video)
                .pipe(catchError(err => this.restExtractor.handleError(err)))
@@ -25,4 +25,10 @@ export class LiveVideoService {
                .get<LiveVideo>(LiveVideoService.BASE_VIDEO_LIVE_URL + videoId)
                .pipe(catchError(err => this.restExtractor.handleError(err)))
   }
+
+  updateLive (videoId: number | string, liveUpdate: LiveVideoUpdate) {
+    return this.authHttp
+      .put(LiveVideoService.BASE_VIDEO_LIVE_URL + videoId, liveUpdate)
+      .pipe(catchError(err => this.restExtractor.handleError(err)))
+  }
 }
index 97b135f96b00d6f19d25ea4c4a447ab44d91c6e4..be46fb1c6e5c940c7dc50d3e91da4a2e2b32093d 100644 (file)
@@ -5,10 +5,10 @@ import { CONFIG } from '@server/initializers/config'
 import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants'
 import { getVideoActivityPubUrl } from '@server/lib/activitypub/url'
 import { buildLocalVideoFromReq, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
-import { videoLiveAddValidator, videoLiveGetValidator } from '@server/middlewares/validators/videos/video-live'
+import { videoLiveAddValidator, videoLiveGetValidator, videoLiveUpdateValidator } from '@server/middlewares/validators/videos/video-live'
 import { VideoLiveModel } from '@server/models/video/video-live'
 import { MVideoDetails, MVideoFullLight } from '@server/types/models'
-import { VideoCreate, VideoState } from '../../../../shared'
+import { LiveVideoCreate, LiveVideoUpdate, VideoState } from '../../../../shared'
 import { logger } from '../../../helpers/logger'
 import { sequelizeTypescript } from '../../../initializers/database'
 import { createVideoMiniatureFromExisting } from '../../../lib/thumbnail'
@@ -36,7 +36,14 @@ liveRouter.post('/live',
 liveRouter.get('/live/:videoId',
   authenticate,
   asyncMiddleware(videoLiveGetValidator),
-  asyncRetryTransactionMiddleware(getVideoLive)
+  asyncRetryTransactionMiddleware(getLiveVideo)
+)
+
+liveRouter.put('/live/:videoId',
+  authenticate,
+  asyncMiddleware(videoLiveGetValidator),
+  videoLiveUpdateValidator,
+  asyncRetryTransactionMiddleware(updateLiveVideo)
 )
 
 // ---------------------------------------------------------------------------
@@ -47,14 +54,25 @@ export {
 
 // ---------------------------------------------------------------------------
 
-async function getVideoLive (req: express.Request, res: express.Response) {
+async function getLiveVideo (req: express.Request, res: express.Response) {
   const videoLive = res.locals.videoLive
 
   return res.json(videoLive.toFormattedJSON())
 }
 
+async function updateLiveVideo (req: express.Request, res: express.Response) {
+  const body: LiveVideoUpdate = req.body
+
+  const videoLive = res.locals.videoLive
+  videoLive.saveReplay = body.saveReplay || false
+
+  await videoLive.save()
+
+  return res.sendStatus(204)
+}
+
 async function addLiveVideo (req: express.Request, res: express.Response) {
-  const videoInfo: VideoCreate = req.body
+  const videoInfo: LiveVideoCreate = req.body
 
   // Prepare data so we don't block the transaction
   const videoData = buildLocalVideoFromReq(videoInfo, res.locals.videoChannel.id)
@@ -66,13 +84,20 @@ async function addLiveVideo (req: express.Request, res: express.Response) {
   video.url = getVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object
 
   const videoLive = new VideoLiveModel()
+  videoLive.saveReplay = videoInfo.saveReplay || false
   videoLive.streamKey = uuidv4()
 
   const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({
     video,
     files: req.files,
     fallback: type => {
-      return createVideoMiniatureFromExisting({ inputPath: ASSETS_PATH.DEFAULT_LIVE_BACKGROUND, video, type, automaticallyGenerated: true })
+      return createVideoMiniatureFromExisting({
+        inputPath: ASSETS_PATH.DEFAULT_LIVE_BACKGROUND,
+        video,
+        type,
+        automaticallyGenerated: true,
+        keepOriginal: true
+      })
     }
   })
 
index b25dcaa9095af2c7457875ce8af6b02bcc890dd2..2f167a5803505a88ca41ca8b2a061207b2f309be 100644 (file)
@@ -424,6 +424,20 @@ function runLiveMuxing (rtmpUrl: string, outPath: string, deleteSegments: boolea
   return command
 }
 
+function hlsPlaylistToFragmentedMP4 (playlistPath: string, outputPath: string) {
+  const command = getFFmpeg(playlistPath)
+
+  command.outputOption('-c copy')
+  command.output(outputPath)
+
+  command.run()
+
+  return new Promise<string>((res, rej) => {
+    command.on('error', err => rej(err))
+    command.on('end', () => res())
+  })
+}
+
 // ---------------------------------------------------------------------------
 
 export {
@@ -443,6 +457,7 @@ export {
   getVideoFileFPS,
   computeResolutionsToTranscode,
   audio,
+  hlsPlaylistToFragmentedMP4,
   getVideoFileBitrate,
   canDoQuickTranscode
 }
index f2f6a004f7ede83514013810f956ab1b73830c7c..5f254a7aaf04594de6af26331ef667b8c0b23eef 100644 (file)
@@ -21,7 +21,7 @@ async function processImage (
   try {
     jimpInstance = await Jimp.read(path)
   } catch (err) {
-    logger.debug('Cannot read %s with jimp. Try to convert the image using ffmpeg first.', { err })
+    logger.debug('Cannot read %s with jimp. Try to convert the image using ffmpeg first.', path, { err })
 
     const newName = path + '.jpg'
     await convertWebPToJPG(path, newName)
index e38a8788c654a308cdc7c112b67968af7d70bde8..7aa15263851b6ac493df47d668ec119fab169432 100644 (file)
@@ -106,22 +106,6 @@ async function buildSha256Segment (segmentPath: string) {
   return sha256(buf)
 }
 
-function getRangesFromPlaylist (playlistContent: string) {
-  const ranges: { offset: number, length: number }[] = []
-  const lines = playlistContent.split('\n')
-  const regex = /^#EXT-X-BYTERANGE:(\d+)@(\d+)$/
-
-  for (const line of lines) {
-    const captured = regex.exec(line)
-
-    if (captured) {
-      ranges.push({ length: parseInt(captured[1], 10), offset: parseInt(captured[2], 10) })
-    }
-  }
-
-  return ranges
-}
-
 function downloadPlaylistSegments (playlistUrl: string, destinationDir: string, timeout: number) {
   let timer
 
@@ -199,3 +183,19 @@ export {
 }
 
 // ---------------------------------------------------------------------------
+
+function getRangesFromPlaylist (playlistContent: string) {
+  const ranges: { offset: number, length: number }[] = []
+  const lines = playlistContent.split('\n')
+  const regex = /^#EXT-X-BYTERANGE:(\d+)@(\d+)$/
+
+  for (const line of lines) {
+    const captured = regex.exec(line)
+
+    if (captured) {
+      ranges.push({ length: parseInt(captured[1], 10), offset: parseInt(captured[2], 10) })
+    }
+  }
+
+  return ranges
+}
index 1a58a9f7ee2c34d38ca91528ade36d252dd632e8..1a9a36129c21067a3a55b572522c944e6aba849c 100644 (file)
@@ -1,24 +1,89 @@
 import * as Bull from 'bull'
 import { readdir, remove } from 'fs-extra'
 import { join } from 'path'
+import { getVideoFileResolution, hlsPlaylistToFragmentedMP4 } from '@server/helpers/ffmpeg-utils'
 import { getHLSDirectory } from '@server/lib/video-paths'
+import { generateHlsPlaylist } from '@server/lib/video-transcoding'
 import { VideoModel } from '@server/models/video/video'
+import { VideoLiveModel } from '@server/models/video/video-live'
 import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
-import { VideoLiveEndingPayload } from '@shared/models'
+import { MStreamingPlaylist, MVideo } from '@server/types/models'
+import { VideoLiveEndingPayload, VideoState } from '@shared/models'
 import { logger } from '../../../helpers/logger'
 
 async function processVideoLiveEnding (job: Bull.Job) {
   const payload = job.data as VideoLiveEndingPayload
 
-  const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoId)
-  if (!video) {
-    logger.warn('Video live %d does not exist anymore. Cannot cleanup.', payload.videoId)
+  const video = await VideoModel.load(payload.videoId)
+  const live = await VideoLiveModel.loadByVideoId(payload.videoId)
+
+  const streamingPlaylist = await VideoStreamingPlaylistModel.loadHLSPlaylistByVideo(video.id)
+  if (!video || !streamingPlaylist || !live) {
+    logger.warn('Video live %d does not exist anymore. Cannot process live ending.', payload.videoId)
     return
   }
 
-  const streamingPlaylist = await VideoStreamingPlaylistModel.loadHLSPlaylistByVideo(video.id)
+  if (live.saveReplay !== true) {
+    return cleanupLive(video, streamingPlaylist)
+  }
+
+  return saveLive(video, streamingPlaylist)
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  processVideoLiveEnding
+}
+
+// ---------------------------------------------------------------------------
+
+async function saveLive (video: MVideo, streamingPlaylist: MStreamingPlaylist) {
+  const videoFiles = await streamingPlaylist.get('VideoFiles')
+  const hlsDirectory = getHLSDirectory(video, false)
+
+  for (const videoFile of videoFiles) {
+    const playlistPath = join(hlsDirectory, VideoStreamingPlaylistModel.getHlsPlaylistFilename(videoFile.resolution))
+
+    const mp4TmpName = buildMP4TmpName(videoFile.resolution)
+    await hlsPlaylistToFragmentedMP4(playlistPath, mp4TmpName)
+  }
+
+  await cleanupLiveFiles(hlsDirectory)
+
+  video.isLive = false
+  video.state = VideoState.TO_TRANSCODE
+  await video.save()
+
+  const videoWithFiles = await VideoModel.loadWithFiles(video.id)
+
+  for (const videoFile of videoFiles) {
+    const videoInputPath = buildMP4TmpName(videoFile.resolution)
+    const { isPortraitMode } = await getVideoFileResolution(videoInputPath)
+
+    await generateHlsPlaylist({
+      video: videoWithFiles,
+      videoInputPath,
+      resolution: videoFile.resolution,
+      copyCodecs: true,
+      isPortraitMode
+    })
+  }
+
+  video.state = VideoState.PUBLISHED
+  await video.save()
+}
+
+async function cleanupLive (video: MVideo, streamingPlaylist: MStreamingPlaylist) {
   const hlsDirectory = getHLSDirectory(video, false)
 
+  await cleanupLiveFiles(hlsDirectory)
+
+  streamingPlaylist.destroy()
+    .catch(err => logger.error('Cannot remove live streaming playlist.', { err }))
+}
+
+async function cleanupLiveFiles (hlsDirectory: string) {
   const files = await readdir(hlsDirectory)
 
   for (const filename of files) {
@@ -35,13 +100,8 @@ async function processVideoLiveEnding (job: Bull.Job) {
         .catch(err => logger.error('Cannot remove %s.', p, { err }))
     }
   }
-
-  streamingPlaylist.destroy()
-    .catch(err => logger.error('Cannot remove live streaming playlist.', { err }))
 }
 
-// ---------------------------------------------------------------------------
-
-export {
-  processVideoLiveEnding
+function buildMP4TmpName (resolution: number) {
+  return resolution + 'tmp.mp4'
 }
index 6659ab716438dada65555926fa7c9836c4ab8374..2aebc29f77a1e4db08065f9d058d68ef5dbb8d29 100644 (file)
@@ -1,21 +1,22 @@
 import * as Bull from 'bull'
+import { getVideoFilePath } from '@server/lib/video-paths'
+import { MVideoFullLight, MVideoUUID, MVideoWithFile } from '@server/types/models'
 import {
   MergeAudioTranscodingPayload,
   NewResolutionTranscodingPayload,
   OptimizeTranscodingPayload,
   VideoTranscodingPayload
 } from '../../../../shared'
+import { retryTransactionWrapper } from '../../../helpers/database-utils'
+import { computeResolutionsToTranscode } from '../../../helpers/ffmpeg-utils'
 import { logger } from '../../../helpers/logger'
+import { CONFIG } from '../../../initializers/config'
+import { sequelizeTypescript } from '../../../initializers/database'
 import { VideoModel } from '../../../models/video/video'
-import { JobQueue } from '../job-queue'
 import { federateVideoIfNeeded } from '../../activitypub/videos'
-import { retryTransactionWrapper } from '../../../helpers/database-utils'
-import { sequelizeTypescript } from '../../../initializers/database'
-import { computeResolutionsToTranscode } from '../../../helpers/ffmpeg-utils'
-import { generateHlsPlaylist, mergeAudioVideofile, optimizeOriginalVideofile, transcodeNewResolution } from '../../video-transcoding'
 import { Notifier } from '../../notifier'
-import { CONFIG } from '../../../initializers/config'
-import { MVideoFullLight, MVideoUUID, MVideoWithFile } from '@server/types/models'
+import { generateHlsPlaylist, mergeAudioVideofile, optimizeOriginalVideofile, transcodeNewResolution } from '../../video-transcoding'
+import { JobQueue } from '../job-queue'
 
 async function processVideoTranscoding (job: Bull.Job) {
   const payload = job.data as VideoTranscodingPayload
@@ -29,7 +30,20 @@ async function processVideoTranscoding (job: Bull.Job) {
   }
 
   if (payload.type === 'hls') {
-    await generateHlsPlaylist(video, payload.resolution, payload.copyCodecs, payload.isPortraitMode || false)
+    const videoFileInput = payload.copyCodecs
+      ? video.getWebTorrentFile(payload.resolution)
+      : video.getMaxQualityFile()
+
+    const videoOrStreamingPlaylist = videoFileInput.getVideoOrStreamingPlaylist()
+    const videoInputPath = getVideoFilePath(videoOrStreamingPlaylist, videoFileInput)
+
+    await generateHlsPlaylist({
+      video,
+      videoInputPath,
+      resolution: payload.resolution,
+      copyCodecs: payload.copyCodecs,
+      isPortraitMode: payload.isPortraitMode || false
+    })
 
     await retryTransactionWrapper(onHlsPlaylistGenerationSuccess, video)
   } else if (payload.type === 'new-resolution') {
index 3ff2434ff4b9e5507d25a41e24c7b55ecefc68c1..692c4900833e551629344622281a707dc044ea11 100644 (file)
@@ -13,7 +13,7 @@ import { VideoModel } from '@server/models/video/video'
 import { VideoFileModel } from '@server/models/video/video-file'
 import { VideoLiveModel } from '@server/models/video/video-live'
 import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
-import { MStreamingPlaylist, MUser, MUserId, MVideoLive, MVideoLiveVideo } from '@server/types/models'
+import { MStreamingPlaylist, MUserId, MVideoLive, MVideoLiveVideo } from '@server/types/models'
 import { VideoState, VideoStreamingPlaylistType } from '@shared/models'
 import { federateVideoIfNeeded } from './activitypub/videos'
 import { buildSha256Segment } from './hls'
index a7b73a30db2a4be67525bc33f2af52868afc4ae0..c62b3c1ceed9be66f12ae0162befcda5a5f455d8 100644 (file)
@@ -147,17 +147,18 @@ async function mergeAudioVideofile (video: MVideoWithAllFiles, resolution: Video
   return onVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, videoOutputPath)
 }
 
-async function generateHlsPlaylist (video: MVideoWithFile, resolution: VideoResolution, copyCodecs: boolean, isPortraitMode: boolean) {
+async function generateHlsPlaylist (options: {
+  video: MVideoWithFile
+  videoInputPath: string
+  resolution: VideoResolution
+  copyCodecs: boolean
+  isPortraitMode: boolean
+}) {
+  const { video, videoInputPath, resolution, copyCodecs, isPortraitMode } = options
+
   const baseHlsDirectory = join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid)
   await ensureDir(join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid))
 
-  const videoFileInput = copyCodecs
-    ? video.getWebTorrentFile(resolution)
-    : video.getMaxQualityFile()
-
-  const videoOrStreamingPlaylist = videoFileInput.getVideoOrStreamingPlaylist()
-  const videoInputPath = getVideoFilePath(videoOrStreamingPlaylist, videoFileInput)
-
   const outputPath = join(baseHlsDirectory, VideoStreamingPlaylistModel.getHlsPlaylistFilename(resolution))
   const videoFilename = generateVideoStreamingPlaylistName(video.uuid, resolution)
 
@@ -184,7 +185,7 @@ async function generateHlsPlaylist (video: MVideoWithFile, resolution: VideoReso
     videoId: video.id,
     playlistUrl,
     segmentsSha256Url: WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsSha256SegmentsStaticPath(video.uuid, video.isLive),
-    p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrl, video.VideoFiles),
+    p2pMediaLoaderInfohashes: [],
     p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
 
     type: VideoStreamingPlaylistType.HLS
@@ -211,6 +212,11 @@ async function generateHlsPlaylist (video: MVideoWithFile, resolution: VideoReso
   await VideoFileModel.customUpsert(newVideoFile, 'streaming-playlist', undefined)
   videoStreamingPlaylist.VideoFiles = await videoStreamingPlaylist.$get('VideoFiles')
 
+  videoStreamingPlaylist.p2pMediaLoaderInfohashes = VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(
+    playlistUrl, videoStreamingPlaylist.VideoFiles
+  )
+  await videoStreamingPlaylist.save()
+
   video.setHLSPlaylist(videoStreamingPlaylist)
 
   await updateMasterHLSPlaylist(video)
index a4c364976703ac364ade5da28201cd0fd8bb4218..ab57e67bf07e895f31ac1a7c6858b89106c20b54 100644 (file)
@@ -1,15 +1,15 @@
 import * as express from 'express'
 import { body, param } from 'express-validator'
 import { checkUserCanManageVideo, doesVideoChannelOfAccountExist, doesVideoExist } from '@server/helpers/middlewares/videos'
-import { UserRight } from '@shared/models'
-import { isIdOrUUIDValid, isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc'
+import { VideoLiveModel } from '@server/models/video/video-live'
+import { UserRight, VideoState } from '@shared/models'
+import { isBooleanValid, isIdOrUUIDValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc'
 import { isVideoNameValid } from '../../../helpers/custom-validators/videos'
 import { cleanUpReqFiles } from '../../../helpers/express-utils'
 import { logger } from '../../../helpers/logger'
 import { CONFIG } from '../../../initializers/config'
 import { areValidationErrors } from '../utils'
 import { getCommonVideoEditAttributes } from './videos'
-import { VideoLiveModel } from '@server/models/video/video-live'
 
 const videoLiveGetValidator = [
   param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),
@@ -41,6 +41,11 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
   body('name')
     .custom(isVideoNameValid).withMessage('Should have a valid name'),
 
+  body('saveReplay')
+    .optional()
+    .customSanitizer(toBooleanOrNull)
+    .custom(isBooleanValid).withMessage('Should have a valid saveReplay attribute'),
+
   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
     logger.debug('Checking videoLiveAddValidator parameters', { parameters: req.body })
 
@@ -49,6 +54,11 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
         .json({ error: 'Live is not enabled on this instance' })
     }
 
+    if (CONFIG.LIVE.ALLOW_REPLAY !== true && req.body.saveReplay === true) {
+      return res.status(403)
+        .json({ error: 'Saving live replay is not allowed instance' })
+    }
+
     if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
 
     const user = res.locals.oauth.token.User
@@ -58,9 +68,35 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
   }
 ])
 
+const videoLiveUpdateValidator = [
+  body('saveReplay')
+    .optional()
+    .customSanitizer(toBooleanOrNull)
+    .custom(isBooleanValid).withMessage('Should have a valid saveReplay attribute'),
+
+  (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking videoLiveUpdateValidator parameters', { parameters: req.body })
+
+    if (areValidationErrors(req, res)) return
+
+    if (CONFIG.LIVE.ALLOW_REPLAY !== true && req.body.saveReplay === true) {
+      return res.status(403)
+        .json({ error: 'Saving live replay is not allowed instance' })
+    }
+
+    if (res.locals.videoAll.state !== VideoState.WAITING_FOR_LIVE) {
+      return res.status(400)
+        .json({ error: 'Cannot update a live that has already started' })
+    }
+
+    return next()
+  }
+]
+
 // ---------------------------------------------------------------------------
 
 export {
   videoLiveAddValidator,
+  videoLiveUpdateValidator,
   videoLiveGetValidator
 }
index 0e229de6ab0c946d7cb6c1015999c8e7610808ed..345918cb9d6e0dad4b5b8848ecad9e46d54d7750 100644 (file)
@@ -94,7 +94,8 @@ export class VideoLiveModel extends Model<VideoLiveModel> {
   toFormattedJSON (): LiveVideo {
     return {
       rtmpUrl: WEBSERVER.RTMP_URL,
-      streamKey: this.streamKey
+      streamKey: this.streamKey,
+      saveReplay: this.saveReplay
     }
   }
 }
index 4f331738b3e4ba04d6af89bceaf8893946f34c3c..a36f42a7dfec81cbfaa5cba8a803e8c00c05c6b3 100644 (file)
@@ -1,3 +1,5 @@
+export * from './live-video-create.model'
 export * from './live-video-event-payload.model'
 export * from './live-video-event.type'
+export * from './live-video-update.model'
 export * from './live-video.model'
diff --git a/shared/models/videos/live/live-video-create.model.ts b/shared/models/videos/live/live-video-create.model.ts
new file mode 100644 (file)
index 0000000..1ef4b70
--- /dev/null
@@ -0,0 +1,5 @@
+import { VideoCreate } from '../video-create.model'
+
+export interface LiveVideoCreate extends VideoCreate {
+  saveReplay?: boolean
+}
diff --git a/shared/models/videos/live/live-video-update.model.ts b/shared/models/videos/live/live-video-update.model.ts
new file mode 100644 (file)
index 0000000..0f0f67d
--- /dev/null
@@ -0,0 +1,3 @@
+export interface LiveVideoUpdate {
+  saveReplay?: boolean
+}
index 74abee96e799c982427edce8e77868c52a1ad84e..a3f8275e3698f9ebc0a3cf00abad771989f6fe0b 100644 (file)
@@ -1,4 +1,5 @@
 export interface LiveVideo {
   rtmpUrl: string
   streamKey: string
+  saveReplay: boolean
 }