From b5b687550d8ef8beafdf706e45d6556fb5f4c876 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Mon, 26 Oct 2020 16:44:23 +0100 Subject: [PATCH] Add ability to save live replay --- .../shared/video-edit.component.html | 14 +++- .../shared/video-edit.component.ts | 7 +- .../video-go-live.component.html | 5 ++ .../video-go-live.component.ts | 39 +++++---- .../+video-edit/video-add.component.html | 2 +- .../+video-edit/video-update.component.ts | 30 +++++-- .../+video-edit/video-update.resolver.ts | 2 +- .../video-duration-formatter.pipe.ts | 23 ----- .../+video-watch/video-watch.component.html | 2 +- .../+video-watch/video-watch.module.ts | 2 - .../angular/duration-formatter.pipe.ts | 32 +++++++ .../app/shared/shared-main/angular/index.ts | 1 + .../shared/shared-main/shared-main.module.ts | 14 +++- .../shared-main/video/live-video.service.ts | 10 ++- server/controllers/api/videos/live.ts | 37 ++++++-- server/helpers/ffmpeg-utils.ts | 15 ++++ server/helpers/image-utils.ts | 2 +- server/lib/hls.ts | 32 +++---- .../job-queue/handlers/video-live-ending.ts | 84 ++++++++++++++++--- .../job-queue/handlers/video-transcoding.ts | 32 +++++-- server/lib/live-manager.ts | 2 +- server/lib/video-transcoding.ts | 24 ++++-- .../validators/videos/video-live.ts | 42 +++++++++- server/models/video/video-live.ts | 3 +- shared/models/videos/live/index.ts | 2 + .../videos/live/live-video-create.model.ts | 5 ++ .../videos/live/live-video-update.model.ts | 3 + shared/models/videos/live/live-video.model.ts | 1 + 28 files changed, 356 insertions(+), 111 deletions(-) delete mode 100644 client/src/app/+videos/+video-watch/video-duration-formatter.pipe.ts create mode 100644 client/src/app/shared/shared-main/angular/duration-formatter.pipe.ts create mode 100644 shared/models/videos/live/live-video-create.model.ts create mode 100644 shared/models/videos/live/live-video-update.model.ts diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.html b/client/src/app/+videos/+video-edit/shared/video-edit.component.html index 0802e906d..d9e09c453 100644 --- a/client/src/app/+videos/+video-edit/shared/video-edit.component.html +++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.html @@ -142,7 +142,7 @@ - + Captions @@ -211,6 +211,18 @@ + +
+ + + Automatically publish a replay when your live ends + + + + ⚠️ If you enable this option, your live will be terminated if you exceed your video quota + + +
diff --git a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts index 304bf7ed0..26d871e59 100644 --- a/client/src/app/+videos/+video-edit/shared/video-edit.component.ts +++ b/client/src/app/+videos/+video-edit/shared/video-edit.component.ts @@ -127,7 +127,8 @@ export class VideoEditComponent implements OnInit, OnDestroy { 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 diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.html b/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.html index 8fae4044a..5657827a9 100644 --- a/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.html +++ b/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.html @@ -27,6 +27,11 @@ {{ error }} +
+ Max live duration is {{ getMaxLiveDuration() | myDurationFormatter }}. + If your live reaches this limit, it will be automatically terminated. +
+
{ - 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 () { diff --git a/client/src/app/+videos/+video-edit/video-add.component.html b/client/src/app/+videos/+video-edit/video-add.component.html index bf2cc9c83..dc8c2f21d 100644 --- a/client/src/app/+videos/+video-edit/video-add.component.html +++ b/client/src/app/+videos/+video-edit/video-add.component.html @@ -13,7 +13,7 @@ Instead, create a dedicated account to upload your videos. - +
Import {{ videoName }} diff --git a/client/src/app/+videos/+video-edit/video-update.component.ts b/client/src/app/+videos/+video-edit/video-update.component.ts index ec1305a33..7126ad05b 100644 --- a/client/src/app/+videos/+video-edit/video-update.component.ts +++ b/client/src/app/+videos/+video-edit/video-update.component.ts @@ -3,10 +3,11 @@ import { Component, HostListener, OnInit } from '@angular/core' 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( () => { diff --git a/client/src/app/+videos/+video-edit/video-update.resolver.ts b/client/src/app/+videos/+video-edit/video-update.resolver.ts index b7ec22dd5..5388a64b0 100644 --- a/client/src/app/+videos/+video-edit/video-update.resolver.ts +++ b/client/src/app/+videos/+video-edit/video-update.resolver.ts @@ -20,7 +20,7 @@ export class VideoUpdateResolver implements Resolve { 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 })) ) } diff --git a/client/src/app/+videos/+video-watch/video-duration-formatter.pipe.ts b/client/src/app/+videos/+video-watch/video-duration-formatter.pipe.ts deleted file mode 100644 index 19b34f984..000000000 --- a/client/src/app/+videos/+video-watch/video-duration-formatter.pipe.ts +++ /dev/null @@ -1,23 +0,0 @@ -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` - } -} diff --git a/client/src/app/+videos/+video-watch/video-watch.component.html b/client/src/app/+videos/+video-watch/video-watch.component.html index 13242a2bc..bc1c302de 100644 --- a/client/src/app/+videos/+video-watch/video-watch.component.html +++ b/client/src/app/+videos/+video-watch/video-watch.component.html @@ -270,7 +270,7 @@
Duration - {{ video.duration | myVideoDurationFormatter }} + {{ video.duration | myDurationFormatter }}
diff --git a/client/src/app/+videos/+video-watch/video-watch.module.ts b/client/src/app/+videos/+video-watch/video-watch.module.ts index 612bbccc4..21aa33b84 100644 --- a/client/src/app/+videos/+video-watch/video-watch.module.ts +++ b/client/src/app/+videos/+video-watch/video-watch.module.ts @@ -15,7 +15,6 @@ import { VideoCommentsComponent } from './comment/video-comments.component' 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 ], diff --git a/client/src/app/shared/shared-main/angular/duration-formatter.pipe.ts b/client/src/app/shared/shared-main/angular/duration-formatter.pipe.ts new file mode 100644 index 000000000..29ff864ec --- /dev/null +++ b/client/src/app/shared/shared-main/angular/duration-formatter.pipe.ts @@ -0,0 +1,32 @@ +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` + } +} diff --git a/client/src/app/shared/shared-main/angular/index.ts b/client/src/app/shared/shared-main/angular/index.ts index 9ba815136..29f8b3650 100644 --- a/client/src/app/shared/shared-main/angular/index.ts +++ b/client/src/app/shared/shared-main/angular/index.ts @@ -1,4 +1,5 @@ export * from './bytes.pipe' +export * from './duration-formatter.pipe' export * from './from-now.pipe' export * from './infinite-scroller.directive' export * from './number-formatter.pipe' diff --git a/client/src/app/shared/shared-main/shared-main.module.ts b/client/src/app/shared/shared-main/shared-main.module.ts index 0580872f4..3816cab19 100644 --- a/client/src/app/shared/shared-main/shared-main.module.ts +++ b/client/src/app/shared/shared-main/shared-main.module.ts @@ -15,7 +15,14 @@ import { } 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, diff --git a/client/src/app/shared/shared-main/video/live-video.service.ts b/client/src/app/shared/shared-main/video/live-video.service.ts index 2cd1c66a5..093d65e83 100644 --- a/client/src/app/shared/shared-main/video/live-video.service.ts +++ b/client/src/app/shared/shared-main/video/live-video.service.ts @@ -2,7 +2,7 @@ import { catchError } from 'rxjs/operators' 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(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))) + } } diff --git a/server/controllers/api/videos/live.ts b/server/controllers/api/videos/live.ts index 97b135f96..be46fb1c6 100644 --- a/server/controllers/api/videos/live.ts +++ b/server/controllers/api/videos/live.ts @@ -5,10 +5,10 @@ import { CONFIG } from '@server/initializers/config' 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 + }) } }) diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts index b25dcaa90..2f167a580 100644 --- a/server/helpers/ffmpeg-utils.ts +++ b/server/helpers/ffmpeg-utils.ts @@ -424,6 +424,20 @@ function runLiveMuxing (rtmpUrl: string, outPath: string, deleteSegments: boolea return command } +function hlsPlaylistToFragmentedMP4 (playlistPath: string, outputPath: string) { + const command = getFFmpeg(playlistPath) + + command.outputOption('-c copy') + command.output(outputPath) + + command.run() + + return new Promise((res, rej) => { + command.on('error', err => rej(err)) + command.on('end', () => res()) + }) +} + // --------------------------------------------------------------------------- export { @@ -443,6 +457,7 @@ export { getVideoFileFPS, computeResolutionsToTranscode, audio, + hlsPlaylistToFragmentedMP4, getVideoFileBitrate, canDoQuickTranscode } diff --git a/server/helpers/image-utils.ts b/server/helpers/image-utils.ts index f2f6a004f..5f254a7aa 100644 --- a/server/helpers/image-utils.ts +++ b/server/helpers/image-utils.ts @@ -21,7 +21,7 @@ async function processImage ( 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) diff --git a/server/lib/hls.ts b/server/lib/hls.ts index e38a8788c..7aa152638 100644 --- a/server/lib/hls.ts +++ b/server/lib/hls.ts @@ -106,22 +106,6 @@ async function buildSha256Segment (segmentPath: string) { 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 +} diff --git a/server/lib/job-queue/handlers/video-live-ending.ts b/server/lib/job-queue/handlers/video-live-ending.ts index 1a58a9f7e..1a9a36129 100644 --- a/server/lib/job-queue/handlers/video-live-ending.ts +++ b/server/lib/job-queue/handlers/video-live-ending.ts @@ -1,24 +1,89 @@ 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' } diff --git a/server/lib/job-queue/handlers/video-transcoding.ts b/server/lib/job-queue/handlers/video-transcoding.ts index 6659ab716..2aebc29f7 100644 --- a/server/lib/job-queue/handlers/video-transcoding.ts +++ b/server/lib/job-queue/handlers/video-transcoding.ts @@ -1,21 +1,22 @@ 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') { diff --git a/server/lib/live-manager.ts b/server/lib/live-manager.ts index 3ff2434ff..692c49008 100644 --- a/server/lib/live-manager.ts +++ b/server/lib/live-manager.ts @@ -13,7 +13,7 @@ import { VideoModel } from '@server/models/video/video' 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' diff --git a/server/lib/video-transcoding.ts b/server/lib/video-transcoding.ts index a7b73a30d..c62b3c1ce 100644 --- a/server/lib/video-transcoding.ts +++ b/server/lib/video-transcoding.ts @@ -147,17 +147,18 @@ async function mergeAudioVideofile (video: MVideoWithAllFiles, resolution: Video 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) diff --git a/server/middlewares/validators/videos/video-live.ts b/server/middlewares/validators/videos/video-live.ts index a4c364976..ab57e67bf 100644 --- a/server/middlewares/validators/videos/video-live.ts +++ b/server/middlewares/validators/videos/video-live.ts @@ -1,15 +1,15 @@ 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 } diff --git a/server/models/video/video-live.ts b/server/models/video/video-live.ts index 0e229de6a..345918cb9 100644 --- a/server/models/video/video-live.ts +++ b/server/models/video/video-live.ts @@ -94,7 +94,8 @@ export class VideoLiveModel extends Model { toFormattedJSON (): LiveVideo { return { rtmpUrl: WEBSERVER.RTMP_URL, - streamKey: this.streamKey + streamKey: this.streamKey, + saveReplay: this.saveReplay } } } diff --git a/shared/models/videos/live/index.ts b/shared/models/videos/live/index.ts index 4f331738b..a36f42a7d 100644 --- a/shared/models/videos/live/index.ts +++ b/shared/models/videos/live/index.ts @@ -1,3 +1,5 @@ +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' diff --git a/shared/models/videos/live/live-video-create.model.ts b/shared/models/videos/live/live-video-create.model.ts new file mode 100644 index 000000000..1ef4b70dd --- /dev/null +++ b/shared/models/videos/live/live-video-create.model.ts @@ -0,0 +1,5 @@ +import { VideoCreate } from '../video-create.model' + +export interface LiveVideoCreate extends VideoCreate { + saveReplay?: boolean +} diff --git a/shared/models/videos/live/live-video-update.model.ts b/shared/models/videos/live/live-video-update.model.ts new file mode 100644 index 000000000..0f0f67d06 --- /dev/null +++ b/shared/models/videos/live/live-video-update.model.ts @@ -0,0 +1,3 @@ +export interface LiveVideoUpdate { + saveReplay?: boolean +} diff --git a/shared/models/videos/live/live-video.model.ts b/shared/models/videos/live/live-video.model.ts index 74abee96e..a3f8275e3 100644 --- a/shared/models/videos/live/live-video.model.ts +++ b/shared/models/videos/live/live-video.model.ts @@ -1,4 +1,5 @@ export interface LiveVideo { rtmpUrl: string streamKey: string + saveReplay: boolean }