From 2ef2b0299a51804db3d77ebe2e43f8728130f886 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Tue, 21 Oct 2025 12:17:05 -0500 Subject: [PATCH] switch back from fmp4 to ts segments (#2554) * restore pts offset calculation * use ts segments again * update changelog --- CHANGELOG.md | 2 +- .../Commands/StartFFmpegSessionHandler.cs | 2 + .../Streaming/HlsSessionWorker.cs | 56 ++++- ErsatzTV.Application/Streaming/PtsTime.cs | 10 +- .../Streaming/Queries/FFmpegProcessRequest.cs | 2 +- .../GetConcatProcessByChannelNumber.cs | 2 +- .../Streaming/Queries/GetErrorProcess.cs | 2 +- .../Streaming/Queries/GetLastPtsTime.cs | 7 + .../Queries/GetLastPtsTimeHandler.cs | 211 ++++++++++++++++++ .../GetPlayoutItemProcessByChannelNumber.cs | 2 +- .../GetWrappedProcessByChannelNumber.cs | 2 +- .../PrepareTroubleshootingPlaybackHandler.cs | 2 +- .../FFmpeg/FFmpegLibraryProcessService.cs | 9 +- ErsatzTV.Core/FFmpeg/TempFileCategory.cs | 1 + .../FFmpeg/IFFmpegProcessService.cs | 4 +- .../PipelineBuilderBaseTests.cs | 8 +- ErsatzTV.FFmpeg/FFmpegState.cs | 4 +- ...QsvResetPtsFilter.cs => ResetPtsFilter.cs} | 4 +- .../OutputFormat/OutputFormatHls.cs | 24 +- .../OutputOption/OutputTsOffsetOption.cs | 4 +- .../Pipeline/PipelineBuilderBase.cs | 14 +- .../Pipeline/QsvPipelineBuilder.cs | 2 +- .../Core/FFmpeg/TranscodingTests.cs | 4 +- .../Controllers/Api/TroubleshootController.cs | 6 +- ErsatzTV/Controllers/InternalController.cs | 2 +- ErsatzTV/Controllers/IptvController.cs | 2 +- 26 files changed, 333 insertions(+), 55 deletions(-) create mode 100644 ErsatzTV.Application/Streaming/Queries/GetLastPtsTime.cs create mode 100644 ErsatzTV.Application/Streaming/Queries/GetLastPtsTimeHandler.cs rename ErsatzTV.FFmpeg/Filter/{Qsv/QsvResetPtsFilter.cs => ResetPtsFilter.cs} (64%) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1ecd581c..2f0d00cc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` - 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 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 ### Added diff --git a/ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs b/ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs index f0cd1b8c4..c7b5cab98 100644 --- a/ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs +++ b/ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs @@ -11,6 +11,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Streaming; +using ErsatzTV.FFmpeg.OutputFormat; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -124,6 +125,7 @@ public class StartFFmpegSessionHandler : IRequestHandler _discontinuityMap = []; private readonly IConfigElementRepository _configElementRepository; @@ -42,6 +43,7 @@ public class HlsSessionWorker : IHlsSessionWorker private DateTimeOffset _channelStart; private int _discontinuitySequence; private bool _disposedValue; + private bool _hasWrittenSegments; private DateTimeOffset _lastAccess; private DateTimeOffset _lastDelete = DateTimeOffset.MinValue; private IServiceScope _serviceScope; @@ -54,6 +56,7 @@ public class HlsSessionWorker : IHlsSessionWorker IServiceScopeFactory serviceScopeFactory, IGraphicsEngine graphicsEngine, IClient client, + OutputFormatKind outputFormatKind, IHlsPlaylistFilter hlsPlaylistFilter, IHlsInitSegmentCache hlsInitSegmentCache, IConfigElementRepository configElementRepository, @@ -65,6 +68,7 @@ public class HlsSessionWorker : IHlsSessionWorker _mediator = _serviceScope.ServiceProvider.GetRequiredService(); _graphicsEngine = graphicsEngine; _client = client; + _outputFormatKind = outputFormatKind; _hlsInitSegmentCache = hlsInitSegmentCache; _hlsPlaylistFilter = hlsPlaylistFilter; _configElementRepository = configElementRepository; @@ -120,7 +124,7 @@ public class HlsSessionWorker : IHlsSessionWorker TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylist( _discontinuityMap, - OutputFormatKind.HlsMp4, + _outputFormatKind, PlaylistStart, filterBefore, _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 - const long PTS_OFFSET = 0; + TimeSpan ptsOffset = await GetPtsOffset(_channelNumber, cancellationToken); _logger.LogDebug("HLS session state: {State}", _state); @@ -457,7 +460,7 @@ public class HlsSessionWorker : IHlsSessionWorker startAtZero, realtime, _channelStart, - PTS_OFFSET, + ptsOffset, _targetFramerate); // _logger.LogInformation("Request {@Request}", request); @@ -528,6 +531,7 @@ public class HlsSessionWorker : IHlsSessionWorker processModel.Until.Subtract(DateTimeOffset.Now).TotalSeconds); _transcodedUntil = processModel.Until; _state = NextState(_state, processModel); + _hasWrittenSegments = true; return true; } else @@ -552,7 +556,7 @@ public class HlsSessionWorker : IHlsSessionWorker _channelNumber, StreamingMode.HttpLiveStreamingSegmenter, realtime, - PTS_OFFSET, + ptsOffset, processModel.MaybeDuration, processModel.Until, errorMessage), @@ -577,6 +581,8 @@ public class HlsSessionWorker : IHlsSessionWorker _transcodedUntil = processModel.Until; _state = NextState(_state, null); + _hasWrittenSegments = true; + return true; } } @@ -648,7 +654,7 @@ public class HlsSessionWorker : IHlsSessionWorker // trim playlist and insert discontinuity before appending with new ffmpeg process TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylistWithDiscontinuity( _discontinuityMap, - OutputFormatKind.HlsMp4, + _outputFormatKind, PlaylistStart, DateTimeOffset.Now.AddMinutes(-1), _hlsInitSegmentCache, @@ -759,6 +765,44 @@ public class HlsSessionWorker : IHlsSessionWorker } } + private async Task 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 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 GetWorkAheadLimit(CancellationToken cancellationToken) => await _configElementRepository.GetValue(ConfigElementKey.FFmpegWorkAheadSegmenters, cancellationToken) .Map(maybeCount => maybeCount.Match(identity, () => 1)); diff --git a/ErsatzTV.Application/Streaming/PtsTime.cs b/ErsatzTV.Application/Streaming/PtsTime.cs index 127c8154c..2c1144d16 100644 --- a/ErsatzTV.Application/Streaming/PtsTime.cs +++ b/ErsatzTV.Application/Streaming/PtsTime.cs @@ -2,19 +2,19 @@ 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) { string[] split = ffprobeLine.Split("|"); - var ptsTime = long.Parse(split[0], CultureInfo.InvariantCulture); - if (long.TryParse(split[1], CultureInfo.InvariantCulture, out long duration)) + var ptsTime = double.Parse(split[0], CultureInfo.InvariantCulture); + if (double.TryParse(split[1], CultureInfo.InvariantCulture, out double duration)) { ptsTime += duration; } - return new PtsTime(ptsTime); + return new PtsTime(TimeSpan.FromSeconds(ptsTime)); } } diff --git a/ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs b/ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs index 734373596..d13fc61c3 100644 --- a/ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs +++ b/ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs @@ -10,4 +10,4 @@ public record FFmpegProcessRequest( bool StartAtZero, bool HlsRealtime, DateTimeOffset ChannelStartTime, - long PtsOffset) : IRequest>; + TimeSpan PtsOffset) : IRequest>; diff --git a/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs b/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs index 7955337ea..c373b83e5 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs @@ -11,7 +11,7 @@ public record GetConcatProcessByChannelNumber : FFmpegProcessRequest false, true, DateTimeOffset.Now, // unused - 0) + TimeSpan.Zero) { Scheme = scheme; Host = host; diff --git a/ErsatzTV.Application/Streaming/Queries/GetErrorProcess.cs b/ErsatzTV.Application/Streaming/Queries/GetErrorProcess.cs index 8a2e5779e..0a4cd5b7e 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetErrorProcess.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetErrorProcess.cs @@ -6,7 +6,7 @@ public record GetErrorProcess( string ChannelNumber, StreamingMode Mode, bool HlsRealtime, - long PtsOffset, + TimeSpan PtsOffset, Option MaybeDuration, DateTimeOffset Until, string ErrorMessage) : FFmpegProcessRequest( diff --git a/ErsatzTV.Application/Streaming/Queries/GetLastPtsTime.cs b/ErsatzTV.Application/Streaming/Queries/GetLastPtsTime.cs new file mode 100644 index 000000000..c52cab433 --- /dev/null +++ b/ErsatzTV.Application/Streaming/Queries/GetLastPtsTime.cs @@ -0,0 +1,7 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.FFmpeg; + +namespace ErsatzTV.Application.Streaming; + +public record GetLastPtsTime(IHlsInitSegmentCache InitSegmentCache, string ChannelNumber) + : IRequest>; diff --git a/ErsatzTV.Application/Streaming/Queries/GetLastPtsTimeHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetLastPtsTimeHandler.cs new file mode 100644 index 000000000..3ec37c461 --- /dev/null +++ b/ErsatzTV.Application/Streaming/Queries/GetLastPtsTimeHandler.cs @@ -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 logger) + : IRequestHandler> +{ + public async Task> Handle( + GetLastPtsTime request, + CancellationToken cancellationToken) + { + Validation validation = await Validate(request, cancellationToken); + return await validation.Match( + parameters => Handle(parameters, cancellationToken), + error => Task.FromResult>(error.Join())); + } + + private async Task> Validate( + GetLastPtsTime request, + CancellationToken cancellationToken) => + await ValidateFFprobePath(cancellationToken) + .MapT(ffprobePath => new RequestParameters(request.InitSegmentCache, request.ChannelNumber, ffprobePath)); + + private async Task> Handle( + RequestParameters parameters, + CancellationToken cancellationToken) + { + Option 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> 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 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.None; + } + + return maxTime; + } + + private Option GetLastSegment(RequestParameters parameters) + { + var directory = new DirectoryInfo(Path.Combine(FileSystemLayout.TranscodeFolder, parameters.ChannelNumber)); + var allFiles = directory.GetFiles("*.ts").Append(directory.GetFiles("*.m4s")).ToList(); + Option 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.None; + } + + private Task> ValidateFFprobePath(CancellationToken cancellationToken) => + configElementRepository.GetValue(ConfigElementKey.FFprobePath, cancellationToken) + .FilterT(File.Exists) + .Map(ffprobePath => ffprobePath.ToValidation("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 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 Files, string EncodedPlaylist, string EncodedProbeOutput); + } +} diff --git a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs index 1b8b19257..3bb6ae565 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs @@ -9,7 +9,7 @@ public record GetPlayoutItemProcessByChannelNumber( bool StartAtZero, bool HlsRealtime, DateTimeOffset ChannelStart, - long PtsOffset, + TimeSpan PtsOffset, Option TargetFramerate) : FFmpegProcessRequest( ChannelNumber, Mode, diff --git a/ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumber.cs b/ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumber.cs index 54e850371..11f312bb5 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumber.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumber.cs @@ -15,7 +15,7 @@ public record GetWrappedProcessByChannelNumber : FFmpegProcessRequest false, true, DateTimeOffset.Now, // unused - 0) + TimeSpan.Zero) { Scheme = scheme; Host = host; diff --git a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs index dfd903d44..7e42fe75d 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs @@ -247,7 +247,7 @@ public class PrepareTroubleshootingPlaybackHandler( FillerKind.None, inPoint, channelStartTime: DateTimeOffset.Now, - 0, + TimeSpan.Zero, None, FileSystemLayout.TranscodeTroubleshootingFolder, _ => { }, diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs index 522f5f209..443e72d62 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs @@ -82,7 +82,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService FillerKind fillerKind, TimeSpan inPoint, DateTimeOffset channelStartTime, - long ptsOffset, + TimeSpan ptsOffset, Option targetFramerate, Option customReportsFolder, Action pipelineAction, @@ -244,7 +244,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService switch (channel.StreamingMode) { case StreamingMode.HttpLiveStreamingSegmenter: - outputFormat = OutputFormatKind.HlsMp4; + outputFormat = OutputFormatKind.Hls; break; case StreamingMode.HttpLiveStreamingDirect: { @@ -554,7 +554,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService Option duration, string errorMessage, bool hlsRealtime, - long ptsOffset, + TimeSpan ptsOffset, string vaapiDisplay, VaapiDriver vaapiDriver, string vaapiDevice, @@ -619,7 +619,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService switch (channel.StreamingMode) { case StreamingMode.HttpLiveStreamingSegmenter: - outputFormat = OutputFormatKind.HlsMp4; + outputFormat = OutputFormatKind.Hls; break; } @@ -631,6 +631,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService Option hlsSegmentTemplate = outputFormat switch { + OutputFormatKind.Hls => Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live%06d.ts"), OutputFormatKind.HlsMp4 => Path.Combine( FileSystemLayout.TranscodeFolder, channel.Number, diff --git a/ErsatzTV.Core/FFmpeg/TempFileCategory.cs b/ErsatzTV.Core/FFmpeg/TempFileCategory.cs index aa30c7d3f..d63510a4d 100644 --- a/ErsatzTV.Core/FFmpeg/TempFileCategory.cs +++ b/ErsatzTV.Core/FFmpeg/TempFileCategory.cs @@ -7,6 +7,7 @@ public enum TempFileCategory CoverArt = 2, CachedArtwork = 3, + Fmp4LastSegment = 97, BadTranscodeFolder = 98, BadPlaylist = 99 } diff --git a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs index e2973723a..495aeb602 100644 --- a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs +++ b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs @@ -37,7 +37,7 @@ public interface IFFmpegProcessService FillerKind fillerKind, TimeSpan inPoint, DateTimeOffset channelStartTime, - long ptsOffset, + TimeSpan ptsOffset, Option targetFramerate, Option customReportsFolder, Action pipelineAction, @@ -50,7 +50,7 @@ public interface IFFmpegProcessService Option duration, string errorMessage, bool hlsRealtime, - long ptsOffset, + TimeSpan ptsOffset, string vaapiDisplay, VaapiDriver vaapiDriver, string vaapiDevice, diff --git a/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs b/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs index 5d1ffa650..553c82530 100644 --- a/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs +++ b/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs @@ -91,7 +91,7 @@ public class PipelineBuilderBaseTests Option.None, Option.None, Option.None, - 0, + TimeSpan.Zero, Option.None, Option.None, false, @@ -190,7 +190,7 @@ public class PipelineBuilderBaseTests Option.None, Option.None, Option.None, - 0, + TimeSpan.Zero, Option.None, Option.None, false, @@ -347,7 +347,7 @@ public class PipelineBuilderBaseTests Option.None, Option.None, Option.None, - 0, + TimeSpan.Zero, Option.None, Option.None, false, @@ -440,7 +440,7 @@ public class PipelineBuilderBaseTests Option.None, Option.None, Option.None, - 0, + TimeSpan.Zero, Option.None, Option.None, false, diff --git a/ErsatzTV.FFmpeg/FFmpegState.cs b/ErsatzTV.FFmpeg/FFmpegState.cs index cd3bb5142..5dd2c73bf 100644 --- a/ErsatzTV.FFmpeg/FFmpegState.cs +++ b/ErsatzTV.FFmpeg/FFmpegState.cs @@ -20,7 +20,7 @@ public record FFmpegState( Option HlsPlaylistPath, Option HlsSegmentTemplate, Option HlsInitTemplate, - long PtsOffset, + TimeSpan PtsOffset, Option ThreadCount, Option MaybeQsvExtraHardwareFrames, bool IsSongWithProgress, @@ -49,7 +49,7 @@ public record FFmpegState( Option.None, Option.None, Option.None, - 0, + TimeSpan.Zero, Option.None, Option.None, false, diff --git a/ErsatzTV.FFmpeg/Filter/Qsv/QsvResetPtsFilter.cs b/ErsatzTV.FFmpeg/Filter/ResetPtsFilter.cs similarity index 64% rename from ErsatzTV.FFmpeg/Filter/Qsv/QsvResetPtsFilter.cs rename to ErsatzTV.FFmpeg/Filter/ResetPtsFilter.cs index 787f0724a..dd724b1f0 100644 --- a/ErsatzTV.FFmpeg/Filter/Qsv/QsvResetPtsFilter.cs +++ b/ErsatzTV.FFmpeg/Filter/ResetPtsFilter.cs @@ -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"; diff --git a/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs b/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs index ff5f55ed3..065b0ce8a 100644 --- a/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs +++ b/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs @@ -95,12 +95,24 @@ public class OutputFormatHls : IPipelineStep } else { - result.AddRange( - [ - "-hls_flags", $"{pdt}append_list+discont_start{independentSegments}", - "-mpegts_flags", "+initial_discontinuity", - _playlistPath - ]); + switch (_outputFormat) + { + case OutputFormatKind.HlsMp4: + result.AddRange( + [ + "-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(); diff --git a/ErsatzTV.FFmpeg/OutputOption/OutputTsOffsetOption.cs b/ErsatzTV.FFmpeg/OutputOption/OutputTsOffsetOption.cs index ad86fd6b8..173f6f515 100644 --- a/ErsatzTV.FFmpeg/OutputOption/OutputTsOffsetOption.cs +++ b/ErsatzTV.FFmpeg/OutputOption/OutputTsOffsetOption.cs @@ -2,12 +2,12 @@ namespace ErsatzTV.FFmpeg.OutputOption; -public class OutputTsOffsetOption(long ptsOffset, int videoTrackTimeScale) : OutputOption +public class OutputTsOffsetOption(TimeSpan ptsOffset) : OutputOption { public override string[] OutputOptions => [ "-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 }; diff --git a/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs b/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs index a0b039129..b19d68509 100644 --- a/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs +++ b/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs @@ -255,6 +255,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder SetTimeLimit(ffmpegState, pipelineSteps); (FilterChain filterChain, ffmpegState) = BuildVideoPipeline( + isFmp4Hls, videoInputFile, videoStream, ffmpegState, @@ -386,7 +387,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder segmentTemplate, ffmpegState.HlsInitTemplate, playlistPath, - ffmpegState.PtsOffset == 0, + ffmpegState.PtsOffset == TimeSpan.Zero, oneSecondGop, ffmpegState.IsTroubleshooting)); } @@ -539,6 +540,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder ICollection pipelineSteps); private FilterChainAndState BuildVideoPipeline( + bool isFmp4Hls, VideoInputFile videoInputFile, VideoStream videoStream, FFmpegState ffmpegState, @@ -593,7 +595,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder _fontsFolder, pipelineSteps); - SetOutputTsOffset(ffmpegState, desiredState, pipelineSteps); + SetOutputTsOffset(isFmp4Hls, ffmpegState, desiredState, pipelineSteps); return new FilterChainAndState(filterChain, ffmpegState); } @@ -674,6 +676,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder } private static void SetOutputTsOffset( + bool isFmp4Hls, FFmpegState ffmpegState, FrameState desiredState, List pipelineSteps) @@ -683,12 +686,9 @@ public abstract class PipelineBuilderBase : IPipelineBuilder return; } - if (ffmpegState.PtsOffset > 0) + if (ffmpegState.PtsOffset > TimeSpan.Zero && !isFmp4Hls) { - foreach (int videoTrackTimeScale in desiredState.VideoTrackTimeScale) - { - pipelineSteps.Add(new OutputTsOffsetOption(ffmpegState.PtsOffset, videoTrackTimeScale)); - } + pipelineSteps.Add(new OutputTsOffsetOption(ffmpegState.PtsOffset)); } } diff --git a/ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs b/ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs index d8283f738..f4d8a260e 100644 --- a/ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs +++ b/ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs @@ -170,7 +170,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder currentState = decoder.NextState(currentState); } - videoInputFile.FilterSteps.Add(new QsvResetPtsFilter()); + videoInputFile.FilterSteps.Add(new ResetPtsFilter()); // easier to use nv12 for overlay if (context.HasSubtitleOverlay || context.HasWatermark || context.HasGraphicsEngine) diff --git a/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs b/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs index 256e9e7e0..1410717af 100644 --- a/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs +++ b/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs @@ -396,7 +396,7 @@ public class TranscodingTests FillerKind.None, TimeSpan.Zero, DateTimeOffset.Now, - 0, + TimeSpan.Zero, None, Option.None, _ => { }, @@ -707,7 +707,7 @@ public class TranscodingTests FillerKind.None, TimeSpan.Zero, DateTimeOffset.Now, - 0, + TimeSpan.Zero, None, Option.None, PipelineAction, diff --git a/ErsatzTV/Controllers/Api/TroubleshootController.cs b/ErsatzTV/Controllers/Api/TroubleshootController.cs index b633632cb..2320c0d7b 100644 --- a/ErsatzTV/Controllers/Api/TroubleshootController.cs +++ b/ErsatzTV/Controllers/Api/TroubleshootController.cs @@ -119,9 +119,9 @@ public class TroubleshootController( string[] segmentFiles = streamingMode switch { - StreamingMode.HttpLiveStreamingSegmenter => Directory.GetFiles( - FileSystemLayout.TranscodeTroubleshootingFolder, - "*.m4s"), + // StreamingMode.HttpLiveStreamingSegmenter => Directory.GetFiles( + // FileSystemLayout.TranscodeTroubleshootingFolder, + // "*.m4s"), _ => Directory.GetFiles(FileSystemLayout.TranscodeTroubleshootingFolder, "*.ts") }; diff --git a/ErsatzTV/Controllers/InternalController.cs b/ErsatzTV/Controllers/InternalController.cs index 9df01ea23..75f77cc27 100644 --- a/ErsatzTV/Controllers/InternalController.cs +++ b/ErsatzTV/Controllers/InternalController.cs @@ -255,7 +255,7 @@ public class InternalController : StreamingControllerBase false, true, DateTimeOffset.Now, - 0, + TimeSpan.Zero, Option.None); Either result = await _mediator.Send(request); diff --git a/ErsatzTV/Controllers/IptvController.cs b/ErsatzTV/Controllers/IptvController.cs index 900ab9010..84a49c8b5 100644 --- a/ErsatzTV/Controllers/IptvController.cs +++ b/ErsatzTV/Controllers/IptvController.cs @@ -342,7 +342,7 @@ public class IptvController : StreamingControllerBase false, true, DateTimeOffset.Now, - 0, + TimeSpan.Zero, Option.None); Either result = await _mediator.Send(request);