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