</ng-template>
</ng-container>
- <ng-container ngbNavItem>
+ <ng-container ngbNavItem *ngIf="!liveVideo">
<a ngbNavLink i18n>Captions</a>
<ng-template ngbNavContent>
<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>
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(
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
{{ 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
+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'
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({
}
goLive () {
- const video: VideoCreate = {
+ const video: LiveVideoCreate = {
name: 'Live',
privacy: VideoPrivacy.PRIVATE,
nsfw: this.serverConfig.instance.isNSFW,
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.`)
+
+ this.router.navigate(['/videos/watch', video.uuid])
+ },
- err => {
- 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 () {
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>
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',
private notifier: Notifier,
private videoService: VideoService,
private loadingBar: LoadingBarService,
- private videoCaptionService: VideoCaptionService
+ private videoCaptionService: VideoCaptionService,
+ private liveVideoService: LiveVideoService
) {
super()
}
}
// 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 => {
this.video.patch(this.form.value)
+ const liveVideoUpdate: LiveVideoUpdate = {
+ saveReplay: this.form.value.saveReplay
+ }
+
this.loadingBar.useRef().start()
this.isUpdatingVideo = true
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(
() => {
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 }))
)
}
+++ /dev/null
-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`
- }
-}
<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>
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'
VideoCommentComponent,
TimestampRouteTransformerDirective,
- VideoDurationPipe,
TimestampRouteTransformerDirective
],
--- /dev/null
+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`
+ }
+}
export * from './bytes.pipe'
+export * from './duration-formatter.pipe'
export * from './from-now.pipe'
export * from './infinite-scroller.directive'
export * from './number-formatter.pipe'
} 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'
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'
FromNowPipe,
NumberFormatterPipe,
BytesPipe,
+ DurationFormatterPipe,
+
InfiniteScrollerDirective,
PeerTubeTemplateDirective,
FromNowPipe,
BytesPipe,
NumberFormatterPipe,
+ DurationFormatterPipe,
InfiniteScrollerDirective,
PeerTubeTemplateDirective,
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()
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)))
.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)))
+ }
}
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'
liveRouter.get('/live/:videoId',
authenticate,
asyncMiddleware(videoLiveGetValidator),
- asyncRetryTransactionMiddleware(getVideoLive)
+ asyncRetryTransactionMiddleware(getLiveVideo)
+)
+
+liveRouter.put('/live/:videoId',
+ authenticate,
+ asyncMiddleware(videoLiveGetValidator),
+ videoLiveUpdateValidator,
+ asyncRetryTransactionMiddleware(updateLiveVideo)
)
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
-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)
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
+ })
}
})
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 {
getVideoFileFPS,
computeResolutionsToTranscode,
audio,
+ hlsPlaylistToFragmentedMP4,
getVideoFileBitrate,
canDoQuickTranscode
}
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)
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
}
// ---------------------------------------------------------------------------
+
+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
+}
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) {
.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'
}
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 { retryTransactionWrapper } from '../../../helpers/database-utils'
+import { computeResolutionsToTranscode } from '../../../helpers/ffmpeg-utils'
import { logger } from '../../../helpers/logger'
+import { CONFIG } from '../../../initializers/config'
+import { sequelizeTypescript } from '../../../initializers/database'
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 { CONFIG } from '../../../initializers/config'
-import { MVideoFullLight, MVideoUUID, MVideoWithFile } from '@server/types/models'
+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
}
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') {
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'
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)
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
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)
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'),
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 })
.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
}
])
+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
}
toFormattedJSON (): LiveVideo {
return {
rtmpUrl: WEBSERVER.RTMP_URL,
- streamKey: this.streamKey
+ streamKey: this.streamKey,
+ saveReplay: this.saveReplay
}
}
}
+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'
--- /dev/null
+import { VideoCreate } from '../video-create.model'
+
+export interface LiveVideoCreate extends VideoCreate {
+ saveReplay?: boolean
+}
--- /dev/null
+export interface LiveVideoUpdate {
+ saveReplay?: boolean
+}
export interface LiveVideo {
rtmpUrl: string
streamKey: string
+ saveReplay: boolean
}