switch back from fmp4 to ts segments (#2554)
* restore pts offset calculation * use ts segments again * update changelog
This commit is contained in:
+1
-1
@@ -50,7 +50,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
- Rename `YAML Validation` tool to `Sequential Schedule Validation`
|
- Rename `YAML Validation` tool to `Sequential Schedule Validation`
|
||||||
- Greatly reduce debug log spam during playout builds by logging summaries of certain warnings at the end
|
- Greatly reduce debug log spam during playout builds by logging summaries of certain warnings at the end
|
||||||
- Remove *experimental* `HLS Segmenter V2` streaming mode; it is not possible to maintain quality output using this mode
|
- Remove *experimental* `HLS Segmenter V2` streaming mode; it is not possible to maintain quality output using this mode
|
||||||
- Remove original `HLS Segmenter` streaming mode and rename `HLS Segmenter (fmp4)` to `HLS Segmenter`
|
- Remove *experimental* `HLS Segmenter (fmp4)` streaming mode; this mode only worked properly in a browser, many clients did not like it
|
||||||
|
|
||||||
## [25.7.1] - 2025-10-09
|
## [25.7.1] - 2025-10-09
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
|
|||||||
using ErsatzTV.Core.Interfaces.Metadata;
|
using ErsatzTV.Core.Interfaces.Metadata;
|
||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
using ErsatzTV.Core.Interfaces.Streaming;
|
using ErsatzTV.Core.Interfaces.Streaming;
|
||||||
|
using ErsatzTV.FFmpeg.OutputFormat;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -124,6 +125,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
|
|||||||
_serviceScopeFactory,
|
_serviceScopeFactory,
|
||||||
_graphicsEngine,
|
_graphicsEngine,
|
||||||
_client,
|
_client,
|
||||||
|
OutputFormatKind.Hls,
|
||||||
_hlsPlaylistFilter,
|
_hlsPlaylistFilter,
|
||||||
_hlsInitSegmentCache,
|
_hlsInitSegmentCache,
|
||||||
_configElementRepository,
|
_configElementRepository,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public class HlsSessionWorker : IHlsSessionWorker
|
|||||||
{
|
{
|
||||||
private static int _workAheadCount;
|
private static int _workAheadCount;
|
||||||
private readonly IClient _client;
|
private readonly IClient _client;
|
||||||
|
private readonly OutputFormatKind _outputFormatKind;
|
||||||
private readonly IHlsInitSegmentCache _hlsInitSegmentCache;
|
private readonly IHlsInitSegmentCache _hlsInitSegmentCache;
|
||||||
private readonly Dictionary<long, int> _discontinuityMap = [];
|
private readonly Dictionary<long, int> _discontinuityMap = [];
|
||||||
private readonly IConfigElementRepository _configElementRepository;
|
private readonly IConfigElementRepository _configElementRepository;
|
||||||
@@ -42,6 +43,7 @@ public class HlsSessionWorker : IHlsSessionWorker
|
|||||||
private DateTimeOffset _channelStart;
|
private DateTimeOffset _channelStart;
|
||||||
private int _discontinuitySequence;
|
private int _discontinuitySequence;
|
||||||
private bool _disposedValue;
|
private bool _disposedValue;
|
||||||
|
private bool _hasWrittenSegments;
|
||||||
private DateTimeOffset _lastAccess;
|
private DateTimeOffset _lastAccess;
|
||||||
private DateTimeOffset _lastDelete = DateTimeOffset.MinValue;
|
private DateTimeOffset _lastDelete = DateTimeOffset.MinValue;
|
||||||
private IServiceScope _serviceScope;
|
private IServiceScope _serviceScope;
|
||||||
@@ -54,6 +56,7 @@ public class HlsSessionWorker : IHlsSessionWorker
|
|||||||
IServiceScopeFactory serviceScopeFactory,
|
IServiceScopeFactory serviceScopeFactory,
|
||||||
IGraphicsEngine graphicsEngine,
|
IGraphicsEngine graphicsEngine,
|
||||||
IClient client,
|
IClient client,
|
||||||
|
OutputFormatKind outputFormatKind,
|
||||||
IHlsPlaylistFilter hlsPlaylistFilter,
|
IHlsPlaylistFilter hlsPlaylistFilter,
|
||||||
IHlsInitSegmentCache hlsInitSegmentCache,
|
IHlsInitSegmentCache hlsInitSegmentCache,
|
||||||
IConfigElementRepository configElementRepository,
|
IConfigElementRepository configElementRepository,
|
||||||
@@ -65,6 +68,7 @@ public class HlsSessionWorker : IHlsSessionWorker
|
|||||||
_mediator = _serviceScope.ServiceProvider.GetRequiredService<IMediator>();
|
_mediator = _serviceScope.ServiceProvider.GetRequiredService<IMediator>();
|
||||||
_graphicsEngine = graphicsEngine;
|
_graphicsEngine = graphicsEngine;
|
||||||
_client = client;
|
_client = client;
|
||||||
|
_outputFormatKind = outputFormatKind;
|
||||||
_hlsInitSegmentCache = hlsInitSegmentCache;
|
_hlsInitSegmentCache = hlsInitSegmentCache;
|
||||||
_hlsPlaylistFilter = hlsPlaylistFilter;
|
_hlsPlaylistFilter = hlsPlaylistFilter;
|
||||||
_configElementRepository = configElementRepository;
|
_configElementRepository = configElementRepository;
|
||||||
@@ -120,7 +124,7 @@ public class HlsSessionWorker : IHlsSessionWorker
|
|||||||
|
|
||||||
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylist(
|
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylist(
|
||||||
_discontinuityMap,
|
_discontinuityMap,
|
||||||
OutputFormatKind.HlsMp4,
|
_outputFormatKind,
|
||||||
PlaylistStart,
|
PlaylistStart,
|
||||||
filterBefore,
|
filterBefore,
|
||||||
_hlsInitSegmentCache,
|
_hlsInitSegmentCache,
|
||||||
@@ -442,8 +446,7 @@ public class HlsSessionWorker : IHlsSessionWorker
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fmp4 doesn't require pts offsets because each new item gets a discontinuity AND a new init segment
|
TimeSpan ptsOffset = await GetPtsOffset(_channelNumber, cancellationToken);
|
||||||
const long PTS_OFFSET = 0;
|
|
||||||
|
|
||||||
_logger.LogDebug("HLS session state: {State}", _state);
|
_logger.LogDebug("HLS session state: {State}", _state);
|
||||||
|
|
||||||
@@ -457,7 +460,7 @@ public class HlsSessionWorker : IHlsSessionWorker
|
|||||||
startAtZero,
|
startAtZero,
|
||||||
realtime,
|
realtime,
|
||||||
_channelStart,
|
_channelStart,
|
||||||
PTS_OFFSET,
|
ptsOffset,
|
||||||
_targetFramerate);
|
_targetFramerate);
|
||||||
|
|
||||||
// _logger.LogInformation("Request {@Request}", request);
|
// _logger.LogInformation("Request {@Request}", request);
|
||||||
@@ -528,6 +531,7 @@ public class HlsSessionWorker : IHlsSessionWorker
|
|||||||
processModel.Until.Subtract(DateTimeOffset.Now).TotalSeconds);
|
processModel.Until.Subtract(DateTimeOffset.Now).TotalSeconds);
|
||||||
_transcodedUntil = processModel.Until;
|
_transcodedUntil = processModel.Until;
|
||||||
_state = NextState(_state, processModel);
|
_state = NextState(_state, processModel);
|
||||||
|
_hasWrittenSegments = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -552,7 +556,7 @@ public class HlsSessionWorker : IHlsSessionWorker
|
|||||||
_channelNumber,
|
_channelNumber,
|
||||||
StreamingMode.HttpLiveStreamingSegmenter,
|
StreamingMode.HttpLiveStreamingSegmenter,
|
||||||
realtime,
|
realtime,
|
||||||
PTS_OFFSET,
|
ptsOffset,
|
||||||
processModel.MaybeDuration,
|
processModel.MaybeDuration,
|
||||||
processModel.Until,
|
processModel.Until,
|
||||||
errorMessage),
|
errorMessage),
|
||||||
@@ -577,6 +581,8 @@ public class HlsSessionWorker : IHlsSessionWorker
|
|||||||
_transcodedUntil = processModel.Until;
|
_transcodedUntil = processModel.Until;
|
||||||
_state = NextState(_state, null);
|
_state = NextState(_state, null);
|
||||||
|
|
||||||
|
_hasWrittenSegments = true;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -648,7 +654,7 @@ public class HlsSessionWorker : IHlsSessionWorker
|
|||||||
// trim playlist and insert discontinuity before appending with new ffmpeg process
|
// trim playlist and insert discontinuity before appending with new ffmpeg process
|
||||||
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylistWithDiscontinuity(
|
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylistWithDiscontinuity(
|
||||||
_discontinuityMap,
|
_discontinuityMap,
|
||||||
OutputFormatKind.HlsMp4,
|
_outputFormatKind,
|
||||||
PlaylistStart,
|
PlaylistStart,
|
||||||
DateTimeOffset.Now.AddMinutes(-1),
|
DateTimeOffset.Now.AddMinutes(-1),
|
||||||
_hlsInitSegmentCache,
|
_hlsInitSegmentCache,
|
||||||
@@ -759,6 +765,44 @@ public class HlsSessionWorker : IHlsSessionWorker
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<TimeSpan> GetPtsOffset(string channelNumber, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _slim.WaitAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
TimeSpan result = TimeSpan.Zero;
|
||||||
|
|
||||||
|
// if we haven't yet written any segments, start at zero
|
||||||
|
if (!_hasWrittenSegments)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
await RefreshInits();
|
||||||
|
|
||||||
|
Either<BaseError, PtsTime> queryResult = await _mediator.Send(
|
||||||
|
new GetLastPtsTime(_hlsInitSegmentCache, channelNumber),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
foreach (BaseError error in queryResult.LeftToSeq())
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Unable to determine last pts offset - {Error}", error.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (PtsTime pts in queryResult.RightToSeq())
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Last pts offset is {Pts}", pts.Value);
|
||||||
|
result = pts.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_slim.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<int> GetWorkAheadLimit(CancellationToken cancellationToken) =>
|
private async Task<int> GetWorkAheadLimit(CancellationToken cancellationToken) =>
|
||||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters, cancellationToken)
|
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters, cancellationToken)
|
||||||
.Map(maybeCount => maybeCount.Match(identity, () => 1));
|
.Map(maybeCount => maybeCount.Match(identity, () => 1));
|
||||||
|
|||||||
@@ -2,19 +2,19 @@
|
|||||||
|
|
||||||
namespace ErsatzTV.Application.Streaming;
|
namespace ErsatzTV.Application.Streaming;
|
||||||
|
|
||||||
public record PtsTime(long Value)
|
public record PtsTime(TimeSpan Value)
|
||||||
{
|
{
|
||||||
public static readonly PtsTime Zero = new(0);
|
public static readonly PtsTime Zero = new(TimeSpan.Zero);
|
||||||
|
|
||||||
public static PtsTime From(string ffprobeLine)
|
public static PtsTime From(string ffprobeLine)
|
||||||
{
|
{
|
||||||
string[] split = ffprobeLine.Split("|");
|
string[] split = ffprobeLine.Split("|");
|
||||||
var ptsTime = long.Parse(split[0], CultureInfo.InvariantCulture);
|
var ptsTime = double.Parse(split[0], CultureInfo.InvariantCulture);
|
||||||
if (long.TryParse(split[1], CultureInfo.InvariantCulture, out long duration))
|
if (double.TryParse(split[1], CultureInfo.InvariantCulture, out double duration))
|
||||||
{
|
{
|
||||||
ptsTime += duration;
|
ptsTime += duration;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new PtsTime(ptsTime);
|
return new PtsTime(TimeSpan.FromSeconds(ptsTime));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,4 +10,4 @@ public record FFmpegProcessRequest(
|
|||||||
bool StartAtZero,
|
bool StartAtZero,
|
||||||
bool HlsRealtime,
|
bool HlsRealtime,
|
||||||
DateTimeOffset ChannelStartTime,
|
DateTimeOffset ChannelStartTime,
|
||||||
long PtsOffset) : IRequest<Either<BaseError, PlayoutItemProcessModel>>;
|
TimeSpan PtsOffset) : IRequest<Either<BaseError, PlayoutItemProcessModel>>;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ public record GetConcatProcessByChannelNumber : FFmpegProcessRequest
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
DateTimeOffset.Now, // unused
|
DateTimeOffset.Now, // unused
|
||||||
0)
|
TimeSpan.Zero)
|
||||||
{
|
{
|
||||||
Scheme = scheme;
|
Scheme = scheme;
|
||||||
Host = host;
|
Host = host;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ public record GetErrorProcess(
|
|||||||
string ChannelNumber,
|
string ChannelNumber,
|
||||||
StreamingMode Mode,
|
StreamingMode Mode,
|
||||||
bool HlsRealtime,
|
bool HlsRealtime,
|
||||||
long PtsOffset,
|
TimeSpan PtsOffset,
|
||||||
Option<TimeSpan> MaybeDuration,
|
Option<TimeSpan> MaybeDuration,
|
||||||
DateTimeOffset Until,
|
DateTimeOffset Until,
|
||||||
string ErrorMessage) : FFmpegProcessRequest(
|
string ErrorMessage) : FFmpegProcessRequest(
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using ErsatzTV.Core;
|
||||||
|
using ErsatzTV.Core.FFmpeg;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Streaming;
|
||||||
|
|
||||||
|
public record GetLastPtsTime(IHlsInitSegmentCache InitSegmentCache, string ChannelNumber)
|
||||||
|
: IRequest<Either<BaseError, PtsTime>>;
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Bugsnag;
|
||||||
|
using CliWrap;
|
||||||
|
using ErsatzTV.Core;
|
||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.FFmpeg;
|
||||||
|
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||||
|
using ErsatzTV.Core.Interfaces.Metadata;
|
||||||
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Streaming;
|
||||||
|
|
||||||
|
public class GetLastPtsTimeHandler(
|
||||||
|
IClient client,
|
||||||
|
ILocalFileSystem localFileSystem,
|
||||||
|
ITempFilePool tempFilePool,
|
||||||
|
IConfigElementRepository configElementRepository,
|
||||||
|
ILogger<GetLastPtsTimeHandler> logger)
|
||||||
|
: IRequestHandler<GetLastPtsTime, Either<BaseError, PtsTime>>
|
||||||
|
{
|
||||||
|
public async Task<Either<BaseError, PtsTime>> Handle(
|
||||||
|
GetLastPtsTime request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Validation<BaseError, RequestParameters> validation = await Validate(request, cancellationToken);
|
||||||
|
return await validation.Match(
|
||||||
|
parameters => Handle(parameters, cancellationToken),
|
||||||
|
error => Task.FromResult<Either<BaseError, PtsTime>>(error.Join()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Validation<BaseError, RequestParameters>> Validate(
|
||||||
|
GetLastPtsTime request,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
await ValidateFFprobePath(cancellationToken)
|
||||||
|
.MapT(ffprobePath => new RequestParameters(request.InitSegmentCache, request.ChannelNumber, ffprobePath));
|
||||||
|
|
||||||
|
private async Task<Either<BaseError, PtsTime>> Handle(
|
||||||
|
RequestParameters parameters,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Option<FileInfo> maybeLastSegment = GetLastSegment(parameters);
|
||||||
|
foreach (FileInfo segment in maybeLastSegment)
|
||||||
|
{
|
||||||
|
return await GetPts(parameters, segment, cancellationToken).IfNoneAsync(PtsTime.Zero);
|
||||||
|
}
|
||||||
|
|
||||||
|
return BaseError.New($"Failed to determine last pts duration for channel {parameters.ChannelNumber}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Option<PtsTime>> GetPts(
|
||||||
|
RequestParameters parameters,
|
||||||
|
FileInfo segment,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string[] argumentList =
|
||||||
|
{
|
||||||
|
"-v", "0",
|
||||||
|
"-show_entries",
|
||||||
|
"packet=pts_time,duration_time",
|
||||||
|
"-of", "compact=p=0:nk=1",
|
||||||
|
segment.FullName
|
||||||
|
};
|
||||||
|
|
||||||
|
PtsTime maxTime = PtsTime.Zero;
|
||||||
|
Action<string> replaceLine = s =>
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(s))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var newPts = PtsTime.From(s.Trim());
|
||||||
|
if (newPts.Value > maxTime.Value)
|
||||||
|
{
|
||||||
|
maxTime = newPts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.LogDebug("ffprobe arguments {FFmpegArguments}", argumentList.ToList());
|
||||||
|
|
||||||
|
CommandResult probe = await Cli.Wrap(parameters.FFprobePath)
|
||||||
|
.WithArguments(argumentList)
|
||||||
|
.WithValidation(CommandResultValidation.None)
|
||||||
|
.WithStandardOutputPipe(PipeTarget.ToDelegate(replaceLine))
|
||||||
|
.ExecuteAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (probe.ExitCode != 0)
|
||||||
|
{
|
||||||
|
return Option<PtsTime>.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Option<FileInfo> GetLastSegment(RequestParameters parameters)
|
||||||
|
{
|
||||||
|
var directory = new DirectoryInfo(Path.Combine(FileSystemLayout.TranscodeFolder, parameters.ChannelNumber));
|
||||||
|
var allFiles = directory.GetFiles("*.ts").Append(directory.GetFiles("*.m4s")).ToList();
|
||||||
|
Option<FileInfo> maybeLastSegment = Optional(allFiles.OrderByDescending(f => f.Name).FirstOrDefault());
|
||||||
|
foreach (var lastSegment in maybeLastSegment)
|
||||||
|
{
|
||||||
|
if (lastSegment.Name.Contains("m4s"))
|
||||||
|
{
|
||||||
|
string[] split = lastSegment.Name.Split('_');
|
||||||
|
if (long.TryParse(split[1], out long generatedAt))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string init = parameters.InitSegmentCache.EarliestSegmentByHash(generatedAt);
|
||||||
|
string fullInit = Path.Combine(directory.FullName, init);
|
||||||
|
string combined = tempFilePool.GetNextTempFile(TempFileCategory.Fmp4LastSegment);
|
||||||
|
|
||||||
|
using (var output = File.OpenWrite(combined))
|
||||||
|
{
|
||||||
|
// copy init
|
||||||
|
using (var readInit = File.OpenRead(fullInit))
|
||||||
|
{
|
||||||
|
readInit.CopyTo(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
// copy segment
|
||||||
|
using (var readSegment = File.OpenRead(lastSegment.FullName))
|
||||||
|
{
|
||||||
|
readSegment.CopyTo(output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// return concatenated init + segment
|
||||||
|
return new FileInfo(combined);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Console.WriteLine(e);
|
||||||
|
Console.WriteLine($"Can't find init for last segment {lastSegment.FullName}");
|
||||||
|
foreach (var file in allFiles)
|
||||||
|
{
|
||||||
|
Console.WriteLine(file.FullName);
|
||||||
|
}
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastSegment;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Option<FileInfo>.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<Validation<BaseError, string>> ValidateFFprobePath(CancellationToken cancellationToken) =>
|
||||||
|
configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath, cancellationToken)
|
||||||
|
.FilterT(File.Exists)
|
||||||
|
.Map(ffprobePath => ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
|
||||||
|
|
||||||
|
private async Task SaveTroubleshootingData(string channelNumber, string output)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var directory = new DirectoryInfo(Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber));
|
||||||
|
FileInfo[] allFiles = directory.GetFiles();
|
||||||
|
|
||||||
|
string playlistFileName = Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live.m3u8");
|
||||||
|
string playlistContents = string.Empty;
|
||||||
|
if (localFileSystem.FileExists(playlistFileName))
|
||||||
|
{
|
||||||
|
playlistContents = await File.ReadAllTextAsync(playlistFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = new TroubleshootingData(allFiles, playlistContents, output);
|
||||||
|
string serialized = data.Serialize();
|
||||||
|
|
||||||
|
string file = tempFilePool.GetNextTempFile(TempFileCategory.BadTranscodeFolder);
|
||||||
|
await File.WriteAllTextAsync(file, serialized);
|
||||||
|
|
||||||
|
logger.LogWarning("Transcode folder is in bad state; troubleshooting info saved to {File}", file);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
client.Notify(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record RequestParameters(
|
||||||
|
IHlsInitSegmentCache InitSegmentCache,
|
||||||
|
string ChannelNumber,
|
||||||
|
string FFprobePath);
|
||||||
|
|
||||||
|
private sealed record TroubleshootingData(IEnumerable<FileInfo> Files, string Playlist, string ProbeOutput)
|
||||||
|
{
|
||||||
|
public string Serialize()
|
||||||
|
{
|
||||||
|
var data = new InternalData(
|
||||||
|
Files.Map(f => new FileData(f.FullName, f.Length, f.LastWriteTimeUtc)).ToList(),
|
||||||
|
Convert.ToBase64String(Encoding.UTF8.GetBytes(Playlist)),
|
||||||
|
Convert.ToBase64String(Encoding.UTF8.GetBytes(ProbeOutput)));
|
||||||
|
|
||||||
|
return JsonConvert.SerializeObject(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record FileData(string FileName, long Bytes, DateTime LastWriteTimeUtc);
|
||||||
|
|
||||||
|
private sealed record InternalData(List<FileData> Files, string EncodedPlaylist, string EncodedProbeOutput);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ public record GetPlayoutItemProcessByChannelNumber(
|
|||||||
bool StartAtZero,
|
bool StartAtZero,
|
||||||
bool HlsRealtime,
|
bool HlsRealtime,
|
||||||
DateTimeOffset ChannelStart,
|
DateTimeOffset ChannelStart,
|
||||||
long PtsOffset,
|
TimeSpan PtsOffset,
|
||||||
Option<int> TargetFramerate) : FFmpegProcessRequest(
|
Option<int> TargetFramerate) : FFmpegProcessRequest(
|
||||||
ChannelNumber,
|
ChannelNumber,
|
||||||
Mode,
|
Mode,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ public record GetWrappedProcessByChannelNumber : FFmpegProcessRequest
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
DateTimeOffset.Now, // unused
|
DateTimeOffset.Now, // unused
|
||||||
0)
|
TimeSpan.Zero)
|
||||||
{
|
{
|
||||||
Scheme = scheme;
|
Scheme = scheme;
|
||||||
Host = host;
|
Host = host;
|
||||||
|
|||||||
+1
-1
@@ -247,7 +247,7 @@ public class PrepareTroubleshootingPlaybackHandler(
|
|||||||
FillerKind.None,
|
FillerKind.None,
|
||||||
inPoint,
|
inPoint,
|
||||||
channelStartTime: DateTimeOffset.Now,
|
channelStartTime: DateTimeOffset.Now,
|
||||||
0,
|
TimeSpan.Zero,
|
||||||
None,
|
None,
|
||||||
FileSystemLayout.TranscodeTroubleshootingFolder,
|
FileSystemLayout.TranscodeTroubleshootingFolder,
|
||||||
_ => { },
|
_ => { },
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
|||||||
FillerKind fillerKind,
|
FillerKind fillerKind,
|
||||||
TimeSpan inPoint,
|
TimeSpan inPoint,
|
||||||
DateTimeOffset channelStartTime,
|
DateTimeOffset channelStartTime,
|
||||||
long ptsOffset,
|
TimeSpan ptsOffset,
|
||||||
Option<int> targetFramerate,
|
Option<int> targetFramerate,
|
||||||
Option<string> customReportsFolder,
|
Option<string> customReportsFolder,
|
||||||
Action<FFmpegPipeline> pipelineAction,
|
Action<FFmpegPipeline> pipelineAction,
|
||||||
@@ -244,7 +244,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
|||||||
switch (channel.StreamingMode)
|
switch (channel.StreamingMode)
|
||||||
{
|
{
|
||||||
case StreamingMode.HttpLiveStreamingSegmenter:
|
case StreamingMode.HttpLiveStreamingSegmenter:
|
||||||
outputFormat = OutputFormatKind.HlsMp4;
|
outputFormat = OutputFormatKind.Hls;
|
||||||
break;
|
break;
|
||||||
case StreamingMode.HttpLiveStreamingDirect:
|
case StreamingMode.HttpLiveStreamingDirect:
|
||||||
{
|
{
|
||||||
@@ -554,7 +554,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
|||||||
Option<TimeSpan> duration,
|
Option<TimeSpan> duration,
|
||||||
string errorMessage,
|
string errorMessage,
|
||||||
bool hlsRealtime,
|
bool hlsRealtime,
|
||||||
long ptsOffset,
|
TimeSpan ptsOffset,
|
||||||
string vaapiDisplay,
|
string vaapiDisplay,
|
||||||
VaapiDriver vaapiDriver,
|
VaapiDriver vaapiDriver,
|
||||||
string vaapiDevice,
|
string vaapiDevice,
|
||||||
@@ -619,7 +619,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
|||||||
switch (channel.StreamingMode)
|
switch (channel.StreamingMode)
|
||||||
{
|
{
|
||||||
case StreamingMode.HttpLiveStreamingSegmenter:
|
case StreamingMode.HttpLiveStreamingSegmenter:
|
||||||
outputFormat = OutputFormatKind.HlsMp4;
|
outputFormat = OutputFormatKind.Hls;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -631,6 +631,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
|||||||
|
|
||||||
Option<string> hlsSegmentTemplate = outputFormat switch
|
Option<string> hlsSegmentTemplate = outputFormat switch
|
||||||
{
|
{
|
||||||
|
OutputFormatKind.Hls => Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live%06d.ts"),
|
||||||
OutputFormatKind.HlsMp4 => Path.Combine(
|
OutputFormatKind.HlsMp4 => Path.Combine(
|
||||||
FileSystemLayout.TranscodeFolder,
|
FileSystemLayout.TranscodeFolder,
|
||||||
channel.Number,
|
channel.Number,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ public enum TempFileCategory
|
|||||||
CoverArt = 2,
|
CoverArt = 2,
|
||||||
CachedArtwork = 3,
|
CachedArtwork = 3,
|
||||||
|
|
||||||
|
Fmp4LastSegment = 97,
|
||||||
BadTranscodeFolder = 98,
|
BadTranscodeFolder = 98,
|
||||||
BadPlaylist = 99
|
BadPlaylist = 99
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ public interface IFFmpegProcessService
|
|||||||
FillerKind fillerKind,
|
FillerKind fillerKind,
|
||||||
TimeSpan inPoint,
|
TimeSpan inPoint,
|
||||||
DateTimeOffset channelStartTime,
|
DateTimeOffset channelStartTime,
|
||||||
long ptsOffset,
|
TimeSpan ptsOffset,
|
||||||
Option<int> targetFramerate,
|
Option<int> targetFramerate,
|
||||||
Option<string> customReportsFolder,
|
Option<string> customReportsFolder,
|
||||||
Action<FFmpegPipeline> pipelineAction,
|
Action<FFmpegPipeline> pipelineAction,
|
||||||
@@ -50,7 +50,7 @@ public interface IFFmpegProcessService
|
|||||||
Option<TimeSpan> duration,
|
Option<TimeSpan> duration,
|
||||||
string errorMessage,
|
string errorMessage,
|
||||||
bool hlsRealtime,
|
bool hlsRealtime,
|
||||||
long ptsOffset,
|
TimeSpan ptsOffset,
|
||||||
string vaapiDisplay,
|
string vaapiDisplay,
|
||||||
VaapiDriver vaapiDriver,
|
VaapiDriver vaapiDriver,
|
||||||
string vaapiDevice,
|
string vaapiDevice,
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ public class PipelineBuilderBaseTests
|
|||||||
Option<string>.None,
|
Option<string>.None,
|
||||||
Option<string>.None,
|
Option<string>.None,
|
||||||
Option<string>.None,
|
Option<string>.None,
|
||||||
0,
|
TimeSpan.Zero,
|
||||||
Option<int>.None,
|
Option<int>.None,
|
||||||
Option<int>.None,
|
Option<int>.None,
|
||||||
false,
|
false,
|
||||||
@@ -190,7 +190,7 @@ public class PipelineBuilderBaseTests
|
|||||||
Option<string>.None,
|
Option<string>.None,
|
||||||
Option<string>.None,
|
Option<string>.None,
|
||||||
Option<string>.None,
|
Option<string>.None,
|
||||||
0,
|
TimeSpan.Zero,
|
||||||
Option<int>.None,
|
Option<int>.None,
|
||||||
Option<int>.None,
|
Option<int>.None,
|
||||||
false,
|
false,
|
||||||
@@ -347,7 +347,7 @@ public class PipelineBuilderBaseTests
|
|||||||
Option<string>.None,
|
Option<string>.None,
|
||||||
Option<string>.None,
|
Option<string>.None,
|
||||||
Option<string>.None,
|
Option<string>.None,
|
||||||
0,
|
TimeSpan.Zero,
|
||||||
Option<int>.None,
|
Option<int>.None,
|
||||||
Option<int>.None,
|
Option<int>.None,
|
||||||
false,
|
false,
|
||||||
@@ -440,7 +440,7 @@ public class PipelineBuilderBaseTests
|
|||||||
Option<string>.None,
|
Option<string>.None,
|
||||||
Option<string>.None,
|
Option<string>.None,
|
||||||
Option<string>.None,
|
Option<string>.None,
|
||||||
0,
|
TimeSpan.Zero,
|
||||||
Option<int>.None,
|
Option<int>.None,
|
||||||
Option<int>.None,
|
Option<int>.None,
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ public record FFmpegState(
|
|||||||
Option<string> HlsPlaylistPath,
|
Option<string> HlsPlaylistPath,
|
||||||
Option<string> HlsSegmentTemplate,
|
Option<string> HlsSegmentTemplate,
|
||||||
Option<string> HlsInitTemplate,
|
Option<string> HlsInitTemplate,
|
||||||
long PtsOffset,
|
TimeSpan PtsOffset,
|
||||||
Option<int> ThreadCount,
|
Option<int> ThreadCount,
|
||||||
Option<int> MaybeQsvExtraHardwareFrames,
|
Option<int> MaybeQsvExtraHardwareFrames,
|
||||||
bool IsSongWithProgress,
|
bool IsSongWithProgress,
|
||||||
@@ -49,7 +49,7 @@ public record FFmpegState(
|
|||||||
Option<string>.None,
|
Option<string>.None,
|
||||||
Option<string>.None,
|
Option<string>.None,
|
||||||
Option<string>.None,
|
Option<string>.None,
|
||||||
0,
|
TimeSpan.Zero,
|
||||||
Option<int>.None,
|
Option<int>.None,
|
||||||
Option<int>.None,
|
Option<int>.None,
|
||||||
false,
|
false,
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
namespace ErsatzTV.FFmpeg.Filter.Qsv;
|
namespace ErsatzTV.FFmpeg.Filter;
|
||||||
|
|
||||||
public class QsvResetPtsFilter : BaseFilter
|
public class ResetPtsFilter : BaseFilter
|
||||||
{
|
{
|
||||||
public override string Filter => "setpts=PTS-STARTPTS";
|
public override string Filter => "setpts=PTS-STARTPTS";
|
||||||
|
|
||||||
@@ -95,12 +95,24 @@ public class OutputFormatHls : IPipelineStep
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
result.AddRange(
|
switch (_outputFormat)
|
||||||
[
|
{
|
||||||
"-hls_flags", $"{pdt}append_list+discont_start{independentSegments}",
|
case OutputFormatKind.HlsMp4:
|
||||||
"-mpegts_flags", "+initial_discontinuity",
|
result.AddRange(
|
||||||
_playlistPath
|
[
|
||||||
]);
|
"-hls_flags", $"{pdt}append_list+discont_start{independentSegments}",
|
||||||
|
_playlistPath
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
result.AddRange(
|
||||||
|
[
|
||||||
|
"-hls_flags", $"{pdt}append_list+discont_start{independentSegments}",
|
||||||
|
"-mpegts_flags", "+initial_discontinuity",
|
||||||
|
_playlistPath
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.ToArray();
|
return result.ToArray();
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
namespace ErsatzTV.FFmpeg.OutputOption;
|
namespace ErsatzTV.FFmpeg.OutputOption;
|
||||||
|
|
||||||
public class OutputTsOffsetOption(long ptsOffset, int videoTrackTimeScale) : OutputOption
|
public class OutputTsOffsetOption(TimeSpan ptsOffset) : OutputOption
|
||||||
{
|
{
|
||||||
public override string[] OutputOptions =>
|
public override string[] OutputOptions =>
|
||||||
[
|
[
|
||||||
"-output_ts_offset",
|
"-output_ts_offset",
|
||||||
$"{(ptsOffset / (double)videoTrackTimeScale).ToString(NumberFormatInfo.InvariantInfo)}"
|
$"{ptsOffset.TotalSeconds.ToString(NumberFormatInfo.InvariantInfo)}s"
|
||||||
];
|
];
|
||||||
|
|
||||||
// public override FrameState NextState(FrameState currentState) => currentState with { PtsOffset = _ptsOffset };
|
// public override FrameState NextState(FrameState currentState) => currentState with { PtsOffset = _ptsOffset };
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
|
|||||||
SetTimeLimit(ffmpegState, pipelineSteps);
|
SetTimeLimit(ffmpegState, pipelineSteps);
|
||||||
|
|
||||||
(FilterChain filterChain, ffmpegState) = BuildVideoPipeline(
|
(FilterChain filterChain, ffmpegState) = BuildVideoPipeline(
|
||||||
|
isFmp4Hls,
|
||||||
videoInputFile,
|
videoInputFile,
|
||||||
videoStream,
|
videoStream,
|
||||||
ffmpegState,
|
ffmpegState,
|
||||||
@@ -386,7 +387,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
|
|||||||
segmentTemplate,
|
segmentTemplate,
|
||||||
ffmpegState.HlsInitTemplate,
|
ffmpegState.HlsInitTemplate,
|
||||||
playlistPath,
|
playlistPath,
|
||||||
ffmpegState.PtsOffset == 0,
|
ffmpegState.PtsOffset == TimeSpan.Zero,
|
||||||
oneSecondGop,
|
oneSecondGop,
|
||||||
ffmpegState.IsTroubleshooting));
|
ffmpegState.IsTroubleshooting));
|
||||||
}
|
}
|
||||||
@@ -539,6 +540,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
|
|||||||
ICollection<IPipelineStep> pipelineSteps);
|
ICollection<IPipelineStep> pipelineSteps);
|
||||||
|
|
||||||
private FilterChainAndState BuildVideoPipeline(
|
private FilterChainAndState BuildVideoPipeline(
|
||||||
|
bool isFmp4Hls,
|
||||||
VideoInputFile videoInputFile,
|
VideoInputFile videoInputFile,
|
||||||
VideoStream videoStream,
|
VideoStream videoStream,
|
||||||
FFmpegState ffmpegState,
|
FFmpegState ffmpegState,
|
||||||
@@ -593,7 +595,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
|
|||||||
_fontsFolder,
|
_fontsFolder,
|
||||||
pipelineSteps);
|
pipelineSteps);
|
||||||
|
|
||||||
SetOutputTsOffset(ffmpegState, desiredState, pipelineSteps);
|
SetOutputTsOffset(isFmp4Hls, ffmpegState, desiredState, pipelineSteps);
|
||||||
|
|
||||||
return new FilterChainAndState(filterChain, ffmpegState);
|
return new FilterChainAndState(filterChain, ffmpegState);
|
||||||
}
|
}
|
||||||
@@ -674,6 +676,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void SetOutputTsOffset(
|
private static void SetOutputTsOffset(
|
||||||
|
bool isFmp4Hls,
|
||||||
FFmpegState ffmpegState,
|
FFmpegState ffmpegState,
|
||||||
FrameState desiredState,
|
FrameState desiredState,
|
||||||
List<IPipelineStep> pipelineSteps)
|
List<IPipelineStep> pipelineSteps)
|
||||||
@@ -683,12 +686,9 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ffmpegState.PtsOffset > 0)
|
if (ffmpegState.PtsOffset > TimeSpan.Zero && !isFmp4Hls)
|
||||||
{
|
{
|
||||||
foreach (int videoTrackTimeScale in desiredState.VideoTrackTimeScale)
|
pipelineSteps.Add(new OutputTsOffsetOption(ffmpegState.PtsOffset));
|
||||||
{
|
|
||||||
pipelineSteps.Add(new OutputTsOffsetOption(ffmpegState.PtsOffset, videoTrackTimeScale));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
|
|||||||
currentState = decoder.NextState(currentState);
|
currentState = decoder.NextState(currentState);
|
||||||
}
|
}
|
||||||
|
|
||||||
videoInputFile.FilterSteps.Add(new QsvResetPtsFilter());
|
videoInputFile.FilterSteps.Add(new ResetPtsFilter());
|
||||||
|
|
||||||
// easier to use nv12 for overlay
|
// easier to use nv12 for overlay
|
||||||
if (context.HasSubtitleOverlay || context.HasWatermark || context.HasGraphicsEngine)
|
if (context.HasSubtitleOverlay || context.HasWatermark || context.HasGraphicsEngine)
|
||||||
|
|||||||
@@ -396,7 +396,7 @@ public class TranscodingTests
|
|||||||
FillerKind.None,
|
FillerKind.None,
|
||||||
TimeSpan.Zero,
|
TimeSpan.Zero,
|
||||||
DateTimeOffset.Now,
|
DateTimeOffset.Now,
|
||||||
0,
|
TimeSpan.Zero,
|
||||||
None,
|
None,
|
||||||
Option<string>.None,
|
Option<string>.None,
|
||||||
_ => { },
|
_ => { },
|
||||||
@@ -707,7 +707,7 @@ public class TranscodingTests
|
|||||||
FillerKind.None,
|
FillerKind.None,
|
||||||
TimeSpan.Zero,
|
TimeSpan.Zero,
|
||||||
DateTimeOffset.Now,
|
DateTimeOffset.Now,
|
||||||
0,
|
TimeSpan.Zero,
|
||||||
None,
|
None,
|
||||||
Option<string>.None,
|
Option<string>.None,
|
||||||
PipelineAction,
|
PipelineAction,
|
||||||
|
|||||||
@@ -119,9 +119,9 @@ public class TroubleshootController(
|
|||||||
|
|
||||||
string[] segmentFiles = streamingMode switch
|
string[] segmentFiles = streamingMode switch
|
||||||
{
|
{
|
||||||
StreamingMode.HttpLiveStreamingSegmenter => Directory.GetFiles(
|
// StreamingMode.HttpLiveStreamingSegmenter => Directory.GetFiles(
|
||||||
FileSystemLayout.TranscodeTroubleshootingFolder,
|
// FileSystemLayout.TranscodeTroubleshootingFolder,
|
||||||
"*.m4s"),
|
// "*.m4s"),
|
||||||
_ => Directory.GetFiles(FileSystemLayout.TranscodeTroubleshootingFolder, "*.ts")
|
_ => Directory.GetFiles(FileSystemLayout.TranscodeTroubleshootingFolder, "*.ts")
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ public class InternalController : StreamingControllerBase
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
DateTimeOffset.Now,
|
DateTimeOffset.Now,
|
||||||
0,
|
TimeSpan.Zero,
|
||||||
Option<int>.None);
|
Option<int>.None);
|
||||||
|
|
||||||
Either<BaseError, PlayoutItemProcessModel> result = await _mediator.Send(request);
|
Either<BaseError, PlayoutItemProcessModel> result = await _mediator.Send(request);
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ public class IptvController : StreamingControllerBase
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
DateTimeOffset.Now,
|
DateTimeOffset.Now,
|
||||||
0,
|
TimeSpan.Zero,
|
||||||
Option<int>.None);
|
Option<int>.None);
|
||||||
|
|
||||||
Either<BaseError, PlayoutItemProcessModel> result = await _mediator.Send(request);
|
Either<BaseError, PlayoutItemProcessModel> result = await _mediator.Send(request);
|
||||||
|
|||||||
Reference in New Issue
Block a user