aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--server/lib/job-queue/handlers/video-live-ending.ts159
-rw-r--r--server/lib/live/live-manager.ts69
-rw-r--r--server/lib/live/live-utils.ts4
-rw-r--r--server/lib/live/shared/muxing-session.ts33
-rw-r--r--server/lib/paths.ts7
-rw-r--r--server/lib/transcoding/transcoding.ts10
-rw-r--r--server/lib/video-blacklist.ts3
-rw-r--r--server/middlewares/validators/videos/video-live.ts6
-rw-r--r--server/models/video/video-live.ts2
-rw-r--r--server/tests/api/check-params/live.ts6
-rw-r--r--server/tests/api/live/live-constraints.ts4
-rw-r--r--server/tests/api/live/live-permanent.ts5
-rw-r--r--server/tests/api/live/live-save-replay.ts155
-rw-r--r--server/tests/api/live/live.ts4
-rw-r--r--server/tests/api/object-storage/live.ts91
-rw-r--r--server/tests/shared/live.ts10
-rw-r--r--shared/models/server/job.model.ts3
-rw-r--r--shared/models/videos/live/live-video-create.model.ts5
-rw-r--r--shared/server-commands/videos/live-command.ts2
-rw-r--r--shared/server-commands/videos/live.ts23
20 files changed, 426 insertions, 175 deletions
diff --git a/server/lib/job-queue/handlers/video-live-ending.ts b/server/lib/job-queue/handlers/video-live-ending.ts
index f4de4b47c..1e290338c 100644
--- a/server/lib/job-queue/handlers/video-live-ending.ts
+++ b/server/lib/job-queue/handlers/video-live-ending.ts
@@ -1,25 +1,33 @@
1import { Job } from 'bull' 1import { Job } from 'bull'
2import { pathExists, readdir, remove } from 'fs-extra' 2import { pathExists, readdir, remove } from 'fs-extra'
3import { join } from 'path' 3import { join } from 'path'
4import { ffprobePromise, getAudioStream, getVideoStreamDuration, getVideoStreamDimensionsInfo } from '@server/helpers/ffmpeg' 4import { ffprobePromise, getAudioStream, getVideoStreamDimensionsInfo, getVideoStreamDuration } from '@server/helpers/ffmpeg'
5import { VIDEO_LIVE } from '@server/initializers/constants' 5import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url'
6import { buildConcatenatedName, cleanupLive, LiveSegmentShaStore } from '@server/lib/live' 6import { federateVideoIfNeeded } from '@server/lib/activitypub/videos'
7import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getLiveDirectory } from '@server/lib/paths' 7import { cleanupLive, LiveSegmentShaStore } from '@server/lib/live'
8import {
9 generateHLSMasterPlaylistFilename,
10 generateHlsSha256SegmentsFilename,
11 getLiveDirectory,
12 getLiveReplayBaseDirectory
13} from '@server/lib/paths'
8import { generateVideoMiniature } from '@server/lib/thumbnail' 14import { generateVideoMiniature } from '@server/lib/thumbnail'
9import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/transcoding' 15import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/transcoding'
10import { VideoPathManager } from '@server/lib/video-path-manager'
11import { moveToNextState } from '@server/lib/video-state' 16import { moveToNextState } from '@server/lib/video-state'
12import { VideoModel } from '@server/models/video/video' 17import { VideoModel } from '@server/models/video/video'
13import { VideoFileModel } from '@server/models/video/video-file' 18import { VideoFileModel } from '@server/models/video/video-file'
14import { VideoLiveModel } from '@server/models/video/video-live' 19import { VideoLiveModel } from '@server/models/video/video-live'
15import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist' 20import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
16import { MStreamingPlaylist, MVideo, MVideoLive } from '@server/types/models' 21import { MVideo, MVideoLive, MVideoWithAllFiles } from '@server/types/models'
17import { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@shared/models' 22import { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@shared/models'
18import { logger } from '../../../helpers/logger' 23import { logger } from '../../../helpers/logger'
24import { VideoBlacklistModel } from '@server/models/video/video-blacklist'
19 25
20async function processVideoLiveEnding (job: Job) { 26async function processVideoLiveEnding (job: Job) {
21 const payload = job.data as VideoLiveEndingPayload 27 const payload = job.data as VideoLiveEndingPayload
22 28
29 logger.info('Processing video live ending for %s.', payload.videoId, { payload })
30
23 function logError () { 31 function logError () {
24 logger.warn('Video live %d does not exist anymore. Cannot process live ending.', payload.videoId) 32 logger.warn('Video live %d does not exist anymore. Cannot process live ending.', payload.videoId)
25 } 33 }
@@ -32,19 +40,19 @@ async function processVideoLiveEnding (job: Job) {
32 return 40 return
33 } 41 }
34 42
35 const streamingPlaylist = await VideoStreamingPlaylistModel.loadHLSPlaylistByVideo(video.id)
36 if (!streamingPlaylist) {
37 logError()
38 return
39 }
40
41 LiveSegmentShaStore.Instance.cleanupShaSegments(video.uuid) 43 LiveSegmentShaStore.Instance.cleanupShaSegments(video.uuid)
42 44
43 if (live.saveReplay !== true) { 45 if (live.saveReplay !== true) {
44 return cleanupLive(video, streamingPlaylist) 46 return cleanupLiveAndFederate(video)
45 } 47 }
46 48
47 return saveLive(video, live, streamingPlaylist) 49 if (live.permanentLive) {
50 await saveReplayToExternalVideo(video, payload.publishedAt, payload.replayDirectory)
51
52 return cleanupLiveAndFederate(video)
53 }
54
55 return replaceLiveByReplay(video, live, payload.replayDirectory)
48} 56}
49 57
50// --------------------------------------------------------------------------- 58// ---------------------------------------------------------------------------
@@ -55,22 +63,66 @@ export {
55 63
56// --------------------------------------------------------------------------- 64// ---------------------------------------------------------------------------
57 65
58async function saveLive (video: MVideo, live: MVideoLive, streamingPlaylist: MStreamingPlaylist) { 66async function saveReplayToExternalVideo (liveVideo: MVideo, publishedAt: string, replayDirectory: string) {
59 const replayDirectory = VideoPathManager.Instance.getFSHLSOutputPath(video, VIDEO_LIVE.REPLAY_DIRECTORY) 67 await cleanupTMPLiveFiles(getLiveDirectory(liveVideo))
68
69 const video = new VideoModel({
70 name: `${liveVideo.name} - ${new Date(publishedAt).toLocaleString()}`,
71 isLive: false,
72 state: VideoState.TO_TRANSCODE,
73 duration: 0,
74
75 remote: liveVideo.remote,
76 category: liveVideo.category,
77 licence: liveVideo.licence,
78 language: liveVideo.language,
79 commentsEnabled: liveVideo.commentsEnabled,
80 downloadEnabled: liveVideo.downloadEnabled,
81 waitTranscoding: liveVideo.waitTranscoding,
82 nsfw: liveVideo.nsfw,
83 description: liveVideo.description,
84 support: liveVideo.support,
85 privacy: liveVideo.privacy,
86 channelId: liveVideo.channelId
87 }) as MVideoWithAllFiles
88
89 video.Thumbnails = []
90 video.VideoFiles = []
91 video.VideoStreamingPlaylists = []
92
93 video.url = getLocalVideoActivityPubUrl(video)
94
95 await video.save()
96
97 // If live is blacklisted, also blacklist the replay
98 const blacklist = await VideoBlacklistModel.loadByVideoId(liveVideo.id)
99 if (blacklist) {
100 await VideoBlacklistModel.create({
101 videoId: video.id,
102 unfederated: blacklist.unfederated,
103 reason: blacklist.reason,
104 type: blacklist.type
105 })
106 }
107
108 await assignReplaysToVideo(video, replayDirectory)
60 109
61 const rootFiles = await readdir(getLiveDirectory(video)) 110 await remove(replayDirectory)
111
112 for (const type of [ ThumbnailType.MINIATURE, ThumbnailType.PREVIEW ]) {
113 const image = await generateVideoMiniature({ video, videoFile: video.getMaxQualityFile(), type })
114 await video.addAndSaveThumbnail(image)
115 }
62 116
63 const playlistFiles = rootFiles.filter(file => { 117 await moveToNextState({ video, isNewVideo: true })
64 return file.endsWith('.m3u8') && file !== streamingPlaylist.playlistFilename 118}
65 })
66 119
120async function replaceLiveByReplay (video: MVideo, live: MVideoLive, replayDirectory: string) {
67 await cleanupTMPLiveFiles(getLiveDirectory(video)) 121 await cleanupTMPLiveFiles(getLiveDirectory(video))
68 122
69 await live.destroy() 123 await live.destroy()
70 124
71 video.isLive = false 125 video.isLive = false
72 // Reinit views
73 video.views = 0
74 video.state = VideoState.TO_TRANSCODE 126 video.state = VideoState.TO_TRANSCODE
75 127
76 await video.save() 128 await video.save()
@@ -87,10 +139,38 @@ async function saveLive (video: MVideo, live: MVideoLive, streamingPlaylist: MSt
87 hlsPlaylist.segmentsSha256Filename = generateHlsSha256SegmentsFilename() 139 hlsPlaylist.segmentsSha256Filename = generateHlsSha256SegmentsFilename()
88 await hlsPlaylist.save() 140 await hlsPlaylist.save()
89 141
142 await assignReplaysToVideo(videoWithFiles, replayDirectory)
143
144 await remove(getLiveReplayBaseDirectory(videoWithFiles))
145
146 // Regenerate the thumbnail & preview?
147 if (videoWithFiles.getMiniature().automaticallyGenerated === true) {
148 const miniature = await generateVideoMiniature({
149 video: videoWithFiles,
150 videoFile: videoWithFiles.getMaxQualityFile(),
151 type: ThumbnailType.MINIATURE
152 })
153 await video.addAndSaveThumbnail(miniature)
154 }
155
156 if (videoWithFiles.getPreview().automaticallyGenerated === true) {
157 const preview = await generateVideoMiniature({
158 video: videoWithFiles,
159 videoFile: videoWithFiles.getMaxQualityFile(),
160 type: ThumbnailType.PREVIEW
161 })
162 await video.addAndSaveThumbnail(preview)
163 }
164
165 await moveToNextState({ video: videoWithFiles, isNewVideo: false })
166}
167
168async function assignReplaysToVideo (video: MVideo, replayDirectory: string) {
90 let durationDone = false 169 let durationDone = false
91 170
92 for (const playlistFile of playlistFiles) { 171 const concatenatedTsFiles = await readdir(replayDirectory)
93 const concatenatedTsFile = buildConcatenatedName(playlistFile) 172
173 for (const concatenatedTsFile of concatenatedTsFiles) {
94 const concatenatedTsFilePath = join(replayDirectory, concatenatedTsFile) 174 const concatenatedTsFilePath = join(replayDirectory, concatenatedTsFile)
95 175
96 const probe = await ffprobePromise(concatenatedTsFilePath) 176 const probe = await ffprobePromise(concatenatedTsFilePath)
@@ -99,7 +179,7 @@ async function saveLive (video: MVideo, live: MVideoLive, streamingPlaylist: MSt
99 const { resolution, isPortraitMode } = await getVideoStreamDimensionsInfo(concatenatedTsFilePath, probe) 179 const { resolution, isPortraitMode } = await getVideoStreamDimensionsInfo(concatenatedTsFilePath, probe)
100 180
101 const { resolutionPlaylistPath: outputPath } = await generateHlsPlaylistResolutionFromTS({ 181 const { resolutionPlaylistPath: outputPath } = await generateHlsPlaylistResolutionFromTS({
102 video: videoWithFiles, 182 video,
103 concatenatedTsFilePath, 183 concatenatedTsFilePath,
104 resolution, 184 resolution,
105 isPortraitMode, 185 isPortraitMode,
@@ -107,33 +187,22 @@ async function saveLive (video: MVideo, live: MVideoLive, streamingPlaylist: MSt
107 }) 187 })
108 188
109 if (!durationDone) { 189 if (!durationDone) {
110 videoWithFiles.duration = await getVideoStreamDuration(outputPath) 190 video.duration = await getVideoStreamDuration(outputPath)
111 await videoWithFiles.save() 191 await video.save()
112 192
113 durationDone = true 193 durationDone = true
114 } 194 }
115 } 195 }
116 196
117 await remove(replayDirectory) 197 return video
118 198}
119 // Regenerate the thumbnail & preview?
120 if (videoWithFiles.getMiniature().automaticallyGenerated === true) {
121 await generateVideoMiniature({
122 video: videoWithFiles,
123 videoFile: videoWithFiles.getMaxQualityFile(),
124 type: ThumbnailType.MINIATURE
125 })
126 }
127 199
128 if (videoWithFiles.getPreview().automaticallyGenerated === true) { 200async function cleanupLiveAndFederate (video: MVideo) {
129 await generateVideoMiniature({ 201 const streamingPlaylist = await VideoStreamingPlaylistModel.loadHLSPlaylistByVideo(video.id)
130 video: videoWithFiles, 202 await cleanupLive(video, streamingPlaylist)
131 videoFile: videoWithFiles.getMaxQualityFile(),
132 type: ThumbnailType.PREVIEW
133 })
134 }
135 203
136 await moveToNextState({ video: videoWithFiles, isNewVideo: false }) 204 const fullVideo = await VideoModel.loadAndPopulateAccountAndServerAndTags(video.id)
205 return federateVideoIfNeeded(fullVideo, false, undefined)
137} 206}
138 207
139async function cleanupTMPLiveFiles (hlsDirectory: string) { 208async function cleanupTMPLiveFiles (hlsDirectory: string) {
diff --git a/server/lib/live/live-manager.ts b/server/lib/live/live-manager.ts
index 920d3a5ec..5ffe41ee3 100644
--- a/server/lib/live/live-manager.ts
+++ b/server/lib/live/live-manager.ts
@@ -1,6 +1,7 @@
1 1
2import { readFile } from 'fs-extra' 2import { readdir, readFile } from 'fs-extra'
3import { createServer, Server } from 'net' 3import { createServer, Server } from 'net'
4import { join } from 'path'
4import { createServer as createServerTLS, Server as ServerTLS } from 'tls' 5import { createServer as createServerTLS, Server as ServerTLS } from 'tls'
5import { 6import {
6 computeLowerResolutionsToTranscode, 7 computeLowerResolutionsToTranscode,
@@ -18,10 +19,11 @@ import { VideoModel } from '@server/models/video/video'
18import { VideoLiveModel } from '@server/models/video/video-live' 19import { VideoLiveModel } from '@server/models/video/video-live'
19import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist' 20import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
20import { MStreamingPlaylistVideo, MVideo, MVideoLiveVideo } from '@server/types/models' 21import { MStreamingPlaylistVideo, MVideo, MVideoLiveVideo } from '@server/types/models'
22import { wait } from '@shared/core-utils'
21import { VideoState, VideoStreamingPlaylistType } from '@shared/models' 23import { VideoState, VideoStreamingPlaylistType } from '@shared/models'
22import { federateVideoIfNeeded } from '../activitypub/videos' 24import { federateVideoIfNeeded } from '../activitypub/videos'
23import { JobQueue } from '../job-queue' 25import { JobQueue } from '../job-queue'
24import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename } from '../paths' 26import { generateHLSMasterPlaylistFilename, generateHlsSha256SegmentsFilename, getLiveReplayBaseDirectory } from '../paths'
25import { PeerTubeSocket } from '../peertube-socket' 27import { PeerTubeSocket } from '../peertube-socket'
26import { LiveQuotaStore } from './live-quota-store' 28import { LiveQuotaStore } from './live-quota-store'
27import { LiveSegmentShaStore } from './live-segment-sha-store' 29import { LiveSegmentShaStore } from './live-segment-sha-store'
@@ -322,7 +324,7 @@ class LiveManager {
322 324
323 muxingSession.destroy() 325 muxingSession.destroy()
324 326
325 return this.onAfterMuxingCleanup(videoId) 327 return this.onAfterMuxingCleanup({ videoId })
326 .catch(err => logger.error('Error in end transmuxing.', { err, ...localLTags })) 328 .catch(err => logger.error('Error in end transmuxing.', { err, ...localLTags }))
327 }) 329 })
328 330
@@ -349,12 +351,15 @@ class LiveManager {
349 351
350 live.Video = video 352 live.Video = video
351 353
352 setTimeout(() => { 354 await wait(getLiveSegmentTime(live.latencyMode) * 1000 * VIDEO_LIVE.EDGE_LIVE_DELAY_SEGMENTS_NOTIFICATION)
353 federateVideoIfNeeded(video, false)
354 .catch(err => logger.error('Cannot federate live video %s.', video.url, { err, ...localLTags }))
355 355
356 PeerTubeSocket.Instance.sendVideoLiveNewState(video) 356 try {
357 }, getLiveSegmentTime(live.latencyMode) * 1000 * VIDEO_LIVE.EDGE_LIVE_DELAY_SEGMENTS_NOTIFICATION) 357 await federateVideoIfNeeded(video, false)
358 } catch (err) {
359 logger.error('Cannot federate live video %s.', video.url, { err, ...localLTags })
360 }
361
362 PeerTubeSocket.Instance.sendVideoLiveNewState(video)
358 } catch (err) { 363 } catch (err) {
359 logger.error('Cannot save/federate live video %d.', videoId, { err, ...localLTags }) 364 logger.error('Cannot save/federate live video %d.', videoId, { err, ...localLTags })
360 } 365 }
@@ -364,25 +369,32 @@ class LiveManager {
364 this.videoSessions.delete(videoId) 369 this.videoSessions.delete(videoId)
365 } 370 }
366 371
367 private async onAfterMuxingCleanup (videoUUID: string, cleanupNow = false) { 372 private async onAfterMuxingCleanup (options: {
373 videoId: number | string
374 cleanupNow?: boolean // Default false
375 }) {
376 const { videoId, cleanupNow = false } = options
377
368 try { 378 try {
369 const fullVideo = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoUUID) 379 const fullVideo = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId)
370 if (!fullVideo) return 380 if (!fullVideo) return
371 381
372 const live = await VideoLiveModel.loadByVideoId(fullVideo.id) 382 const live = await VideoLiveModel.loadByVideoId(fullVideo.id)
373 383
374 if (!live.permanentLive) { 384 JobQueue.Instance.createJob({
375 JobQueue.Instance.createJob({ 385 type: 'video-live-ending',
376 type: 'video-live-ending', 386 payload: {
377 payload: { 387 videoId: fullVideo.id,
378 videoId: fullVideo.id 388 replayDirectory: live.saveReplay
379 } 389 ? await this.findReplayDirectory(fullVideo)
380 }, { delay: cleanupNow ? 0 : VIDEO_LIVE.CLEANUP_DELAY }) 390 : undefined,
381 391 publishedAt: fullVideo.publishedAt.toISOString()
382 fullVideo.state = VideoState.LIVE_ENDED 392 }
383 } else { 393 }, { delay: cleanupNow ? 0 : VIDEO_LIVE.CLEANUP_DELAY })
384 fullVideo.state = VideoState.WAITING_FOR_LIVE 394
385 } 395 fullVideo.state = live.permanentLive
396 ? VideoState.WAITING_FOR_LIVE
397 : VideoState.LIVE_ENDED
386 398
387 await fullVideo.save() 399 await fullVideo.save()
388 400
@@ -390,7 +402,7 @@ class LiveManager {
390 402
391 await federateVideoIfNeeded(fullVideo, false) 403 await federateVideoIfNeeded(fullVideo, false)
392 } catch (err) { 404 } catch (err) {
393 logger.error('Cannot save/federate new video state of live streaming of video %d.', videoUUID, { err, ...lTags(videoUUID) }) 405 logger.error('Cannot save/federate new video state of live streaming of video %d.', videoId, { err, ...lTags(videoId + '') })
394 } 406 }
395 } 407 }
396 408
@@ -398,10 +410,19 @@ class LiveManager {
398 const videoUUIDs = await VideoModel.listPublishedLiveUUIDs() 410 const videoUUIDs = await VideoModel.listPublishedLiveUUIDs()
399 411
400 for (const uuid of videoUUIDs) { 412 for (const uuid of videoUUIDs) {
401 await this.onAfterMuxingCleanup(uuid, true) 413 await this.onAfterMuxingCleanup({ videoId: uuid, cleanupNow: true })
402 } 414 }
403 } 415 }
404 416
417 private async findReplayDirectory (video: MVideo) {
418 const directory = getLiveReplayBaseDirectory(video)
419 const files = await readdir(directory)
420
421 if (files.length === 0) return undefined
422
423 return join(directory, files.sort().reverse()[0])
424 }
425
405 private buildAllResolutionsToTranscode (originResolution: number) { 426 private buildAllResolutionsToTranscode (originResolution: number) {
406 const resolutionsEnabled = CONFIG.LIVE.TRANSCODING.ENABLED 427 const resolutionsEnabled = CONFIG.LIVE.TRANSCODING.ENABLED
407 ? computeLowerResolutionsToTranscode(originResolution, 'live') 428 ? computeLowerResolutionsToTranscode(originResolution, 'live')
diff --git a/server/lib/live/live-utils.ts b/server/lib/live/live-utils.ts
index 3bf723b98..46c7fd2f8 100644
--- a/server/lib/live/live-utils.ts
+++ b/server/lib/live/live-utils.ts
@@ -9,12 +9,12 @@ function buildConcatenatedName (segmentOrPlaylistPath: string) {
9 return 'concat-' + num[1] + '.ts' 9 return 'concat-' + num[1] + '.ts'
10} 10}
11 11
12async function cleanupLive (video: MVideo, streamingPlaylist: MStreamingPlaylist) { 12async function cleanupLive (video: MVideo, streamingPlaylist?: MStreamingPlaylist) {
13 const hlsDirectory = getLiveDirectory(video) 13 const hlsDirectory = getLiveDirectory(video)
14 14
15 await remove(hlsDirectory) 15 await remove(hlsDirectory)
16 16
17 await streamingPlaylist.destroy() 17 if (streamingPlaylist) await streamingPlaylist.destroy()
18} 18}
19 19
20export { 20export {
diff --git a/server/lib/live/shared/muxing-session.ts b/server/lib/live/shared/muxing-session.ts
index a703f5b5f..588ee8749 100644
--- a/server/lib/live/shared/muxing-session.ts
+++ b/server/lib/live/shared/muxing-session.ts
@@ -11,7 +11,7 @@ import { CONFIG } from '@server/initializers/config'
11import { MEMOIZE_TTL, VIDEO_LIVE } from '@server/initializers/constants' 11import { MEMOIZE_TTL, VIDEO_LIVE } from '@server/initializers/constants'
12import { VideoFileModel } from '@server/models/video/video-file' 12import { VideoFileModel } from '@server/models/video/video-file'
13import { MStreamingPlaylistVideo, MUserId, MVideoLiveVideo } from '@server/types/models' 13import { MStreamingPlaylistVideo, MUserId, MVideoLiveVideo } from '@server/types/models'
14import { getLiveDirectory } from '../../paths' 14import { getLiveDirectory, getLiveReplayBaseDirectory } from '../../paths'
15import { VideoTranscodingProfilesManager } from '../../transcoding/default-transcoding-profiles' 15import { VideoTranscodingProfilesManager } from '../../transcoding/default-transcoding-profiles'
16import { isAbleToUploadVideo } from '../../user' 16import { isAbleToUploadVideo } from '../../user'
17import { LiveQuotaStore } from '../live-quota-store' 17import { LiveQuotaStore } from '../live-quota-store'
@@ -63,6 +63,9 @@ class MuxingSession extends EventEmitter {
63 private readonly videoUUID: string 63 private readonly videoUUID: string
64 private readonly saveReplay: boolean 64 private readonly saveReplay: boolean
65 65
66 private readonly outDirectory: string
67 private readonly replayDirectory: string
68
66 private readonly lTags: LoggerTagsFn 69 private readonly lTags: LoggerTagsFn
67 70
68 private segmentsToProcessPerPlaylist: { [playlistId: string]: string[] } = {} 71 private segmentsToProcessPerPlaylist: { [playlistId: string]: string[] } = {}
@@ -110,19 +113,22 @@ class MuxingSession extends EventEmitter {
110 113
111 this.saveReplay = this.videoLive.saveReplay 114 this.saveReplay = this.videoLive.saveReplay
112 115
116 this.outDirectory = getLiveDirectory(this.videoLive.Video)
117 this.replayDirectory = join(getLiveReplayBaseDirectory(this.videoLive.Video), new Date().toISOString())
118
113 this.lTags = loggerTagsFactory('live', this.sessionId, this.videoUUID) 119 this.lTags = loggerTagsFactory('live', this.sessionId, this.videoUUID)
114 } 120 }
115 121
116 async runMuxing () { 122 async runMuxing () {
117 this.createFiles() 123 this.createFiles()
118 124
119 const outPath = await this.prepareDirectories() 125 await this.prepareDirectories()
120 126
121 this.ffmpegCommand = CONFIG.LIVE.TRANSCODING.ENABLED 127 this.ffmpegCommand = CONFIG.LIVE.TRANSCODING.ENABLED
122 ? await getLiveTranscodingCommand({ 128 ? await getLiveTranscodingCommand({
123 inputUrl: this.inputUrl, 129 inputUrl: this.inputUrl,
124 130
125 outPath, 131 outPath: this.outDirectory,
126 masterPlaylistName: this.streamingPlaylist.playlistFilename, 132 masterPlaylistName: this.streamingPlaylist.playlistFilename,
127 133
128 latencyMode: this.videoLive.latencyMode, 134 latencyMode: this.videoLive.latencyMode,
@@ -137,15 +143,15 @@ class MuxingSession extends EventEmitter {
137 }) 143 })
138 : getLiveMuxingCommand({ 144 : getLiveMuxingCommand({
139 inputUrl: this.inputUrl, 145 inputUrl: this.inputUrl,
140 outPath, 146 outPath: this.outDirectory,
141 masterPlaylistName: this.streamingPlaylist.playlistFilename, 147 masterPlaylistName: this.streamingPlaylist.playlistFilename,
142 latencyMode: this.videoLive.latencyMode 148 latencyMode: this.videoLive.latencyMode
143 }) 149 })
144 150
145 logger.info('Running live muxing/transcoding for %s.', this.videoUUID, this.lTags()) 151 logger.info('Running live muxing/transcoding for %s.', this.videoUUID, this.lTags())
146 152
147 this.watchTSFiles(outPath) 153 this.watchTSFiles(this.outDirectory)
148 this.watchMasterFile(outPath) 154 this.watchMasterFile(this.outDirectory)
149 155
150 let ffmpegShellCommand: string 156 let ffmpegShellCommand: string
151 this.ffmpegCommand.on('start', cmdline => { 157 this.ffmpegCommand.on('start', cmdline => {
@@ -155,10 +161,10 @@ class MuxingSession extends EventEmitter {
155 }) 161 })
156 162
157 this.ffmpegCommand.on('error', (err, stdout, stderr) => { 163 this.ffmpegCommand.on('error', (err, stdout, stderr) => {
158 this.onFFmpegError({ err, stdout, stderr, outPath, ffmpegShellCommand }) 164 this.onFFmpegError({ err, stdout, stderr, outPath: this.outDirectory, ffmpegShellCommand })
159 }) 165 })
160 166
161 this.ffmpegCommand.on('end', () => this.onFFmpegEnded(outPath)) 167 this.ffmpegCommand.on('end', () => this.onFFmpegEnded(this.outDirectory))
162 168
163 this.ffmpegCommand.run() 169 this.ffmpegCommand.run()
164 } 170 }
@@ -304,16 +310,11 @@ class MuxingSession extends EventEmitter {
304 } 310 }
305 311
306 private async prepareDirectories () { 312 private async prepareDirectories () {
307 const outPath = getLiveDirectory(this.videoLive.Video) 313 await ensureDir(this.outDirectory)
308 await ensureDir(outPath)
309
310 const replayDirectory = join(outPath, VIDEO_LIVE.REPLAY_DIRECTORY)
311 314
312 if (this.videoLive.saveReplay === true) { 315 if (this.videoLive.saveReplay === true) {
313 await ensureDir(replayDirectory) 316 await ensureDir(this.replayDirectory)
314 } 317 }
315
316 return outPath
317 } 318 }
318 319
319 private isDurationConstraintValid (streamingStartTime: number) { 320 private isDurationConstraintValid (streamingStartTime: number) {
@@ -364,7 +365,7 @@ class MuxingSession extends EventEmitter {
364 365
365 private async addSegmentToReplay (hlsVideoPath: string, segmentPath: string) { 366 private async addSegmentToReplay (hlsVideoPath: string, segmentPath: string) {
366 const segmentName = basename(segmentPath) 367 const segmentName = basename(segmentPath)
367 const dest = join(hlsVideoPath, VIDEO_LIVE.REPLAY_DIRECTORY, buildConcatenatedName(segmentName)) 368 const dest = join(this.replayDirectory, buildConcatenatedName(segmentName))
368 369
369 try { 370 try {
370 const data = await readFile(segmentPath) 371 const data = await readFile(segmentPath)
diff --git a/server/lib/paths.ts b/server/lib/paths.ts
index 5a85bea42..b29854700 100644
--- a/server/lib/paths.ts
+++ b/server/lib/paths.ts
@@ -1,6 +1,6 @@
1import { join } from 'path' 1import { join } from 'path'
2import { CONFIG } from '@server/initializers/config' 2import { CONFIG } from '@server/initializers/config'
3import { HLS_REDUNDANCY_DIRECTORY, HLS_STREAMING_PLAYLIST_DIRECTORY } from '@server/initializers/constants' 3import { HLS_REDUNDANCY_DIRECTORY, HLS_STREAMING_PLAYLIST_DIRECTORY, VIDEO_LIVE } from '@server/initializers/constants'
4import { isStreamingPlaylist, MStreamingPlaylistVideo, MVideo, MVideoFile, MVideoUUID } from '@server/types/models' 4import { isStreamingPlaylist, MStreamingPlaylistVideo, MVideo, MVideoFile, MVideoUUID } from '@server/types/models'
5import { removeFragmentedMP4Ext } from '@shared/core-utils' 5import { removeFragmentedMP4Ext } from '@shared/core-utils'
6import { buildUUID } from '@shared/extra-utils' 6import { buildUUID } from '@shared/extra-utils'
@@ -21,6 +21,10 @@ function getLiveDirectory (video: MVideoUUID) {
21 return getHLSDirectory(video) 21 return getHLSDirectory(video)
22} 22}
23 23
24function getLiveReplayBaseDirectory (video: MVideoUUID) {
25 return join(getLiveDirectory(video), VIDEO_LIVE.REPLAY_DIRECTORY)
26}
27
24function getHLSDirectory (video: MVideoUUID) { 28function getHLSDirectory (video: MVideoUUID) {
25 return join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid) 29 return join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid)
26} 30}
@@ -74,6 +78,7 @@ export {
74 78
75 getHLSDirectory, 79 getHLSDirectory,
76 getLiveDirectory, 80 getLiveDirectory,
81 getLiveReplayBaseDirectory,
77 getHLSRedundancyDirectory, 82 getHLSRedundancyDirectory,
78 83
79 generateHLSMasterPlaylistFilename, 84 generateHLSMasterPlaylistFilename,
diff --git a/server/lib/transcoding/transcoding.ts b/server/lib/transcoding/transcoding.ts
index d55364e25..9a15f8613 100644
--- a/server/lib/transcoding/transcoding.ts
+++ b/server/lib/transcoding/transcoding.ts
@@ -3,13 +3,13 @@ import { copyFile, ensureDir, move, remove, stat } from 'fs-extra'
3import { basename, extname as extnameUtil, join } from 'path' 3import { basename, extname as extnameUtil, join } from 'path'
4import { toEven } from '@server/helpers/core-utils' 4import { toEven } from '@server/helpers/core-utils'
5import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' 5import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
6import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoFullLight } from '@server/types/models' 6import { MStreamingPlaylistFilesVideo, MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
7import { VideoResolution, VideoStorage } from '../../../shared/models/videos' 7import { VideoResolution, VideoStorage } from '../../../shared/models/videos'
8import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type' 8import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
9import { 9import {
10 buildFileMetadata,
10 canDoQuickTranscode, 11 canDoQuickTranscode,
11 getVideoStreamDuration, 12 getVideoStreamDuration,
12 buildFileMetadata,
13 getVideoStreamFPS, 13 getVideoStreamFPS,
14 transcodeVOD, 14 transcodeVOD,
15 TranscodeVODOptions, 15 TranscodeVODOptions,
@@ -191,7 +191,7 @@ function mergeAudioVideofile (video: MVideoFullLight, resolution: VideoResolutio
191 191
192// Concat TS segments from a live video to a fragmented mp4 HLS playlist 192// Concat TS segments from a live video to a fragmented mp4 HLS playlist
193async function generateHlsPlaylistResolutionFromTS (options: { 193async function generateHlsPlaylistResolutionFromTS (options: {
194 video: MVideoFullLight 194 video: MVideo
195 concatenatedTsFilePath: string 195 concatenatedTsFilePath: string
196 resolution: VideoResolution 196 resolution: VideoResolution
197 isPortraitMode: boolean 197 isPortraitMode: boolean
@@ -209,7 +209,7 @@ async function generateHlsPlaylistResolutionFromTS (options: {
209 209
210// Generate an HLS playlist from an input file, and update the master playlist 210// Generate an HLS playlist from an input file, and update the master playlist
211function generateHlsPlaylistResolution (options: { 211function generateHlsPlaylistResolution (options: {
212 video: MVideoFullLight 212 video: MVideo
213 videoInputPath: string 213 videoInputPath: string
214 resolution: VideoResolution 214 resolution: VideoResolution
215 copyCodecs: boolean 215 copyCodecs: boolean
@@ -265,7 +265,7 @@ async function onWebTorrentVideoFileTranscoding (
265 265
266async function generateHlsPlaylistCommon (options: { 266async function generateHlsPlaylistCommon (options: {
267 type: 'hls' | 'hls-from-ts' 267 type: 'hls' | 'hls-from-ts'
268 video: MVideoFullLight 268 video: MVideo
269 inputPath: string 269 inputPath: string
270 resolution: VideoResolution 270 resolution: VideoResolution
271 copyCodecs?: boolean 271 copyCodecs?: boolean
diff --git a/server/lib/video-blacklist.ts b/server/lib/video-blacklist.ts
index 0984c0d7a..91f44cb11 100644
--- a/server/lib/video-blacklist.ts
+++ b/server/lib/video-blacklist.ts
@@ -73,8 +73,7 @@ async function blacklistVideo (videoInstance: MVideoAccountLight, options: Video
73 unfederated: options.unfederate === true, 73 unfederated: options.unfederate === true,
74 reason: options.reason, 74 reason: options.reason,
75 type: VideoBlacklistType.MANUAL 75 type: VideoBlacklistType.MANUAL
76 } 76 })
77 )
78 blacklist.Video = videoInstance 77 blacklist.Video = videoInstance
79 78
80 if (options.unfederate === true) { 79 if (options.unfederate === true) {
diff --git a/server/middlewares/validators/videos/video-live.ts b/server/middlewares/validators/videos/video-live.ts
index 8e52c953f..b756c0bf1 100644
--- a/server/middlewares/validators/videos/video-live.ts
+++ b/server/middlewares/validators/videos/video-live.ts
@@ -118,12 +118,6 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
118 }) 118 })
119 } 119 }
120 120
121 if (body.permanentLive && body.saveReplay) {
122 cleanUpReqFiles(req)
123
124 return res.fail({ message: 'Cannot set this live as permanent while saving its replay' })
125 }
126
127 const user = res.locals.oauth.token.User 121 const user = res.locals.oauth.token.User
128 if (!await doesVideoChannelOfAccountExist(body.channelId, user, res)) return cleanUpReqFiles(req) 122 if (!await doesVideoChannelOfAccountExist(body.channelId, user, res)) return cleanUpReqFiles(req)
129 123
diff --git a/server/models/video/video-live.ts b/server/models/video/video-live.ts
index 904f712b4..96c0bf7f7 100644
--- a/server/models/video/video-live.ts
+++ b/server/models/video/video-live.ts
@@ -2,7 +2,7 @@ import { AllowNull, BelongsTo, Column, CreatedAt, DataType, DefaultScope, Foreig
2import { CONFIG } from '@server/initializers/config' 2import { CONFIG } from '@server/initializers/config'
3import { WEBSERVER } from '@server/initializers/constants' 3import { WEBSERVER } from '@server/initializers/constants'
4import { MVideoLive, MVideoLiveVideo } from '@server/types/models' 4import { MVideoLive, MVideoLiveVideo } from '@server/types/models'
5import { LiveVideo, LiveVideoLatencyMode, VideoState } from '@shared/models' 5import { LiveVideo, LiveVideoLatencyMode, VideoPrivacy, VideoState } from '@shared/models'
6import { AttributesOnly } from '@shared/typescript-utils' 6import { AttributesOnly } from '@shared/typescript-utils'
7import { VideoModel } from './video' 7import { VideoModel } from './video'
8import { VideoBlacklistModel } from './video-blacklist' 8import { VideoBlacklistModel } from './video-blacklist'
diff --git a/server/tests/api/check-params/live.ts b/server/tests/api/check-params/live.ts
index b253f5e20..2f1c1257e 100644
--- a/server/tests/api/check-params/live.ts
+++ b/server/tests/api/check-params/live.ts
@@ -212,12 +212,6 @@ describe('Test video lives API validator', function () {
212 await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches }) 212 await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches })
213 }) 213 })
214 214
215 it('Should fail with save replay and permanent live set to true', async function () {
216 const fields = { ...baseCorrectParams, saveReplay: true, permanentLive: true }
217
218 await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields })
219 })
220
221 it('Should fail with bad latency setting', async function () { 215 it('Should fail with bad latency setting', async function () {
222 const fields = { ...baseCorrectParams, latencyMode: 42 } 216 const fields = { ...baseCorrectParams, latencyMode: 42 }
223 217
diff --git a/server/tests/api/live/live-constraints.ts b/server/tests/api/live/live-constraints.ts
index 909399836..b92dc7b89 100644
--- a/server/tests/api/live/live-constraints.ts
+++ b/server/tests/api/live/live-constraints.ts
@@ -14,7 +14,7 @@ import {
14 setDefaultVideoChannel, 14 setDefaultVideoChannel,
15 waitJobs 15 waitJobs
16} from '@shared/server-commands' 16} from '@shared/server-commands'
17import { checkLiveCleanupAfterSave } from '../../shared' 17import { checkLiveCleanup } from '../../shared'
18 18
19const expect = chai.expect 19const expect = chai.expect
20 20
@@ -43,7 +43,7 @@ describe('Test live constraints', function () {
43 expect(video.duration).to.be.greaterThan(0) 43 expect(video.duration).to.be.greaterThan(0)
44 } 44 }
45 45
46 await checkLiveCleanupAfterSave(servers[0], videoId, resolutions) 46 await checkLiveCleanup(servers[0], videoId, resolutions)
47 } 47 }
48 48
49 async function waitUntilLivePublishedOnAllServers (videoId: string) { 49 async function waitUntilLivePublishedOnAllServers (videoId: string) {
diff --git a/server/tests/api/live/live-permanent.ts b/server/tests/api/live/live-permanent.ts
index 3e6fec453..a88d71dd9 100644
--- a/server/tests/api/live/live-permanent.ts
+++ b/server/tests/api/live/live-permanent.ts
@@ -121,7 +121,7 @@ describe('Permanent live', function () {
121 await waitJobs(servers) 121 await waitJobs(servers)
122 }) 122 })
123 123
124 it('Should not have cleaned up this live', async function () { 124 it('Should have cleaned up this live', async function () {
125 this.timeout(40000) 125 this.timeout(40000)
126 126
127 await wait(5000) 127 await wait(5000)
@@ -129,7 +129,8 @@ describe('Permanent live', function () {
129 129
130 for (const server of servers) { 130 for (const server of servers) {
131 const videoDetails = await server.videos.get({ id: videoUUID }) 131 const videoDetails = await server.videos.get({ id: videoUUID })
132 expect(videoDetails.streamingPlaylists).to.have.lengthOf(1) 132
133 expect(videoDetails.streamingPlaylists).to.have.lengthOf(0)
133 } 134 }
134 }) 135 })
135 136
diff --git a/server/tests/api/live/live-save-replay.ts b/server/tests/api/live/live-save-replay.ts
index 95a342b01..ba68a4287 100644
--- a/server/tests/api/live/live-save-replay.ts
+++ b/server/tests/api/live/live-save-replay.ts
@@ -3,7 +3,7 @@
3import 'mocha' 3import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { FfmpegCommand } from 'fluent-ffmpeg' 5import { FfmpegCommand } from 'fluent-ffmpeg'
6import { checkLiveCleanupAfterSave } from '@server/tests/shared' 6import { checkLiveCleanup } from '@server/tests/shared'
7import { wait } from '@shared/core-utils' 7import { wait } from '@shared/core-utils'
8import { HttpStatusCode, LiveVideoCreate, VideoPrivacy, VideoState } from '@shared/models' 8import { HttpStatusCode, LiveVideoCreate, VideoPrivacy, VideoState } from '@shared/models'
9import { 9import {
@@ -11,6 +11,7 @@ import {
11 ConfigCommand, 11 ConfigCommand,
12 createMultipleServers, 12 createMultipleServers,
13 doubleFollow, 13 doubleFollow,
14 findExternalSavedVideo,
14 PeerTubeServer, 15 PeerTubeServer,
15 setAccessTokensToServers, 16 setAccessTokensToServers,
16 setDefaultVideoChannel, 17 setDefaultVideoChannel,
@@ -18,7 +19,8 @@ import {
18 testFfmpegStreamError, 19 testFfmpegStreamError,
19 waitJobs, 20 waitJobs,
20 waitUntilLivePublishedOnAllServers, 21 waitUntilLivePublishedOnAllServers,
21 waitUntilLiveSavedOnAllServers 22 waitUntilLiveReplacedByReplayOnAllServers,
23 waitUntilLiveWaitingOnAllServers
22} from '@shared/server-commands' 24} from '@shared/server-commands'
23 25
24const expect = chai.expect 26const expect = chai.expect
@@ -28,7 +30,7 @@ describe('Save replay setting', function () {
28 let liveVideoUUID: string 30 let liveVideoUUID: string
29 let ffmpegCommand: FfmpegCommand 31 let ffmpegCommand: FfmpegCommand
30 32
31 async function createLiveWrapper (saveReplay: boolean) { 33 async function createLiveWrapper (options: { permanent: boolean, replay: boolean }) {
32 if (liveVideoUUID) { 34 if (liveVideoUUID) {
33 try { 35 try {
34 await servers[0].videos.remove({ id: liveVideoUUID }) 36 await servers[0].videos.remove({ id: liveVideoUUID })
@@ -40,7 +42,8 @@ describe('Save replay setting', function () {
40 channelId: servers[0].store.channel.id, 42 channelId: servers[0].store.channel.id,
41 privacy: VideoPrivacy.PUBLIC, 43 privacy: VideoPrivacy.PUBLIC,
42 name: 'my super live', 44 name: 'my super live',
43 saveReplay 45 saveReplay: options.replay,
46 permanentLive: options.permanent
44 } 47 }
45 48
46 const { uuid } = await servers[0].live.create({ fields: attributes }) 49 const { uuid } = await servers[0].live.create({ fields: attributes })
@@ -104,7 +107,7 @@ describe('Save replay setting', function () {
104 it('Should correctly create and federate the "waiting for stream" live', async function () { 107 it('Should correctly create and federate the "waiting for stream" live', async function () {
105 this.timeout(20000) 108 this.timeout(20000)
106 109
107 liveVideoUUID = await createLiveWrapper(false) 110 liveVideoUUID = await createLiveWrapper({ permanent: false, replay: false })
108 111
109 await waitJobs(servers) 112 await waitJobs(servers)
110 113
@@ -140,13 +143,13 @@ describe('Save replay setting', function () {
140 await checkVideoState(liveVideoUUID, VideoState.LIVE_ENDED) 143 await checkVideoState(liveVideoUUID, VideoState.LIVE_ENDED)
141 144
142 // No resolutions saved since we did not save replay 145 // No resolutions saved since we did not save replay
143 await checkLiveCleanupAfterSave(servers[0], liveVideoUUID, []) 146 await checkLiveCleanup(servers[0], liveVideoUUID, [])
144 }) 147 })
145 148
146 it('Should correctly terminate the stream on blacklist and delete the live', async function () { 149 it('Should correctly terminate the stream on blacklist and delete the live', async function () {
147 this.timeout(40000) 150 this.timeout(40000)
148 151
149 liveVideoUUID = await createLiveWrapper(false) 152 liveVideoUUID = await createLiveWrapper({ permanent: false, replay: false })
150 153
151 ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveVideoUUID }) 154 ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveVideoUUID })
152 155
@@ -169,13 +172,13 @@ describe('Save replay setting', function () {
169 172
170 await wait(5000) 173 await wait(5000)
171 await waitJobs(servers) 174 await waitJobs(servers)
172 await checkLiveCleanupAfterSave(servers[0], liveVideoUUID, []) 175 await checkLiveCleanup(servers[0], liveVideoUUID, [])
173 }) 176 })
174 177
175 it('Should correctly terminate the stream on delete and delete the video', async function () { 178 it('Should correctly terminate the stream on delete and delete the video', async function () {
176 this.timeout(40000) 179 this.timeout(40000)
177 180
178 liveVideoUUID = await createLiveWrapper(false) 181 liveVideoUUID = await createLiveWrapper({ permanent: false, replay: false })
179 182
180 ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveVideoUUID }) 183 ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveVideoUUID })
181 184
@@ -193,16 +196,16 @@ describe('Save replay setting', function () {
193 await waitJobs(servers) 196 await waitJobs(servers)
194 197
195 await checkVideosExist(liveVideoUUID, false, HttpStatusCode.NOT_FOUND_404) 198 await checkVideosExist(liveVideoUUID, false, HttpStatusCode.NOT_FOUND_404)
196 await checkLiveCleanupAfterSave(servers[0], liveVideoUUID, []) 199 await checkLiveCleanup(servers[0], liveVideoUUID, [])
197 }) 200 })
198 }) 201 })
199 202
200 describe('With save replay enabled', function () { 203 describe('With save replay enabled on non permanent live', function () {
201 204
202 it('Should correctly create and federate the "waiting for stream" live', async function () { 205 it('Should correctly create and federate the "waiting for stream" live', async function () {
203 this.timeout(20000) 206 this.timeout(20000)
204 207
205 liveVideoUUID = await createLiveWrapper(true) 208 liveVideoUUID = await createLiveWrapper({ permanent: false, replay: true })
206 209
207 await waitJobs(servers) 210 await waitJobs(servers)
208 211
@@ -227,7 +230,7 @@ describe('Save replay setting', function () {
227 230
228 await stopFfmpeg(ffmpegCommand) 231 await stopFfmpeg(ffmpegCommand)
229 232
230 await waitUntilLiveSavedOnAllServers(servers, liveVideoUUID) 233 await waitUntilLiveReplacedByReplayOnAllServers(servers, liveVideoUUID)
231 await waitJobs(servers) 234 await waitJobs(servers)
232 235
233 // Live has been transcoded 236 // Live has been transcoded
@@ -249,13 +252,13 @@ describe('Save replay setting', function () {
249 }) 252 })
250 253
251 it('Should have cleaned up the live files', async function () { 254 it('Should have cleaned up the live files', async function () {
252 await checkLiveCleanupAfterSave(servers[0], liveVideoUUID, [ 720 ]) 255 await checkLiveCleanup(servers[0], liveVideoUUID, [ 720 ])
253 }) 256 })
254 257
255 it('Should correctly terminate the stream on blacklist and blacklist the saved replay video', async function () { 258 it('Should correctly terminate the stream on blacklist and blacklist the saved replay video', async function () {
256 this.timeout(40000) 259 this.timeout(40000)
257 260
258 liveVideoUUID = await createLiveWrapper(true) 261 liveVideoUUID = await createLiveWrapper({ permanent: false, replay: true })
259 262
260 ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveVideoUUID }) 263 ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveVideoUUID })
261 await waitUntilLivePublishedOnAllServers(servers, liveVideoUUID) 264 await waitUntilLivePublishedOnAllServers(servers, liveVideoUUID)
@@ -277,13 +280,13 @@ describe('Save replay setting', function () {
277 280
278 await wait(5000) 281 await wait(5000)
279 await waitJobs(servers) 282 await waitJobs(servers)
280 await checkLiveCleanupAfterSave(servers[0], liveVideoUUID, [ 720 ]) 283 await checkLiveCleanup(servers[0], liveVideoUUID, [ 720 ])
281 }) 284 })
282 285
283 it('Should correctly terminate the stream on delete and delete the video', async function () { 286 it('Should correctly terminate the stream on delete and delete the video', async function () {
284 this.timeout(40000) 287 this.timeout(40000)
285 288
286 liveVideoUUID = await createLiveWrapper(true) 289 liveVideoUUID = await createLiveWrapper({ permanent: false, replay: true })
287 290
288 ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveVideoUUID }) 291 ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveVideoUUID })
289 await waitUntilLivePublishedOnAllServers(servers, liveVideoUUID) 292 await waitUntilLivePublishedOnAllServers(servers, liveVideoUUID)
@@ -300,7 +303,123 @@ describe('Save replay setting', function () {
300 await waitJobs(servers) 303 await waitJobs(servers)
301 304
302 await checkVideosExist(liveVideoUUID, false, HttpStatusCode.NOT_FOUND_404) 305 await checkVideosExist(liveVideoUUID, false, HttpStatusCode.NOT_FOUND_404)
303 await checkLiveCleanupAfterSave(servers[0], liveVideoUUID, []) 306 await checkLiveCleanup(servers[0], liveVideoUUID, [])
307 })
308 })
309
310 describe('With save replay enabled on permanent live', function () {
311 let lastReplayUUID: string
312
313 it('Should correctly create and federate the "waiting for stream" live', async function () {
314 this.timeout(20000)
315
316 liveVideoUUID = await createLiveWrapper({ permanent: true, replay: true })
317
318 await waitJobs(servers)
319
320 await checkVideosExist(liveVideoUUID, false, HttpStatusCode.OK_200)
321 await checkVideoState(liveVideoUUID, VideoState.WAITING_FOR_LIVE)
322 })
323
324 it('Should correctly have updated the live and federated it when streaming in the live', async function () {
325 this.timeout(20000)
326
327 ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveVideoUUID })
328 await waitUntilLivePublishedOnAllServers(servers, liveVideoUUID)
329
330 await waitJobs(servers)
331
332 await checkVideosExist(liveVideoUUID, true, HttpStatusCode.OK_200)
333 await checkVideoState(liveVideoUUID, VideoState.PUBLISHED)
334 })
335
336 it('Should correctly have saved the live and federated it after the streaming', async function () {
337 this.timeout(30000)
338
339 const liveDetails = await servers[0].videos.get({ id: liveVideoUUID })
340
341 await stopFfmpeg(ffmpegCommand)
342
343 await waitUntilLiveWaitingOnAllServers(servers, liveVideoUUID)
344 await waitJobs(servers)
345
346 const video = await findExternalSavedVideo(servers[0], liveDetails)
347 expect(video).to.exist
348
349 for (const server of servers) {
350 await server.videos.get({ id: video.uuid })
351 }
352
353 lastReplayUUID = video.uuid
354 })
355
356 it('Should have cleaned up the live files', async function () {
357 await checkLiveCleanup(servers[0], liveVideoUUID, [])
358 })
359
360 it('Should correctly terminate the stream on blacklist and blacklist the saved replay video', async function () {
361 this.timeout(60000)
362
363 await servers[0].videos.remove({ id: lastReplayUUID })
364
365 liveVideoUUID = await createLiveWrapper({ permanent: true, replay: true })
366
367 ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveVideoUUID })
368 await waitUntilLivePublishedOnAllServers(servers, liveVideoUUID)
369
370 const liveDetails = await servers[0].videos.get({ id: liveVideoUUID })
371
372 await waitJobs(servers)
373 await checkVideosExist(liveVideoUUID, true, HttpStatusCode.OK_200)
374
375 await Promise.all([
376 servers[0].blacklist.add({ videoId: liveVideoUUID, reason: 'bad live', unfederate: true }),
377 testFfmpegStreamError(ffmpegCommand, true)
378 ])
379
380 await waitJobs(servers)
381 await wait(5000)
382 await waitJobs(servers)
383
384 const replay = await findExternalSavedVideo(servers[0], liveDetails)
385 expect(replay).to.exist
386
387 for (const videoId of [ liveVideoUUID, replay.uuid ]) {
388 await checkVideosExist(videoId, false)
389
390 await servers[0].videos.get({ id: videoId, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
391 await servers[1].videos.get({ id: videoId, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
392 }
393
394 await checkLiveCleanup(servers[0], liveVideoUUID, [])
395 })
396
397 it('Should correctly terminate the stream on delete and not save the video', async function () {
398 this.timeout(40000)
399
400 liveVideoUUID = await createLiveWrapper({ permanent: true, replay: true })
401
402 ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveVideoUUID })
403 await waitUntilLivePublishedOnAllServers(servers, liveVideoUUID)
404
405 const liveDetails = await servers[0].videos.get({ id: liveVideoUUID })
406
407 await waitJobs(servers)
408 await checkVideosExist(liveVideoUUID, true, HttpStatusCode.OK_200)
409
410 await Promise.all([
411 servers[0].videos.remove({ id: liveVideoUUID }),
412 testFfmpegStreamError(ffmpegCommand, true)
413 ])
414
415 await wait(5000)
416 await waitJobs(servers)
417
418 const replay = await findExternalSavedVideo(servers[0], liveDetails)
419 expect(replay).to.not.exist
420
421 await checkVideosExist(liveVideoUUID, false, HttpStatusCode.NOT_FOUND_404)
422 await checkLiveCleanup(servers[0], liveVideoUUID, [])
304 }) 423 })
305 }) 424 })
306 425
diff --git a/server/tests/api/live/live.ts b/server/tests/api/live/live.ts
index aeb039696..6e7b77bce 100644
--- a/server/tests/api/live/live.ts
+++ b/server/tests/api/live/live.ts
@@ -4,7 +4,7 @@ import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { basename, join } from 'path' 5import { basename, join } from 'path'
6import { ffprobePromise, getVideoStream } from '@server/helpers/ffmpeg' 6import { ffprobePromise, getVideoStream } from '@server/helpers/ffmpeg'
7import { checkLiveCleanupAfterSave, checkLiveSegmentHash, checkResolutionsInMasterPlaylist, testImage } from '@server/tests/shared' 7import { checkLiveCleanup, checkLiveSegmentHash, checkResolutionsInMasterPlaylist, testImage } from '@server/tests/shared'
8import { wait } from '@shared/core-utils' 8import { wait } from '@shared/core-utils'
9import { 9import {
10 HttpStatusCode, 10 HttpStatusCode,
@@ -583,7 +583,7 @@ describe('Test live', function () {
583 it('Should correctly have cleaned up the live files', async function () { 583 it('Should correctly have cleaned up the live files', async function () {
584 this.timeout(30000) 584 this.timeout(30000)
585 585
586 await checkLiveCleanupAfterSave(servers[0], liveVideoId, [ 240, 360, 720 ]) 586 await checkLiveCleanup(servers[0], liveVideoId, [ 240, 360, 720 ])
587 }) 587 })
588 }) 588 })
589 589
diff --git a/server/tests/api/object-storage/live.ts b/server/tests/api/object-storage/live.ts
index 0cb0a6e34..5d6281dec 100644
--- a/server/tests/api/object-storage/live.ts
+++ b/server/tests/api/object-storage/live.ts
@@ -2,13 +2,13 @@
2 2
3import 'mocha' 3import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { FfmpegCommand } from 'fluent-ffmpeg'
6import { expectStartWith } from '@server/tests/shared' 5import { expectStartWith } from '@server/tests/shared'
7import { areObjectStorageTestsDisabled } from '@shared/core-utils' 6import { areObjectStorageTestsDisabled } from '@shared/core-utils'
8import { HttpStatusCode, LiveVideoCreate, VideoFile, VideoPrivacy } from '@shared/models' 7import { HttpStatusCode, LiveVideoCreate, VideoFile, VideoPrivacy } from '@shared/models'
9import { 8import {
10 createMultipleServers, 9 createMultipleServers,
11 doubleFollow, 10 doubleFollow,
11 findExternalSavedVideo,
12 killallServers, 12 killallServers,
13 makeRawRequest, 13 makeRawRequest,
14 ObjectStorageCommand, 14 ObjectStorageCommand,
@@ -18,17 +18,19 @@ import {
18 stopFfmpeg, 18 stopFfmpeg,
19 waitJobs, 19 waitJobs,
20 waitUntilLivePublishedOnAllServers, 20 waitUntilLivePublishedOnAllServers,
21 waitUntilLiveSavedOnAllServers 21 waitUntilLiveReplacedByReplayOnAllServers,
22 waitUntilLiveWaitingOnAllServers
22} from '@shared/server-commands' 23} from '@shared/server-commands'
23 24
24const expect = chai.expect 25const expect = chai.expect
25 26
26async function createLive (server: PeerTubeServer) { 27async function createLive (server: PeerTubeServer, permanent: boolean) {
27 const attributes: LiveVideoCreate = { 28 const attributes: LiveVideoCreate = {
28 channelId: server.store.channel.id, 29 channelId: server.store.channel.id,
29 privacy: VideoPrivacy.PUBLIC, 30 privacy: VideoPrivacy.PUBLIC,
30 name: 'my super live', 31 name: 'my super live',
31 saveReplay: true 32 saveReplay: true,
33 permanentLive: permanent
32 } 34 }
33 35
34 const { uuid } = await server.live.create({ fields: attributes }) 36 const { uuid } = await server.live.create({ fields: attributes })
@@ -44,12 +46,39 @@ async function checkFiles (files: VideoFile[]) {
44 } 46 }
45} 47}
46 48
49async function getFiles (server: PeerTubeServer, videoUUID: string) {
50 const video = await server.videos.get({ id: videoUUID })
51
52 expect(video.files).to.have.lengthOf(0)
53 expect(video.streamingPlaylists).to.have.lengthOf(1)
54
55 return video.streamingPlaylists[0].files
56}
57
58async function streamAndEnd (servers: PeerTubeServer[], liveUUID: string) {
59 const ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: liveUUID })
60 await waitUntilLivePublishedOnAllServers(servers, liveUUID)
61
62 const videoLiveDetails = await servers[0].videos.get({ id: liveUUID })
63 const liveDetails = await servers[0].live.get({ videoId: liveUUID })
64
65 await stopFfmpeg(ffmpegCommand)
66
67 if (liveDetails.permanentLive) {
68 await waitUntilLiveWaitingOnAllServers(servers, liveUUID)
69 } else {
70 await waitUntilLiveReplacedByReplayOnAllServers(servers, liveUUID)
71 }
72
73 await waitJobs(servers)
74
75 return { videoLiveDetails, liveDetails }
76}
77
47describe('Object storage for lives', function () { 78describe('Object storage for lives', function () {
48 if (areObjectStorageTestsDisabled()) return 79 if (areObjectStorageTestsDisabled()) return
49 80
50 let ffmpegCommand: FfmpegCommand
51 let servers: PeerTubeServer[] 81 let servers: PeerTubeServer[]
52 let videoUUID: string
53 82
54 before(async function () { 83 before(async function () {
55 this.timeout(120000) 84 this.timeout(120000)
@@ -66,31 +95,22 @@ describe('Object storage for lives', function () {
66 }) 95 })
67 96
68 describe('Without live transcoding', async function () { 97 describe('Without live transcoding', async function () {
98 let videoUUID: string
69 99
70 before(async function () { 100 before(async function () {
71 await servers[0].config.enableLive({ transcoding: false }) 101 await servers[0].config.enableLive({ transcoding: false })
72 102
73 videoUUID = await createLive(servers[0]) 103 videoUUID = await createLive(servers[0], false)
74 }) 104 })
75 105
76 it('Should create a live and save the replay on object storage', async function () { 106 it('Should create a live and save the replay on object storage', async function () {
77 this.timeout(220000) 107 this.timeout(220000)
78 108
79 ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: videoUUID }) 109 await streamAndEnd(servers, videoUUID)
80 await waitUntilLivePublishedOnAllServers(servers, videoUUID)
81
82 await stopFfmpeg(ffmpegCommand)
83
84 await waitUntilLiveSavedOnAllServers(servers, videoUUID)
85 await waitJobs(servers)
86 110
87 for (const server of servers) { 111 for (const server of servers) {
88 const video = await server.videos.get({ id: videoUUID }) 112 const files = await getFiles(server, videoUUID)
89 113 expect(files).to.have.lengthOf(1)
90 expect(video.files).to.have.lengthOf(0)
91 expect(video.streamingPlaylists).to.have.lengthOf(1)
92
93 const files = video.streamingPlaylists[0].files
94 114
95 await checkFiles(files) 115 await checkFiles(files)
96 } 116 }
@@ -98,31 +118,38 @@ describe('Object storage for lives', function () {
98 }) 118 })
99 119
100 describe('With live transcoding', async function () { 120 describe('With live transcoding', async function () {
121 let videoUUIDPermanent: string
122 let videoUUIDNonPermanent: string
101 123
102 before(async function () { 124 before(async function () {
103 await servers[0].config.enableLive({ transcoding: true }) 125 await servers[0].config.enableLive({ transcoding: true })
104 126
105 videoUUID = await createLive(servers[0]) 127 videoUUIDPermanent = await createLive(servers[0], true)
128 videoUUIDNonPermanent = await createLive(servers[0], false)
106 }) 129 })
107 130
108 it('Should import a video and have sent it to object storage', async function () { 131 it('Should create a live and save the replay on object storage', async function () {
109 this.timeout(240000) 132 this.timeout(240000)
110 133
111 ffmpegCommand = await servers[0].live.sendRTMPStreamInVideo({ videoId: videoUUID }) 134 await streamAndEnd(servers, videoUUIDNonPermanent)
112 await waitUntilLivePublishedOnAllServers(servers, videoUUID)
113 135
114 await stopFfmpeg(ffmpegCommand) 136 for (const server of servers) {
137 const files = await getFiles(server, videoUUIDNonPermanent)
138 expect(files).to.have.lengthOf(5)
115 139
116 await waitUntilLiveSavedOnAllServers(servers, videoUUID) 140 await checkFiles(files)
117 await waitJobs(servers) 141 }
142 })
118 143
119 for (const server of servers) { 144 it('Should create a live and save the replay of permanent live on object storage', async function () {
120 const video = await server.videos.get({ id: videoUUID }) 145 this.timeout(240000)
146
147 const { videoLiveDetails } = await streamAndEnd(servers, videoUUIDPermanent)
121 148
122 expect(video.files).to.have.lengthOf(0) 149 const replay = await findExternalSavedVideo(servers[0], videoLiveDetails)
123 expect(video.streamingPlaylists).to.have.lengthOf(1)
124 150
125 const files = video.streamingPlaylists[0].files 151 for (const server of servers) {
152 const files = await getFiles(server, replay.uuid)
126 expect(files).to.have.lengthOf(5) 153 expect(files).to.have.lengthOf(5)
127 154
128 await checkFiles(files) 155 await checkFiles(files)
diff --git a/server/tests/shared/live.ts b/server/tests/shared/live.ts
index 72e3e27f6..6ee4899b0 100644
--- a/server/tests/shared/live.ts
+++ b/server/tests/shared/live.ts
@@ -5,11 +5,11 @@ import { pathExists, readdir } from 'fs-extra'
5import { join } from 'path' 5import { join } from 'path'
6import { PeerTubeServer } from '@shared/server-commands' 6import { PeerTubeServer } from '@shared/server-commands'
7 7
8async function checkLiveCleanupAfterSave (server: PeerTubeServer, videoUUID: string, resolutions: number[] = []) { 8async function checkLiveCleanup (server: PeerTubeServer, videoUUID: string, savedResolutions: number[] = []) {
9 const basePath = server.servers.buildDirectory('streaming-playlists') 9 const basePath = server.servers.buildDirectory('streaming-playlists')
10 const hlsPath = join(basePath, 'hls', videoUUID) 10 const hlsPath = join(basePath, 'hls', videoUUID)
11 11
12 if (resolutions.length === 0) { 12 if (savedResolutions.length === 0) {
13 const result = await pathExists(hlsPath) 13 const result = await pathExists(hlsPath)
14 expect(result).to.be.false 14 expect(result).to.be.false
15 15
@@ -19,9 +19,9 @@ async function checkLiveCleanupAfterSave (server: PeerTubeServer, videoUUID: str
19 const files = await readdir(hlsPath) 19 const files = await readdir(hlsPath)
20 20
21 // fragmented file and playlist per resolution + master playlist + segments sha256 json file 21 // fragmented file and playlist per resolution + master playlist + segments sha256 json file
22 expect(files).to.have.lengthOf(resolutions.length * 2 + 2) 22 expect(files).to.have.lengthOf(savedResolutions.length * 2 + 2)
23 23
24 for (const resolution of resolutions) { 24 for (const resolution of savedResolutions) {
25 const fragmentedFile = files.find(f => f.endsWith(`-${resolution}-fragmented.mp4`)) 25 const fragmentedFile = files.find(f => f.endsWith(`-${resolution}-fragmented.mp4`))
26 expect(fragmentedFile).to.exist 26 expect(fragmentedFile).to.exist
27 27
@@ -37,5 +37,5 @@ async function checkLiveCleanupAfterSave (server: PeerTubeServer, videoUUID: str
37} 37}
38 38
39export { 39export {
40 checkLiveCleanupAfterSave 40 checkLiveCleanup
41} 41}
diff --git a/shared/models/server/job.model.ts b/shared/models/server/job.model.ts
index 92d1b5698..9370cf011 100644
--- a/shared/models/server/job.model.ts
+++ b/shared/models/server/job.model.ts
@@ -159,6 +159,9 @@ export type VideoTranscodingPayload =
159 159
160export interface VideoLiveEndingPayload { 160export interface VideoLiveEndingPayload {
161 videoId: number 161 videoId: number
162 publishedAt: string
163
164 replayDirectory?: string
162} 165}
163 166
164export interface ActorKeysPayload { 167export interface ActorKeysPayload {
diff --git a/shared/models/videos/live/live-video-create.model.ts b/shared/models/videos/live/live-video-create.model.ts
index 49ccaf45b..bd245dec5 100644
--- a/shared/models/videos/live/live-video-create.model.ts
+++ b/shared/models/videos/live/live-video-create.model.ts
@@ -1,8 +1,9 @@
1import { LiveVideoLatencyMode } from '.'
2import { VideoCreate } from '../video-create.model' 1import { VideoCreate } from '../video-create.model'
2import { LiveVideoLatencyMode } from './live-video-latency-mode.enum'
3 3
4export interface LiveVideoCreate extends VideoCreate { 4export interface LiveVideoCreate extends VideoCreate {
5 saveReplay?: boolean
6 permanentLive?: boolean 5 permanentLive?: boolean
7 latencyMode?: LiveVideoLatencyMode 6 latencyMode?: LiveVideoLatencyMode
7
8 saveReplay?: boolean
8} 9}
diff --git a/shared/server-commands/videos/live-command.ts b/shared/server-commands/videos/live-command.ts
index f7816eca0..c24c7a5fc 100644
--- a/shared/server-commands/videos/live-command.ts
+++ b/shared/server-commands/videos/live-command.ts
@@ -117,7 +117,7 @@ export class LiveCommand extends AbstractCommand {
117 return this.server.servers.waitUntilLog(`${videoUUID}/${segmentName}`, 2, false) 117 return this.server.servers.waitUntilLog(`${videoUUID}/${segmentName}`, 2, false)
118 } 118 }
119 119
120 async waitUntilSaved (options: OverrideCommandOptions & { 120 async waitUntilReplacedByReplay (options: OverrideCommandOptions & {
121 videoId: number | string 121 videoId: number | string
122 }) { 122 }) {
123 let video: VideoDetails 123 let video: VideoDetails
diff --git a/shared/server-commands/videos/live.ts b/shared/server-commands/videos/live.ts
index 7a7faa911..6f180b05f 100644
--- a/shared/server-commands/videos/live.ts
+++ b/shared/server-commands/videos/live.ts
@@ -1,6 +1,7 @@
1import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg' 1import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg'
2import { buildAbsoluteFixturePath, wait } from '@shared/core-utils' 2import { buildAbsoluteFixturePath, wait } from '@shared/core-utils'
3import { PeerTubeServer } from '../server/server' 3import { PeerTubeServer } from '../server/server'
4import { VideoDetails, VideoInclude } from '@shared/models'
4 5
5function sendRTMPStream (options: { 6function sendRTMPStream (options: {
6 rtmpBaseUrl: string 7 rtmpBaseUrl: string
@@ -84,17 +85,33 @@ async function waitUntilLivePublishedOnAllServers (servers: PeerTubeServer[], vi
84 } 85 }
85} 86}
86 87
87async function waitUntilLiveSavedOnAllServers (servers: PeerTubeServer[], videoId: string) { 88async function waitUntilLiveWaitingOnAllServers (servers: PeerTubeServer[], videoId: string) {
88 for (const server of servers) { 89 for (const server of servers) {
89 await server.live.waitUntilSaved({ videoId }) 90 await server.live.waitUntilWaiting({ videoId })
90 } 91 }
91} 92}
92 93
94async function waitUntilLiveReplacedByReplayOnAllServers (servers: PeerTubeServer[], videoId: string) {
95 for (const server of servers) {
96 await server.live.waitUntilReplacedByReplay({ videoId })
97 }
98}
99
100async function findExternalSavedVideo (server: PeerTubeServer, liveDetails: VideoDetails) {
101 const { data } = await server.videos.list({ token: server.accessToken, sort: '-publishedAt', include: VideoInclude.BLACKLISTED })
102
103 return data.find(v => v.name === liveDetails.name + ' - ' + new Date(liveDetails.publishedAt).toLocaleString())
104}
105
93export { 106export {
94 sendRTMPStream, 107 sendRTMPStream,
95 waitFfmpegUntilError, 108 waitFfmpegUntilError,
96 testFfmpegStreamError, 109 testFfmpegStreamError,
97 stopFfmpeg, 110 stopFfmpeg,
111
98 waitUntilLivePublishedOnAllServers, 112 waitUntilLivePublishedOnAllServers,
99 waitUntilLiveSavedOnAllServers 113 waitUntilLiveReplacedByReplayOnAllServers,
114 waitUntilLiveWaitingOnAllServers,
115
116 findExternalSavedVideo
100} 117}