Browse Source

switch back from fmp4 to ts segments (#2554)

* restore pts offset calculation

* use ts segments again

* update changelog
pull/2556/head
Jason Dove 10 months ago committed by GitHub
parent
commit
2ef2b0299a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      CHANGELOG.md
  2. 2
      ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs
  3. 56
      ErsatzTV.Application/Streaming/HlsSessionWorker.cs
  4. 10
      ErsatzTV.Application/Streaming/PtsTime.cs
  5. 2
      ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs
  6. 2
      ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs
  7. 2
      ErsatzTV.Application/Streaming/Queries/GetErrorProcess.cs
  8. 7
      ErsatzTV.Application/Streaming/Queries/GetLastPtsTime.cs
  9. 211
      ErsatzTV.Application/Streaming/Queries/GetLastPtsTimeHandler.cs
  10. 2
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs
  11. 2
      ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumber.cs
  12. 2
      ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs
  13. 9
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  14. 1
      ErsatzTV.Core/FFmpeg/TempFileCategory.cs
  15. 4
      ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs
  16. 8
      ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs
  17. 4
      ErsatzTV.FFmpeg/FFmpegState.cs
  18. 4
      ErsatzTV.FFmpeg/Filter/ResetPtsFilter.cs
  19. 24
      ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs
  20. 4
      ErsatzTV.FFmpeg/OutputOption/OutputTsOffsetOption.cs
  21. 14
      ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs
  22. 2
      ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs
  23. 4
      ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs
  24. 6
      ErsatzTV/Controllers/Api/TroubleshootController.cs
  25. 2
      ErsatzTV/Controllers/InternalController.cs
  26. 2
      ErsatzTV/Controllers/IptvController.cs

2
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` - 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

2
ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs

@ -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,

56
ErsatzTV.Application/Streaming/HlsSessionWorker.cs

@ -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));

10
ErsatzTV.Application/Streaming/PtsTime.cs

@ -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));
} }
} }

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

@ -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>>;

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

@ -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;

2
ErsatzTV.Application/Streaming/Queries/GetErrorProcess.cs

@ -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(

7
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<Either<BaseError, PtsTime>>;

211
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<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);
}
}

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

@ -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,

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

@ -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;

2
ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs

@ -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,
_ => { }, _ => { },

9
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -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,

1
ErsatzTV.Core/FFmpeg/TempFileCategory.cs

@ -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
} }

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

@ -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,

8
ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs

@ -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,

4
ErsatzTV.FFmpeg/FFmpegState.cs

@ -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,

4
ErsatzTV.FFmpeg/Filter/Qsv/QsvResetPtsFilter.cs → 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"; public override string Filter => "setpts=PTS-STARTPTS";

24
ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs

@ -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();

4
ErsatzTV.FFmpeg/OutputOption/OutputTsOffsetOption.cs

@ -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 };

14
ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs

@ -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));
}
} }
} }

2
ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs

@ -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)

4
ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs

@ -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,

6
ErsatzTV/Controllers/Api/TroubleshootController.cs

@ -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")
}; };

2
ErsatzTV/Controllers/InternalController.cs

@ -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);

2
ErsatzTV/Controllers/IptvController.cs

@ -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);

Loading…
Cancel
Save