diff options
-rw-r--r-- | server/helpers/ffmpeg-utils.ts | 23 | ||||
-rw-r--r-- | server/initializers/constants.ts | 1 | ||||
-rw-r--r-- | server/lib/job-queue/handlers/video-live-ending.ts | 32 | ||||
-rw-r--r-- | server/lib/live-manager.ts | 28 |
4 files changed, 55 insertions, 29 deletions
diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts index 7c997877c..085635b5a 100644 --- a/server/helpers/ffmpeg-utils.ts +++ b/server/helpers/ffmpeg-utils.ts | |||
@@ -190,12 +190,11 @@ async function getLiveTranscodingCommand (options: { | |||
190 | outPath: string | 190 | outPath: string |
191 | resolutions: number[] | 191 | resolutions: number[] |
192 | fps: number | 192 | fps: number |
193 | deleteSegments: boolean | ||
194 | 193 | ||
195 | availableEncoders: AvailableEncoders | 194 | availableEncoders: AvailableEncoders |
196 | profile: string | 195 | profile: string |
197 | }) { | 196 | }) { |
198 | const { rtmpUrl, outPath, resolutions, fps, deleteSegments, availableEncoders, profile } = options | 197 | const { rtmpUrl, outPath, resolutions, fps, availableEncoders, profile } = options |
199 | const input = rtmpUrl | 198 | const input = rtmpUrl |
200 | 199 | ||
201 | const command = getFFmpeg(input) | 200 | const command = getFFmpeg(input) |
@@ -272,14 +271,14 @@ async function getLiveTranscodingCommand (options: { | |||
272 | varStreamMap.push(`v:${i},a:${i}`) | 271 | varStreamMap.push(`v:${i},a:${i}`) |
273 | } | 272 | } |
274 | 273 | ||
275 | addDefaultLiveHLSParams(command, outPath, deleteSegments) | 274 | addDefaultLiveHLSParams(command, outPath) |
276 | 275 | ||
277 | command.outputOption('-var_stream_map', varStreamMap.join(' ')) | 276 | command.outputOption('-var_stream_map', varStreamMap.join(' ')) |
278 | 277 | ||
279 | return command | 278 | return command |
280 | } | 279 | } |
281 | 280 | ||
282 | function getLiveMuxingCommand (rtmpUrl: string, outPath: string, deleteSegments: boolean) { | 281 | function getLiveMuxingCommand (rtmpUrl: string, outPath: string) { |
283 | const command = getFFmpeg(rtmpUrl) | 282 | const command = getFFmpeg(rtmpUrl) |
284 | command.inputOption('-fflags nobuffer') | 283 | command.inputOption('-fflags nobuffer') |
285 | 284 | ||
@@ -288,17 +287,17 @@ function getLiveMuxingCommand (rtmpUrl: string, outPath: string, deleteSegments: | |||
288 | command.outputOption('-map 0:a?') | 287 | command.outputOption('-map 0:a?') |
289 | command.outputOption('-map 0:v?') | 288 | command.outputOption('-map 0:v?') |
290 | 289 | ||
291 | addDefaultLiveHLSParams(command, outPath, deleteSegments) | 290 | addDefaultLiveHLSParams(command, outPath) |
292 | 291 | ||
293 | return command | 292 | return command |
294 | } | 293 | } |
295 | 294 | ||
296 | async function hlsPlaylistToFragmentedMP4 (hlsDirectory: string, segmentFiles: string[], outputPath: string) { | 295 | async function hlsPlaylistToFragmentedMP4 (replayDirectory: string, segmentFiles: string[], outputPath: string) { |
297 | const concatFilePath = join(hlsDirectory, 'concat.txt') | 296 | const concatFilePath = join(replayDirectory, 'concat.txt') |
298 | 297 | ||
299 | function cleaner () { | 298 | function cleaner () { |
300 | remove(concatFilePath) | 299 | remove(concatFilePath) |
301 | .catch(err => logger.error('Cannot remove concat file in %s.', hlsDirectory, { err })) | 300 | .catch(err => logger.error('Cannot remove concat file in %s.', replayDirectory, { err })) |
302 | } | 301 | } |
303 | 302 | ||
304 | // First concat the ts files to a mp4 file | 303 | // First concat the ts files to a mp4 file |
@@ -385,14 +384,10 @@ function addDefaultEncoderParams (options: { | |||
385 | } | 384 | } |
386 | } | 385 | } |
387 | 386 | ||
388 | function addDefaultLiveHLSParams (command: ffmpeg.FfmpegCommand, outPath: string, deleteSegments: boolean) { | 387 | function addDefaultLiveHLSParams (command: ffmpeg.FfmpegCommand, outPath: string) { |
389 | command.outputOption('-hls_time ' + VIDEO_LIVE.SEGMENT_TIME_SECONDS) | 388 | command.outputOption('-hls_time ' + VIDEO_LIVE.SEGMENT_TIME_SECONDS) |
390 | command.outputOption('-hls_list_size ' + VIDEO_LIVE.SEGMENTS_LIST_SIZE) | 389 | command.outputOption('-hls_list_size ' + VIDEO_LIVE.SEGMENTS_LIST_SIZE) |
391 | 390 | command.outputOption('-hls_flags delete_segments') | |
392 | if (deleteSegments === true) { | ||
393 | command.outputOption('-hls_flags delete_segments') | ||
394 | } | ||
395 | |||
396 | command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`) | 391 | command.outputOption(`-hls_segment_filename ${join(outPath, '%v-%06d.ts')}`) |
397 | command.outputOption('-master_pl_name master.m3u8') | 392 | command.outputOption('-master_pl_name master.m3u8') |
398 | command.outputOption(`-f hls`) | 393 | command.outputOption(`-f hls`) |
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 6c44d703e..da837837e 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts | |||
@@ -634,6 +634,7 @@ const VIDEO_LIVE = { | |||
634 | CLEANUP_DELAY: 1000 * 60 * 5, // 5 minutes | 634 | CLEANUP_DELAY: 1000 * 60 * 5, // 5 minutes |
635 | SEGMENT_TIME_SECONDS: 4, // 4 seconds | 635 | SEGMENT_TIME_SECONDS: 4, // 4 seconds |
636 | SEGMENTS_LIST_SIZE: 15, // 15 maximum segments in live playlist | 636 | SEGMENTS_LIST_SIZE: 15, // 15 maximum segments in live playlist |
637 | REPLAY_DIRECTORY: 'replay', | ||
637 | EDGE_LIVE_DELAY_SEGMENTS_NOTIFICATION: 4, | 638 | EDGE_LIVE_DELAY_SEGMENTS_NOTIFICATION: 4, |
638 | RTMP: { | 639 | RTMP: { |
639 | CHUNK_SIZE: 60000, | 640 | CHUNK_SIZE: 60000, |
diff --git a/server/lib/job-queue/handlers/video-live-ending.ts b/server/lib/job-queue/handlers/video-live-ending.ts index 447744224..0d2bcaa28 100644 --- a/server/lib/job-queue/handlers/video-live-ending.ts +++ b/server/lib/job-queue/handlers/video-live-ending.ts | |||
@@ -1,5 +1,5 @@ | |||
1 | import * as Bull from 'bull' | 1 | import * as Bull from 'bull' |
2 | import { readdir, remove } from 'fs-extra' | 2 | import { move, readdir, remove } from 'fs-extra' |
3 | import { join } from 'path' | 3 | import { join } from 'path' |
4 | import { hlsPlaylistToFragmentedMP4 } from '@server/helpers/ffmpeg-utils' | 4 | import { hlsPlaylistToFragmentedMP4 } from '@server/helpers/ffmpeg-utils' |
5 | import { getDurationFromVideoFile, getVideoFileResolution } from '@server/helpers/ffprobe-utils' | 5 | import { getDurationFromVideoFile, getVideoFileResolution } from '@server/helpers/ffprobe-utils' |
@@ -14,6 +14,7 @@ import { VideoStreamingPlaylistModel } from '@server/models/video/video-streamin | |||
14 | import { MStreamingPlaylist, MVideo, MVideoLive } from '@server/types/models' | 14 | import { MStreamingPlaylist, MVideo, MVideoLive } from '@server/types/models' |
15 | import { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@shared/models' | 15 | import { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@shared/models' |
16 | import { logger } from '../../../helpers/logger' | 16 | import { logger } from '../../../helpers/logger' |
17 | import { VIDEO_LIVE } from '@server/initializers/constants' | ||
17 | 18 | ||
18 | async function processVideoLiveEnding (job: Bull.Job) { | 19 | async function processVideoLiveEnding (job: Bull.Job) { |
19 | const payload = job.data as VideoLiveEndingPayload | 20 | const payload = job.data as VideoLiveEndingPayload |
@@ -53,24 +54,40 @@ export { | |||
53 | 54 | ||
54 | async function saveLive (video: MVideo, live: MVideoLive) { | 55 | async function saveLive (video: MVideo, live: MVideoLive) { |
55 | const hlsDirectory = getHLSDirectory(video, false) | 56 | const hlsDirectory = getHLSDirectory(video, false) |
56 | const files = await readdir(hlsDirectory) | 57 | const replayDirectory = join(hlsDirectory, VIDEO_LIVE.REPLAY_DIRECTORY) |
58 | |||
59 | const rootFiles = await readdir(hlsDirectory) | ||
60 | |||
61 | const playlistFiles: string[] = [] | ||
62 | |||
63 | for (const file of rootFiles) { | ||
64 | if (file.endsWith('.m3u8') !== true) continue | ||
65 | |||
66 | await move(join(hlsDirectory, file), join(replayDirectory, file)) | ||
67 | |||
68 | if (file !== 'master.m3u8') { | ||
69 | playlistFiles.push(file) | ||
70 | } | ||
71 | } | ||
72 | |||
73 | const replayFiles = await readdir(replayDirectory) | ||
57 | 74 | ||
58 | const playlistFiles = files.filter(f => f.endsWith('.m3u8') && f !== 'master.m3u8') | ||
59 | const resolutions: number[] = [] | 75 | const resolutions: number[] = [] |
60 | let duration: number | 76 | let duration: number |
61 | 77 | ||
62 | for (const playlistFile of playlistFiles) { | 78 | for (const playlistFile of playlistFiles) { |
63 | const playlistPath = join(hlsDirectory, playlistFile) | 79 | const playlistPath = join(replayDirectory, playlistFile) |
64 | const { videoFileResolution } = await getVideoFileResolution(playlistPath) | 80 | const { videoFileResolution } = await getVideoFileResolution(playlistPath) |
65 | 81 | ||
82 | // Put the final mp4 in the hls directory, and not in the replay directory | ||
66 | const mp4TmpPath = buildMP4TmpPath(hlsDirectory, videoFileResolution) | 83 | const mp4TmpPath = buildMP4TmpPath(hlsDirectory, videoFileResolution) |
67 | 84 | ||
68 | // Playlist name is for example 3.m3u8 | 85 | // Playlist name is for example 3.m3u8 |
69 | // Segments names are 3-0.ts 3-1.ts etc | 86 | // Segments names are 3-0.ts 3-1.ts etc |
70 | const shouldStartWith = playlistFile.replace(/\.m3u8$/, '') + '-' | 87 | const shouldStartWith = playlistFile.replace(/\.m3u8$/, '') + '-' |
71 | 88 | ||
72 | const segmentFiles = files.filter(f => f.startsWith(shouldStartWith) && f.endsWith('.ts')) | 89 | const segmentFiles = replayFiles.filter(f => f.startsWith(shouldStartWith) && f.endsWith('.ts')) |
73 | await hlsPlaylistToFragmentedMP4(hlsDirectory, segmentFiles, mp4TmpPath) | 90 | await hlsPlaylistToFragmentedMP4(replayDirectory, segmentFiles, mp4TmpPath) |
74 | 91 | ||
75 | if (!duration) { | 92 | if (!duration) { |
76 | duration = await getDurationFromVideoFile(mp4TmpPath) | 93 | duration = await getDurationFromVideoFile(mp4TmpPath) |
@@ -143,7 +160,8 @@ async function cleanupLiveFiles (hlsDirectory: string) { | |||
143 | filename.endsWith('.m3u8') || | 160 | filename.endsWith('.m3u8') || |
144 | filename.endsWith('.mpd') || | 161 | filename.endsWith('.mpd') || |
145 | filename.endsWith('.m4s') || | 162 | filename.endsWith('.m4s') || |
146 | filename.endsWith('.tmp') | 163 | filename.endsWith('.tmp') || |
164 | filename === VIDEO_LIVE.REPLAY_DIRECTORY | ||
147 | ) { | 165 | ) { |
148 | const p = join(hlsDirectory, filename) | 166 | const p = join(hlsDirectory, filename) |
149 | 167 | ||
diff --git a/server/lib/live-manager.ts b/server/lib/live-manager.ts index d63e79dfc..d201465fa 100644 --- a/server/lib/live-manager.ts +++ b/server/lib/live-manager.ts | |||
@@ -1,8 +1,8 @@ | |||
1 | 1 | ||
2 | import * as chokidar from 'chokidar' | 2 | import * as chokidar from 'chokidar' |
3 | import { FfmpegCommand } from 'fluent-ffmpeg' | 3 | import { FfmpegCommand } from 'fluent-ffmpeg' |
4 | import { ensureDir, stat } from 'fs-extra' | 4 | import { copy, ensureDir, stat } from 'fs-extra' |
5 | import { basename } from 'path' | 5 | import { basename, join } from 'path' |
6 | import { isTestInstance } from '@server/helpers/core-utils' | 6 | import { isTestInstance } from '@server/helpers/core-utils' |
7 | import { getLiveMuxingCommand, getLiveTranscodingCommand } from '@server/helpers/ffmpeg-utils' | 7 | import { getLiveMuxingCommand, getLiveTranscodingCommand } from '@server/helpers/ffmpeg-utils' |
8 | import { computeResolutionsToTranscode, getVideoFileFPS, getVideoFileResolution } from '@server/helpers/ffprobe-utils' | 8 | import { computeResolutionsToTranscode, getVideoFileFPS, getVideoFileResolution } from '@server/helpers/ffprobe-utils' |
@@ -25,6 +25,7 @@ import { getHLSDirectory } from './video-paths' | |||
25 | import { availableEncoders } from './video-transcoding-profiles' | 25 | import { availableEncoders } from './video-transcoding-profiles' |
26 | 26 | ||
27 | import memoizee = require('memoizee') | 27 | import memoizee = require('memoizee') |
28 | import { mkdir } from 'fs' | ||
28 | const NodeRtmpServer = require('node-media-server/node_rtmp_server') | 29 | const NodeRtmpServer = require('node-media-server/node_rtmp_server') |
29 | const context = require('node-media-server/node_core_ctx') | 30 | const context = require('node-media-server/node_core_ctx') |
30 | const nodeMediaServerLogger = require('node-media-server/node_core_logger') | 31 | const nodeMediaServerLogger = require('node-media-server/node_core_logger') |
@@ -261,8 +262,13 @@ class LiveManager { | |||
261 | const outPath = getHLSDirectory(videoLive.Video) | 262 | const outPath = getHLSDirectory(videoLive.Video) |
262 | await ensureDir(outPath) | 263 | await ensureDir(outPath) |
263 | 264 | ||
265 | const replayDirectory = join(outPath, VIDEO_LIVE.REPLAY_DIRECTORY) | ||
266 | |||
267 | if (videoLive.saveReplay === true) { | ||
268 | await ensureDir(replayDirectory) | ||
269 | } | ||
270 | |||
264 | const videoUUID = videoLive.Video.uuid | 271 | const videoUUID = videoLive.Video.uuid |
265 | const deleteSegments = videoLive.saveReplay === false | ||
266 | 272 | ||
267 | const ffmpegExec = CONFIG.LIVE.TRANSCODING.ENABLED | 273 | const ffmpegExec = CONFIG.LIVE.TRANSCODING.ENABLED |
268 | ? await getLiveTranscodingCommand({ | 274 | ? await getLiveTranscodingCommand({ |
@@ -270,11 +276,10 @@ class LiveManager { | |||
270 | outPath, | 276 | outPath, |
271 | resolutions: allResolutions, | 277 | resolutions: allResolutions, |
272 | fps, | 278 | fps, |
273 | deleteSegments, | ||
274 | availableEncoders, | 279 | availableEncoders, |
275 | profile: 'default' | 280 | profile: 'default' |
276 | }) | 281 | }) |
277 | : getLiveMuxingCommand(rtmpUrl, outPath, deleteSegments) | 282 | : getLiveMuxingCommand(rtmpUrl, outPath) |
278 | 283 | ||
279 | logger.info('Running live muxing/transcoding for %s.', videoUUID) | 284 | logger.info('Running live muxing/transcoding for %s.', videoUUID) |
280 | this.transSessions.set(sessionId, ffmpegExec) | 285 | this.transSessions.set(sessionId, ffmpegExec) |
@@ -284,11 +289,18 @@ class LiveManager { | |||
284 | const segmentsToProcessPerPlaylist: { [playlistId: string]: string[] } = {} | 289 | const segmentsToProcessPerPlaylist: { [playlistId: string]: string[] } = {} |
285 | const playlistIdMatcher = /^([\d+])-/ | 290 | const playlistIdMatcher = /^([\d+])-/ |
286 | 291 | ||
287 | const processHashSegments = (segmentsToProcess: string[]) => { | 292 | const processSegments = (segmentsToProcess: string[]) => { |
288 | // Add sha hash of previous segments, because ffmpeg should have finished generating them | 293 | // Add sha hash of previous segments, because ffmpeg should have finished generating them |
289 | for (const previousSegment of segmentsToProcess) { | 294 | for (const previousSegment of segmentsToProcess) { |
290 | this.addSegmentSha(videoUUID, previousSegment) | 295 | this.addSegmentSha(videoUUID, previousSegment) |
291 | .catch(err => logger.error('Cannot add sha segment of video %s -> %s.', videoUUID, previousSegment, { err })) | 296 | .catch(err => logger.error('Cannot add sha segment of video %s -> %s.', videoUUID, previousSegment, { err })) |
297 | |||
298 | if (videoLive.saveReplay) { | ||
299 | const segmentName = basename(previousSegment) | ||
300 | |||
301 | copy(previousSegment, join(outPath, VIDEO_LIVE.REPLAY_DIRECTORY, segmentName)) | ||
302 | .catch(err => logger.error('Cannot copy segment %s to repay directory.', previousSegment, { err })) | ||
303 | } | ||
292 | } | 304 | } |
293 | } | 305 | } |
294 | 306 | ||
@@ -298,7 +310,7 @@ class LiveManager { | |||
298 | const playlistId = basename(segmentPath).match(playlistIdMatcher)[0] | 310 | const playlistId = basename(segmentPath).match(playlistIdMatcher)[0] |
299 | 311 | ||
300 | const segmentsToProcess = segmentsToProcessPerPlaylist[playlistId] || [] | 312 | const segmentsToProcess = segmentsToProcessPerPlaylist[playlistId] || [] |
301 | processHashSegments(segmentsToProcess) | 313 | processSegments(segmentsToProcess) |
302 | 314 | ||
303 | segmentsToProcessPerPlaylist[playlistId] = [ segmentPath ] | 315 | segmentsToProcessPerPlaylist[playlistId] = [ segmentPath ] |
304 | 316 | ||
@@ -369,7 +381,7 @@ class LiveManager { | |||
369 | .then(() => { | 381 | .then(() => { |
370 | // Process remaining segments hash | 382 | // Process remaining segments hash |
371 | for (const key of Object.keys(segmentsToProcessPerPlaylist)) { | 383 | for (const key of Object.keys(segmentsToProcessPerPlaylist)) { |
372 | processHashSegments(segmentsToProcessPerPlaylist[key]) | 384 | processSegments(segmentsToProcessPerPlaylist[key]) |
373 | } | 385 | } |
374 | }) | 386 | }) |
375 | .catch(err => logger.error('Cannot close watchers of %s or process remaining hash segments.', outPath, { err })) | 387 | .catch(err => logger.error('Cannot close watchers of %s or process remaining hash segments.', outPath, { err })) |