diff --git a/CHANGELOG.md b/CHANGELOG.md index bb8aa2486..e3d4341a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `ts-legacy` for `MPEG-TS (Legacy)` - omitting the `mode` parameter returns each channel as configured - Link `File Not Found` health check to `Trash` page to allow deletion - -### Changed -- Minor HLS Segmenter improvements +- Fix `HLS Segmenter` streaming mode with multiple ffmpeg-based clients + - Jellyfin (web) and TiviMate (Android) were specifically tested ## [0.3.8-alpha] - 2022-01-23 ### Fixed diff --git a/ErsatzTV.Application/Streaming/HlsSessionWorker.cs b/ErsatzTV.Application/Streaming/HlsSessionWorker.cs index 84b531c45..86ffbfd88 100644 --- a/ErsatzTV.Application/Streaming/HlsSessionWorker.cs +++ b/ErsatzTV.Application/Streaming/HlsSessionWorker.cs @@ -1,6 +1,7 @@ using System; using System.Diagnostics; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using System.Timers; @@ -132,12 +133,16 @@ namespace ErsatzTV.Application.Streaming using IServiceScope scope = _serviceScopeFactory.CreateScope(); IMediator mediator = scope.ServiceProvider.GetRequiredService(); + long ptsOffset = await GetPtsOffset(mediator, channelNumber, cancellationToken); + // _logger.LogInformation("PTS offset: {PtsOffset}", ptsOffset); + var request = new GetPlayoutItemProcessByChannelNumber( channelNumber, "segmenter", firstProcess ? DateTimeOffset.Now : _transcodedUntil.AddSeconds(1), !firstProcess, - realtime); + realtime, + ptsOffset); // _logger.LogInformation("Request {@Request}", request); @@ -231,6 +236,28 @@ namespace ErsatzTV.Application.Streaming _playlistStart = trimResult.PlaylistStart; } } + + private async Task GetPtsOffset(IMediator mediator, string channelNumber, CancellationToken cancellationToken) + { + var directory = new DirectoryInfo(Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber)); + Option lastSegment = + Optional(directory.GetFiles("*.ts").OrderByDescending(f => f.Name).FirstOrDefault()); + + long result = 0; + foreach (FileInfo segment in lastSegment) + { + Either queryResult = await mediator.Send( + new GetLastPtsDuration(segment.FullName), + cancellationToken); + + foreach (PtsAndDuration ptsAndDuration in queryResult.RightToSeq()) + { + result = ptsAndDuration.Pts + ptsAndDuration.Duration; + } + } + + return result; + } private async Task GetWorkAheadLimit() { diff --git a/ErsatzTV.Application/Streaming/PtsAndDuration.cs b/ErsatzTV.Application/Streaming/PtsAndDuration.cs new file mode 100644 index 000000000..0b872bed7 --- /dev/null +++ b/ErsatzTV.Application/Streaming/PtsAndDuration.cs @@ -0,0 +1,12 @@ +namespace ErsatzTV.Application.Streaming; + +public record PtsAndDuration(long Pts, long Duration) +{ + public static PtsAndDuration From(string ffprobeLine) + { + string[] split = ffprobeLine.Split("|"); + var left = long.Parse(split[0]); + var right = long.Parse(split[1]); + return new PtsAndDuration(left, right); + } +} diff --git a/ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs b/ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs index 4bdf63527..635a1723f 100644 --- a/ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs +++ b/ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs @@ -11,5 +11,6 @@ namespace ErsatzTV.Application.Streaming.Queries string Mode, DateTimeOffset Now, bool StartAtZero, - bool HlsRealtime) : IRequest>; + bool HlsRealtime, + long PtsOffset) : IRequest>; } diff --git a/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs b/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs index 71fd20dfb..5a6f6621c 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs @@ -9,7 +9,8 @@ namespace ErsatzTV.Application.Streaming.Queries "ts-legacy", DateTimeOffset.Now, false, - true) + true, + 0) { Scheme = scheme; Host = host; diff --git a/ErsatzTV.Application/Streaming/Queries/GetLastPtsDuration.cs b/ErsatzTV.Application/Streaming/Queries/GetLastPtsDuration.cs new file mode 100644 index 000000000..bdd289d12 --- /dev/null +++ b/ErsatzTV.Application/Streaming/Queries/GetLastPtsDuration.cs @@ -0,0 +1,7 @@ +using ErsatzTV.Core; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Streaming.Queries; + +public record GetLastPtsDuration(string FileName) : IRequest>; diff --git a/ErsatzTV.Application/Streaming/Queries/GetLastPtsDurationHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetLastPtsDurationHandler.cs new file mode 100644 index 000000000..fce411c73 --- /dev/null +++ b/ErsatzTV.Application/Streaming/Queries/GetLastPtsDurationHandler.cs @@ -0,0 +1,87 @@ +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Streaming.Queries; + +public class GetLastPtsDurationHandler : IRequestHandler> +{ + private readonly IConfigElementRepository _configElementRepository; + + public GetLastPtsDurationHandler(IConfigElementRepository configElementRepository) + { + _configElementRepository = configElementRepository; + } + + public async Task> Handle( + GetLastPtsDuration request, + CancellationToken cancellationToken) + { + Validation validation = await Validate(request); + return await validation.Match( + Handle, + error => Task.FromResult>(error.Join())); + } + + private async Task> Validate(GetLastPtsDuration request) => + await ValidateFFprobePath() + .MapT( + ffprobePath => new RequestParameters( + request.FileName, + ffprobePath)); + + private async Task> Handle(RequestParameters parameters) + { + var startInfo = new ProcessStartInfo + { + FileName = parameters.FFprobePath, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8 + }; + + startInfo.ArgumentList.Add("-v"); + startInfo.ArgumentList.Add("0"); + startInfo.ArgumentList.Add("-show_entries"); + startInfo.ArgumentList.Add("packet=pts,duration"); + startInfo.ArgumentList.Add("-of"); + startInfo.ArgumentList.Add("compact=p=0:nk=1"); + startInfo.ArgumentList.Add("-read_intervals"); + startInfo.ArgumentList.Add("-999999"); + startInfo.ArgumentList.Add(parameters.FileName); + + var probe = new Process + { + StartInfo = startInfo + }; + + probe.Start(); + return await probe.StandardOutput.ReadToEndAsync().MapAsync>( + async output => + { + await probe.WaitForExitAsync(); + return probe.ExitCode == 0 + ? PtsAndDuration.From(output.Split("\n").Filter(s => !string.IsNullOrWhiteSpace(s)).Last().Trim()) + : BaseError.New($"FFprobe at {parameters.FFprobePath} exited with code {probe.ExitCode}"); + }); + } + + private Task> ValidateFFprobePath() => + _configElementRepository.GetValue(ConfigElementKey.FFprobePath) + .FilterT(File.Exists) + .Map( + ffprobePath => + ffprobePath.ToValidation("FFprobe path does not exist on the file system")); + + private record RequestParameters(string FileName, string FFprobePath); +} diff --git a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs index 53606e173..4dfccf579 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs @@ -2,20 +2,15 @@ namespace ErsatzTV.Application.Streaming.Queries { - public record GetPlayoutItemProcessByChannelNumber : FFmpegProcessRequest - { - public GetPlayoutItemProcessByChannelNumber( - string channelNumber, - string mode, - DateTimeOffset now, - bool startAtZero, - bool hlsRealtime) : base( - channelNumber, - mode, - now, - startAtZero, - hlsRealtime) - { - } - } + public record GetPlayoutItemProcessByChannelNumber(string ChannelNumber, + string Mode, + DateTimeOffset Now, + bool StartAtZero, + bool HlsRealtime, + long PtsOffset) : FFmpegProcessRequest(ChannelNumber, + Mode, + Now, + StartAtZero, + HlsRealtime, + PtsOffset); } diff --git a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs index 3bd396277..400f66acd 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs @@ -158,7 +158,8 @@ namespace ErsatzTV.Application.Streaming.Queries request.HlsRealtime, playoutItemWithPath.PlayoutItem.FillerKind, playoutItemWithPath.PlayoutItem.InPoint, - playoutItemWithPath.PlayoutItem.OutPoint); + playoutItemWithPath.PlayoutItem.OutPoint, + request.PtsOffset); var result = new PlayoutItemProcessModel(process, playoutItemWithPath.PlayoutItem.FinishOffset); @@ -193,7 +194,8 @@ namespace ErsatzTV.Application.Streaming.Queries channel, maybeDuration, "Channel is Offline", - request.HlsRealtime); + request.HlsRealtime, + request.PtsOffset); return new PlayoutItemProcessModel(errorProcess, finish); } @@ -212,7 +214,8 @@ namespace ErsatzTV.Application.Streaming.Queries channel, maybeDuration, error.Value, - request.HlsRealtime); + request.HlsRealtime, + request.PtsOffset); return new PlayoutItemProcessModel(errorProcess, finish); } @@ -231,7 +234,8 @@ namespace ErsatzTV.Application.Streaming.Queries channel, maybeDuration, "Channel is Offline", - request.HlsRealtime); + request.HlsRealtime, + request.PtsOffset); return new PlayoutItemProcessModel(errorProcess, finish); } diff --git a/ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumber.cs b/ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumber.cs index c436acb28..71b6b7b37 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumber.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumber.cs @@ -9,7 +9,8 @@ namespace ErsatzTV.Application.Streaming.Queries "ts", DateTimeOffset.Now, false, - true) + true, + 0) { Scheme = scheme; Host = host; diff --git a/ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsCalculatorTests.cs b/ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsCalculatorTests.cs index 30b5db99b..6115dbe71 100644 --- a/ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsCalculatorTests.cs +++ b/ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsCalculatorTests.cs @@ -16,6 +16,26 @@ namespace ErsatzTV.Core.Tests.FFmpeg private readonly FFmpegPlaybackSettingsCalculator _calculator; public CalculateSettings() => _calculator = new FFmpegPlaybackSettingsCalculator(); + + [Test] + public void Should_Not_GenPts_ForHlsSegmenter() + { + FFmpegProfile ffmpegProfile = TestProfile(); + + FFmpegPlaybackSettings actual = _calculator.CalculateSettings( + StreamingMode.HttpLiveStreamingSegmenter, + ffmpegProfile, + new MediaVersion(), + new MediaStream(), + new MediaStream(), + DateTimeOffset.Now, + DateTimeOffset.Now, + TimeSpan.Zero, + TimeSpan.Zero, + false); + + actual.FormatFlags.Should().NotContain("+genpts"); + } [Test] public void Should_Not_UseSpecifiedThreadCount_ForTransportStream() diff --git a/ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs b/ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs index de9716435..76cf970a2 100644 --- a/ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs +++ b/ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs @@ -214,7 +214,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg false, FillerKind.None, TimeSpan.Zero, - TimeSpan.FromSeconds(5)); + TimeSpan.FromSeconds(5), + 0); process.StartInfo.RedirectStandardError = true; diff --git a/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs b/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs index 859f40bde..ab11325d2 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs @@ -36,6 +36,12 @@ namespace ErsatzTV.Core.FFmpeg "+igndts" }; + private static readonly List SegmenterFormatFlags = new() + { + "+discardcorrupt", + "+igndts" + }; + public FFmpegPlaybackSettings ConcatSettings => new() { ThreadCount = 1, @@ -56,7 +62,11 @@ namespace ErsatzTV.Core.FFmpeg { var result = new FFmpegPlaybackSettings { - FormatFlags = CommonFormatFlags, + FormatFlags = streamingMode switch + { + StreamingMode.HttpLiveStreamingSegmenter => SegmenterFormatFlags, + _ => CommonFormatFlags, + }, RealtimeOutput = streamingMode switch { StreamingMode.HttpLiveStreamingSegmenter => hlsRealtime, diff --git a/ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs b/ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs index 625fa2d55..5b43f8047 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs @@ -21,6 +21,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Globalization; using System.IO; using System.Runtime.InteropServices; using System.Text; @@ -387,7 +388,7 @@ namespace ErsatzTV.Core.FFmpeg return this; } - public FFmpegProcessBuilder WithHls(string channelNumber, Option mediaVersion) + public FFmpegProcessBuilder WithHls(string channelNumber, Option mediaVersion, long ptsOffset, Option maybeTimeScale) { const int SEGMENT_SECONDS = 4; @@ -412,10 +413,15 @@ namespace ErsatzTV.Core.FFmpeg frameRate = fr; } + foreach (int timescale in maybeTimeScale) + { + _arguments.Add("-output_ts_offset"); + _arguments.Add($"{(ptsOffset / (double)timescale).ToString(NumberFormatInfo.InvariantInfo)}"); + } + _arguments.AddRange( new[] { - "-use_wallclock_as_timestamps", "1", "-g", $"{frameRate * SEGMENT_SECONDS}", "-keyint_min", $"{frameRate * SEGMENT_SECONDS}", "-force_key_frames", $"expr:gte(t,n_forced*{SEGMENT_SECONDS})", @@ -427,7 +433,6 @@ namespace ErsatzTV.Core.FFmpeg Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live%06d.ts"), "-hls_flags", "program_date_time+append_list+discont_start+omit_endlist+independent_segments", "-mpegts_flags", "+initial_discontinuity", - "-mpegts_copyts", "1", Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live.m3u8") }); diff --git a/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs index 1e3c70cca..fece5d1a8 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs @@ -51,7 +51,8 @@ namespace ErsatzTV.Core.FFmpeg bool hlsRealtime, FillerKind fillerKind, TimeSpan inPoint, - TimeSpan outPoint) + TimeSpan outPoint, + long ptsOffset) { MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, videoVersion); Option maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, audioVersion); @@ -158,7 +159,7 @@ namespace ErsatzTV.Core.FFmpeg { // HLS needs to segment and generate playlist case StreamingMode.HttpLiveStreamingSegmenter: - return builder.WithHls(channel.Number, videoVersion) + return builder.WithHls(channel.Number, videoVersion, ptsOffset, playbackSettings.VideoTrackTimeScale) .Build(); default: return builder.WithFormat("mpegts") @@ -173,7 +174,8 @@ namespace ErsatzTV.Core.FFmpeg Channel channel, Option duration, string errorMessage, - bool hlsRealtime) + bool hlsRealtime, + long ptsOffset) { FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.CalculateErrorSettings(channel.FFmpegProfile); @@ -221,7 +223,7 @@ namespace ErsatzTV.Core.FFmpeg { // HLS needs to segment and generate playlist case StreamingMode.HttpLiveStreamingSegmenter: - return builder.WithHls(channel.Number, None) + return builder.WithHls(channel.Number, None, ptsOffset, playbackSettings.VideoTrackTimeScale) .Build(); default: return builder.WithFormat("mpegts") diff --git a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs index 85f0850cc..07e0ffec5 100644 --- a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs +++ b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs @@ -27,14 +27,16 @@ namespace ErsatzTV.Core.Interfaces.FFmpeg bool hlsRealtime, FillerKind fillerKind, TimeSpan inPoint, - TimeSpan outPoint); + TimeSpan outPoint, + long ptsOffset); Task ForError( string ffmpegPath, Channel channel, Option duration, string errorMessage, - bool hlsRealtime); + bool hlsRealtime, + long ptsOffset); Process ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host); diff --git a/ErsatzTV/Controllers/InternalController.cs b/ErsatzTV/Controllers/InternalController.cs index aa4a09644..6b96c5018 100644 --- a/ErsatzTV/Controllers/InternalController.cs +++ b/ErsatzTV/Controllers/InternalController.cs @@ -27,14 +27,14 @@ namespace ErsatzTV.Controllers public Task GetConcatPlaylist(string channelNumber) => _mediator.Send(new GetConcatPlaylistByChannelNumber(Request.Scheme, Request.Host.ToString(), channelNumber)) .ToActionResult(); - + [HttpGet("ffmpeg/stream/{channelNumber}")] public Task GetStream( string channelNumber, [FromQuery] string mode = "mixed") => _mediator.Send( - new GetPlayoutItemProcessByChannelNumber(channelNumber, mode, DateTimeOffset.Now, false, true)) + new GetPlayoutItemProcessByChannelNumber(channelNumber, mode, DateTimeOffset.Now, false, true, 0)) .Map( result => result.Match(