aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-edit.component.html14
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-edit.component.ts7
-rw-r--r--client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.html5
-rw-r--r--client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts39
-rw-r--r--client/src/app/+videos/+video-edit/video-add.component.html2
-rw-r--r--client/src/app/+videos/+video-edit/video-update.component.ts30
-rw-r--r--client/src/app/+videos/+video-edit/video-update.resolver.ts2
-rw-r--r--client/src/app/+videos/+video-watch/video-duration-formatter.pipe.ts23
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.component.html2
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.module.ts2
-rw-r--r--client/src/app/shared/shared-main/angular/duration-formatter.pipe.ts32
-rw-r--r--client/src/app/shared/shared-main/angular/index.ts1
-rw-r--r--client/src/app/shared/shared-main/shared-main.module.ts14
-rw-r--r--client/src/app/shared/shared-main/video/live-video.service.ts10
-rw-r--r--server/controllers/api/videos/live.ts37
-rw-r--r--server/helpers/ffmpeg-utils.ts15
-rw-r--r--server/helpers/image-utils.ts2
-rw-r--r--server/lib/hls.ts32
-rw-r--r--server/lib/job-queue/handlers/video-live-ending.ts84
-rw-r--r--server/lib/job-queue/handlers/video-transcoding.ts30
-rw-r--r--server/lib/live-manager.ts2
-rw-r--r--server/lib/video-transcoding.ts24
-rw-r--r--server/middlewares/validators/videos/video-live.ts42
-rw-r--r--server/models/video/video-live.ts3
-rw-r--r--shared/models/videos/live/index.ts2
-rw-r--r--shared/models/videos/live/live-video-create.model.ts5
-rw-r--r--shared/models/videos/live/live-video-update.model.ts3
-rw-r--r--shared/models/videos/live/live-video.model.ts1
28 files changed, 355 insertions, 110 deletions
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.html b/client/src/app/+videos/+video-edit/shared/video-edit.component.html
index 0802e906d..d9e09c453 100644
--- a/client/src/app/+videos/+video-edit/shared/video-edit.component.html
+++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.html
@@ -142,7 +142,7 @@
142 </ng-template> 142 </ng-template>
143 </ng-container> 143 </ng-container>
144 144
145 <ng-container ngbNavItem> 145 <ng-container ngbNavItem *ngIf="!liveVideo">
146 <a ngbNavLink i18n>Captions</a> 146 <a ngbNavLink i18n>Captions</a>
147 147
148 <ng-template ngbNavContent> 148 <ng-template ngbNavContent>
@@ -211,6 +211,18 @@
211 <label for="liveVideoStreamKey" i18n>Live stream key</label> 211 <label for="liveVideoStreamKey" i18n>Live stream key</label>
212 <my-input-readonly-copy id="liveVideoStreamKey" [value]="liveVideo.streamKey"></my-input-readonly-copy> 212 <my-input-readonly-copy id="liveVideoStreamKey" [value]="liveVideo.streamKey"></my-input-readonly-copy>
213 </div> 213 </div>
214
215 <div class="form-group" *ngIf="isSaveReplayEnabled()">
216 <my-peertube-checkbox inputName="liveVideoSaveReplay" formControlName="saveReplay">
217 <ng-template ptTemplate="label">
218 <ng-container i18n>Automatically publish a replay when your live ends</ng-container>
219 </ng-template>
220
221 <ng-container ngProjectAs="description">
222 <span i18n>⚠️ If you enable this option, your live will be terminated if you exceed your video quota</span>
223 </ng-container>
224 </my-peertube-checkbox>
225 </div>
214 </div> 226 </div>
215 </div> 227 </div>
216 </ng-template> 228 </ng-template>
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts
index 304bf7ed0..26d871e59 100644
--- a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts
+++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts
@@ -127,7 +127,8 @@ export class VideoEditComponent implements OnInit, OnDestroy {
127 support: VIDEO_SUPPORT_VALIDATOR, 127 support: VIDEO_SUPPORT_VALIDATOR,
128 schedulePublicationAt: VIDEO_SCHEDULE_PUBLICATION_AT_VALIDATOR, 128 schedulePublicationAt: VIDEO_SCHEDULE_PUBLICATION_AT_VALIDATOR,
129 originallyPublishedAt: VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR, 129 originallyPublishedAt: VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR,
130 liveStreamKey: null 130 liveStreamKey: null,
131 saveReplay: null
131 } 132 }
132 133
133 this.formValidatorService.updateForm( 134 this.formValidatorService.updateForm(
@@ -239,6 +240,10 @@ export class VideoEditComponent implements OnInit, OnDestroy {
239 this.videoCaptionAddModal.show() 240 this.videoCaptionAddModal.show()
240 } 241 }
241 242
243 isSaveReplayEnabled () {
244 return this.serverConfig.live.allowReplay
245 }
246
242 private sortVideoCaptions () { 247 private sortVideoCaptions () {
243 this.videoCaptions.sort((v1, v2) => { 248 this.videoCaptions.sort((v1, v2) => {
244 if (v1.language.label < v2.language.label) return -1 249 if (v1.language.label < v2.language.label) return -1
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.html b/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.html
index 8fae4044a..5657827a9 100644
--- a/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.html
+++ b/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.html
@@ -27,6 +27,11 @@
27 {{ error }} 27 {{ error }}
28</div> 28</div>
29 29
30<div class="alert alert-info" i18n *ngIf="isInUpdateForm && getMaxLiveDuration()">
31 Max live duration is {{ getMaxLiveDuration() | myDurationFormatter }}.
32 If your live reaches this limit, it will be automatically terminated.
33</div>
34
30<!-- Hidden because we want to load the component --> 35<!-- Hidden because we want to load the component -->
31<form [hidden]="!isInUpdateForm" novalidate [formGroup]="form"> 36<form [hidden]="!isInUpdateForm" novalidate [formGroup]="form">
32 <my-video-edit 37 <my-video-edit
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts
index 0a9efc693..9868c37d2 100644
--- a/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts
+++ b/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts
@@ -1,4 +1,5 @@
1 1
2import { forkJoin } from 'rxjs'
2import { Component, EventEmitter, OnInit, Output } from '@angular/core' 3import { Component, EventEmitter, OnInit, Output } from '@angular/core'
3import { Router } from '@angular/router' 4import { Router } from '@angular/router'
4import { AuthService, CanComponentDeactivate, Notifier, ServerService } from '@app/core' 5import { AuthService, CanComponentDeactivate, Notifier, ServerService } from '@app/core'
@@ -6,7 +7,7 @@ import { scrollToTop } from '@app/helpers'
6import { FormValidatorService } from '@app/shared/shared-forms' 7import { FormValidatorService } from '@app/shared/shared-forms'
7import { LiveVideoService, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' 8import { LiveVideoService, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
8import { LoadingBarService } from '@ngx-loading-bar/core' 9import { LoadingBarService } from '@ngx-loading-bar/core'
9import { LiveVideo, VideoCreate, VideoPrivacy } from '@shared/models' 10import { LiveVideo, LiveVideoCreate, LiveVideoUpdate, VideoPrivacy } from '@shared/models'
10import { VideoSend } from './video-send' 11import { VideoSend } from './video-send'
11 12
12@Component({ 13@Component({
@@ -53,7 +54,7 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, CanCompon
53 } 54 }
54 55
55 goLive () { 56 goLive () {
56 const video: VideoCreate = { 57 const video: LiveVideoCreate = {
57 name: 'Live', 58 name: 'Live',
58 privacy: VideoPrivacy.PRIVATE, 59 privacy: VideoPrivacy.PRIVATE,
59 nsfw: this.serverConfig.instance.isNSFW, 60 nsfw: this.serverConfig.instance.isNSFW,
@@ -95,22 +96,32 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, CanCompon
95 video.id = this.videoId 96 video.id = this.videoId
96 video.uuid = this.videoUUID 97 video.uuid = this.videoUUID
97 98
99 const liveVideoUpdate: LiveVideoUpdate = {
100 saveReplay: this.form.value.saveReplay
101 }
102
98 // Update the video 103 // Update the video
99 this.updateVideoAndCaptions(video) 104 forkJoin([
100 .subscribe( 105 this.updateVideoAndCaptions(video),
101 () => {
102 this.notifier.success($localize`Live published.`)
103 106
104 this.router.navigate([ '/videos/watch', video.uuid ]) 107 this.liveVideoService.updateLive(this.videoId, liveVideoUpdate)
105 }, 108 ]).subscribe(
109 () => {
110 this.notifier.success($localize`Live published.`)
111
112 this.router.navigate(['/videos/watch', video.uuid])
113 },
106 114
107 err => { 115 err => {
108 this.error = err.message 116 this.error = err.message
109 scrollToTop() 117 scrollToTop()
110 console.error(err) 118 console.error(err)
111 } 119 }
112 ) 120 )
121 }
113 122
123 getMaxLiveDuration () {
124 return this.serverConfig.live.maxDuration / 1000
114 } 125 }
115 126
116 private fetchVideoLive () { 127 private fetchVideoLive () {
diff --git a/client/src/app/+videos/+video-edit/video-add.component.html b/client/src/app/+videos/+video-edit/video-add.component.html
index bf2cc9c83..dc8c2f21d 100644
--- a/client/src/app/+videos/+video-edit/video-add.component.html
+++ b/client/src/app/+videos/+video-edit/video-add.component.html
@@ -13,7 +13,7 @@
13 Instead, <a routerLink="/admin/users">create a dedicated account</a> to upload your videos. 13 Instead, <a routerLink="/admin/users">create a dedicated account</a> to upload your videos.
14 </div> 14 </div>
15 15
16 <my-user-quota *ngIf="!isInSecondStep()" [user]="user" [userInformationLoaded]="userInformationLoaded"></my-user-quota> 16 <my-user-quota *ngIf="!isInSecondStep() || secondStepType === 'go-live'" [user]="user" [userInformationLoaded]="userInformationLoaded"></my-user-quota>
17 17
18 <div class="title-page title-page-single" *ngIf="isInSecondStep()"> 18 <div class="title-page title-page-single" *ngIf="isInSecondStep()">
19 <ng-container *ngIf="secondStepType === 'import-url' || secondStepType === 'import-torrent'" i18n>Import {{ videoName }}</ng-container> 19 <ng-container *ngIf="secondStepType === 'import-url' || secondStepType === 'import-torrent'" i18n>Import {{ videoName }}</ng-container>
diff --git a/client/src/app/+videos/+video-edit/video-update.component.ts b/client/src/app/+videos/+video-edit/video-update.component.ts
index ec1305a33..7126ad05b 100644
--- a/client/src/app/+videos/+video-edit/video-update.component.ts
+++ b/client/src/app/+videos/+video-edit/video-update.component.ts
@@ -3,10 +3,11 @@ import { Component, HostListener, OnInit } from '@angular/core'
3import { ActivatedRoute, Router } from '@angular/router' 3import { ActivatedRoute, Router } from '@angular/router'
4import { Notifier } from '@app/core' 4import { Notifier } from '@app/core'
5import { FormReactive, FormValidatorService, SelectChannelItem } from '@app/shared/shared-forms' 5import { FormReactive, FormValidatorService, SelectChannelItem } from '@app/shared/shared-forms'
6import { VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main' 6import { LiveVideoService, VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main'
7import { LoadingBarService } from '@ngx-loading-bar/core' 7import { LoadingBarService } from '@ngx-loading-bar/core'
8import { LiveVideo, VideoPrivacy } from '@shared/models' 8import { LiveVideo, LiveVideoUpdate, VideoPrivacy } from '@shared/models'
9import { hydrateFormFromVideo } from './shared/video-edit-utils' 9import { hydrateFormFromVideo } from './shared/video-edit-utils'
10import { of } from 'rxjs'
10 11
11@Component({ 12@Component({
12 selector: 'my-videos-update', 13 selector: 'my-videos-update',
@@ -32,7 +33,8 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
32 private notifier: Notifier, 33 private notifier: Notifier,
33 private videoService: VideoService, 34 private videoService: VideoService,
34 private loadingBar: LoadingBarService, 35 private loadingBar: LoadingBarService,
35 private videoCaptionService: VideoCaptionService 36 private videoCaptionService: VideoCaptionService,
37 private liveVideoService: LiveVideoService
36 ) { 38 ) {
37 super() 39 super()
38 } 40 }
@@ -56,7 +58,15 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
56 } 58 }
57 59
58 // FIXME: Angular does not detect the change inside this subscription, so use the patched setTimeout 60 // FIXME: Angular does not detect the change inside this subscription, so use the patched setTimeout
59 setTimeout(() => hydrateFormFromVideo(this.form, this.video, true)) 61 setTimeout(() => {
62 hydrateFormFromVideo(this.form, this.video, true)
63
64 if (this.liveVideo) {
65 this.form.patchValue({
66 saveReplay: this.liveVideo.saveReplay
67 })
68 }
69 })
60 }, 70 },
61 71
62 err => { 72 err => {
@@ -102,6 +112,10 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
102 112
103 this.video.patch(this.form.value) 113 this.video.patch(this.form.value)
104 114
115 const liveVideoUpdate: LiveVideoUpdate = {
116 saveReplay: this.form.value.saveReplay
117 }
118
105 this.loadingBar.useRef().start() 119 this.loadingBar.useRef().start()
106 this.isUpdatingVideo = true 120 this.isUpdatingVideo = true
107 121
@@ -109,7 +123,13 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
109 this.videoService.updateVideo(this.video) 123 this.videoService.updateVideo(this.video)
110 .pipe( 124 .pipe(
111 // Then update captions 125 // Then update captions
112 switchMap(() => this.videoCaptionService.updateCaptions(this.video.id, this.videoCaptions)) 126 switchMap(() => this.videoCaptionService.updateCaptions(this.video.id, this.videoCaptions)),
127
128 switchMap(() => {
129 if (!this.liveVideo) return of(undefined)
130
131 return this.liveVideoService.updateLive(this.video.id, liveVideoUpdate)
132 })
113 ) 133 )
114 .subscribe( 134 .subscribe(
115 () => { 135 () => {
diff --git a/client/src/app/+videos/+video-edit/video-update.resolver.ts b/client/src/app/+videos/+video-edit/video-update.resolver.ts
index b7ec22dd5..5388a64b0 100644
--- a/client/src/app/+videos/+video-edit/video-update.resolver.ts
+++ b/client/src/app/+videos/+video-edit/video-update.resolver.ts
@@ -20,7 +20,7 @@ export class VideoUpdateResolver implements Resolve<any> {
20 return this.videoService.getVideo({ videoId: uuid }) 20 return this.videoService.getVideo({ videoId: uuid })
21 .pipe( 21 .pipe(
22 switchMap(video => forkJoin(this.buildVideoObservables(video))), 22 switchMap(video => forkJoin(this.buildVideoObservables(video))),
23 map(([ video, videoChannels, videoCaptions, videoLive ]) => ({ video, videoChannels, videoCaptions, videoLive })) 23 map(([ video, videoChannels, videoCaptions, liveVideo ]) => ({ video, videoChannels, videoCaptions, liveVideo }))
24 ) 24 )
25 } 25 }
26 26
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
index 19b34f984..000000000
--- a/client/src/app/+videos/+video-watch/video-duration-formatter.pipe.ts
+++ /dev/null
@@ -1,23 +0,0 @@
1import { Pipe, PipeTransform } from '@angular/core'
2
3@Pipe({
4 name: 'myVideoDurationFormatter'
5})
6export class VideoDurationPipe implements PipeTransform {
7
8 transform (value: number): string {
9 const hours = Math.floor(value / 3600)
10 const minutes = Math.floor((value % 3600) / 60)
11 const seconds = value % 60
12
13 if (hours > 0) {
14 return $localize`${hours} h ${minutes} min ${seconds} sec`
15 }
16
17 if (minutes > 0) {
18 return $localize`${minutes} min ${seconds} sec`
19 }
20
21 return $localize`${seconds} sec`
22 }
23}
diff --git a/client/src/app/+videos/+video-watch/video-watch.component.html b/client/src/app/+videos/+video-watch/video-watch.component.html
index 13242a2bc..bc1c302de 100644
--- a/client/src/app/+videos/+video-watch/video-watch.component.html
+++ b/client/src/app/+videos/+video-watch/video-watch.component.html
@@ -270,7 +270,7 @@
270 270
271 <div class="video-attribute"> 271 <div class="video-attribute">
272 <span i18n class="video-attribute-label">Duration</span> 272 <span i18n class="video-attribute-label">Duration</span>
273 <span class="video-attribute-value">{{ video.duration | myVideoDurationFormatter }}</span> 273 <span class="video-attribute-value">{{ video.duration | myDurationFormatter }}</span>
274 </div> 274 </div>
275 </div> 275 </div>
276 276
diff --git a/client/src/app/+videos/+video-watch/video-watch.module.ts b/client/src/app/+videos/+video-watch/video-watch.module.ts
index 612bbccc4..21aa33b84 100644
--- a/client/src/app/+videos/+video-watch/video-watch.module.ts
+++ b/client/src/app/+videos/+video-watch/video-watch.module.ts
@@ -15,7 +15,6 @@ import { VideoCommentsComponent } from './comment/video-comments.component'
15import { VideoSupportComponent } from './modal/video-support.component' 15import { VideoSupportComponent } from './modal/video-support.component'
16import { RecommendationsModule } from './recommendations/recommendations.module' 16import { RecommendationsModule } from './recommendations/recommendations.module'
17import { TimestampRouteTransformerDirective } from './timestamp-route-transformer.directive' 17import { TimestampRouteTransformerDirective } from './timestamp-route-transformer.directive'
18import { VideoDurationPipe } from './video-duration-formatter.pipe'
19import { VideoWatchPlaylistComponent } from './video-watch-playlist.component' 18import { VideoWatchPlaylistComponent } from './video-watch-playlist.component'
20import { VideoWatchRoutingModule } from './video-watch-routing.module' 19import { VideoWatchRoutingModule } from './video-watch-routing.module'
21import { VideoWatchComponent } from './video-watch.component' 20import { VideoWatchComponent } from './video-watch.component'
@@ -46,7 +45,6 @@ import { VideoWatchComponent } from './video-watch.component'
46 VideoCommentComponent, 45 VideoCommentComponent,
47 46
48 TimestampRouteTransformerDirective, 47 TimestampRouteTransformerDirective,
49 VideoDurationPipe,
50 TimestampRouteTransformerDirective 48 TimestampRouteTransformerDirective
51 ], 49 ],
52 50
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
index 000000000..29ff864ec
--- /dev/null
+++ b/client/src/app/shared/shared-main/angular/duration-formatter.pipe.ts
@@ -0,0 +1,32 @@
1import { Pipe, PipeTransform } from '@angular/core'
2
3@Pipe({
4 name: 'myDurationFormatter'
5})
6export class DurationFormatterPipe implements PipeTransform {
7
8 transform (value: number): string {
9 const hours = Math.floor(value / 3600)
10 const minutes = Math.floor((value % 3600) / 60)
11 const seconds = value % 60
12
13 if (hours > 0) {
14 let result = $localize`${hours}h`
15
16 if (minutes !== 0) result += ' ' + $localize`${minutes}min`
17 if (seconds !== 0) result += ' ' + $localize`${seconds}sec`
18
19 return result
20 }
21
22 if (minutes > 0) {
23 let result = $localize`${minutes}min`
24
25 if (seconds !== 0) result += ' ' + `${seconds}sec`
26
27 return result
28 }
29
30 return $localize`${seconds} sec`
31 }
32}
diff --git a/client/src/app/shared/shared-main/angular/index.ts b/client/src/app/shared/shared-main/angular/index.ts
index 9ba815136..29f8b3650 100644
--- a/client/src/app/shared/shared-main/angular/index.ts
+++ b/client/src/app/shared/shared-main/angular/index.ts
@@ -1,4 +1,5 @@
1export * from './bytes.pipe' 1export * from './bytes.pipe'
2export * from './duration-formatter.pipe'
2export * from './from-now.pipe' 3export * from './from-now.pipe'
3export * from './infinite-scroller.directive' 4export * from './infinite-scroller.directive'
4export * from './number-formatter.pipe' 5export * from './number-formatter.pipe'
diff --git a/client/src/app/shared/shared-main/shared-main.module.ts b/client/src/app/shared/shared-main/shared-main.module.ts
index 0580872f4..3816cab19 100644
--- a/client/src/app/shared/shared-main/shared-main.module.ts
+++ b/client/src/app/shared/shared-main/shared-main.module.ts
@@ -15,7 +15,14 @@ import {
15} from '@ng-bootstrap/ng-bootstrap' 15} from '@ng-bootstrap/ng-bootstrap'
16import { SharedGlobalIconModule } from '../shared-icons' 16import { SharedGlobalIconModule } from '../shared-icons'
17import { AccountService, ActorAvatarInfoComponent, AvatarComponent } from './account' 17import { AccountService, ActorAvatarInfoComponent, AvatarComponent } from './account'
18import { FromNowPipe, InfiniteScrollerDirective, NumberFormatterPipe, PeerTubeTemplateDirective, BytesPipe } from './angular' 18import {
19 BytesPipe,
20 DurationFormatterPipe,
21 FromNowPipe,
22 InfiniteScrollerDirective,
23 NumberFormatterPipe,
24 PeerTubeTemplateDirective
25} from './angular'
19import { AUTH_INTERCEPTOR_PROVIDER } from './auth' 26import { AUTH_INTERCEPTOR_PROVIDER } from './auth'
20import { ActionDropdownComponent, ButtonComponent, DeleteButtonComponent, EditButtonComponent } from './buttons' 27import { ActionDropdownComponent, ButtonComponent, DeleteButtonComponent, EditButtonComponent } from './buttons'
21import { DateToggleComponent } from './date' 28import { DateToggleComponent } from './date'
@@ -23,7 +30,7 @@ import { FeedComponent } from './feeds'
23import { LoaderComponent, SmallLoaderComponent } from './loaders' 30import { LoaderComponent, SmallLoaderComponent } from './loaders'
24import { HelpComponent, ListOverflowComponent, TopMenuDropdownComponent } from './misc' 31import { HelpComponent, ListOverflowComponent, TopMenuDropdownComponent } from './misc'
25import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users' 32import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users'
26import { RedundancyService, VideoImportService, VideoOwnershipService, VideoService, LiveVideoService } from './video' 33import { LiveVideoService, RedundancyService, VideoImportService, VideoOwnershipService, VideoService } from './video'
27import { VideoCaptionService } from './video-caption' 34import { VideoCaptionService } from './video-caption'
28import { VideoChannelService } from './video-channel' 35import { VideoChannelService } from './video-channel'
29 36
@@ -56,6 +63,8 @@ import { VideoChannelService } from './video-channel'
56 FromNowPipe, 63 FromNowPipe,
57 NumberFormatterPipe, 64 NumberFormatterPipe,
58 BytesPipe, 65 BytesPipe,
66 DurationFormatterPipe,
67
59 InfiniteScrollerDirective, 68 InfiniteScrollerDirective,
60 PeerTubeTemplateDirective, 69 PeerTubeTemplateDirective,
61 70
@@ -103,6 +112,7 @@ import { VideoChannelService } from './video-channel'
103 FromNowPipe, 112 FromNowPipe,
104 BytesPipe, 113 BytesPipe,
105 NumberFormatterPipe, 114 NumberFormatterPipe,
115 DurationFormatterPipe,
106 116
107 InfiniteScrollerDirective, 117 InfiniteScrollerDirective,
108 PeerTubeTemplateDirective, 118 PeerTubeTemplateDirective,
diff --git a/client/src/app/shared/shared-main/video/live-video.service.ts b/client/src/app/shared/shared-main/video/live-video.service.ts
index 2cd1c66a5..093d65e83 100644
--- a/client/src/app/shared/shared-main/video/live-video.service.ts
+++ b/client/src/app/shared/shared-main/video/live-video.service.ts
@@ -2,7 +2,7 @@ import { catchError } from 'rxjs/operators'
2import { HttpClient } from '@angular/common/http' 2import { HttpClient } from '@angular/common/http'
3import { Injectable } from '@angular/core' 3import { Injectable } from '@angular/core'
4import { RestExtractor } from '@app/core' 4import { RestExtractor } from '@app/core'
5import { VideoCreate, LiveVideo } from '@shared/models' 5import { LiveVideo, LiveVideoCreate, LiveVideoUpdate } from '@shared/models'
6import { environment } from '../../../../environments/environment' 6import { environment } from '../../../../environments/environment'
7 7
8@Injectable() 8@Injectable()
@@ -14,7 +14,7 @@ export class LiveVideoService {
14 private restExtractor: RestExtractor 14 private restExtractor: RestExtractor
15 ) {} 15 ) {}
16 16
17 goLive (video: VideoCreate) { 17 goLive (video: LiveVideoCreate) {
18 return this.authHttp 18 return this.authHttp
19 .post<{ video: { id: number, uuid: string } }>(LiveVideoService.BASE_VIDEO_LIVE_URL, video) 19 .post<{ video: { id: number, uuid: string } }>(LiveVideoService.BASE_VIDEO_LIVE_URL, video)
20 .pipe(catchError(err => this.restExtractor.handleError(err))) 20 .pipe(catchError(err => this.restExtractor.handleError(err)))
@@ -25,4 +25,10 @@ export class LiveVideoService {
25 .get<LiveVideo>(LiveVideoService.BASE_VIDEO_LIVE_URL + videoId) 25 .get<LiveVideo>(LiveVideoService.BASE_VIDEO_LIVE_URL + videoId)
26 .pipe(catchError(err => this.restExtractor.handleError(err))) 26 .pipe(catchError(err => this.restExtractor.handleError(err)))
27 } 27 }
28
29 updateLive (videoId: number | string, liveUpdate: LiveVideoUpdate) {
30 return this.authHttp
31 .put(LiveVideoService.BASE_VIDEO_LIVE_URL + videoId, liveUpdate)
32 .pipe(catchError(err => this.restExtractor.handleError(err)))
33 }
28} 34}
diff --git a/server/controllers/api/videos/live.ts b/server/controllers/api/videos/live.ts
index 97b135f96..be46fb1c6 100644
--- a/server/controllers/api/videos/live.ts
+++ b/server/controllers/api/videos/live.ts
@@ -5,10 +5,10 @@ import { CONFIG } from '@server/initializers/config'
5import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants' 5import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants'
6import { getVideoActivityPubUrl } from '@server/lib/activitypub/url' 6import { getVideoActivityPubUrl } from '@server/lib/activitypub/url'
7import { buildLocalVideoFromReq, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video' 7import { buildLocalVideoFromReq, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
8import { videoLiveAddValidator, videoLiveGetValidator } from '@server/middlewares/validators/videos/video-live' 8import { videoLiveAddValidator, videoLiveGetValidator, videoLiveUpdateValidator } from '@server/middlewares/validators/videos/video-live'
9import { VideoLiveModel } from '@server/models/video/video-live' 9import { VideoLiveModel } from '@server/models/video/video-live'
10import { MVideoDetails, MVideoFullLight } from '@server/types/models' 10import { MVideoDetails, MVideoFullLight } from '@server/types/models'
11import { VideoCreate, VideoState } from '../../../../shared' 11import { LiveVideoCreate, LiveVideoUpdate, VideoState } from '../../../../shared'
12import { logger } from '../../../helpers/logger' 12import { logger } from '../../../helpers/logger'
13import { sequelizeTypescript } from '../../../initializers/database' 13import { sequelizeTypescript } from '../../../initializers/database'
14import { createVideoMiniatureFromExisting } from '../../../lib/thumbnail' 14import { createVideoMiniatureFromExisting } from '../../../lib/thumbnail'
@@ -36,7 +36,14 @@ liveRouter.post('/live',
36liveRouter.get('/live/:videoId', 36liveRouter.get('/live/:videoId',
37 authenticate, 37 authenticate,
38 asyncMiddleware(videoLiveGetValidator), 38 asyncMiddleware(videoLiveGetValidator),
39 asyncRetryTransactionMiddleware(getVideoLive) 39 asyncRetryTransactionMiddleware(getLiveVideo)
40)
41
42liveRouter.put('/live/:videoId',
43 authenticate,
44 asyncMiddleware(videoLiveGetValidator),
45 videoLiveUpdateValidator,
46 asyncRetryTransactionMiddleware(updateLiveVideo)
40) 47)
41 48
42// --------------------------------------------------------------------------- 49// ---------------------------------------------------------------------------
@@ -47,14 +54,25 @@ export {
47 54
48// --------------------------------------------------------------------------- 55// ---------------------------------------------------------------------------
49 56
50async function getVideoLive (req: express.Request, res: express.Response) { 57async function getLiveVideo (req: express.Request, res: express.Response) {
51 const videoLive = res.locals.videoLive 58 const videoLive = res.locals.videoLive
52 59
53 return res.json(videoLive.toFormattedJSON()) 60 return res.json(videoLive.toFormattedJSON())
54} 61}
55 62
63async function updateLiveVideo (req: express.Request, res: express.Response) {
64 const body: LiveVideoUpdate = req.body
65
66 const videoLive = res.locals.videoLive
67 videoLive.saveReplay = body.saveReplay || false
68
69 await videoLive.save()
70
71 return res.sendStatus(204)
72}
73
56async function addLiveVideo (req: express.Request, res: express.Response) { 74async function addLiveVideo (req: express.Request, res: express.Response) {
57 const videoInfo: VideoCreate = req.body 75 const videoInfo: LiveVideoCreate = req.body
58 76
59 // Prepare data so we don't block the transaction 77 // Prepare data so we don't block the transaction
60 const videoData = buildLocalVideoFromReq(videoInfo, res.locals.videoChannel.id) 78 const videoData = buildLocalVideoFromReq(videoInfo, res.locals.videoChannel.id)
@@ -66,13 +84,20 @@ async function addLiveVideo (req: express.Request, res: express.Response) {
66 video.url = getVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object 84 video.url = getVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object
67 85
68 const videoLive = new VideoLiveModel() 86 const videoLive = new VideoLiveModel()
87 videoLive.saveReplay = videoInfo.saveReplay || false
69 videoLive.streamKey = uuidv4() 88 videoLive.streamKey = uuidv4()
70 89
71 const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({ 90 const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({
72 video, 91 video,
73 files: req.files, 92 files: req.files,
74 fallback: type => { 93 fallback: type => {
75 return createVideoMiniatureFromExisting({ inputPath: ASSETS_PATH.DEFAULT_LIVE_BACKGROUND, video, type, automaticallyGenerated: true }) 94 return createVideoMiniatureFromExisting({
95 inputPath: ASSETS_PATH.DEFAULT_LIVE_BACKGROUND,
96 video,
97 type,
98 automaticallyGenerated: true,
99 keepOriginal: true
100 })
76 } 101 }
77 }) 102 })
78 103
diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts
index b25dcaa90..2f167a580 100644
--- a/server/helpers/ffmpeg-utils.ts
+++ b/server/helpers/ffmpeg-utils.ts
@@ -424,6 +424,20 @@ function runLiveMuxing (rtmpUrl: string, outPath: string, deleteSegments: boolea
424 return command 424 return command
425} 425}
426 426
427function hlsPlaylistToFragmentedMP4 (playlistPath: string, outputPath: string) {
428 const command = getFFmpeg(playlistPath)
429
430 command.outputOption('-c copy')
431 command.output(outputPath)
432
433 command.run()
434
435 return new Promise<string>((res, rej) => {
436 command.on('error', err => rej(err))
437 command.on('end', () => res())
438 })
439}
440
427// --------------------------------------------------------------------------- 441// ---------------------------------------------------------------------------
428 442
429export { 443export {
@@ -443,6 +457,7 @@ export {
443 getVideoFileFPS, 457 getVideoFileFPS,
444 computeResolutionsToTranscode, 458 computeResolutionsToTranscode,
445 audio, 459 audio,
460 hlsPlaylistToFragmentedMP4,
446 getVideoFileBitrate, 461 getVideoFileBitrate,
447 canDoQuickTranscode 462 canDoQuickTranscode
448} 463}
diff --git a/server/helpers/image-utils.ts b/server/helpers/image-utils.ts
index f2f6a004f..5f254a7aa 100644
--- a/server/helpers/image-utils.ts
+++ b/server/helpers/image-utils.ts
@@ -21,7 +21,7 @@ async function processImage (
21 try { 21 try {
22 jimpInstance = await Jimp.read(path) 22 jimpInstance = await Jimp.read(path)
23 } catch (err) { 23 } catch (err) {
24 logger.debug('Cannot read %s with jimp. Try to convert the image using ffmpeg first.', { err }) 24 logger.debug('Cannot read %s with jimp. Try to convert the image using ffmpeg first.', path, { err })
25 25
26 const newName = path + '.jpg' 26 const newName = path + '.jpg'
27 await convertWebPToJPG(path, newName) 27 await convertWebPToJPG(path, newName)
diff --git a/server/lib/hls.ts b/server/lib/hls.ts
index e38a8788c..7aa152638 100644
--- a/server/lib/hls.ts
+++ b/server/lib/hls.ts
@@ -106,22 +106,6 @@ async function buildSha256Segment (segmentPath: string) {
106 return sha256(buf) 106 return sha256(buf)
107} 107}
108 108
109function getRangesFromPlaylist (playlistContent: string) {
110 const ranges: { offset: number, length: number }[] = []
111 const lines = playlistContent.split('\n')
112 const regex = /^#EXT-X-BYTERANGE:(\d+)@(\d+)$/
113
114 for (const line of lines) {
115 const captured = regex.exec(line)
116
117 if (captured) {
118 ranges.push({ length: parseInt(captured[1], 10), offset: parseInt(captured[2], 10) })
119 }
120 }
121
122 return ranges
123}
124
125function downloadPlaylistSegments (playlistUrl: string, destinationDir: string, timeout: number) { 109function downloadPlaylistSegments (playlistUrl: string, destinationDir: string, timeout: number) {
126 let timer 110 let timer
127 111
@@ -199,3 +183,19 @@ export {
199} 183}
200 184
201// --------------------------------------------------------------------------- 185// ---------------------------------------------------------------------------
186
187function getRangesFromPlaylist (playlistContent: string) {
188 const ranges: { offset: number, length: number }[] = []
189 const lines = playlistContent.split('\n')
190 const regex = /^#EXT-X-BYTERANGE:(\d+)@(\d+)$/
191
192 for (const line of lines) {
193 const captured = regex.exec(line)
194
195 if (captured) {
196 ranges.push({ length: parseInt(captured[1], 10), offset: parseInt(captured[2], 10) })
197 }
198 }
199
200 return ranges
201}
diff --git a/server/lib/job-queue/handlers/video-live-ending.ts b/server/lib/job-queue/handlers/video-live-ending.ts
index 1a58a9f7e..1a9a36129 100644
--- a/server/lib/job-queue/handlers/video-live-ending.ts
+++ b/server/lib/job-queue/handlers/video-live-ending.ts
@@ -1,24 +1,89 @@
1import * as Bull from 'bull' 1import * as Bull from 'bull'
2import { readdir, remove } from 'fs-extra' 2import { readdir, remove } from 'fs-extra'
3import { join } from 'path' 3import { join } from 'path'
4import { getVideoFileResolution, hlsPlaylistToFragmentedMP4 } from '@server/helpers/ffmpeg-utils'
4import { getHLSDirectory } from '@server/lib/video-paths' 5import { getHLSDirectory } from '@server/lib/video-paths'
6import { generateHlsPlaylist } from '@server/lib/video-transcoding'
5import { VideoModel } from '@server/models/video/video' 7import { VideoModel } from '@server/models/video/video'
8import { VideoLiveModel } from '@server/models/video/video-live'
6import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist' 9import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
7import { VideoLiveEndingPayload } from '@shared/models' 10import { MStreamingPlaylist, MVideo } from '@server/types/models'
11import { VideoLiveEndingPayload, VideoState } from '@shared/models'
8import { logger } from '../../../helpers/logger' 12import { logger } from '../../../helpers/logger'
9 13
10async function processVideoLiveEnding (job: Bull.Job) { 14async function processVideoLiveEnding (job: Bull.Job) {
11 const payload = job.data as VideoLiveEndingPayload 15 const payload = job.data as VideoLiveEndingPayload
12 16
13 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoId) 17 const video = await VideoModel.load(payload.videoId)
14 if (!video) { 18 const live = await VideoLiveModel.loadByVideoId(payload.videoId)
15 logger.warn('Video live %d does not exist anymore. Cannot cleanup.', payload.videoId) 19
20 const streamingPlaylist = await VideoStreamingPlaylistModel.loadHLSPlaylistByVideo(video.id)
21 if (!video || !streamingPlaylist || !live) {
22 logger.warn('Video live %d does not exist anymore. Cannot process live ending.', payload.videoId)
16 return 23 return
17 } 24 }
18 25
19 const streamingPlaylist = await VideoStreamingPlaylistModel.loadHLSPlaylistByVideo(video.id) 26 if (live.saveReplay !== true) {
27 return cleanupLive(video, streamingPlaylist)
28 }
29
30 return saveLive(video, streamingPlaylist)
31}
32
33// ---------------------------------------------------------------------------
34
35export {
36 processVideoLiveEnding
37}
38
39// ---------------------------------------------------------------------------
40
41async function saveLive (video: MVideo, streamingPlaylist: MStreamingPlaylist) {
42 const videoFiles = await streamingPlaylist.get('VideoFiles')
43 const hlsDirectory = getHLSDirectory(video, false)
44
45 for (const videoFile of videoFiles) {
46 const playlistPath = join(hlsDirectory, VideoStreamingPlaylistModel.getHlsPlaylistFilename(videoFile.resolution))
47
48 const mp4TmpName = buildMP4TmpName(videoFile.resolution)
49 await hlsPlaylistToFragmentedMP4(playlistPath, mp4TmpName)
50 }
51
52 await cleanupLiveFiles(hlsDirectory)
53
54 video.isLive = false
55 video.state = VideoState.TO_TRANSCODE
56 await video.save()
57
58 const videoWithFiles = await VideoModel.loadWithFiles(video.id)
59
60 for (const videoFile of videoFiles) {
61 const videoInputPath = buildMP4TmpName(videoFile.resolution)
62 const { isPortraitMode } = await getVideoFileResolution(videoInputPath)
63
64 await generateHlsPlaylist({
65 video: videoWithFiles,
66 videoInputPath,
67 resolution: videoFile.resolution,
68 copyCodecs: true,
69 isPortraitMode
70 })
71 }
72
73 video.state = VideoState.PUBLISHED
74 await video.save()
75}
76
77async function cleanupLive (video: MVideo, streamingPlaylist: MStreamingPlaylist) {
20 const hlsDirectory = getHLSDirectory(video, false) 78 const hlsDirectory = getHLSDirectory(video, false)
21 79
80 await cleanupLiveFiles(hlsDirectory)
81
82 streamingPlaylist.destroy()
83 .catch(err => logger.error('Cannot remove live streaming playlist.', { err }))
84}
85
86async function cleanupLiveFiles (hlsDirectory: string) {
22 const files = await readdir(hlsDirectory) 87 const files = await readdir(hlsDirectory)
23 88
24 for (const filename of files) { 89 for (const filename of files) {
@@ -35,13 +100,8 @@ async function processVideoLiveEnding (job: Bull.Job) {
35 .catch(err => logger.error('Cannot remove %s.', p, { err })) 100 .catch(err => logger.error('Cannot remove %s.', p, { err }))
36 } 101 }
37 } 102 }
38
39 streamingPlaylist.destroy()
40 .catch(err => logger.error('Cannot remove live streaming playlist.', { err }))
41} 103}
42 104
43// --------------------------------------------------------------------------- 105function buildMP4TmpName (resolution: number) {
44 106 return resolution + 'tmp.mp4'
45export {
46 processVideoLiveEnding
47} 107}
diff --git a/server/lib/job-queue/handlers/video-transcoding.ts b/server/lib/job-queue/handlers/video-transcoding.ts
index 6659ab716..2aebc29f7 100644
--- a/server/lib/job-queue/handlers/video-transcoding.ts
+++ b/server/lib/job-queue/handlers/video-transcoding.ts
@@ -1,21 +1,22 @@
1import * as Bull from 'bull' 1import * as Bull from 'bull'
2import { getVideoFilePath } from '@server/lib/video-paths'
3import { MVideoFullLight, MVideoUUID, MVideoWithFile } from '@server/types/models'
2import { 4import {
3 MergeAudioTranscodingPayload, 5 MergeAudioTranscodingPayload,
4 NewResolutionTranscodingPayload, 6 NewResolutionTranscodingPayload,
5 OptimizeTranscodingPayload, 7 OptimizeTranscodingPayload,
6 VideoTranscodingPayload 8 VideoTranscodingPayload
7} from '../../../../shared' 9} from '../../../../shared'
10import { retryTransactionWrapper } from '../../../helpers/database-utils'
11import { computeResolutionsToTranscode } from '../../../helpers/ffmpeg-utils'
8import { logger } from '../../../helpers/logger' 12import { logger } from '../../../helpers/logger'
13import { CONFIG } from '../../../initializers/config'
14import { sequelizeTypescript } from '../../../initializers/database'
9import { VideoModel } from '../../../models/video/video' 15import { VideoModel } from '../../../models/video/video'
10import { JobQueue } from '../job-queue'
11import { federateVideoIfNeeded } from '../../activitypub/videos' 16import { federateVideoIfNeeded } from '../../activitypub/videos'
12import { retryTransactionWrapper } from '../../../helpers/database-utils'
13import { sequelizeTypescript } from '../../../initializers/database'
14import { computeResolutionsToTranscode } from '../../../helpers/ffmpeg-utils'
15import { generateHlsPlaylist, mergeAudioVideofile, optimizeOriginalVideofile, transcodeNewResolution } from '../../video-transcoding'
16import { Notifier } from '../../notifier' 17import { Notifier } from '../../notifier'
17import { CONFIG } from '../../../initializers/config' 18import { generateHlsPlaylist, mergeAudioVideofile, optimizeOriginalVideofile, transcodeNewResolution } from '../../video-transcoding'
18import { MVideoFullLight, MVideoUUID, MVideoWithFile } from '@server/types/models' 19import { JobQueue } from '../job-queue'
19 20
20async function processVideoTranscoding (job: Bull.Job) { 21async function processVideoTranscoding (job: Bull.Job) {
21 const payload = job.data as VideoTranscodingPayload 22 const payload = job.data as VideoTranscodingPayload
@@ -29,7 +30,20 @@ async function processVideoTranscoding (job: Bull.Job) {
29 } 30 }
30 31
31 if (payload.type === 'hls') { 32 if (payload.type === 'hls') {
32 await generateHlsPlaylist(video, payload.resolution, payload.copyCodecs, payload.isPortraitMode || false) 33 const videoFileInput = payload.copyCodecs
34 ? video.getWebTorrentFile(payload.resolution)
35 : video.getMaxQualityFile()
36
37 const videoOrStreamingPlaylist = videoFileInput.getVideoOrStreamingPlaylist()
38 const videoInputPath = getVideoFilePath(videoOrStreamingPlaylist, videoFileInput)
39
40 await generateHlsPlaylist({
41 video,
42 videoInputPath,
43 resolution: payload.resolution,
44 copyCodecs: payload.copyCodecs,
45 isPortraitMode: payload.isPortraitMode || false
46 })
33 47
34 await retryTransactionWrapper(onHlsPlaylistGenerationSuccess, video) 48 await retryTransactionWrapper(onHlsPlaylistGenerationSuccess, video)
35 } else if (payload.type === 'new-resolution') { 49 } else if (payload.type === 'new-resolution') {
diff --git a/server/lib/live-manager.ts b/server/lib/live-manager.ts
index 3ff2434ff..692c49008 100644
--- a/server/lib/live-manager.ts
+++ b/server/lib/live-manager.ts
@@ -13,7 +13,7 @@ import { VideoModel } from '@server/models/video/video'
13import { VideoFileModel } from '@server/models/video/video-file' 13import { VideoFileModel } from '@server/models/video/video-file'
14import { VideoLiveModel } from '@server/models/video/video-live' 14import { VideoLiveModel } from '@server/models/video/video-live'
15import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist' 15import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
16import { MStreamingPlaylist, MUser, MUserId, MVideoLive, MVideoLiveVideo } from '@server/types/models' 16import { MStreamingPlaylist, MUserId, MVideoLive, MVideoLiveVideo } from '@server/types/models'
17import { VideoState, VideoStreamingPlaylistType } from '@shared/models' 17import { VideoState, VideoStreamingPlaylistType } from '@shared/models'
18import { federateVideoIfNeeded } from './activitypub/videos' 18import { federateVideoIfNeeded } from './activitypub/videos'
19import { buildSha256Segment } from './hls' 19import { buildSha256Segment } from './hls'
diff --git a/server/lib/video-transcoding.ts b/server/lib/video-transcoding.ts
index a7b73a30d..c62b3c1ce 100644
--- a/server/lib/video-transcoding.ts
+++ b/server/lib/video-transcoding.ts
@@ -147,17 +147,18 @@ async function mergeAudioVideofile (video: MVideoWithAllFiles, resolution: Video
147 return onVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, videoOutputPath) 147 return onVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, videoOutputPath)
148} 148}
149 149
150async function generateHlsPlaylist (video: MVideoWithFile, resolution: VideoResolution, copyCodecs: boolean, isPortraitMode: boolean) { 150async function generateHlsPlaylist (options: {
151 video: MVideoWithFile
152 videoInputPath: string
153 resolution: VideoResolution
154 copyCodecs: boolean
155 isPortraitMode: boolean
156}) {
157 const { video, videoInputPath, resolution, copyCodecs, isPortraitMode } = options
158
151 const baseHlsDirectory = join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid) 159 const baseHlsDirectory = join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid)
152 await ensureDir(join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid)) 160 await ensureDir(join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid))
153 161
154 const videoFileInput = copyCodecs
155 ? video.getWebTorrentFile(resolution)
156 : video.getMaxQualityFile()
157
158 const videoOrStreamingPlaylist = videoFileInput.getVideoOrStreamingPlaylist()
159 const videoInputPath = getVideoFilePath(videoOrStreamingPlaylist, videoFileInput)
160
161 const outputPath = join(baseHlsDirectory, VideoStreamingPlaylistModel.getHlsPlaylistFilename(resolution)) 162 const outputPath = join(baseHlsDirectory, VideoStreamingPlaylistModel.getHlsPlaylistFilename(resolution))
162 const videoFilename = generateVideoStreamingPlaylistName(video.uuid, resolution) 163 const videoFilename = generateVideoStreamingPlaylistName(video.uuid, resolution)
163 164
@@ -184,7 +185,7 @@ async function generateHlsPlaylist (video: MVideoWithFile, resolution: VideoReso
184 videoId: video.id, 185 videoId: video.id,
185 playlistUrl, 186 playlistUrl,
186 segmentsSha256Url: WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsSha256SegmentsStaticPath(video.uuid, video.isLive), 187 segmentsSha256Url: WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsSha256SegmentsStaticPath(video.uuid, video.isLive),
187 p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrl, video.VideoFiles), 188 p2pMediaLoaderInfohashes: [],
188 p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION, 189 p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
189 190
190 type: VideoStreamingPlaylistType.HLS 191 type: VideoStreamingPlaylistType.HLS
@@ -211,6 +212,11 @@ async function generateHlsPlaylist (video: MVideoWithFile, resolution: VideoReso
211 await VideoFileModel.customUpsert(newVideoFile, 'streaming-playlist', undefined) 212 await VideoFileModel.customUpsert(newVideoFile, 'streaming-playlist', undefined)
212 videoStreamingPlaylist.VideoFiles = await videoStreamingPlaylist.$get('VideoFiles') 213 videoStreamingPlaylist.VideoFiles = await videoStreamingPlaylist.$get('VideoFiles')
213 214
215 videoStreamingPlaylist.p2pMediaLoaderInfohashes = VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(
216 playlistUrl, videoStreamingPlaylist.VideoFiles
217 )
218 await videoStreamingPlaylist.save()
219
214 video.setHLSPlaylist(videoStreamingPlaylist) 220 video.setHLSPlaylist(videoStreamingPlaylist)
215 221
216 await updateMasterHLSPlaylist(video) 222 await updateMasterHLSPlaylist(video)
diff --git a/server/middlewares/validators/videos/video-live.ts b/server/middlewares/validators/videos/video-live.ts
index a4c364976..ab57e67bf 100644
--- a/server/middlewares/validators/videos/video-live.ts
+++ b/server/middlewares/validators/videos/video-live.ts
@@ -1,15 +1,15 @@
1import * as express from 'express' 1import * as express from 'express'
2import { body, param } from 'express-validator' 2import { body, param } from 'express-validator'
3import { checkUserCanManageVideo, doesVideoChannelOfAccountExist, doesVideoExist } from '@server/helpers/middlewares/videos' 3import { checkUserCanManageVideo, doesVideoChannelOfAccountExist, doesVideoExist } from '@server/helpers/middlewares/videos'
4import { UserRight } from '@shared/models' 4import { VideoLiveModel } from '@server/models/video/video-live'
5import { isIdOrUUIDValid, isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc' 5import { UserRight, VideoState } from '@shared/models'
6import { isBooleanValid, isIdOrUUIDValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc'
6import { isVideoNameValid } from '../../../helpers/custom-validators/videos' 7import { isVideoNameValid } from '../../../helpers/custom-validators/videos'
7import { cleanUpReqFiles } from '../../../helpers/express-utils' 8import { cleanUpReqFiles } from '../../../helpers/express-utils'
8import { logger } from '../../../helpers/logger' 9import { logger } from '../../../helpers/logger'
9import { CONFIG } from '../../../initializers/config' 10import { CONFIG } from '../../../initializers/config'
10import { areValidationErrors } from '../utils' 11import { areValidationErrors } from '../utils'
11import { getCommonVideoEditAttributes } from './videos' 12import { getCommonVideoEditAttributes } from './videos'
12import { VideoLiveModel } from '@server/models/video/video-live'
13 13
14const videoLiveGetValidator = [ 14const videoLiveGetValidator = [
15 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), 15 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),
@@ -41,6 +41,11 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
41 body('name') 41 body('name')
42 .custom(isVideoNameValid).withMessage('Should have a valid name'), 42 .custom(isVideoNameValid).withMessage('Should have a valid name'),
43 43
44 body('saveReplay')
45 .optional()
46 .customSanitizer(toBooleanOrNull)
47 .custom(isBooleanValid).withMessage('Should have a valid saveReplay attribute'),
48
44 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 49 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
45 logger.debug('Checking videoLiveAddValidator parameters', { parameters: req.body }) 50 logger.debug('Checking videoLiveAddValidator parameters', { parameters: req.body })
46 51
@@ -49,6 +54,11 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
49 .json({ error: 'Live is not enabled on this instance' }) 54 .json({ error: 'Live is not enabled on this instance' })
50 } 55 }
51 56
57 if (CONFIG.LIVE.ALLOW_REPLAY !== true && req.body.saveReplay === true) {
58 return res.status(403)
59 .json({ error: 'Saving live replay is not allowed instance' })
60 }
61
52 if (areValidationErrors(req, res)) return cleanUpReqFiles(req) 62 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
53 63
54 const user = res.locals.oauth.token.User 64 const user = res.locals.oauth.token.User
@@ -58,9 +68,35 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
58 } 68 }
59]) 69])
60 70
71const videoLiveUpdateValidator = [
72 body('saveReplay')
73 .optional()
74 .customSanitizer(toBooleanOrNull)
75 .custom(isBooleanValid).withMessage('Should have a valid saveReplay attribute'),
76
77 (req: express.Request, res: express.Response, next: express.NextFunction) => {
78 logger.debug('Checking videoLiveUpdateValidator parameters', { parameters: req.body })
79
80 if (areValidationErrors(req, res)) return
81
82 if (CONFIG.LIVE.ALLOW_REPLAY !== true && req.body.saveReplay === true) {
83 return res.status(403)
84 .json({ error: 'Saving live replay is not allowed instance' })
85 }
86
87 if (res.locals.videoAll.state !== VideoState.WAITING_FOR_LIVE) {
88 return res.status(400)
89 .json({ error: 'Cannot update a live that has already started' })
90 }
91
92 return next()
93 }
94]
95
61// --------------------------------------------------------------------------- 96// ---------------------------------------------------------------------------
62 97
63export { 98export {
64 videoLiveAddValidator, 99 videoLiveAddValidator,
100 videoLiveUpdateValidator,
65 videoLiveGetValidator 101 videoLiveGetValidator
66} 102}
diff --git a/server/models/video/video-live.ts b/server/models/video/video-live.ts
index 0e229de6a..345918cb9 100644
--- a/server/models/video/video-live.ts
+++ b/server/models/video/video-live.ts
@@ -94,7 +94,8 @@ export class VideoLiveModel extends Model<VideoLiveModel> {
94 toFormattedJSON (): LiveVideo { 94 toFormattedJSON (): LiveVideo {
95 return { 95 return {
96 rtmpUrl: WEBSERVER.RTMP_URL, 96 rtmpUrl: WEBSERVER.RTMP_URL,
97 streamKey: this.streamKey 97 streamKey: this.streamKey,
98 saveReplay: this.saveReplay
98 } 99 }
99 } 100 }
100} 101}
diff --git a/shared/models/videos/live/index.ts b/shared/models/videos/live/index.ts
index 4f331738b..a36f42a7d 100644
--- a/shared/models/videos/live/index.ts
+++ b/shared/models/videos/live/index.ts
@@ -1,3 +1,5 @@
1export * from './live-video-create.model'
1export * from './live-video-event-payload.model' 2export * from './live-video-event-payload.model'
2export * from './live-video-event.type' 3export * from './live-video-event.type'
4export * from './live-video-update.model'
3export * from './live-video.model' 5export * 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
index 000000000..1ef4b70dd
--- /dev/null
+++ b/shared/models/videos/live/live-video-create.model.ts
@@ -0,0 +1,5 @@
1import { VideoCreate } from '../video-create.model'
2
3export interface LiveVideoCreate extends VideoCreate {
4 saveReplay?: boolean
5}
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
index 000000000..0f0f67d06
--- /dev/null
+++ b/shared/models/videos/live/live-video-update.model.ts
@@ -0,0 +1,3 @@
1export interface LiveVideoUpdate {
2 saveReplay?: boolean
3}
diff --git a/shared/models/videos/live/live-video.model.ts b/shared/models/videos/live/live-video.model.ts
index 74abee96e..a3f8275e3 100644
--- a/shared/models/videos/live/live-video.model.ts
+++ b/shared/models/videos/live/live-video.model.ts
@@ -1,4 +1,5 @@
1export interface LiveVideo { 1export interface LiveVideo {
2 rtmpUrl: string 2 rtmpUrl: string
3 streamKey: string 3 streamKey: string
4 saveReplay: boolean
4} 5}