]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Fix live replay duration glitch
authorChocobozzz <me@florianbigard.com>
Wed, 2 Dec 2020 09:07:26 +0000 (10:07 +0100)
committerChocobozzz <me@florianbigard.com>
Wed, 2 Dec 2020 09:18:15 +0000 (10:18 +0100)
server/helpers/ffmpeg-utils.ts
server/lib/job-queue/handlers/video-live-ending.ts
server/lib/video-transcoding.ts
server/models/video/video-playlist.ts
server/tests/api/live/live.ts

index 085635b5a40c65b4231692038ebf3625cc22c3f3..c6b8a0eb0bb01bc883bdbd9e59bf6f5f70665222 100644 (file)
@@ -110,7 +110,7 @@ async function generateImageFromVideoFile (fromPath: string, folder: string, ima
 // Transcode meta function
 // ---------------------------------------------------------------------------
 
-type TranscodeOptionsType = 'hls' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio'
+type TranscodeOptionsType = 'hls' | 'hls-from-ts' | 'quick-transcode' | 'video' | 'merge-audio' | 'only-audio'
 
 interface BaseTranscodeOptions {
   type: TranscodeOptionsType
@@ -134,6 +134,14 @@ interface HLSTranscodeOptions extends BaseTranscodeOptions {
   }
 }
 
+interface HLSFromTSTranscodeOptions extends BaseTranscodeOptions {
+  type: 'hls-from-ts'
+
+  hlsPlaylist: {
+    videoFilename: string
+  }
+}
+
 interface QuickTranscodeOptions extends BaseTranscodeOptions {
   type: 'quick-transcode'
 }
@@ -153,6 +161,7 @@ interface OnlyAudioTranscodeOptions extends BaseTranscodeOptions {
 
 type TranscodeOptions =
   HLSTranscodeOptions
+  | HLSFromTSTranscodeOptions
   | VideoTranscodeOptions
   | MergeAudioTranscodeOptions
   | OnlyAudioTranscodeOptions
@@ -163,6 +172,7 @@ const builders: {
 } = {
   'quick-transcode': buildQuickTranscodeCommand,
   'hls': buildHLSVODCommand,
+  'hls-from-ts': buildHLSVODFromTSCommand,
   'merge-audio': buildAudioMergeCommand,
   'only-audio': buildOnlyAudioCommand,
   'video': buildx264VODCommand
@@ -292,31 +302,6 @@ function getLiveMuxingCommand (rtmpUrl: string, outPath: string) {
   return command
 }
 
-async function hlsPlaylistToFragmentedMP4 (replayDirectory: string, segmentFiles: string[], outputPath: string) {
-  const concatFilePath = join(replayDirectory, 'concat.txt')
-
-  function cleaner () {
-    remove(concatFilePath)
-      .catch(err => logger.error('Cannot remove concat file in %s.', replayDirectory, { err }))
-  }
-
-  // First concat the ts files to a mp4 file
-  const content = segmentFiles.map(f => 'file ' + f)
-                              .join('\n')
-
-  await writeFile(concatFilePath, content + '\n')
-
-  const command = getFFmpeg(concatFilePath)
-  command.inputOption('-safe 0')
-  command.inputOption('-f concat')
-
-  command.outputOption('-c:v copy')
-  command.audioFilter('aresample=async=1:first_pts=0')
-  command.output(outputPath)
-
-  return runCommand(command, cleaner)
-}
-
 function buildStreamSuffix (base: string, streamNum?: number) {
   if (streamNum !== undefined) {
     return `${base}:${streamNum}`
@@ -336,8 +321,7 @@ export {
   generateImageFromVideoFile,
   TranscodeOptions,
   TranscodeOptionsType,
-  transcode,
-  hlsPlaylistToFragmentedMP4
+  transcode
 }
 
 // ---------------------------------------------------------------------------
@@ -447,6 +431,16 @@ function buildQuickTranscodeCommand (command: ffmpeg.FfmpegCommand) {
   return command
 }
 
+function addCommonHLSVODCommandOptions (command: ffmpeg.FfmpegCommand, outputPath: string) {
+  return command.outputOption('-hls_time 4')
+                .outputOption('-hls_list_size 0')
+                .outputOption('-hls_playlist_type vod')
+                .outputOption('-hls_segment_filename ' + outputPath)
+                .outputOption('-hls_segment_type fmp4')
+                .outputOption('-f hls')
+                .outputOption('-hls_flags single_file')
+}
+
 async function buildHLSVODCommand (command: ffmpeg.FfmpegCommand, options: HLSTranscodeOptions) {
   const videoPath = getHLSVideoPath(options)
 
@@ -454,19 +448,27 @@ async function buildHLSVODCommand (command: ffmpeg.FfmpegCommand, options: HLSTr
   else if (options.resolution === VideoResolution.H_NOVIDEO) command = presetOnlyAudio(command)
   else command = await buildx264VODCommand(command, options)
 
-  command = command.outputOption('-hls_time 4')
-                   .outputOption('-hls_list_size 0')
-                   .outputOption('-hls_playlist_type vod')
-                   .outputOption('-hls_segment_filename ' + videoPath)
-                   .outputOption('-hls_segment_type fmp4')
-                   .outputOption('-f hls')
-                   .outputOption('-hls_flags single_file')
+  addCommonHLSVODCommandOptions(command, videoPath)
+
+  return command
+}
+
+async function buildHLSVODFromTSCommand (command: ffmpeg.FfmpegCommand, options: HLSFromTSTranscodeOptions) {
+  const videoPath = getHLSVideoPath(options)
+
+  command.inputOption('-safe 0')
+  command.inputOption('-f concat')
+
+  command.outputOption('-c:v copy')
+  command.audioFilter('aresample=async=1:first_pts=0')
+
+  addCommonHLSVODCommandOptions(command, videoPath)
 
   return command
 }
 
 async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) {
-  if (options.type !== 'hls') return
+  if (options.type !== 'hls' && options.type !== 'hls-from-ts') return
 
   const fileContent = await readFile(options.outputPath)
 
@@ -480,7 +482,7 @@ async function fixHLSPlaylistIfNeeded (options: TranscodeOptions) {
   await writeFile(options.outputPath, newContent)
 }
 
-function getHLSVideoPath (options: HLSTranscodeOptions) {
+function getHLSVideoPath (options: HLSTranscodeOptions | HLSFromTSTranscodeOptions) {
   return `${dirname(options.outputPath)}/${options.hlsPlaylist.videoFilename}`
 }
 
index 6e1076d8f2340767a2d73f31779f38e98ca663cb..55bee0b838b52d3fadae28616df44f90fa5ec3ab 100644 (file)
@@ -1,13 +1,12 @@
 import * as Bull from 'bull'
 import { copy, readdir, remove } from 'fs-extra'
 import { join } from 'path'
-import { hlsPlaylistToFragmentedMP4 } from '@server/helpers/ffmpeg-utils'
 import { getDurationFromVideoFile, getVideoFileResolution } from '@server/helpers/ffprobe-utils'
 import { VIDEO_LIVE } from '@server/initializers/constants'
 import { generateVideoMiniature } from '@server/lib/thumbnail'
 import { publishAndFederateIfNeeded } from '@server/lib/video'
 import { getHLSDirectory } from '@server/lib/video-paths'
-import { generateHlsPlaylist } from '@server/lib/video-transcoding'
+import { generateHlsPlaylistFromTS } from '@server/lib/video-transcoding'
 import { VideoModel } from '@server/models/video/video'
 import { VideoFileModel } from '@server/models/video/video-file'
 import { VideoLiveModel } from '@server/models/video/video-live'
@@ -71,32 +70,6 @@ async function saveLive (video: MVideo, live: MVideoLive) {
     }
   }
 
-  const replayFiles = await readdir(replayDirectory)
-
-  const resolutions: number[] = []
-  let duration: number
-
-  for (const playlistFile of playlistFiles) {
-    const playlistPath = join(replayDirectory, playlistFile)
-    const { videoFileResolution } = await getVideoFileResolution(playlistPath)
-
-    // Put the final mp4 in the hls directory, and not in the replay directory
-    const mp4TmpPath = buildMP4TmpPath(hlsDirectory, videoFileResolution)
-
-    // Playlist name is for example 3.m3u8
-    // Segments names are 3-0.ts 3-1.ts etc
-    const shouldStartWith = playlistFile.replace(/\.m3u8$/, '') + '-'
-
-    const segmentFiles = replayFiles.filter(f => f.startsWith(shouldStartWith) && f.endsWith('.ts'))
-    await hlsPlaylistToFragmentedMP4(replayDirectory, segmentFiles, mp4TmpPath)
-
-    if (!duration) {
-      duration = await getDurationFromVideoFile(mp4TmpPath)
-    }
-
-    resolutions.push(videoFileResolution)
-  }
-
   await cleanupLiveFiles(hlsDirectory)
 
   await live.destroy()
@@ -105,7 +78,6 @@ async function saveLive (video: MVideo, live: MVideoLive) {
   // Reinit views
   video.views = 0
   video.state = VideoState.TO_TRANSCODE
-  video.duration = duration
 
   await video.save()
 
@@ -116,21 +88,35 @@ async function saveLive (video: MVideo, live: MVideoLive) {
   await VideoFileModel.removeHLSFilesOfVideoId(hlsPlaylist.id)
   hlsPlaylist.VideoFiles = []
 
-  for (const resolution of resolutions) {
-    const videoInputPath = buildMP4TmpPath(hlsDirectory, resolution)
-    const { isPortraitMode } = await getVideoFileResolution(videoInputPath)
+  const replayFiles = await readdir(replayDirectory)
+  let duration: number
 
-    await generateHlsPlaylist({
+  for (const playlistFile of playlistFiles) {
+    const playlistPath = join(replayDirectory, playlistFile)
+    const { videoFileResolution, isPortraitMode } = await getVideoFileResolution(playlistPath)
+
+    // Playlist name is for example 3.m3u8
+    // Segments names are 3-0.ts 3-1.ts etc
+    const shouldStartWith = playlistFile.replace(/\.m3u8$/, '') + '-'
+
+    const segmentFiles = replayFiles.filter(f => f.startsWith(shouldStartWith) && f.endsWith('.ts'))
+
+    const outputPath = await generateHlsPlaylistFromTS({
       video: videoWithFiles,
-      videoInputPath,
-      resolution: resolution,
-      copyCodecs: true,
+      replayDirectory,
+      segmentFiles,
+      resolution: videoFileResolution,
       isPortraitMode
     })
 
-    await remove(videoInputPath)
+    if (!duration) {
+      videoWithFiles.duration = await getDurationFromVideoFile(outputPath)
+      await videoWithFiles.save()
+    }
   }
 
+  await remove(replayDirectory)
+
   // Regenerate the thumbnail & preview?
   if (videoWithFiles.getMiniature().automaticallyGenerated === true) {
     await generateVideoMiniature(videoWithFiles, videoWithFiles.getMaxQualityFile(), ThumbnailType.MINIATURE)
@@ -161,8 +147,7 @@ async function cleanupLiveFiles (hlsDirectory: string) {
       filename.endsWith('.m3u8') ||
       filename.endsWith('.mpd') ||
       filename.endsWith('.m4s') ||
-      filename.endsWith('.tmp') ||
-      filename === VIDEO_LIVE.REPLAY_DIRECTORY
+      filename.endsWith('.tmp')
     ) {
       const p = join(hlsDirectory, filename)
 
@@ -171,7 +156,3 @@ async function cleanupLiveFiles (hlsDirectory: string) {
     }
   }
 }
-
-function buildMP4TmpPath (basePath: string, resolution: number) {
-  return join(basePath, resolution + '-tmp.mp4')
-}
index e022f2a68affdcff375585d208a127e65fae4bfe..890b23a449b75e4aede4139938198b065762ae60 100644 (file)
@@ -1,4 +1,4 @@
-import { copyFile, ensureDir, move, remove, stat } from 'fs-extra'
+import { copyFile, ensureDir, move, remove, stat, writeFile } from 'fs-extra'
 import { basename, extname as extnameUtil, join } from 'path'
 import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
 import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoWithAllFiles, MVideoWithFile } from '@server/types/models'
@@ -163,15 +163,104 @@ async function mergeAudioVideofile (video: MVideoWithAllFiles, resolution: Video
   return onVideoFileTranscoding(video, inputVideoFile, videoTranscodedPath, videoOutputPath)
 }
 
+// Concat TS segments from a live video to a fragmented mp4 HLS playlist
+async function generateHlsPlaylistFromTS (options: {
+  video: MVideoWithFile
+  replayDirectory: string
+  segmentFiles: string[]
+  resolution: VideoResolution
+  isPortraitMode: boolean
+}) {
+  const concatFilePath = join(options.replayDirectory, 'concat.txt')
+
+  function cleaner () {
+    remove(concatFilePath)
+      .catch(err => logger.error('Cannot remove concat file in %s.', options.replayDirectory, { err }))
+  }
+
+  // First concat the ts files to a mp4 file
+  const content = options.segmentFiles.map(f => 'file ' + f)
+                                      .join('\n')
+
+  await writeFile(concatFilePath, content + '\n')
+
+  try {
+    const outputPath = await generateHlsPlaylistCommon({
+      video: options.video,
+      resolution: options.resolution,
+      isPortraitMode: options.isPortraitMode,
+      inputPath: concatFilePath,
+      type: 'hls-from-ts' as 'hls-from-ts'
+    })
+
+    cleaner()
+
+    return outputPath
+  } catch (err) {
+    cleaner()
+
+    throw err
+  }
+}
+
 // Generate an HLS playlist from an input file, and update the master playlist
-async function generateHlsPlaylist (options: {
+function generateHlsPlaylist (options: {
   video: MVideoWithFile
   videoInputPath: string
   resolution: VideoResolution
   copyCodecs: boolean
   isPortraitMode: boolean
 }) {
-  const { video, videoInputPath, resolution, copyCodecs, isPortraitMode } = options
+  return generateHlsPlaylistCommon({
+    video: options.video,
+    resolution: options.resolution,
+    copyCodecs: options.copyCodecs,
+    isPortraitMode: options.isPortraitMode,
+    inputPath: options.videoInputPath,
+    type: 'hls' as 'hls'
+  })
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  generateHlsPlaylist,
+  generateHlsPlaylistFromTS,
+  optimizeOriginalVideofile,
+  transcodeNewResolution,
+  mergeAudioVideofile
+}
+
+// ---------------------------------------------------------------------------
+
+async function onVideoFileTranscoding (video: MVideoWithFile, videoFile: MVideoFile, transcodingPath: string, outputPath: string) {
+  const stats = await stat(transcodingPath)
+  const fps = await getVideoFileFPS(transcodingPath)
+  const metadata = await getMetadataFromFile(transcodingPath)
+
+  await move(transcodingPath, outputPath, { overwrite: true })
+
+  videoFile.size = stats.size
+  videoFile.fps = fps
+  videoFile.metadata = metadata
+
+  await createTorrentAndSetInfoHash(video, videoFile)
+
+  await VideoFileModel.customUpsert(videoFile, 'video', undefined)
+  video.VideoFiles = await video.$get('VideoFiles')
+
+  return video
+}
+
+async function generateHlsPlaylistCommon (options: {
+  type: 'hls' | 'hls-from-ts'
+  video: MVideoWithFile
+  inputPath: string
+  resolution: VideoResolution
+  copyCodecs?: boolean
+  isPortraitMode: boolean
+}) {
+  const { type, video, inputPath, resolution, copyCodecs, isPortraitMode } = options
 
   const baseHlsDirectory = join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid)
   await ensureDir(join(HLS_STREAMING_PLAYLIST_DIRECTORY, video.uuid))
@@ -180,9 +269,9 @@ async function generateHlsPlaylist (options: {
   const videoFilename = generateVideoStreamingPlaylistName(video.uuid, resolution)
 
   const transcodeOptions = {
-    type: 'hls' as 'hls',
+    type,
 
-    inputPath: videoInputPath,
+    inputPath,
     outputPath,
 
     availableEncoders,
@@ -242,35 +331,5 @@ async function generateHlsPlaylist (options: {
   await updateMasterHLSPlaylist(video)
   await updateSha256VODSegments(video)
 
-  return video
-}
-
-// ---------------------------------------------------------------------------
-
-export {
-  generateHlsPlaylist,
-  optimizeOriginalVideofile,
-  transcodeNewResolution,
-  mergeAudioVideofile
-}
-
-// ---------------------------------------------------------------------------
-
-async function onVideoFileTranscoding (video: MVideoWithFile, videoFile: MVideoFile, transcodingPath: string, outputPath: string) {
-  const stats = await stat(transcodingPath)
-  const fps = await getVideoFileFPS(transcodingPath)
-  const metadata = await getMetadataFromFile(transcodingPath)
-
-  await move(transcodingPath, outputPath, { overwrite: true })
-
-  videoFile.size = stats.size
-  videoFile.fps = fps
-  videoFile.metadata = metadata
-
-  await createTorrentAndSetInfoHash(video, videoFile)
-
-  await VideoFileModel.customUpsert(videoFile, 'video', undefined)
-  video.VideoFiles = await video.$get('VideoFiles')
-
-  return video
+  return outputPath
 }
index b020bfa45796fbb827382d2ad2ea3667f73069c6..9f9e0b069a03197a8fd8ee06fa007121a1357d64 100644 (file)
@@ -1,3 +1,6 @@
+import * as Bluebird from 'bluebird'
+import { join } from 'path'
+import { FindOptions, literal, Op, ScopeOptions, Transaction, WhereOptions } from 'sequelize'
 import {
   AllowNull,
   BelongsTo,
@@ -15,14 +18,19 @@ import {
   Table,
   UpdatedAt
 } from 'sequelize-typescript'
+import { MAccountId, MChannelId } from '@server/types/models'
+import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
+import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
 import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
-import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getPlaylistSort, getPlaylistSort, isOutdated, throwIfNotValid } from '../utils'
+import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model'
+import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model'
+import { activityPubCollectionPagination } from '../../helpers/activitypub'
+import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
 import {
   isVideoPlaylistDescriptionValid,
   isVideoPlaylistNameValid,
   isVideoPlaylistPrivacyValid
 } from '../../helpers/custom-validators/video-playlists'
-import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
 import {
   ACTIVITY_PUB,
   CONSTRAINTS_FIELDS,
@@ -32,18 +40,7 @@ import {
   VIDEO_PLAYLIST_TYPES,
   WEBSERVER
 } from '../../initializers/constants'
-import { VideoPlaylist } from '../../../shared/models/videos/playlist/video-playlist.model'
-import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
-import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
-import { join } from 'path'
-import { VideoPlaylistElementModel } from './video-playlist-element'
-import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
-import { activityPubCollectionPagination } from '../../helpers/activitypub'
-import { VideoPlaylistType } from '../../../shared/models/videos/playlist/video-playlist-type.model'
-import { ThumbnailModel } from './thumbnail'
-import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
-import { FindOptions, literal, Op, ScopeOptions, Transaction, WhereOptions } from 'sequelize'
-import * as Bluebird from 'bluebird'
+import { MThumbnail } from '../../types/models/video/thumbnail'
 import {
   MVideoPlaylistAccountThumbnail,
   MVideoPlaylistAP,
@@ -52,8 +49,11 @@ import {
   MVideoPlaylistFullSummary,
   MVideoPlaylistIdWithElements
 } from '../../types/models/video/video-playlist'
-import { MThumbnail } from '../../types/models/video/thumbnail'
-import { MAccountId, MChannelId } from '@server/types/models'
+import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
+import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getPlaylistSort, isOutdated, throwIfNotValid } from '../utils'
+import { ThumbnailModel } from './thumbnail'
+import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
+import { VideoPlaylistElementModel } from './video-playlist-element'
 
 enum ScopeNames {
   AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
index d0586499ba081396bed82b0bb05224a9081461c4..23f8d2be1ccc1fea394e1d584479211050eaac88 100644 (file)
@@ -430,6 +430,8 @@ describe('Test live', function () {
         expect(video.files).to.have.lengthOf(0)
 
         const hlsPlaylist = video.streamingPlaylists.find(s => s.type === VideoStreamingPlaylistType.HLS)
+        await makeRawRequest(hlsPlaylist.playlistUrl, 200)
+        await makeRawRequest(hlsPlaylist.segmentsSha256Url, 200)
 
         expect(hlsPlaylist.files).to.have.lengthOf(resolutions.length)