Add ability to save live replay

pull/3284/head
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-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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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 './duration-formatter.pipe'
export * from './from-now.pipe'
export * from './infinite-scroller.directive'
export * from './number-formatter.pipe'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 {
rtmpUrl: string
streamKey: string
saveReplay: boolean
}