1
0
Fork 0

Add ability to save live replay

This commit is contained in:
Chocobozzz 2020-10-26 16:44:23 +01:00 committed by Chocobozzz
parent ef680f6835
commit b5b687550d
28 changed files with 356 additions and 111 deletions

View file

@ -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>

View file

@ -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

View file

@ -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

View file

@ -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 () {

View file

@ -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>

View file

@ -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(
() => { () => {

View file

@ -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 }))
) )
} }

View file

@ -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`
}
}

View file

@ -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>

View file

@ -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
], ],

View file

@ -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`
}
}

View file

@ -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'

View file

@ -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,

View file

@ -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)))
}
} }

View file

@ -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
})
} }
}) })

View file

@ -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
} }

View file

@ -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)

View file

@ -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
}

View file

@ -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
} }

View file

@ -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') {

View file

@ -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'

View file

@ -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)

View file

@ -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
} }

View file

@ -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
} }
} }
} }

View file

@ -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'

View file

@ -0,0 +1,5 @@
import { VideoCreate } from '../video-create.model'
export interface LiveVideoCreate extends VideoCreate {
saveReplay?: boolean
}

View file

@ -0,0 +1,3 @@
export interface LiveVideoUpdate {
saveReplay?: boolean
}

View file

@ -1,4 +1,5 @@
export interface LiveVideo { export interface LiveVideo {
rtmpUrl: string rtmpUrl: string
streamKey: string streamKey: string
saveReplay: boolean
} }