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