switch ffmpeg to hls muxer for live streaming
segment muxer cannot make fMP4 init file. '-strict -2' option doesn't work with segment muxer for flac remuxing.
This commit is contained in:
parent
d91a099c9e
commit
8c0778e827
|
@ -41,6 +41,9 @@ namespace Jellyfin.Api.Controllers
|
||||||
[Authorize(Policy = Policies.DefaultAuthorization)]
|
[Authorize(Policy = Policies.DefaultAuthorization)]
|
||||||
public class DynamicHlsController : BaseJellyfinApiController
|
public class DynamicHlsController : BaseJellyfinApiController
|
||||||
{
|
{
|
||||||
|
private const string DefaultEncoderPreset = "veryfast";
|
||||||
|
private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Hls;
|
||||||
|
|
||||||
private readonly ILibraryManager _libraryManager;
|
private readonly ILibraryManager _libraryManager;
|
||||||
private readonly IUserManager _userManager;
|
private readonly IUserManager _userManager;
|
||||||
private readonly IDlnaManager _dlnaManager;
|
private readonly IDlnaManager _dlnaManager;
|
||||||
|
@ -56,8 +59,7 @@ namespace Jellyfin.Api.Controllers
|
||||||
private readonly ILogger<DynamicHlsController> _logger;
|
private readonly ILogger<DynamicHlsController> _logger;
|
||||||
private readonly EncodingHelper _encodingHelper;
|
private readonly EncodingHelper _encodingHelper;
|
||||||
private readonly DynamicHlsHelper _dynamicHlsHelper;
|
private readonly DynamicHlsHelper _dynamicHlsHelper;
|
||||||
|
private readonly EncodingOptions _encodingOptions;
|
||||||
private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Hls;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="DynamicHlsController"/> class.
|
/// Initializes a new instance of the <see cref="DynamicHlsController"/> class.
|
||||||
|
@ -92,6 +94,8 @@ namespace Jellyfin.Api.Controllers
|
||||||
ILogger<DynamicHlsController> logger,
|
ILogger<DynamicHlsController> logger,
|
||||||
DynamicHlsHelper dynamicHlsHelper)
|
DynamicHlsHelper dynamicHlsHelper)
|
||||||
{
|
{
|
||||||
|
_encodingHelper = new EncodingHelper(mediaEncoder, fileSystem, subtitleEncoder, configuration);
|
||||||
|
|
||||||
_libraryManager = libraryManager;
|
_libraryManager = libraryManager;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_dlnaManager = dlnaManager;
|
_dlnaManager = dlnaManager;
|
||||||
|
@ -106,8 +110,7 @@ namespace Jellyfin.Api.Controllers
|
||||||
_transcodingJobHelper = transcodingJobHelper;
|
_transcodingJobHelper = transcodingJobHelper;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_dynamicHlsHelper = dynamicHlsHelper;
|
_dynamicHlsHelper = dynamicHlsHelper;
|
||||||
|
_encodingOptions = serverConfigurationManager.GetEncodingOptions();
|
||||||
_encodingHelper = new EncodingHelper(_mediaEncoder, _fileSystem, _subtitleEncoder, _configuration);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -1255,7 +1258,7 @@ namespace Jellyfin.Api.Controllers
|
||||||
|
|
||||||
if (segmentId == -1)
|
if (segmentId == -1)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Starting transcoding because fmp4 header file is being requested");
|
_logger.LogDebug("Starting transcoding because fmp4 init file is being requested");
|
||||||
startTranscoding = true;
|
startTranscoding = true;
|
||||||
segmentId = 0;
|
segmentId = 0;
|
||||||
}
|
}
|
||||||
|
@ -1291,11 +1294,10 @@ namespace Jellyfin.Api.Controllers
|
||||||
streamingRequest.StartTimeTicks = GetStartPositionTicks(state, segmentId);
|
streamingRequest.StartTimeTicks = GetStartPositionTicks(state, segmentId);
|
||||||
|
|
||||||
state.WaitForPath = segmentPath;
|
state.WaitForPath = segmentPath;
|
||||||
var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
|
|
||||||
job = await _transcodingJobHelper.StartFfMpeg(
|
job = await _transcodingJobHelper.StartFfMpeg(
|
||||||
state,
|
state,
|
||||||
playlistPath,
|
playlistPath,
|
||||||
GetCommandLineArguments(playlistPath, encodingOptions, state, true, segmentId),
|
GetCommandLineArguments(playlistPath, state, true, segmentId),
|
||||||
Request,
|
Request,
|
||||||
_transcodingJobType,
|
_transcodingJobType,
|
||||||
cancellationTokenSource).ConfigureAwait(false);
|
cancellationTokenSource).ConfigureAwait(false);
|
||||||
|
@ -1351,11 +1353,10 @@ namespace Jellyfin.Api.Controllers
|
||||||
return result.ToArray();
|
return result.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetCommandLineArguments(string outputPath, EncodingOptions encodingOptions, StreamState state, bool isEncoding, int startNumber)
|
private string GetCommandLineArguments(string outputPath, StreamState state, bool isEncoding, int startNumber)
|
||||||
{
|
{
|
||||||
var videoCodec = _encodingHelper.GetVideoEncoder(state, encodingOptions);
|
var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
|
||||||
|
var threads = _encodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec);
|
||||||
var threads = _encodingHelper.GetNumberOfThreads(state, encodingOptions, videoCodec);
|
|
||||||
|
|
||||||
if (state.BaseRequest.BreakOnNonKeyFrames)
|
if (state.BaseRequest.BreakOnNonKeyFrames)
|
||||||
{
|
{
|
||||||
|
@ -1367,11 +1368,9 @@ namespace Jellyfin.Api.Controllers
|
||||||
state.BaseRequest.BreakOnNonKeyFrames = false;
|
state.BaseRequest.BreakOnNonKeyFrames = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var inputModifier = _encodingHelper.GetInputModifier(state, encodingOptions);
|
|
||||||
|
|
||||||
// If isEncoding is true we're actually starting ffmpeg
|
// If isEncoding is true we're actually starting ffmpeg
|
||||||
var startNumberParam = isEncoding ? startNumber.ToString(CultureInfo.InvariantCulture) : "0";
|
var startNumberParam = isEncoding ? startNumber.ToString(CultureInfo.InvariantCulture) : "0";
|
||||||
|
var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions);
|
||||||
var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty;
|
var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty;
|
||||||
|
|
||||||
var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath);
|
var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath);
|
||||||
|
@ -1379,7 +1378,7 @@ namespace Jellyfin.Api.Controllers
|
||||||
var outputExtension = GetSegmentFileExtension(state.Request.SegmentContainer);
|
var outputExtension = GetSegmentFileExtension(state.Request.SegmentContainer);
|
||||||
var outputTsArg = outputPrefix + "%d" + outputExtension;
|
var outputTsArg = outputPrefix + "%d" + outputExtension;
|
||||||
|
|
||||||
var segmentFormat = GetSegmentFileExtension(state.Request.SegmentContainer).TrimStart('.');
|
var segmentFormat = outputExtension.TrimStart('.');
|
||||||
if (string.Equals(segmentFormat, "ts", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(segmentFormat, "ts", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
segmentFormat = "mpegts";
|
segmentFormat = "mpegts";
|
||||||
|
@ -1406,19 +1405,19 @@ namespace Jellyfin.Api.Controllers
|
||||||
_logger.LogError("Invalid HLS segment container: " + segmentFormat);
|
_logger.LogError("Invalid HLS segment container: " + segmentFormat);
|
||||||
}
|
}
|
||||||
|
|
||||||
var maxMuxingQueueSize = encodingOptions.MaxMuxingQueueSize > 128
|
var maxMuxingQueueSize = _encodingOptions.MaxMuxingQueueSize > 128
|
||||||
? encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture)
|
? _encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture)
|
||||||
: "128";
|
: "128";
|
||||||
|
|
||||||
return string.Format(
|
return string.Format(
|
||||||
CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
"{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts 0 -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -individual_header_trailer 0 -hls_segment_type {8} -start_number {9} -hls_segment_filename \"{10}\" -hls_playlist_type vod -hls_list_size 0 -y \"{11}\"",
|
"{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts 0 -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -individual_header_trailer 0 -hls_segment_type {8} -start_number {9} -hls_segment_filename \"{10}\" -hls_playlist_type vod -hls_list_size 0 -y \"{11}\"",
|
||||||
inputModifier,
|
inputModifier,
|
||||||
_encodingHelper.GetInputArgument(state, encodingOptions),
|
_encodingHelper.GetInputArgument(state, _encodingOptions),
|
||||||
threads,
|
threads,
|
||||||
mapArgs,
|
mapArgs,
|
||||||
GetVideoArguments(state, encodingOptions, startNumber),
|
GetVideoArguments(state, startNumber),
|
||||||
GetAudioArguments(state, encodingOptions),
|
GetAudioArguments(state),
|
||||||
maxMuxingQueueSize,
|
maxMuxingQueueSize,
|
||||||
state.SegmentLength.ToString(CultureInfo.InvariantCulture),
|
state.SegmentLength.ToString(CultureInfo.InvariantCulture),
|
||||||
segmentFormat,
|
segmentFormat,
|
||||||
|
@ -1427,7 +1426,12 @@ namespace Jellyfin.Api.Controllers
|
||||||
outputPath).Trim();
|
outputPath).Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetAudioArguments(StreamState state, EncodingOptions encodingOptions)
|
/// <summary>
|
||||||
|
/// Gets the audio arguments for transcoding.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="state">The <see cref="StreamState"/>.</param>
|
||||||
|
/// <returns>The command line arguments for audio transcoding.</returns>
|
||||||
|
private string GetAudioArguments(StreamState state)
|
||||||
{
|
{
|
||||||
if (state.AudioStream == null)
|
if (state.AudioStream == null)
|
||||||
{
|
{
|
||||||
|
@ -1468,7 +1472,7 @@ namespace Jellyfin.Api.Controllers
|
||||||
|
|
||||||
if (EncodingHelper.IsCopyCodec(audioCodec))
|
if (EncodingHelper.IsCopyCodec(audioCodec))
|
||||||
{
|
{
|
||||||
var videoCodec = _encodingHelper.GetVideoEncoder(state, encodingOptions);
|
var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
|
||||||
|
|
||||||
if (EncodingHelper.IsCopyCodec(videoCodec) && state.EnableBreakOnNonKeyFrames(videoCodec))
|
if (EncodingHelper.IsCopyCodec(videoCodec) && state.EnableBreakOnNonKeyFrames(videoCodec))
|
||||||
{
|
{
|
||||||
|
@ -1499,23 +1503,34 @@ namespace Jellyfin.Api.Controllers
|
||||||
args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture);
|
args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture);
|
||||||
}
|
}
|
||||||
|
|
||||||
args += " " + _encodingHelper.GetAudioFilterParam(state, encodingOptions, true);
|
args += " " + _encodingHelper.GetAudioFilterParam(state, _encodingOptions, true);
|
||||||
|
|
||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetVideoArguments(StreamState state, EncodingOptions encodingOptions, int startNumber)
|
/// <summary>
|
||||||
|
/// Gets the video arguments for transcoding.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="state">The <see cref="StreamState"/>.</param>
|
||||||
|
/// <param name="startNumber">The first number in the hls sequence.</param>
|
||||||
|
/// <returns>The command line arguments for video transcoding.</returns>
|
||||||
|
private string GetVideoArguments(StreamState state, int startNumber)
|
||||||
{
|
{
|
||||||
|
if (state.VideoStream == null)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
if (!state.IsOutputVideo)
|
if (!state.IsOutputVideo)
|
||||||
{
|
{
|
||||||
return string.Empty;
|
return string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
var codec = _encodingHelper.GetVideoEncoder(state, encodingOptions);
|
var codec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
|
||||||
|
|
||||||
var args = "-codec:v:0 " + codec;
|
var args = "-codec:v:0 " + codec;
|
||||||
|
|
||||||
// Prefer hvc1 to hev1
|
// Prefer hvc1 to hev1.
|
||||||
if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
|
if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
|
||||||
|| string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
|
|| string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
|
||||||
|| string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase)
|
|| string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase)
|
||||||
|
@ -1529,7 +1544,7 @@ namespace Jellyfin.Api.Controllers
|
||||||
// args += " -mpegts_m2ts_mode 1";
|
// args += " -mpegts_m2ts_mode 1";
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// See if we can save come cpu cycles by avoiding encoding
|
// See if we can save come cpu cycles by avoiding encoding.
|
||||||
if (EncodingHelper.IsCopyCodec(codec))
|
if (EncodingHelper.IsCopyCodec(codec))
|
||||||
{
|
{
|
||||||
if (state.VideoStream != null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase))
|
if (state.VideoStream != null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase))
|
||||||
|
@ -1553,22 +1568,21 @@ namespace Jellyfin.Api.Controllers
|
||||||
state.SegmentLength);
|
state.SegmentLength);
|
||||||
|
|
||||||
var framerate = state.VideoStream?.RealFrameRate;
|
var framerate = state.VideoStream?.RealFrameRate;
|
||||||
|
|
||||||
if (framerate.HasValue)
|
if (framerate.HasValue)
|
||||||
{
|
{
|
||||||
// This is to make sure keyframe interval is limited to our segment,
|
// This is to make sure keyframe interval is limited to our segment,
|
||||||
// as forcing keyframes is not enough.
|
// as forcing keyframes is not enough.
|
||||||
// Example: we encoded half of desired length, then codec detected
|
// Example: we encoded half of desired length, then codec detected
|
||||||
// scene cut and inserted a keyframe; next forced keyframe would
|
// scene cut and inserted a keyframe; next forced keyframe would
|
||||||
// be created outside of segment, which breaks seeking
|
// be created outside of segment, which breaks seeking.
|
||||||
// -sc_threshold 0 is used to prevent the hardware encoder from post processing to break the set keyframe
|
// -sc_threshold 0 is used to prevent the hardware encoder from post processing to break the set keyframe.
|
||||||
gopArg = string.Format(
|
gopArg = string.Format(
|
||||||
CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
" -g {0} -keyint_min {0} -sc_threshold 0",
|
" -g {0} -keyint_min {0} -sc_threshold 0",
|
||||||
Math.Ceiling(state.SegmentLength * framerate.Value));
|
Math.Ceiling(state.SegmentLength * framerate.Value));
|
||||||
}
|
}
|
||||||
|
|
||||||
args += " " + _encodingHelper.GetVideoQualityParam(state, codec, encodingOptions, "veryfast");
|
args += " " + _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, DefaultEncoderPreset);
|
||||||
|
|
||||||
// Unable to force key frames using these encoders, set key frames by GOP.
|
// Unable to force key frames using these encoders, set key frames by GOP.
|
||||||
if (string.Equals(codec, "h264_qsv", StringComparison.OrdinalIgnoreCase)
|
if (string.Equals(codec, "h264_qsv", StringComparison.OrdinalIgnoreCase)
|
||||||
|
@ -1602,16 +1616,15 @@ namespace Jellyfin.Api.Controllers
|
||||||
|
|
||||||
var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode;
|
var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode;
|
||||||
|
|
||||||
// This is for graphical subs
|
|
||||||
if (hasGraphicalSubs)
|
if (hasGraphicalSubs)
|
||||||
{
|
{
|
||||||
args += _encodingHelper.GetGraphicalSubtitleParam(state, encodingOptions, codec);
|
// Graphical subs overlay and resolution params.
|
||||||
|
args += _encodingHelper.GetGraphicalSubtitleParam(state, _encodingOptions, codec);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add resolution params, if specified
|
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
args += _encodingHelper.GetOutputSizeParam(state, encodingOptions, codec);
|
// Resolution params.
|
||||||
|
args += _encodingHelper.GetOutputSizeParam(state, _encodingOptions, codec);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -start_at_zero is necessary to use with -ss when seeking,
|
// -start_at_zero is necessary to use with -ss when seeking,
|
||||||
|
|
|
@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Api.Attributes;
|
using Jellyfin.Api.Attributes;
|
||||||
|
@ -37,7 +38,7 @@ namespace Jellyfin.Api.Controllers
|
||||||
public class VideoHlsController : BaseJellyfinApiController
|
public class VideoHlsController : BaseJellyfinApiController
|
||||||
{
|
{
|
||||||
private const string DefaultEncoderPreset = "superfast";
|
private const string DefaultEncoderPreset = "superfast";
|
||||||
private const TranscodingJobType TranscodingJobType = MediaBrowser.Controller.MediaEncoding.TranscodingJobType.Hls;
|
private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Hls;
|
||||||
|
|
||||||
private readonly EncodingHelper _encodingHelper;
|
private readonly EncodingHelper _encodingHelper;
|
||||||
private readonly IDlnaManager _dlnaManager;
|
private readonly IDlnaManager _dlnaManager;
|
||||||
|
@ -290,30 +291,30 @@ namespace Jellyfin.Api.Controllers
|
||||||
_dlnaManager,
|
_dlnaManager,
|
||||||
_deviceManager,
|
_deviceManager,
|
||||||
_transcodingJobHelper,
|
_transcodingJobHelper,
|
||||||
TranscodingJobType,
|
_transcodingJobType,
|
||||||
cancellationTokenSource.Token)
|
cancellationTokenSource.Token)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
TranscodingJobDto? job = null;
|
TranscodingJobDto? job = null;
|
||||||
var playlist = state.OutputFilePath;
|
var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8");
|
||||||
|
|
||||||
if (!System.IO.File.Exists(playlist))
|
if (!System.IO.File.Exists(playlistPath))
|
||||||
{
|
{
|
||||||
var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlist);
|
var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath);
|
||||||
await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false);
|
await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!System.IO.File.Exists(playlist))
|
if (!System.IO.File.Exists(playlistPath))
|
||||||
{
|
{
|
||||||
// If the playlist doesn't already exist, startup ffmpeg
|
// If the playlist doesn't already exist, startup ffmpeg
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
job = await _transcodingJobHelper.StartFfMpeg(
|
job = await _transcodingJobHelper.StartFfMpeg(
|
||||||
state,
|
state,
|
||||||
playlist,
|
playlistPath,
|
||||||
GetCommandLineArguments(playlist, state),
|
GetCommandLineArguments(playlistPath, state),
|
||||||
Request,
|
Request,
|
||||||
TranscodingJobType,
|
_transcodingJobType,
|
||||||
cancellationTokenSource)
|
cancellationTokenSource)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
job.IsLiveOutput = true;
|
job.IsLiveOutput = true;
|
||||||
|
@ -327,7 +328,7 @@ namespace Jellyfin.Api.Controllers
|
||||||
minSegments = state.MinSegments;
|
minSegments = state.MinSegments;
|
||||||
if (minSegments > 0)
|
if (minSegments > 0)
|
||||||
{
|
{
|
||||||
await HlsHelpers.WaitForMinimumSegmentCount(playlist, minSegments, _logger, cancellationTokenSource.Token).ConfigureAwait(false);
|
await HlsHelpers.WaitForMinimumSegmentCount(playlistPath, minSegments, _logger, cancellationTokenSource.Token).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -337,14 +338,14 @@ namespace Jellyfin.Api.Controllers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlist, TranscodingJobType);
|
job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, _transcodingJobType);
|
||||||
|
|
||||||
if (job != null)
|
if (job != null)
|
||||||
{
|
{
|
||||||
_transcodingJobHelper.OnTranscodeEndRequest(job);
|
_transcodingJobHelper.OnTranscodeEndRequest(job);
|
||||||
}
|
}
|
||||||
|
|
||||||
var playlistText = HlsHelpers.GetLivePlaylistText(playlist, state.SegmentLength);
|
var playlistText = HlsHelpers.GetLivePlaylistText(playlistPath, state);
|
||||||
|
|
||||||
return Content(playlistText, MimeTypes.GetMimeType("playlist.m3u8"));
|
return Content(playlistText, MimeTypes.GetMimeType("playlist.m3u8"));
|
||||||
}
|
}
|
||||||
|
@ -360,14 +361,43 @@ namespace Jellyfin.Api.Controllers
|
||||||
var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
|
var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
|
||||||
var threads = _encodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec);
|
var threads = _encodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec);
|
||||||
var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions);
|
var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions);
|
||||||
var format = !string.IsNullOrWhiteSpace(state.Request.SegmentContainer) ? "." + state.Request.SegmentContainer : ".ts";
|
var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty;
|
||||||
var outputTsArg = Path.Combine(Path.GetDirectoryName(outputPath), Path.GetFileNameWithoutExtension(outputPath)) + "%d" + format;
|
|
||||||
|
|
||||||
var segmentFormat = format.TrimStart('.');
|
var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath);
|
||||||
|
var outputPrefix = Path.Combine(Path.GetDirectoryName(outputPath), outputFileNameWithoutExtension);
|
||||||
|
var outputExtension = HlsHelpers.GetSegmentFileExtension(state.Request.SegmentContainer);
|
||||||
|
var outputTsArg = outputPrefix + "%d" + outputExtension;
|
||||||
|
|
||||||
|
var segmentFormat = outputExtension.TrimStart('.');
|
||||||
if (string.Equals(segmentFormat, "ts", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(segmentFormat, "ts", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
segmentFormat = "mpegts";
|
segmentFormat = "mpegts";
|
||||||
}
|
}
|
||||||
|
else if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var outputFmp4HeaderArg = string.Empty;
|
||||||
|
var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
|
||||||
|
if (isWindows)
|
||||||
|
{
|
||||||
|
// on Windows, the path of fmp4 header file needs to be configured
|
||||||
|
outputFmp4HeaderArg = " -hls_fmp4_init_filename \"" + outputPrefix + "-1" + outputExtension + "\"";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// on Linux/Unix, ffmpeg generate fmp4 header file to m3u8 output folder
|
||||||
|
outputFmp4HeaderArg = " -hls_fmp4_init_filename \"" + outputFileNameWithoutExtension + "-1" + outputExtension + "\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
segmentFormat = "fmp4" + outputFmp4HeaderArg;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogError("Invalid HLS segment container: " + segmentFormat);
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxMuxingQueueSize = _encodingOptions.MaxMuxingQueueSize > 128
|
||||||
|
? _encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture)
|
||||||
|
: "128";
|
||||||
|
|
||||||
var baseUrlParam = string.Format(
|
var baseUrlParam = string.Format(
|
||||||
CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
|
@ -376,20 +406,19 @@ namespace Jellyfin.Api.Controllers
|
||||||
|
|
||||||
return string.Format(
|
return string.Format(
|
||||||
CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
"{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -f segment -max_delay 5000000 -avoid_negative_ts disabled -start_at_zero -segment_time {6} {7} -individual_header_trailer 0 -segment_format {8} -segment_list_entry_prefix {9} -segment_list_type m3u8 -segment_start_number 0 -segment_list \"{10}\" -y \"{11}\"",
|
"{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts 0 -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -individual_header_trailer 0 -hls_segment_type {8} -start_number 0 -hls_base_url {9} -hls_playlist_type event -hls_segment_filename \"{10}\" -y \"{11}\"",
|
||||||
inputModifier,
|
inputModifier,
|
||||||
_encodingHelper.GetInputArgument(state, _encodingOptions),
|
_encodingHelper.GetInputArgument(state, _encodingOptions),
|
||||||
threads,
|
threads,
|
||||||
_encodingHelper.GetMapArgs(state),
|
_encodingHelper.GetMapArgs(state),
|
||||||
GetVideoArguments(state),
|
GetVideoArguments(state),
|
||||||
GetAudioArguments(state),
|
GetAudioArguments(state),
|
||||||
|
maxMuxingQueueSize,
|
||||||
state.SegmentLength.ToString(CultureInfo.InvariantCulture),
|
state.SegmentLength.ToString(CultureInfo.InvariantCulture),
|
||||||
string.Empty,
|
|
||||||
segmentFormat,
|
segmentFormat,
|
||||||
baseUrlParam,
|
baseUrlParam,
|
||||||
outputPath,
|
outputTsArg,
|
||||||
outputTsArg)
|
outputPath).Trim();
|
||||||
.Trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -399,14 +428,49 @@ namespace Jellyfin.Api.Controllers
|
||||||
/// <returns>The command line arguments for audio transcoding.</returns>
|
/// <returns>The command line arguments for audio transcoding.</returns>
|
||||||
private string GetAudioArguments(StreamState state)
|
private string GetAudioArguments(StreamState state)
|
||||||
{
|
{
|
||||||
var codec = _encodingHelper.GetAudioEncoder(state);
|
if (state.AudioStream == null)
|
||||||
|
|
||||||
if (EncodingHelper.IsCopyCodec(codec))
|
|
||||||
{
|
{
|
||||||
return "-codec:a:0 copy";
|
return string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
var args = "-codec:a:0 " + codec;
|
var audioCodec = _encodingHelper.GetAudioEncoder(state);
|
||||||
|
|
||||||
|
if (!state.IsOutputVideo)
|
||||||
|
{
|
||||||
|
if (EncodingHelper.IsCopyCodec(audioCodec))
|
||||||
|
{
|
||||||
|
return "-acodec copy -strict -2";
|
||||||
|
}
|
||||||
|
|
||||||
|
var audioTranscodeParams = new List<string>();
|
||||||
|
|
||||||
|
audioTranscodeParams.Add("-acodec " + audioCodec);
|
||||||
|
|
||||||
|
if (state.OutputAudioBitrate.HasValue)
|
||||||
|
{
|
||||||
|
audioTranscodeParams.Add("-ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.OutputAudioChannels.HasValue)
|
||||||
|
{
|
||||||
|
audioTranscodeParams.Add("-ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.OutputAudioSampleRate.HasValue)
|
||||||
|
{
|
||||||
|
audioTranscodeParams.Add("-ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
|
||||||
|
audioTranscodeParams.Add("-vn");
|
||||||
|
return string.Join(' ', audioTranscodeParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EncodingHelper.IsCopyCodec(audioCodec))
|
||||||
|
{
|
||||||
|
return "-codec:a:0 copy -strict -2";
|
||||||
|
}
|
||||||
|
|
||||||
|
var args = "-codec:a:0 " + audioCodec;
|
||||||
|
|
||||||
var channels = state.OutputAudioChannels;
|
var channels = state.OutputAudioChannels;
|
||||||
|
|
||||||
|
@ -439,6 +503,11 @@ namespace Jellyfin.Api.Controllers
|
||||||
/// <returns>The command line arguments for video transcoding.</returns>
|
/// <returns>The command line arguments for video transcoding.</returns>
|
||||||
private string GetVideoArguments(StreamState state)
|
private string GetVideoArguments(StreamState state)
|
||||||
{
|
{
|
||||||
|
if (state.VideoStream == null)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
if (!state.IsOutputVideo)
|
if (!state.IsOutputVideo)
|
||||||
{
|
{
|
||||||
return string.Empty;
|
return string.Empty;
|
||||||
|
@ -448,17 +517,25 @@ namespace Jellyfin.Api.Controllers
|
||||||
|
|
||||||
var args = "-codec:v:0 " + codec;
|
var args = "-codec:v:0 " + codec;
|
||||||
|
|
||||||
|
// Prefer hvc1 to hev1.
|
||||||
|
if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
args += " -tag:v:0 hvc1";
|
||||||
|
}
|
||||||
|
|
||||||
// if (state.EnableMpegtsM2TsMode)
|
// if (state.EnableMpegtsM2TsMode)
|
||||||
// {
|
// {
|
||||||
// args += " -mpegts_m2ts_mode 1";
|
// args += " -mpegts_m2ts_mode 1";
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// See if we can save come cpu cycles by avoiding encoding
|
// See if we can save come cpu cycles by avoiding encoding.
|
||||||
if (codec.Equals("copy", StringComparison.OrdinalIgnoreCase))
|
if (EncodingHelper.IsCopyCodec(codec))
|
||||||
{
|
{
|
||||||
// if h264_mp4toannexb is ever added, do not use it for live tv
|
// If h264_mp4toannexb is ever added, do not use it for live tv.
|
||||||
if (state.VideoStream != null &&
|
if (state.VideoStream != null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase))
|
||||||
!string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
string bitStreamArgs = _encodingHelper.GetBitStreamArgs(state.VideoStream);
|
string bitStreamArgs = _encodingHelper.GetBitStreamArgs(state.VideoStream);
|
||||||
if (!string.IsNullOrEmpty(bitStreamArgs))
|
if (!string.IsNullOrEmpty(bitStreamArgs))
|
||||||
|
@ -469,25 +546,73 @@ namespace Jellyfin.Api.Controllers
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
var gopArg = string.Empty;
|
||||||
var keyFrameArg = string.Format(
|
var keyFrameArg = string.Format(
|
||||||
CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
" -force_key_frames \"expr:gte(t,n_forced*{0})\"",
|
" -force_key_frames \"expr:gte(t,n_forced*{0})\"",
|
||||||
state.SegmentLength.ToString(CultureInfo.InvariantCulture));
|
state.SegmentLength.ToString(CultureInfo.InvariantCulture));
|
||||||
|
|
||||||
|
var framerate = state.VideoStream?.RealFrameRate;
|
||||||
|
if (framerate.HasValue)
|
||||||
|
{
|
||||||
|
// This is to make sure keyframe interval is limited to our segment,
|
||||||
|
// as forcing keyframes is not enough.
|
||||||
|
// Example: we encoded half of desired length, then codec detected
|
||||||
|
// scene cut and inserted a keyframe; next forced keyframe would
|
||||||
|
// be created outside of segment, which breaks seeking.
|
||||||
|
// -sc_threshold 0 is used to prevent the hardware encoder from post processing to break the set keyframe.
|
||||||
|
gopArg = string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
" -g {0} -keyint_min {0} -sc_threshold 0",
|
||||||
|
Math.Ceiling(state.SegmentLength * framerate.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
args += " " + _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, DefaultEncoderPreset);
|
||||||
|
|
||||||
|
// Unable to force key frames using these encoders, set key frames by GOP.
|
||||||
|
if (string.Equals(codec, "h264_qsv", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(codec, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(codec, "h264_amf", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(codec, "hevc_qsv", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(codec, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(codec, "hevc_amf", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
args += " " + gopArg;
|
||||||
|
}
|
||||||
|
else if (string.Equals(codec, "libx264", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(codec, "h264_vaapi", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(codec, "hevc_vaapi", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
args += " " + keyFrameArg;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
args += " " + keyFrameArg + gopArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Currenly b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now.
|
||||||
|
if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
args += " -bf 0";
|
||||||
|
}
|
||||||
|
|
||||||
var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode;
|
var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode;
|
||||||
|
|
||||||
args += " " + _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, DefaultEncoderPreset) + keyFrameArg;
|
if (hasGraphicalSubs)
|
||||||
|
|
||||||
// Add resolution params, if specified
|
|
||||||
if (!hasGraphicalSubs)
|
|
||||||
{
|
{
|
||||||
|
// Graphical subs overlay and resolution params.
|
||||||
|
args += _encodingHelper.GetGraphicalSubtitleParam(state, _encodingOptions, codec);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Resolution params.
|
||||||
args += _encodingHelper.GetOutputSizeParam(state, _encodingOptions, codec);
|
args += _encodingHelper.GetOutputSizeParam(state, _encodingOptions, codec);
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is for internal graphical subs
|
if (!(state.SubtitleStream != null && state.SubtitleStream.IsExternal && !state.SubtitleStream.IsTextSubtitleStream))
|
||||||
if (hasGraphicalSubs)
|
|
||||||
{
|
{
|
||||||
args += _encodingHelper.GetGraphicalSubtitleParam(state, _encodingOptions, codec);
|
args += " -start_at_zero";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Api.Models.StreamingDtos;
|
||||||
using MediaBrowser.Model.IO;
|
using MediaBrowser.Model.IO;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
@ -69,25 +71,79 @@ namespace Jellyfin.Api.Helpers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the extension of segment container.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="segmentContainer">The name of the segment container.</param>
|
||||||
|
/// <returns>The string text of extension.</returns>
|
||||||
|
public static string GetSegmentFileExtension(string? segmentContainer)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(segmentContainer))
|
||||||
|
{
|
||||||
|
return "." + segmentContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ".ts";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the #EXT-X-MAP string.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="outputPath">The output path of the file.</param>
|
||||||
|
/// <param name="state">The <see cref="StreamState"/>.</param>
|
||||||
|
/// <param name="isOsDepends">Get a normal string or depends on OS.</param>
|
||||||
|
/// <returns>The string text of #EXT-X-MAP.</returns>
|
||||||
|
public static string GetFmp4InitFileName(string outputPath, StreamState state, bool isOsDepends)
|
||||||
|
{
|
||||||
|
var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath);
|
||||||
|
var outputPrefix = Path.Combine(Path.GetDirectoryName(outputPath), outputFileNameWithoutExtension);
|
||||||
|
var outputExtension = GetSegmentFileExtension(state.Request.SegmentContainer);
|
||||||
|
|
||||||
|
// on Linux/Unix
|
||||||
|
// #EXT-X-MAP:URI="prefix-1.mp4"
|
||||||
|
var fmp4InitFileName = outputFileNameWithoutExtension + "-1" + outputExtension;
|
||||||
|
if (!isOsDepends)
|
||||||
|
{
|
||||||
|
return fmp4InitFileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
|
||||||
|
if (isWindows)
|
||||||
|
{
|
||||||
|
// on Windows
|
||||||
|
// #EXT-X-MAP:URI="X:\transcodes\prefix-1.mp4"
|
||||||
|
fmp4InitFileName = outputPrefix + "-1" + outputExtension;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmp4InitFileName;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the hls playlist text.
|
/// Gets the hls playlist text.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="path">The path to the playlist file.</param>
|
/// <param name="path">The path to the playlist file.</param>
|
||||||
/// <param name="segmentLength">The segment length.</param>
|
/// <param name="state">The <see cref="StreamState"/>.</param>
|
||||||
/// <returns>The playlist text as a string.</returns>
|
/// <returns>The playlist text as a string.</returns>
|
||||||
public static string GetLivePlaylistText(string path, int segmentLength)
|
public static string GetLivePlaylistText(string path, StreamState state)
|
||||||
{
|
{
|
||||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||||
using var reader = new StreamReader(stream);
|
using var reader = new StreamReader(stream);
|
||||||
|
|
||||||
var text = reader.ReadToEnd();
|
var text = reader.ReadToEnd();
|
||||||
|
|
||||||
text = text.Replace("#EXTM3U", "#EXTM3U\n#EXT-X-PLAYLIST-TYPE:EVENT", StringComparison.InvariantCulture);
|
var segmentFormat = GetSegmentFileExtension(state.Request.SegmentContainer).TrimStart('.');
|
||||||
|
if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var fmp4InitFileName = GetFmp4InitFileName(path, state, true);
|
||||||
|
var baseUrlParam = string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"hls/{0}/",
|
||||||
|
Path.GetFileNameWithoutExtension(path));
|
||||||
|
var newFmp4InitFileName = baseUrlParam + GetFmp4InitFileName(path, state, false);
|
||||||
|
|
||||||
var newDuration = "#EXT-X-TARGETDURATION:" + segmentLength.ToString(CultureInfo.InvariantCulture);
|
// Replace fMP4 init file URI.
|
||||||
|
text = text.Replace(fmp4InitFileName, newFmp4InitFileName, StringComparison.InvariantCulture);
|
||||||
text = text.Replace("#EXT-X-TARGETDURATION:" + (segmentLength - 1).ToString(CultureInfo.InvariantCulture), newDuration, StringComparison.OrdinalIgnoreCase);
|
}
|
||||||
// text = text.Replace("#EXT-X-TARGETDURATION:" + (segmentLength + 1).ToString(CultureInfo.InvariantCulture), newDuration, StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user