diff options
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 | ||
2 | import { forkJoin } from 'rxjs' | ||
2 | import { Component, EventEmitter, OnInit, Output } from '@angular/core' | 3 | import { Component, EventEmitter, OnInit, Output } from '@angular/core' |
3 | import { Router } from '@angular/router' | 4 | import { Router } from '@angular/router' |
4 | import { AuthService, CanComponentDeactivate, Notifier, ServerService } from '@app/core' | 5 | import { AuthService, CanComponentDeactivate, Notifier, ServerService } from '@app/core' |
@@ -6,7 +7,7 @@ import { scrollToTop } from '@app/helpers' | |||
6 | import { FormValidatorService } from '@app/shared/shared-forms' | 7 | import { FormValidatorService } from '@app/shared/shared-forms' |
7 | import { LiveVideoService, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' | 8 | import { LiveVideoService, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' |
8 | import { LoadingBarService } from '@ngx-loading-bar/core' | 9 | import { LoadingBarService } from '@ngx-loading-bar/core' |
9 | import { LiveVideo, VideoCreate, VideoPrivacy } from '@shared/models' | 10 | import { LiveVideo, LiveVideoCreate, LiveVideoUpdate, VideoPrivacy } from '@shared/models' |
10 | import { VideoSend } from './video-send' | 11 | import { 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' | |||
3 | import { ActivatedRoute, Router } from '@angular/router' | 3 | import { ActivatedRoute, Router } from '@angular/router' |
4 | import { Notifier } from '@app/core' | 4 | import { Notifier } from '@app/core' |
5 | import { FormReactive, FormValidatorService, SelectChannelItem } from '@app/shared/shared-forms' | 5 | import { FormReactive, FormValidatorService, SelectChannelItem } from '@app/shared/shared-forms' |
6 | import { VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main' | 6 | import { LiveVideoService, VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main' |
7 | import { LoadingBarService } from '@ngx-loading-bar/core' | 7 | import { LoadingBarService } from '@ngx-loading-bar/core' |
8 | import { LiveVideo, VideoPrivacy } from '@shared/models' | 8 | import { LiveVideo, LiveVideoUpdate, VideoPrivacy } from '@shared/models' |
9 | import { hydrateFormFromVideo } from './shared/video-edit-utils' | 9 | import { hydrateFormFromVideo } from './shared/video-edit-utils' |
10 | import { 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 @@ | |||
1 | import { Pipe, PipeTransform } from '@angular/core' | ||
2 | |||
3 | @Pipe({ | ||
4 | name: 'myVideoDurationFormatter' | ||
5 | }) | ||
6 | export 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' | |||
15 | import { VideoSupportComponent } from './modal/video-support.component' | 15 | import { VideoSupportComponent } from './modal/video-support.component' |
16 | import { RecommendationsModule } from './recommendations/recommendations.module' | 16 | import { RecommendationsModule } from './recommendations/recommendations.module' |
17 | import { TimestampRouteTransformerDirective } from './timestamp-route-transformer.directive' | 17 | import { TimestampRouteTransformerDirective } from './timestamp-route-transformer.directive' |
18 | import { VideoDurationPipe } from './video-duration-formatter.pipe' | ||
19 | import { VideoWatchPlaylistComponent } from './video-watch-playlist.component' | 18 | import { VideoWatchPlaylistComponent } from './video-watch-playlist.component' |
20 | import { VideoWatchRoutingModule } from './video-watch-routing.module' | 19 | import { VideoWatchRoutingModule } from './video-watch-routing.module' |
21 | import { VideoWatchComponent } from './video-watch.component' | 20 | import { 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 @@ | |||
1 | import { Pipe, PipeTransform } from '@angular/core' | ||
2 | |||
3 | @Pipe({ | ||
4 | name: 'myDurationFormatter' | ||
5 | }) | ||
6 | export 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 @@ | |||
1 | export * from './bytes.pipe' | 1 | export * from './bytes.pipe' |
2 | export * from './duration-formatter.pipe' | ||
2 | export * from './from-now.pipe' | 3 | export * from './from-now.pipe' |
3 | export * from './infinite-scroller.directive' | 4 | export * from './infinite-scroller.directive' |
4 | export * from './number-formatter.pipe' | 5 | export * 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' |
16 | import { SharedGlobalIconModule } from '../shared-icons' | 16 | import { SharedGlobalIconModule } from '../shared-icons' |
17 | import { AccountService, ActorAvatarInfoComponent, AvatarComponent } from './account' | 17 | import { AccountService, ActorAvatarInfoComponent, AvatarComponent } from './account' |
18 | import { FromNowPipe, InfiniteScrollerDirective, NumberFormatterPipe, PeerTubeTemplateDirective, BytesPipe } from './angular' | 18 | import { |
19 | BytesPipe, | ||
20 | DurationFormatterPipe, | ||
21 | FromNowPipe, | ||
22 | InfiniteScrollerDirective, | ||
23 | NumberFormatterPipe, | ||
24 | PeerTubeTemplateDirective | ||
25 | } from './angular' | ||
19 | import { AUTH_INTERCEPTOR_PROVIDER } from './auth' | 26 | import { AUTH_INTERCEPTOR_PROVIDER } from './auth' |
20 | import { ActionDropdownComponent, ButtonComponent, DeleteButtonComponent, EditButtonComponent } from './buttons' | 27 | import { ActionDropdownComponent, ButtonComponent, DeleteButtonComponent, EditButtonComponent } from './buttons' |
21 | import { DateToggleComponent } from './date' | 28 | import { DateToggleComponent } from './date' |
@@ -23,7 +30,7 @@ import { FeedComponent } from './feeds' | |||
23 | import { LoaderComponent, SmallLoaderComponent } from './loaders' | 30 | import { LoaderComponent, SmallLoaderComponent } from './loaders' |
24 | import { HelpComponent, ListOverflowComponent, TopMenuDropdownComponent } from './misc' | 31 | import { HelpComponent, ListOverflowComponent, TopMenuDropdownComponent } from './misc' |
25 | import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users' | 32 | import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users' |
26 | import { RedundancyService, VideoImportService, VideoOwnershipService, VideoService, LiveVideoService } from './video' | 33 | import { LiveVideoService, RedundancyService, VideoImportService, VideoOwnershipService, VideoService } from './video' |
27 | import { VideoCaptionService } from './video-caption' | 34 | import { VideoCaptionService } from './video-caption' |
28 | import { VideoChannelService } from './video-channel' | 35 | import { 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' | |||
2 | import { HttpClient } from '@angular/common/http' | 2 | import { HttpClient } from '@angular/common/http' |
3 | import { Injectable } from '@angular/core' | 3 | import { Injectable } from '@angular/core' |
4 | import { RestExtractor } from '@app/core' | 4 | import { RestExtractor } from '@app/core' |
5 | import { VideoCreate, LiveVideo } from '@shared/models' | 5 | import { LiveVideo, LiveVideoCreate, LiveVideoUpdate } from '@shared/models' |
6 | import { environment } from '../../../../environments/environment' | 6 | import { 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' | |||
5 | import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants' | 5 | import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants' |
6 | import { getVideoActivityPubUrl } from '@server/lib/activitypub/url' | 6 | import { getVideoActivityPubUrl } from '@server/lib/activitypub/url' |
7 | import { buildLocalVideoFromReq, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video' | 7 | import { buildLocalVideoFromReq, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video' |
8 | import { videoLiveAddValidator, videoLiveGetValidator } from '@server/middlewares/validators/videos/video-live' | 8 | import { videoLiveAddValidator, videoLiveGetValidator, videoLiveUpdateValidator } from '@server/middlewares/validators/videos/video-live' |
9 | import { VideoLiveModel } from '@server/models/video/video-live' | 9 | import { VideoLiveModel } from '@server/models/video/video-live' |
10 | import { MVideoDetails, MVideoFullLight } from '@server/types/models' | 10 | import { MVideoDetails, MVideoFullLight } from '@server/types/models' |
11 | import { VideoCreate, VideoState } from '../../../../shared' | 11 | import { LiveVideoCreate, LiveVideoUpdate, VideoState } from '../../../../shared' |
12 | import { logger } from '../../../helpers/logger' | 12 | import { logger } from '../../../helpers/logger' |
13 | import { sequelizeTypescript } from '../../../initializers/database' | 13 | import { sequelizeTypescript } from '../../../initializers/database' |
14 | import { createVideoMiniatureFromExisting } from '../../../lib/thumbnail' | 14 | import { createVideoMiniatureFromExisting } from '../../../lib/thumbnail' |
@@ -36,7 +36,14 @@ liveRouter.post('/live', | |||
36 | liveRouter.get('/live/:videoId', | 36 | liveRouter.get('/live/:videoId', |
37 | authenticate, | 37 | authenticate, |
38 | asyncMiddleware(videoLiveGetValidator), | 38 | asyncMiddleware(videoLiveGetValidator), |
39 | asyncRetryTransactionMiddleware(getVideoLive) | 39 | asyncRetryTransactionMiddleware(getLiveVideo) |
40 | ) | ||
41 | |||
42 | liveRouter.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 | ||
50 | async function getVideoLive (req: express.Request, res: express.Response) { | 57 | async 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 | ||
63 | async 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 | |||
56 | async function addLiveVideo (req: express.Request, res: express.Response) { | 74 | async 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 | ||
427 | function 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 | ||
429 | export { | 443 | export { |
@@ -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 | ||
109 | function 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 | |||
125 | function downloadPlaylistSegments (playlistUrl: string, destinationDir: string, timeout: number) { | 109 | function 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 | |||
187 | function 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 @@ | |||
1 | import * as Bull from 'bull' | 1 | import * as Bull from 'bull' |
2 | import { readdir, remove } from 'fs-extra' | 2 | import { readdir, remove } from 'fs-extra' |
3 | import { join } from 'path' | 3 | import { join } from 'path' |
4 | import { getVideoFileResolution, hlsPlaylistToFragmentedMP4 } from '@server/helpers/ffmpeg-utils' | ||
4 | import { getHLSDirectory } from '@server/lib/video-paths' | 5 | import { getHLSDirectory } from '@server/lib/video-paths' |
6 | import { generateHlsPlaylist } from '@server/lib/video-transcoding' | ||
5 | import { VideoModel } from '@server/models/video/video' | 7 | import { VideoModel } from '@server/models/video/video' |
8 | import { VideoLiveModel } from '@server/models/video/video-live' | ||
6 | import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist' | 9 | import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist' |
7 | import { VideoLiveEndingPayload } from '@shared/models' | 10 | import { MStreamingPlaylist, MVideo } from '@server/types/models' |
11 | import { VideoLiveEndingPayload, VideoState } from '@shared/models' | ||
8 | import { logger } from '../../../helpers/logger' | 12 | import { logger } from '../../../helpers/logger' |
9 | 13 | ||
10 | async function processVideoLiveEnding (job: Bull.Job) { | 14 | async 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 | |||
35 | export { | ||
36 | processVideoLiveEnding | ||
37 | } | ||
38 | |||
39 | // --------------------------------------------------------------------------- | ||
40 | |||
41 | async 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 | |||
77 | async 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 | |||
86 | async 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 | // --------------------------------------------------------------------------- | 105 | function buildMP4TmpName (resolution: number) { |
44 | 106 | return resolution + 'tmp.mp4' | |
45 | export { | ||
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 @@ | |||
1 | import * as Bull from 'bull' | 1 | import * as Bull from 'bull' |
2 | import { getVideoFilePath } from '@server/lib/video-paths' | ||
3 | import { MVideoFullLight, MVideoUUID, MVideoWithFile } from '@server/types/models' | ||
2 | import { | 4 | import { |
3 | MergeAudioTranscodingPayload, | 5 | MergeAudioTranscodingPayload, |
4 | NewResolutionTranscodingPayload, | 6 | NewResolutionTranscodingPayload, |
5 | OptimizeTranscodingPayload, | 7 | OptimizeTranscodingPayload, |
6 | VideoTranscodingPayload | 8 | VideoTranscodingPayload |
7 | } from '../../../../shared' | 9 | } from '../../../../shared' |
10 | import { retryTransactionWrapper } from '../../../helpers/database-utils' | ||
11 | import { computeResolutionsToTranscode } from '../../../helpers/ffmpeg-utils' | ||
8 | import { logger } from '../../../helpers/logger' | 12 | import { logger } from '../../../helpers/logger' |
13 | import { CONFIG } from '../../../initializers/config' | ||
14 | import { sequelizeTypescript } from '../../../initializers/database' | ||
9 | import { VideoModel } from '../../../models/video/video' | 15 | import { VideoModel } from '../../../models/video/video' |
10 | import { JobQueue } from '../job-queue' | ||
11 | import { federateVideoIfNeeded } from '../../activitypub/videos' | 16 | import { federateVideoIfNeeded } from '../../activitypub/videos' |
12 | import { retryTransactionWrapper } from '../../../helpers/database-utils' | ||
13 | import { sequelizeTypescript } from '../../../initializers/database' | ||
14 | import { computeResolutionsToTranscode } from '../../../helpers/ffmpeg-utils' | ||
15 | import { generateHlsPlaylist, mergeAudioVideofile, optimizeOriginalVideofile, transcodeNewResolution } from '../../video-transcoding' | ||
16 | import { Notifier } from '../../notifier' | 17 | import { Notifier } from '../../notifier' |
17 | import { CONFIG } from '../../../initializers/config' | 18 | import { generateHlsPlaylist, mergeAudioVideofile, optimizeOriginalVideofile, transcodeNewResolution } from '../../video-transcoding' |
18 | import { MVideoFullLight, MVideoUUID, MVideoWithFile } from '@server/types/models' | 19 | import { JobQueue } from '../job-queue' |
19 | 20 | ||
20 | async function processVideoTranscoding (job: Bull.Job) { | 21 | async 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' | |||
13 | import { VideoFileModel } from '@server/models/video/video-file' | 13 | import { VideoFileModel } from '@server/models/video/video-file' |
14 | import { VideoLiveModel } from '@server/models/video/video-live' | 14 | import { VideoLiveModel } from '@server/models/video/video-live' |
15 | import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist' | 15 | import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist' |
16 | import { MStreamingPlaylist, MUser, MUserId, MVideoLive, MVideoLiveVideo } from '@server/types/models' | 16 | import { MStreamingPlaylist, MUserId, MVideoLive, MVideoLiveVideo } from '@server/types/models' |
17 | import { VideoState, VideoStreamingPlaylistType } from '@shared/models' | 17 | import { VideoState, VideoStreamingPlaylistType } from '@shared/models' |
18 | import { federateVideoIfNeeded } from './activitypub/videos' | 18 | import { federateVideoIfNeeded } from './activitypub/videos' |
19 | import { buildSha256Segment } from './hls' | 19 | import { 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 | ||
150 | async function generateHlsPlaylist (video: MVideoWithFile, resolution: VideoResolution, copyCodecs: boolean, isPortraitMode: boolean) { | 150 | async 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 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { body, param } from 'express-validator' | 2 | import { body, param } from 'express-validator' |
3 | import { checkUserCanManageVideo, doesVideoChannelOfAccountExist, doesVideoExist } from '@server/helpers/middlewares/videos' | 3 | import { checkUserCanManageVideo, doesVideoChannelOfAccountExist, doesVideoExist } from '@server/helpers/middlewares/videos' |
4 | import { UserRight } from '@shared/models' | 4 | import { VideoLiveModel } from '@server/models/video/video-live' |
5 | import { isIdOrUUIDValid, isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc' | 5 | import { UserRight, VideoState } from '@shared/models' |
6 | import { isBooleanValid, isIdOrUUIDValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc' | ||
6 | import { isVideoNameValid } from '../../../helpers/custom-validators/videos' | 7 | import { isVideoNameValid } from '../../../helpers/custom-validators/videos' |
7 | import { cleanUpReqFiles } from '../../../helpers/express-utils' | 8 | import { cleanUpReqFiles } from '../../../helpers/express-utils' |
8 | import { logger } from '../../../helpers/logger' | 9 | import { logger } from '../../../helpers/logger' |
9 | import { CONFIG } from '../../../initializers/config' | 10 | import { CONFIG } from '../../../initializers/config' |
10 | import { areValidationErrors } from '../utils' | 11 | import { areValidationErrors } from '../utils' |
11 | import { getCommonVideoEditAttributes } from './videos' | 12 | import { getCommonVideoEditAttributes } from './videos' |
12 | import { VideoLiveModel } from '@server/models/video/video-live' | ||
13 | 13 | ||
14 | const videoLiveGetValidator = [ | 14 | const 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 | ||
71 | const 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 | ||
63 | export { | 98 | export { |
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 @@ | |||
1 | export * from './live-video-create.model' | ||
1 | export * from './live-video-event-payload.model' | 2 | export * from './live-video-event-payload.model' |
2 | export * from './live-video-event.type' | 3 | export * from './live-video-event.type' |
4 | export * from './live-video-update.model' | ||
3 | export * from './live-video.model' | 5 | 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 index 000000000..1ef4b70dd --- /dev/null +++ b/shared/models/videos/live/live-video-create.model.ts | |||
@@ -0,0 +1,5 @@ | |||
1 | import { VideoCreate } from '../video-create.model' | ||
2 | |||
3 | export 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 @@ | |||
1 | export 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 @@ | |||
1 | export interface LiveVideo { | 1 | export interface LiveVideo { |
2 | rtmpUrl: string | 2 | rtmpUrl: string |
3 | streamKey: string | 3 | streamKey: string |
4 | saveReplay: boolean | ||
4 | } | 5 | } |