Browse Source

fix hls timestamps (#598)

pull/599/head
Jason Dove 4 years ago committed by GitHub
parent
commit
1ee01c1d78
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 5
      CHANGELOG.md
  2. 29
      ErsatzTV.Application/Streaming/HlsSessionWorker.cs
  3. 12
      ErsatzTV.Application/Streaming/PtsAndDuration.cs
  4. 3
      ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs
  5. 3
      ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs
  6. 7
      ErsatzTV.Application/Streaming/Queries/GetLastPtsDuration.cs
  7. 87
      ErsatzTV.Application/Streaming/Queries/GetLastPtsDurationHandler.cs
  8. 27
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs
  9. 12
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  10. 3
      ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumber.cs
  11. 20
      ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsCalculatorTests.cs
  12. 3
      ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs
  13. 12
      ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs
  14. 11
      ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs
  15. 10
      ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs
  16. 6
      ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs
  17. 4
      ErsatzTV/Controllers/InternalController.cs

5
CHANGELOG.md

@ -12,9 +12,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -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

29
ErsatzTV.Application/Streaming/HlsSessionWorker.cs

@ -1,6 +1,7 @@ @@ -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 @@ -132,12 +133,16 @@ namespace ErsatzTV.Application.Streaming
using IServiceScope scope = _serviceScopeFactory.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
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 @@ -231,6 +236,28 @@ namespace ErsatzTV.Application.Streaming
_playlistStart = trimResult.PlaylistStart;
}
}
private async Task<long> GetPtsOffset(IMediator mediator, string channelNumber, CancellationToken cancellationToken)
{
var directory = new DirectoryInfo(Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber));
Option<FileInfo> lastSegment =
Optional(directory.GetFiles("*.ts").OrderByDescending(f => f.Name).FirstOrDefault());
long result = 0;
foreach (FileInfo segment in lastSegment)
{
Either<BaseError, PtsAndDuration> 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<int> GetWorkAheadLimit()
{

12
ErsatzTV.Application/Streaming/PtsAndDuration.cs

@ -0,0 +1,12 @@ @@ -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);
}
}

3
ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs

@ -11,5 +11,6 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -11,5 +11,6 @@ namespace ErsatzTV.Application.Streaming.Queries
string Mode,
DateTimeOffset Now,
bool StartAtZero,
bool HlsRealtime) : IRequest<Either<BaseError, PlayoutItemProcessModel>>;
bool HlsRealtime,
long PtsOffset) : IRequest<Either<BaseError, PlayoutItemProcessModel>>;
}

3
ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs

@ -9,7 +9,8 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -9,7 +9,8 @@ namespace ErsatzTV.Application.Streaming.Queries
"ts-legacy",
DateTimeOffset.Now,
false,
true)
true,
0)
{
Scheme = scheme;
Host = host;

7
ErsatzTV.Application/Streaming/Queries/GetLastPtsDuration.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Streaming.Queries;
public record GetLastPtsDuration(string FileName) : IRequest<Either<BaseError, PtsAndDuration>>;

87
ErsatzTV.Application/Streaming/Queries/GetLastPtsDurationHandler.cs

@ -0,0 +1,87 @@ @@ -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<GetLastPtsDuration, Either<BaseError, PtsAndDuration>>
{
private readonly IConfigElementRepository _configElementRepository;
public GetLastPtsDurationHandler(IConfigElementRepository configElementRepository)
{
_configElementRepository = configElementRepository;
}
public async Task<Either<BaseError, PtsAndDuration>> Handle(
GetLastPtsDuration request,
CancellationToken cancellationToken)
{
Validation<BaseError, RequestParameters> validation = await Validate(request);
return await validation.Match(
Handle,
error => Task.FromResult<Either<BaseError, PtsAndDuration>>(error.Join()));
}
private async Task<Validation<BaseError, RequestParameters>> Validate(GetLastPtsDuration request) =>
await ValidateFFprobePath()
.MapT(
ffprobePath => new RequestParameters(
request.FileName,
ffprobePath));
private async Task<Either<BaseError, PtsAndDuration>> 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<string, Either<BaseError, PtsAndDuration>>(
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<Validation<BaseError, string>> ValidateFFprobePath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
.FilterT(File.Exists)
.Map(
ffprobePath =>
ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
private record RequestParameters(string FileName, string FFprobePath);
}

27
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs

@ -2,20 +2,15 @@ @@ -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);
}

12
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs

@ -158,7 +158,8 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -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 @@ -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 @@ -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 @@ -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);
}

3
ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumber.cs

@ -9,7 +9,8 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -9,7 +9,8 @@ namespace ErsatzTV.Application.Streaming.Queries
"ts",
DateTimeOffset.Now,
false,
true)
true,
0)
{
Scheme = scheme;
Host = host;

20
ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsCalculatorTests.cs

@ -16,6 +16,26 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -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()

3
ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs

@ -214,7 +214,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -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;

12
ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs

@ -36,6 +36,12 @@ namespace ErsatzTV.Core.FFmpeg @@ -36,6 +36,12 @@ namespace ErsatzTV.Core.FFmpeg
"+igndts"
};
private static readonly List<string> SegmenterFormatFlags = new()
{
"+discardcorrupt",
"+igndts"
};
public FFmpegPlaybackSettings ConcatSettings => new()
{
ThreadCount = 1,
@ -56,7 +62,11 @@ namespace ErsatzTV.Core.FFmpeg @@ -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,

11
ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs

@ -21,6 +21,7 @@ @@ -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 @@ -387,7 +388,7 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithHls(string channelNumber, Option<MediaVersion> mediaVersion)
public FFmpegProcessBuilder WithHls(string channelNumber, Option<MediaVersion> mediaVersion, long ptsOffset, Option<int> maybeTimeScale)
{
const int SEGMENT_SECONDS = 4;
@ -412,10 +413,15 @@ namespace ErsatzTV.Core.FFmpeg @@ -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 @@ -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")
});

10
ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs

@ -51,7 +51,8 @@ namespace ErsatzTV.Core.FFmpeg @@ -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<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, audioVersion);
@ -158,7 +159,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -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 @@ -173,7 +174,8 @@ namespace ErsatzTV.Core.FFmpeg
Channel channel,
Option<TimeSpan> duration,
string errorMessage,
bool hlsRealtime)
bool hlsRealtime,
long ptsOffset)
{
FFmpegPlaybackSettings playbackSettings =
_playbackSettingsCalculator.CalculateErrorSettings(channel.FFmpegProfile);
@ -221,7 +223,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -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")

6
ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs

@ -27,14 +27,16 @@ namespace ErsatzTV.Core.Interfaces.FFmpeg @@ -27,14 +27,16 @@ namespace ErsatzTV.Core.Interfaces.FFmpeg
bool hlsRealtime,
FillerKind fillerKind,
TimeSpan inPoint,
TimeSpan outPoint);
TimeSpan outPoint,
long ptsOffset);
Task<Process> ForError(
string ffmpegPath,
Channel channel,
Option<TimeSpan> duration,
string errorMessage,
bool hlsRealtime);
bool hlsRealtime,
long ptsOffset);
Process ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);

4
ErsatzTV/Controllers/InternalController.cs

@ -27,14 +27,14 @@ namespace ErsatzTV.Controllers @@ -27,14 +27,14 @@ namespace ErsatzTV.Controllers
public Task<IActionResult> GetConcatPlaylist(string channelNumber) =>
_mediator.Send(new GetConcatPlaylistByChannelNumber(Request.Scheme, Request.Host.ToString(), channelNumber))
.ToActionResult();
[HttpGet("ffmpeg/stream/{channelNumber}")]
public Task<IActionResult> 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<IActionResult>(

Loading…
Cancel
Save