Browse Source

add hls segmenter fmp4 streaming mode (#2468)

* add streaming mode segmenter fmp4

* allow hevc channel preview
pull/2469/head
Jason Dove 3 months ago committed by GitHub
parent
commit
b46de50801
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      CHANGELOG.md
  2. 1
      ErsatzTV.Application/Channels/Mapper.cs
  3. 4
      ErsatzTV.Application/Channels/Queries/GetChannelPlaylistHandler.cs
  4. 12
      ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs
  5. 79
      ErsatzTV.Application/Streaming/HlsSessionWorker.cs
  6. 3
      ErsatzTV.Application/Streaming/HlsSessionWorkerV2.cs
  7. 11
      ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs
  8. 3
      ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs
  9. 6
      ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs
  10. 6
      ErsatzTV.Application/Streaming/Queries/GetConcatSegmenterProcessByChannelNumber.cs
  11. 6
      ErsatzTV.Application/Streaming/Queries/GetErrorProcess.cs
  12. 6
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs
  13. 6
      ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumber.cs
  14. 3
      ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResults.cs
  15. 2
      ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlayback.cs
  16. 2
      ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs
  17. 42
      ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsCalculatorTests.cs
  18. 76
      ErsatzTV.Core.Tests/FFmpeg/HlsPlaylistFilterTests.cs
  19. 4
      ErsatzTV.Core/Domain/StreamingMode.cs
  20. 28
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  21. 4
      ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs
  22. 119
      ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs
  23. 9
      ErsatzTV.Core/FFmpeg/IHlsPlaylistFilter.cs
  24. 1
      ErsatzTV.Core/Iptv/ChannelPlaylist.cs
  25. 29
      ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs
  26. 1
      ErsatzTV.FFmpeg/OutputFormat/OutputFormatKind.cs
  27. 9
      ErsatzTV.FFmpeg/OutputOption/HlsDirectMp4OutputOptions.cs
  28. 6
      ErsatzTV.FFmpeg/OutputOption/Mp4OutputOptions.cs
  29. 10
      ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs
  30. 8
      ErsatzTV/Controllers/Api/TroubleshootController.cs
  31. 3
      ErsatzTV/Controllers/InternalController.cs
  32. 4
      ErsatzTV/Controllers/IptvController.cs
  33. 1
      ErsatzTV/Pages/ChannelEditor.razor
  34. 113
      ErsatzTV/Pages/Channels.razor
  35. 14
      ErsatzTV/Pages/PlaybackTroubleshooting.razor
  36. 7
      ErsatzTV/Pages/_Host.cshtml

6
CHANGELOG.md

@ -54,6 +54,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -54,6 +54,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- New page is at **Channels** > **Edit Channel Numbers**
- Scripted schedules: add setting to configure timeout of scripted playout build
- New setting is at **Settings** > **Playout** > **Scripted Schedule Timeout**
- Add *experimental* streaming mode `HLS Segmenter (fmp4)`
- This mode is required for better compliance with HLS spec, and to support new output codecs
- This mode *will replace* `HLS Segmenter` when it has received more testing
- Allow HEVC playback in channel preview
- This is restricted to compatible browsers
- Preview button will be red when preview is disabled due to browser incompatibility
### Fixed
- Fix green output when libplacebo tonemapping is used with NVIDIA acceleration and 10-bit output in FFmpeg Profile

1
ErsatzTV.Application/Channels/Mapper.cs

@ -75,6 +75,7 @@ internal static class Mapper @@ -75,6 +75,7 @@ internal static class Mapper
StreamingMode.TransportStreamHybrid => "MPEG-TS",
StreamingMode.HttpLiveStreamingDirect => "HLS Direct",
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter",
StreamingMode.HttpLiveStreamingSegmenterFmp4 => "HLS Segmenter (fmp4)",
StreamingMode.HttpLiveStreamingSegmenterV2 => "HLS Segmenter V2",
_ => throw new ArgumentOutOfRangeException(nameof(channel))
};

4
ErsatzTV.Application/Channels/Queries/GetChannelPlaylistHandler.cs

@ -38,6 +38,10 @@ public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, Cha @@ -38,6 +38,10 @@ public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, Cha
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenter;
result.Add(channel);
break;
case "segmenter-fmp4":
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenterFmp4;
result.Add(channel);
break;
case "segmenter-v2":
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenterV2;
result.Add(channel);

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

@ -11,6 +11,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg; @@ -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;
@ -127,8 +128,19 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit @@ -127,8 +128,19 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
targetFramerate,
request.Scheme,
request.Host),
"segmenter-fmp4" => new HlsSessionWorker(
_serviceScopeFactory,
OutputFormatKind.HlsMp4,
_graphicsEngine,
_client,
_hlsPlaylistFilter,
_configElementRepository,
_localFileSystem,
_sessionWorkerLogger,
targetFramerate),
_ => new HlsSessionWorker(
_serviceScopeFactory,
OutputFormatKind.Hls,
_graphicsEngine,
_client,
_hlsPlaylistFilter,

79
ErsatzTV.Application/Streaming/HlsSessionWorker.cs

@ -15,6 +15,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg; @@ -15,6 +15,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.Logging;
using Timer = System.Timers.Timer;
@ -26,6 +27,7 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -26,6 +27,7 @@ public class HlsSessionWorker : IHlsSessionWorker
private static int _workAheadCount;
private readonly IClient _client;
private readonly IConfigElementRepository _configElementRepository;
private readonly OutputFormatKind _outputFormat;
private readonly IGraphicsEngine _graphicsEngine;
private readonly IHlsPlaylistFilter _hlsPlaylistFilter;
private readonly ILocalFileSystem _localFileSystem;
@ -48,6 +50,7 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -48,6 +50,7 @@ public class HlsSessionWorker : IHlsSessionWorker
public HlsSessionWorker(
IServiceScopeFactory serviceScopeFactory,
OutputFormatKind outputFormat,
IGraphicsEngine graphicsEngine,
IClient client,
IHlsPlaylistFilter hlsPlaylistFilter,
@ -58,6 +61,7 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -58,6 +61,7 @@ public class HlsSessionWorker : IHlsSessionWorker
{
_serviceScope = serviceScopeFactory.CreateScope();
_mediator = _serviceScope.ServiceProvider.GetRequiredService<IMediator>();
_outputFormat = outputFormat;
_graphicsEngine = graphicsEngine;
_client = client;
_hlsPlaylistFilter = hlsPlaylistFilter;
@ -110,7 +114,12 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -110,7 +114,12 @@ public class HlsSessionWorker : IHlsSessionWorker
Option<string[]> maybeLines = await ReadPlaylistLines(cancellationToken);
foreach (string[] input in maybeLines)
{
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylist(PlaylistStart, filterBefore, input);
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylist(
_outputFormat,
PlaylistStart,
filterBefore,
GetAllInits(),
input);
if (DateTimeOffset.Now > _lastDelete.AddSeconds(30))
{
DeleteOldSegments(trimResult);
@ -174,7 +183,10 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -174,7 +183,10 @@ public class HlsSessionWorker : IHlsSessionWorker
CancellationToken cancellationToken = _cancellationTokenSource.Token;
_logger.LogInformation("Starting HLS session for channel {Channel}", channelNumber);
_logger.LogInformation(
"Starting HLS session for channel {Channel} with output format {OutputFormat}",
channelNumber,
_outputFormat);
if (_localFileSystem.ListFiles(Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber)).Any())
{
@ -436,7 +448,7 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -436,7 +448,7 @@ public class HlsSessionWorker : IHlsSessionWorker
var request = new GetPlayoutItemProcessByChannelNumber(
_channelNumber,
"segmenter",
_outputFormat is OutputFormatKind.HlsMp4 ? StreamingMode.HttpLiveStreamingSegmenterFmp4 : StreamingMode.HttpLiveStreamingSegmenter,
now,
startAtZero,
realtime,
@ -523,7 +535,7 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -523,7 +535,7 @@ public class HlsSessionWorker : IHlsSessionWorker
Either<BaseError, PlayoutItemProcessModel> maybeOfflineProcess = await _mediator.Send(
new GetErrorProcess(
_channelNumber,
"segmenter",
_outputFormat is OutputFormatKind.HlsMp4 ? StreamingMode.HttpLiveStreamingSegmenterFmp4 : StreamingMode.HttpLiveStreamingSegmenter,
realtime,
ptsOffset,
processModel.MaybeDuration,
@ -620,8 +632,10 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -620,8 +632,10 @@ public class HlsSessionWorker : IHlsSessionWorker
{
// trim playlist and insert discontinuity before appending with new ffmpeg process
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylistWithDiscontinuity(
_outputFormat,
PlaylistStart,
DateTimeOffset.Now.AddMinutes(-1),
GetAllInits(),
lines);
await WritePlaylist(trimResult.Playlist, cancellationToken);
@ -639,20 +653,34 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -639,20 +653,34 @@ public class HlsSessionWorker : IHlsSessionWorker
private void DeleteOldSegments(TrimPlaylistResult trimResult)
{
// delete old segments
var allSegments = Directory.GetFiles(
Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber),
"live*.ts").Append(
Directory.GetFiles(
Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber),
"live*.mp4"))
var allSegments = Directory.GetFiles(Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber), "live*.ts")
.Append(Directory.GetFiles(Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber), "live*.mp4"))
.Append(Directory.GetFiles(Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber), "live*.m4s"))
.Map(file =>
{
string fileName = Path.GetFileName(file);
var sequenceNumber = int.Parse(
fileName.Replace("live", string.Empty).Split('.')[0],
var sequenceNumber = long.Parse(
fileName.Contains('_')
? fileName.Split('_')[2].Split('.')[0]
: fileName.Replace("live", string.Empty).Split('.')[0],
CultureInfo.InvariantCulture);
return new Segment(file, sequenceNumber);
if (!fileName.Contains('_') || !long.TryParse(fileName.Split('_')[1], out long generatedAt))
{
generatedAt = 0;
}
return new Segment(file, sequenceNumber, generatedAt);
})
.ToList();
var allInits = Directory.GetFiles(Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber), "*init.mp4")
.Map(file =>
{
string fileName = Path.GetFileName(file);
return long.TryParse(fileName.Split('_')[0], out long generatedAt)
? new Segment(file, 0, generatedAt)
: Option<Segment>.None;
})
.Somes()
.ToList();
var toDelete = allSegments.Filter(s => s.SequenceNumber < trimResult.Sequence).ToList();
@ -665,6 +693,18 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -665,6 +693,18 @@ public class HlsSessionWorker : IHlsSessionWorker
// trimResult.Sequence);
}
if (allInits.Count > 0 && allSegments.Count > 0)
{
long minKeep = allSegments.Except(toDelete).Map(s => s.GeneratedAt).Min();
Option<Segment> maybeMinKeepInit =
allInits.OrderByDescending(i => i.GeneratedAt).Find(i => i.GeneratedAt <= minKeep);
foreach (var minKeepInit in maybeMinKeepInit)
{
// _logger.LogDebug("Deleting HLS inits less than {GeneratedAt}", minKeepInit.GeneratedAt);
toDelete.AddRange(allInits.Where(i => i.GeneratedAt < minKeepInit.GeneratedAt));
}
}
foreach (Segment segment in toDelete)
{
try
@ -680,6 +720,17 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -680,6 +720,17 @@ public class HlsSessionWorker : IHlsSessionWorker
}
}
private List<long> GetAllInits() =>
_outputFormat is OutputFormatKind.HlsMp4
? Directory.GetFiles(Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber), "*init.mp4")
.Map(file =>
{
string fileName = Path.GetFileName(file);
return long.TryParse(fileName.Split('_')[0], out long generatedAt) ? generatedAt : long.MaxValue;
})
.ToList()
: [];
private async Task<long> GetPtsOffset(string channelNumber, CancellationToken cancellationToken)
{
await _slim.WaitAsync(cancellationToken);
@ -742,5 +793,5 @@ public class HlsSessionWorker : IHlsSessionWorker @@ -742,5 +793,5 @@ public class HlsSessionWorker : IHlsSessionWorker
_channelNumber,
"live.m3u8");
private sealed record Segment(string File, int SequenceNumber);
private sealed record Segment(string File, long SequenceNumber, long GeneratedAt);
}

3
ErsatzTV.Application/Streaming/HlsSessionWorkerV2.cs

@ -6,6 +6,7 @@ using CliWrap; @@ -6,6 +6,7 @@ using CliWrap;
using CliWrap.Buffered;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata;
@ -315,7 +316,7 @@ public class HlsSessionWorkerV2 : IHlsSessionWorker @@ -315,7 +316,7 @@ public class HlsSessionWorkerV2 : IHlsSessionWorker
var request = new GetPlayoutItemProcessByChannelNumber(
_channelNumber,
"segmenter-v2",
StreamingMode.HttpLiveStreamingSegmenterV2,
_transcodedUntil,
startAtZero,
realtime,

11
ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs

@ -61,16 +61,7 @@ public abstract class FFmpegProcessHandler<T> : IRequestHandler<T, Either<BaseEr @@ -61,16 +61,7 @@ public abstract class FFmpegProcessHandler<T> : IRequestHandler<T, Either<BaseEr
.SelectOneAsync(c => c.Number, c => c.Number == request.ChannelNumber, cancellationToken)
.MapT(channel =>
{
channel.StreamingMode = request.Mode.ToLowerInvariant() switch
{
"hls-direct" => StreamingMode.HttpLiveStreamingDirect,
"segmenter" => StreamingMode.HttpLiveStreamingSegmenter,
"segmenter-v2" => StreamingMode.HttpLiveStreamingSegmenterV2,
"ts" => StreamingMode.TransportStreamHybrid,
"ts-legacy" => StreamingMode.TransportStream,
_ => channel.StreamingMode
};
channel.StreamingMode = request.Mode;
return channel;
})
.Map(o => o.ToValidation<BaseError>($"Channel number {request.ChannelNumber} does not exist."));

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

@ -1,10 +1,11 @@ @@ -1,10 +1,11 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Streaming;
public record FFmpegProcessRequest(
string ChannelNumber,
string Mode,
StreamingMode Mode,
DateTimeOffset Now,
bool StartAtZero,
bool HlsRealtime,

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

@ -1,10 +1,12 @@ @@ -1,10 +1,12 @@
namespace ErsatzTV.Application.Streaming;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Streaming;
public record GetConcatProcessByChannelNumber : FFmpegProcessRequest
{
public GetConcatProcessByChannelNumber(string scheme, string host, string channelNumber) : base(
channelNumber,
"ts-legacy",
StreamingMode.TransportStream,
DateTimeOffset.Now,
false,
true,

6
ErsatzTV.Application/Streaming/Queries/GetConcatSegmenterProcessByChannelNumber.cs

@ -1,10 +1,12 @@ @@ -1,10 +1,12 @@
namespace ErsatzTV.Application.Streaming;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Streaming;
public record GetConcatSegmenterProcessByChannelNumber : FFmpegProcessRequest
{
public GetConcatSegmenterProcessByChannelNumber(string scheme, string host, string channelNumber) : base(
channelNumber,
"ts-legacy",
StreamingMode.TransportStream,
DateTimeOffset.Now,
false,
true,

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

@ -1,8 +1,10 @@ @@ -1,8 +1,10 @@
namespace ErsatzTV.Application.Streaming;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Streaming;
public record GetErrorProcess(
string ChannelNumber,
string Mode,
StreamingMode Mode,
bool HlsRealtime,
long PtsOffset,
Option<TimeSpan> MaybeDuration,

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

@ -1,8 +1,10 @@ @@ -1,8 +1,10 @@
namespace ErsatzTV.Application.Streaming;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Streaming;
public record GetPlayoutItemProcessByChannelNumber(
string ChannelNumber,
string Mode,
StreamingMode Mode,
DateTimeOffset Now,
bool StartAtZero,
bool HlsRealtime,

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

@ -1,4 +1,6 @@ @@ -1,4 +1,6 @@
namespace ErsatzTV.Application.Streaming;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Streaming;
public record GetWrappedProcessByChannelNumber : FFmpegProcessRequest
{
@ -8,7 +10,7 @@ public record GetWrappedProcessByChannelNumber : FFmpegProcessRequest @@ -8,7 +10,7 @@ public record GetWrappedProcessByChannelNumber : FFmpegProcessRequest
string accessToken,
string channelNumber) : base(
channelNumber,
"ts",
StreamingMode.TransportStreamHybrid,
DateTimeOffset.Now,
false,
true,

3
ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResults.cs

@ -1,8 +1,11 @@ @@ -1,8 +1,11 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Troubleshooting;
public record ArchiveTroubleshootingResults(
int MediaItemId,
int FFmpegProfileId,
StreamingMode StreamingMode,
List<int> WatermarkIds,
List<int> GraphicsElementIds,
Option<int> SeekSeconds)

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

@ -1,9 +1,11 @@ @@ -1,9 +1,11 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
namespace ErsatzTV.Application.Troubleshooting;
public record PrepareTroubleshootingPlayback(
StreamingMode StreamingMode,
int MediaItemId,
int FFmpegProfileId,
List<int> WatermarkIds,

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

@ -102,7 +102,7 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -102,7 +102,7 @@ public class PrepareTroubleshootingPlaybackHandler(
Name = "ETV",
Number = ".troubleshooting",
FFmpegProfile = ffmpegProfile,
StreamingMode = StreamingMode.HttpLiveStreamingSegmenter,
StreamingMode = request.StreamingMode,
StreamSelectorMode = ChannelStreamSelectorMode.Troubleshooting,
SubtitleMode = SUBTITLE_MODE
//SongVideoMode = ChannelSongVideoMode.WithProgress

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

@ -37,6 +37,27 @@ public class FFmpegPlaybackSettingsCalculatorTests @@ -37,6 +37,27 @@ public class FFmpegPlaybackSettingsCalculatorTests
actual.FormatFlags.ShouldNotContain("+genpts");
}
[Test]
public void Should_Not_GenPts_ForHlsSegmenterFmp4()
{
FFmpegProfile ffmpegProfile = TestProfile();
FFmpegPlaybackSettings actual = FFmpegPlaybackSettingsCalculator.CalculateSettings(
StreamingMode.HttpLiveStreamingSegmenterFmp4,
ffmpegProfile,
TestVersion,
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false,
StreamInputKind.Vod,
None);
actual.FormatFlags.ShouldNotContain("+genpts");
}
[Test]
public void Should_UseSpecifiedThreadCount_ForTransportStream()
{
@ -83,6 +104,27 @@ public class FFmpegPlaybackSettingsCalculatorTests @@ -83,6 +104,27 @@ public class FFmpegPlaybackSettingsCalculatorTests
actual.ThreadCount.ShouldBe(7);
}
[Test]
public void Should_UseSpecifiedThreadCount_ForHttpLiveStreamingSegmenterFmp4()
{
FFmpegProfile ffmpegProfile = TestProfile() with { ThreadCount = 7 };
FFmpegPlaybackSettings actual = FFmpegPlaybackSettingsCalculator.CalculateSettings(
StreamingMode.HttpLiveStreamingSegmenterFmp4,
ffmpegProfile,
TestVersion,
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false,
StreamInputKind.Vod,
None);
actual.ThreadCount.ShouldBe(7);
}
[Test]
public void Should_SetFormatFlags_ForTransportStream()
{

76
ErsatzTV.Core.Tests/FFmpeg/HlsPlaylistFilterTests.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.FFmpeg.OutputFormat;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NUnit.Framework;
@ -25,7 +26,7 @@ public class HlsPlaylistFilterTests @@ -25,7 +26,7 @@ public class HlsPlaylistFilterTests
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = NormalizeLineEndings(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-INDEPENDENT-SEGMENTS
@ -40,14 +41,19 @@ live001138.ts @@ -40,14 +41,19 @@ live001138.ts
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts").Split(Environment.NewLine);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(-30), input);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(
OutputFormatKind.Hls,
start,
start.AddSeconds(-30),
[],
input);
result.PlaylistStart.ShouldBe(start);
result.Sequence.ShouldBe(1137);
result.Playlist.ShouldBe(
NormalizeLineEndings(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-DISCONTINUITY-SEQUENCE:1
@ -70,7 +76,7 @@ live001139.ts @@ -70,7 +76,7 @@ live001139.ts
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = NormalizeLineEndings(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-INDEPENDENT-SEGMENTS
@ -85,14 +91,20 @@ live001138.ts @@ -85,14 +91,20 @@ live001138.ts
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts").Split(Environment.NewLine);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(-30), input, 2);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(
OutputFormatKind.Hls,
start,
start.AddSeconds(-30),
[],
input,
2);
result.PlaylistStart.ShouldBe(start);
result.Sequence.ShouldBe(1137);
result.Playlist.ShouldBe(
NormalizeLineEndings(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-DISCONTINUITY-SEQUENCE:1
@ -112,7 +124,7 @@ live001138.ts @@ -112,7 +124,7 @@ live001138.ts
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = NormalizeLineEndings(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-INDEPENDENT-SEGMENTS
@ -128,8 +140,10 @@ live001138.ts @@ -128,8 +140,10 @@ live001138.ts
live001139.ts").Split(Environment.NewLine);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(
OutputFormatKind.Hls,
start,
start.AddSeconds(-30),
[],
input,
int.MaxValue,
true);
@ -139,7 +153,7 @@ live001139.ts").Split(Environment.NewLine); @@ -139,7 +153,7 @@ live001139.ts").Split(Environment.NewLine);
result.Playlist.ShouldBe(
NormalizeLineEndings(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-DISCONTINUITY-SEQUENCE:1
@ -163,7 +177,7 @@ live001139.ts @@ -163,7 +177,7 @@ live001139.ts
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = NormalizeLineEndings(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-INDEPENDENT-SEGMENTS
@ -178,14 +192,20 @@ live001138.ts @@ -178,14 +192,20 @@ live001138.ts
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts").Split(Environment.NewLine);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input, 1);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(
OutputFormatKind.Hls,
start,
start.AddSeconds(6),
[],
input,
1);
result.PlaylistStart.ShouldBe(start.AddSeconds(8));
result.Sequence.ShouldBe(1139);
result.Playlist.ShouldBe(
NormalizeLineEndings(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1139
#EXT-X-DISCONTINUITY-SEQUENCE:1
@ -202,7 +222,7 @@ live001139.ts @@ -202,7 +222,7 @@ live001139.ts
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = NormalizeLineEndings(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-INDEPENDENT-SEGMENTS
@ -218,14 +238,19 @@ live001138.ts @@ -218,14 +238,19 @@ live001138.ts
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts").Split(Environment.NewLine);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(
OutputFormatKind.Hls,
start,
start.AddSeconds(6),
[],
input);
result.PlaylistStart.ShouldBe(start);
result.Sequence.ShouldBe(1137);
result.Playlist.ShouldBe(
NormalizeLineEndings(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-DISCONTINUITY-SEQUENCE:1
@ -249,7 +274,7 @@ live001139.ts @@ -249,7 +274,7 @@ live001139.ts
var start = new DateTimeOffset(2022, 5, 25, 20, 8, 0, TimeSpan.FromHours(-5));
string[] input = NormalizeLineEndings(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-DISCONTINUITY
@ -506,14 +531,19 @@ live000081.ts @@ -506,14 +531,19 @@ live000081.ts
#EXT-X-PROGRAM-DATE-TIME:2022-05-25T20:13:26.007-0500
live000082.ts").Split(Environment.NewLine);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(220), input);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(
OutputFormatKind.Hls,
start,
start.AddSeconds(220),
[],
input);
// result.PlaylistStart.ShouldBe(start);
result.Sequence.ShouldBe(56);
result.Playlist.ShouldBe(
NormalizeLineEndings(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:56
#EXT-X-DISCONTINUITY-SEQUENCE:2
@ -557,7 +587,7 @@ live000065.ts @@ -557,7 +587,7 @@ live000065.ts
var start = new DateTimeOffset(2025, 9, 17, 10, 11, 5, 31, TimeSpan.FromHours(-5));
string[] input = NormalizeLineEndings(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:46
#EXT-X-DISCONTINUITY-SEQUENCE:2
@ -577,13 +607,19 @@ live000048.ts @@ -577,13 +607,19 @@ live000048.ts
// filter 'live000046.ts'
var filterBefore = start.AddSeconds(2);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(start, filterBefore, input, 2);
TrimPlaylistResult result = _hlsPlaylistFilter.TrimPlaylist(
OutputFormatKind.Hls,
start,
filterBefore,
[],
input,
2);
result.Sequence.ShouldBe(47);
string expectedPlaylist = NormalizeLineEndings(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:47
#EXT-X-DISCONTINUITY-SEQUENCE:3

4
ErsatzTV.Core/Domain/StreamingMode.cs

@ -8,5 +8,7 @@ public enum StreamingMode @@ -8,5 +8,7 @@ public enum StreamingMode
// HttpLiveStreamingHybrid = 3,
HttpLiveStreamingSegmenter = 4,
TransportStreamHybrid = 5,
HttpLiveStreamingSegmenterV2 = 6
HttpLiveStreamingSegmenterV2 = 6,
HttpLiveStreamingSegmenterFmp4 = 100
}

28
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -242,6 +242,9 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -242,6 +242,9 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
case StreamingMode.HttpLiveStreamingSegmenter:
outputFormat = OutputFormatKind.Hls;
break;
case StreamingMode.HttpLiveStreamingSegmenterFmp4:
outputFormat = OutputFormatKind.HlsMp4;
break;
case StreamingMode.HttpLiveStreamingSegmenterV2:
outputFormat = OutputFormatKind.Nut;
break;
@ -344,13 +347,16 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -344,13 +347,16 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
channel.FFmpegProfile.VideoPreset,
FFmpegLibraryHelper.MapBitDepth(channel.FFmpegProfile.BitDepth));
Option<string> hlsPlaylistPath = outputFormat == OutputFormatKind.Hls
Option<string> hlsPlaylistPath = outputFormat is OutputFormatKind.Hls or OutputFormatKind.HlsMp4
? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live.m3u8")
: Option<string>.None;
Option<string> hlsSegmentTemplate = outputFormat == OutputFormatKind.Hls
? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live%06d.ts")
: Option<string>.None;
Option<string> hlsSegmentTemplate = outputFormat switch
{
OutputFormatKind.Hls => Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live%06d.ts"),
OutputFormatKind.HlsMp4 => Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live_%s_%%06d.m4s"),
_ => Option<string>.None
};
FrameSize scaledSize = ffmpegVideoStream.SquarePixelFrameSize(
new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height));
@ -568,18 +574,24 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -568,18 +574,24 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
case StreamingMode.HttpLiveStreamingSegmenter:
outputFormat = OutputFormatKind.Hls;
break;
case StreamingMode.HttpLiveStreamingSegmenterFmp4:
outputFormat = OutputFormatKind.HlsMp4;
break;
case StreamingMode.HttpLiveStreamingSegmenterV2:
outputFormat = OutputFormatKind.Nut;
break;
}
Option<string> hlsPlaylistPath = outputFormat == OutputFormatKind.Hls
Option<string> hlsPlaylistPath = outputFormat is OutputFormatKind.Hls or OutputFormatKind.HlsMp4
? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live.m3u8")
: Option<string>.None;
Option<string> hlsSegmentTemplate = outputFormat == OutputFormatKind.Hls
? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live%06d.ts")
: Option<string>.None;
Option<string> hlsSegmentTemplate = outputFormat switch
{
OutputFormatKind.Hls => Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live%06d.ts"),
OutputFormatKind.HlsMp4 => Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live_%s_%%06d.m4s"),
_ => Option<string>.None
};
string videoPath = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "background.png");

4
ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs

@ -60,11 +60,13 @@ public static class FFmpegPlaybackSettingsCalculator @@ -60,11 +60,13 @@ public static class FFmpegPlaybackSettingsCalculator
FormatFlags = streamingMode switch
{
StreamingMode.HttpLiveStreamingSegmenter => SegmenterFormatFlags,
StreamingMode.HttpLiveStreamingSegmenterFmp4 => SegmenterFormatFlags,
_ => CommonFormatFlags
},
RealtimeOutput = streamingMode switch
{
StreamingMode.HttpLiveStreamingSegmenter => hlsRealtime,
StreamingMode.HttpLiveStreamingSegmenterFmp4 => hlsRealtime,
StreamingMode.HttpLiveStreamingSegmenterV2 => hlsRealtime,
_ => true
},
@ -85,6 +87,7 @@ public static class FFmpegPlaybackSettingsCalculator @@ -85,6 +87,7 @@ public static class FFmpegPlaybackSettingsCalculator
break;
case StreamingMode.TransportStreamHybrid:
case StreamingMode.HttpLiveStreamingSegmenter:
case StreamingMode.HttpLiveStreamingSegmenterFmp4:
case StreamingMode.HttpLiveStreamingSegmenterV2:
case StreamingMode.TransportStream:
result.HardwareAcceleration = ffmpegProfile.HardwareAcceleration;
@ -218,6 +221,7 @@ public static class FFmpegPlaybackSettingsCalculator @@ -218,6 +221,7 @@ public static class FFmpegPlaybackSettingsCalculator
RealtimeOutput = streamingMode switch
{
StreamingMode.HttpLiveStreamingSegmenter => hlsRealtime,
StreamingMode.HttpLiveStreamingSegmenterFmp4 => hlsRealtime,
StreamingMode.HttpLiveStreamingSegmenterV2 => hlsRealtime,
_ => true
},

119
ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
using System.Globalization;
using System.Text;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.FFmpeg.OutputFormat;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.FFmpeg;
@ -17,18 +18,21 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter @@ -17,18 +18,21 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter
}
public TrimPlaylistResult TrimPlaylist(
OutputFormatKind outputFormat,
DateTimeOffset playlistStart,
DateTimeOffset filterBefore,
List<long> inits,
string[] lines,
int maxSegments = 10,
bool endWithDiscontinuity = false)
{
try
{
List<PlaylistItem> items = new();
List<PlaylistItem> items = [];
DateTimeOffset currentTime = playlistStart;
var targetDuration = 0;
var discontinuitySequence = 0;
var i = 0;
while (!lines[i].StartsWith("#EXTINF:", StringComparison.OrdinalIgnoreCase))
@ -41,6 +45,10 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter @@ -41,6 +45,10 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter
{
items.Add(new PlaylistDiscontinuity());
}
else if (lines[i].StartsWith("#EXT-X-TARGETDURATION", StringComparison.OrdinalIgnoreCase))
{
targetDuration = int.Parse(lines[i].Split(':')[1], CultureInfo.InvariantCulture);
}
i++;
}
@ -61,13 +69,30 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter @@ -61,13 +69,30 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter
continue;
}
if (lines[i].StartsWith("#EXT-X-MAP:URI=", StringComparison.OrdinalIgnoreCase))
{
i++;
continue;
}
var durationDecimal = decimal.Parse(
lines[i].TrimEnd(',').Split(':')[1],
NumberStyles.Number,
CultureInfo.InvariantCulture);
var duration = TimeSpan.FromTicks((long)(durationDecimal * TimeSpan.TicksPerSecond));
items.Add(new PlaylistSegment(currentTime, lines[i], lines[i + 2]));
long segmentNameTimeSeconds = long.MaxValue;
if (outputFormat is OutputFormatKind.HlsMp4)
{
if (!lines[i + 2].Contains('_') || !long.TryParse(
lines[i + 2].Split('_')[1],
out segmentNameTimeSeconds))
{
segmentNameTimeSeconds = long.MaxValue;
}
}
items.Add(new PlaylistSegment(currentTime, segmentNameTimeSeconds, lines[i], lines[i + 2]));
currentTime += duration;
i += 3;
@ -78,13 +103,17 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter @@ -78,13 +103,17 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter
items.Add(new PlaylistDiscontinuity());
}
(string playlist, DateTimeOffset nextPlaylistStart, int startSequence, int segments) = GeneratePlaylist(
items,
filterBefore,
discontinuitySequence,
maxSegments);
return new TrimPlaylistResult(nextPlaylistStart, startSequence, playlist, segments);
(string playlist, DateTimeOffset nextPlaylistStart, long startSequence, long generatedAt, int segments) =
GeneratePlaylist(
outputFormat,
items,
inits,
filterBefore,
targetDuration,
discontinuitySequence,
maxSegments);
return new TrimPlaylistResult(nextPlaylistStart, startSequence, generatedAt, playlist, segments);
}
catch (Exception ex)
{
@ -96,7 +125,7 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter @@ -96,7 +125,7 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter
_logger.LogError(ex, "Error filtering playlist. Bad playlist saved to {BadPlaylistFile}", file);
// TODO: better error result?
return new TrimPlaylistResult(playlistStart, 0, string.Empty, 0);
return new TrimPlaylistResult(playlistStart, 0, 0, string.Empty, 0);
}
catch
{
@ -108,14 +137,19 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter @@ -108,14 +137,19 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter
}
public TrimPlaylistResult TrimPlaylistWithDiscontinuity(
OutputFormatKind outputFormat,
DateTimeOffset playlistStart,
DateTimeOffset filterBefore,
List<long> inits,
string[] lines) =>
TrimPlaylist(playlistStart, filterBefore, lines, int.MaxValue, true);
TrimPlaylist(outputFormat, playlistStart, filterBefore, inits, lines, int.MaxValue, true);
private static Tuple<string, DateTimeOffset, int, int> GeneratePlaylist(
private static Tuple<string, DateTimeOffset, long, long, int> GeneratePlaylist(
OutputFormatKind outputFormat,
List<PlaylistItem> items,
List<long> inits,
DateTimeOffset filterBefore,
int targetDuration,
int discontinuitySequence,
int maxSegments)
{
@ -142,10 +176,19 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter @@ -142,10 +176,19 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter
: allSegments.TakeLast(maxSegments).ToList();
}
int startSequence = allSegments
.HeadOrNone()
.Map(s => s.StartSequence)
.IfNone(0);
long startSequence = 0;
long generatedAt = 0;
foreach (var startSegment in allSegments.HeadOrNone())
{
startSequence = startSegment.StartSequence;
generatedAt = startSegment.GeneratedAt;
}
long minGeneratedAt = 0;
foreach (var firstSegment in allSegments.HeadOrNone())
{
minGeneratedAt = firstSegment.GeneratedAt;
}
// count all discontinuities that were filtered out
if (allSegments.Count != 0)
@ -157,12 +200,22 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter @@ -157,12 +200,22 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter
var output = new StringBuilder();
output.AppendLine("#EXTM3U");
output.AppendLine("#EXT-X-VERSION:6");
output.AppendLine("#EXT-X-TARGETDURATION:4");
output.AppendLine("#EXT-X-VERSION:7");
output.AppendLine(CultureInfo.InvariantCulture, $"#EXT-X-TARGETDURATION:{targetDuration}");
output.AppendLine(CultureInfo.InvariantCulture, $"#EXT-X-MEDIA-SEQUENCE:{startSequence}");
output.AppendLine(CultureInfo.InvariantCulture, $"#EXT-X-DISCONTINUITY-SEQUENCE:{discontinuitySequence}");
output.AppendLine("#EXT-X-INDEPENDENT-SEGMENTS");
if (outputFormat is OutputFormatKind.HlsMp4)
{
Option<long> maybeStartInit = Optional(inits.Find(init => init > 0 && init <= minGeneratedAt));
foreach (long init in maybeStartInit)
{
output.AppendLine(CultureInfo.InvariantCulture, $"#EXT-X-MAP:URI=\"{init}_init.mp4\"");
inits.Remove(init);
}
}
for (var i = 0; i < items.Count; i++)
{
switch (items[i])
@ -173,12 +226,26 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter @@ -173,12 +226,26 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter
if (items[i + 1] is PlaylistSegment nextSegment && allSegments.Head() != nextSegment)
{
output.AppendLine("#EXT-X-DISCONTINUITY");
if (outputFormat is OutputFormatKind.HlsMp4)
{
Option<long> maybeInit = Optional(
inits.Find(init => init > 0 && init <= nextSegment.GeneratedAt));
foreach (long init in maybeInit)
{
output.AppendLine(
CultureInfo.InvariantCulture,
$"#EXT-X-MAP:URI=\"{init}_init.mp4\"");
inits.Remove(init);
}
}
}
}
else if (i == items.Count - 1 && allSegments.Count > 0) // discontinuity at the end
{
output.AppendLine("#EXT-X-DISCONTINUITY");
}
break;
case PlaylistSegment segment:
if (allSegments.Contains(segment))
@ -202,19 +269,25 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter @@ -202,19 +269,25 @@ public class HlsPlaylistFilter : IHlsPlaylistFilter
.Map(s => s.StartTime)
.IfNone(DateTimeOffset.MaxValue);
return Tuple(playlist, nextPlaylistStart, startSequence, allSegments.Count);
return Tuple(playlist, nextPlaylistStart, startSequence, generatedAt, allSegments.Count);
}
private abstract record PlaylistItem;
private record PlaylistSegment(DateTimeOffset StartTime, string ExtInf, string Line) : PlaylistItem
private record PlaylistSegment(DateTimeOffset StartTime, long GeneratedAt, string ExtInf, string Line)
: PlaylistItem
{
public int StartSequence => int.Parse(
Line.Replace("live", string.Empty).Split('.')[0],
public long StartSequence => long.Parse(
Line.Contains('_') ? Line.Split('_')[2].Split('.')[0] : Line.Replace("live", string.Empty).Split('.')[0],
CultureInfo.InvariantCulture);
}
private record PlaylistDiscontinuity : PlaylistItem;
}
public record TrimPlaylistResult(DateTimeOffset PlaylistStart, int Sequence, string Playlist, int SegmentCount);
public record TrimPlaylistResult(
DateTimeOffset PlaylistStart,
long Sequence,
long GeneratedAt,
string Playlist,
int SegmentCount);

9
ErsatzTV.Core/FFmpeg/IHlsPlaylistFilter.cs

@ -1,16 +1,23 @@ @@ -1,16 +1,23 @@
namespace ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Domain;
using ErsatzTV.FFmpeg.OutputFormat;
namespace ErsatzTV.Core.FFmpeg;
public interface IHlsPlaylistFilter
{
TrimPlaylistResult TrimPlaylist(
OutputFormatKind outputFormat,
DateTimeOffset playlistStart,
DateTimeOffset filterBefore,
List<long> inits,
string[] lines,
int maxSegments = 10,
bool endWithDiscontinuity = false);
TrimPlaylistResult TrimPlaylistWithDiscontinuity(
OutputFormatKind outputFormat,
DateTimeOffset playlistStart,
DateTimeOffset filterBefore,
List<long> inits,
string[] lines);
}

1
ErsatzTV.Core/Iptv/ChannelPlaylist.cs

@ -79,6 +79,7 @@ public class ChannelPlaylist @@ -79,6 +79,7 @@ public class ChannelPlaylist
{
StreamingMode.HttpLiveStreamingDirect => $"m3u8?mode=hls-direct{accessTokenUriAmp}",
StreamingMode.HttpLiveStreamingSegmenter => $"m3u8?mode=segmenter{accessTokenUriAmp}",
StreamingMode.HttpLiveStreamingSegmenterFmp4 => $"m3u8?mode=segmenter-fmp4{accessTokenUriAmp}",
StreamingMode.HttpLiveStreamingSegmenterV2 => $"m3u8?mode=segmenter-v2{accessTokenUriAmp}",
StreamingMode.TransportStreamHybrid => $"ts{accessTokenUri}",
_ => $"ts?mode=ts-legacy{accessTokenUriAmp}"

29
ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs

@ -10,6 +10,7 @@ public class OutputFormatHls : IPipelineStep @@ -10,6 +10,7 @@ public class OutputFormatHls : IPipelineStep
private readonly bool _isFirstTranscode;
private readonly bool _isTroubleshooting;
private readonly Option<string> _mediaFrameRate;
private readonly OutputFormatKind _outputFormat;
private readonly bool _oneSecondGop;
private readonly string _playlistPath;
private readonly string _segmentTemplate;
@ -17,6 +18,7 @@ public class OutputFormatHls : IPipelineStep @@ -17,6 +18,7 @@ public class OutputFormatHls : IPipelineStep
public OutputFormatHls(
FrameState desiredState,
Option<string> mediaFrameRate,
OutputFormatKind outputFormat,
string segmentTemplate,
string playlistPath,
bool isFirstTranscode,
@ -25,6 +27,7 @@ public class OutputFormatHls : IPipelineStep @@ -25,6 +27,7 @@ public class OutputFormatHls : IPipelineStep
{
_desiredState = desiredState;
_mediaFrameRate = mediaFrameRate;
_outputFormat = outputFormat;
_segmentTemplate = segmentTemplate;
_playlistPath = playlistPath;
_isFirstTranscode = isFirstTranscode;
@ -58,13 +61,35 @@ public class OutputFormatHls : IPipelineStep @@ -58,13 +61,35 @@ public class OutputFormatHls : IPipelineStep
_segmentTemplate
];
var independentSegments = "+independent_segments";
var secondLevelSegmentIndex = "";
switch (_outputFormat)
{
case OutputFormatKind.Hls:
result.AddRange(
[
"-hls_segment_type", "mpegts"
]);
break;
case OutputFormatKind.HlsMp4:
result.AddRange(
[
"-hls_segment_type", "fmp4",
"-hls_fmp4_init_filename", $"{DateTimeOffset.Now.ToUnixTimeSeconds()}_init.mp4",
"-strftime", "1",
]);
secondLevelSegmentIndex = "+second_level_segment_index";
break;
}
string pdt = _isTroubleshooting ? string.Empty : "program_date_time+omit_endlist+";
if (_isFirstTranscode)
{
result.AddRange(
[
"-hls_flags", $"{pdt}append_list+independent_segments",
"-hls_flags", $"{pdt}append_list{independentSegments}{secondLevelSegmentIndex}",
_playlistPath
]);
}
@ -72,7 +97,7 @@ public class OutputFormatHls : IPipelineStep @@ -72,7 +97,7 @@ public class OutputFormatHls : IPipelineStep
{
result.AddRange(
[
"-hls_flags", $"{pdt}append_list+discont_start+independent_segments",
"-hls_flags", $"{pdt}append_list+discont_start{independentSegments}{secondLevelSegmentIndex}",
"-mpegts_flags", "+initial_discontinuity",
_playlistPath
]);

1
ErsatzTV.FFmpeg/OutputFormat/OutputFormatKind.cs

@ -7,6 +7,7 @@ public enum OutputFormatKind @@ -7,6 +7,7 @@ public enum OutputFormatKind
MpegTs,
Mp4,
Hls,
HlsMp4,
Nut
}

9
ErsatzTV.FFmpeg/OutputOption/HlsDirectMp4OutputOptions.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
namespace ErsatzTV.FFmpeg.OutputOption;
public class HlsDirectMp4OutputOptions : OutputOption
{
public override string[] OutputOptions =>
[
"-movflags", "+faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov"
];
}

6
ErsatzTV.FFmpeg/OutputOption/Mp4OutputOptions.cs

@ -2,6 +2,8 @@ @@ -2,6 +2,8 @@
public class Mp4OutputOptions : OutputOption
{
public override string[] OutputOptions => new[]
{ "-movflags", "+faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov" };
public override string[] OutputOptions =>
[
"-movflags", "+empty_moov+omit_tfhd_offset+frag_keyframe+default_base_moof"
];
}

10
ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs

@ -178,7 +178,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder @@ -178,7 +178,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
OutputOption.OutputOption outputOption = new FastStartOutputOption();
var isFmp4Hls = false;
if (ffmpegState.OutputFormat is OutputFormatKind.Hls)
if (ffmpegState.OutputFormat is OutputFormatKind.Hls or OutputFormatKind.HlsMp4)
{
foreach (string segmentTemplate in ffmpegState.HlsSegmentTemplate)
{
@ -186,7 +186,11 @@ public abstract class PipelineBuilderBase : IPipelineBuilder @@ -186,7 +186,11 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
}
}
if (ffmpegState.OutputFormat == OutputFormatKind.Mp4 || isFmp4Hls)
if (ffmpegState.OutputFormat == OutputFormatKind.Mp4 && desiredState.VideoFormat == VideoFormat.Copy)
{
outputOption = new HlsDirectMp4OutputOptions();
}
else if (isFmp4Hls)
{
outputOption = new Mp4OutputOptions();
}
@ -362,6 +366,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder @@ -362,6 +366,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
pipelineSteps.Add(new PipeProtocol());
break;
case OutputFormatKind.Hls:
case OutputFormatKind.HlsMp4:
foreach (string playlistPath in ffmpegState.HlsPlaylistPath)
{
foreach (string segmentTemplate in ffmpegState.HlsSegmentTemplate)
@ -372,6 +377,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder @@ -372,6 +377,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
new OutputFormatHls(
desiredState,
videoStream.FrameRate,
ffmpegState.OutputFormat,
segmentTemplate,
playlistPath,
ffmpegState.PtsOffset == 0,

8
ErsatzTV/Controllers/Api/TroubleshootController.cs

@ -4,6 +4,7 @@ using ErsatzTV.Application.MediaItems; @@ -4,6 +4,7 @@ using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.Troubleshooting;
using ErsatzTV.Application.Troubleshooting.Queries;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Troubleshooting;
@ -27,6 +28,8 @@ public class TroubleshootController( @@ -27,6 +28,8 @@ public class TroubleshootController(
[FromQuery]
int ffmpegProfile,
[FromQuery]
StreamingMode streamingMode,
[FromQuery]
List<int> watermark,
[FromQuery]
List<int> graphicsElement,
@ -42,6 +45,7 @@ public class TroubleshootController( @@ -42,6 +45,7 @@ public class TroubleshootController(
Either<BaseError, PlayoutItemResult> result = await mediator.Send(
new PrepareTroubleshootingPlayback(
streamingMode,
mediaItem,
ffmpegProfile,
watermark,
@ -125,6 +129,8 @@ public class TroubleshootController( @@ -125,6 +129,8 @@ public class TroubleshootController(
[FromQuery]
int ffmpegProfile,
[FromQuery]
StreamingMode streamingMode,
[FromQuery]
List<int> watermark,
[FromQuery]
List<int> graphicsElement,
@ -135,7 +141,7 @@ public class TroubleshootController( @@ -135,7 +141,7 @@ public class TroubleshootController(
Option<int> ss = seekSeconds > 0 ? seekSeconds : Option<int>.None;
Option<string> maybeArchivePath = await mediator.Send(
new ArchiveTroubleshootingResults(mediaItem, ffmpegProfile, watermark, graphicsElement, ss),
new ArchiveTroubleshootingResults(mediaItem, ffmpegProfile, streamingMode, watermark, graphicsElement, ss),
cancellationToken);
foreach (string archivePath in maybeArchivePath)

3
ErsatzTV/Controllers/InternalController.cs

@ -10,6 +10,7 @@ using ErsatzTV.Application.Streaming; @@ -10,6 +10,7 @@ using ErsatzTV.Application.Streaming;
using ErsatzTV.Application.Subtitles;
using ErsatzTV.Application.Subtitles.Queries;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Streaming;
@ -281,7 +282,7 @@ public class InternalController : ControllerBase @@ -281,7 +282,7 @@ public class InternalController : ControllerBase
{
var request = new GetPlayoutItemProcessByChannelNumber(
channelNumber,
mode,
StreamingMode.TransportStream,
DateTimeOffset.Now,
false,
true,

4
ErsatzTV/Controllers/IptvController.cs

@ -205,6 +205,9 @@ public class IptvController : ControllerBase @@ -205,6 +205,9 @@ public class IptvController : ControllerBase
case StreamingMode.HttpLiveStreamingSegmenter:
mode = "segmenter";
break;
case StreamingMode.HttpLiveStreamingSegmenterFmp4:
mode = "segmenter-fmp4";
break;
case StreamingMode.HttpLiveStreamingSegmenterV2:
mode = "segmenter-v2";
break;
@ -217,6 +220,7 @@ public class IptvController : ControllerBase @@ -217,6 +220,7 @@ public class IptvController : ControllerBase
switch (mode)
{
case "segmenter":
case "segmenter-fmp4":
case "segmenter-v2":
_logger.LogDebug(
"Maybe starting ffmpeg session for channel {Channel}, mode {Mode}",

1
ErsatzTV/Pages/ChannelEditor.razor

@ -140,6 +140,7 @@ else @@ -140,6 +140,7 @@ else
<MudSelectItem Value="@(StreamingMode.TransportStream)">MPEG-TS (Legacy)</MudSelectItem>
<MudSelectItem Value="@(StreamingMode.HttpLiveStreamingDirect)">HLS Direct</MudSelectItem>
<MudSelectItem Value="@(StreamingMode.HttpLiveStreamingSegmenter)">HLS Segmenter</MudSelectItem>
<MudSelectItem Value="@(StreamingMode.HttpLiveStreamingSegmenterFmp4)">HLS Segmenter (fmp4)</MudSelectItem>
<MudSelectItem Value="@(StreamingMode.HttpLiveStreamingSegmenterV2)">HLS Segmenter V2</MudSelectItem>
</MudSelect>
</MudStack>

113
ErsatzTV/Pages/Channels.razor

@ -6,6 +6,7 @@ @@ -6,6 +6,7 @@
@using ErsatzTV.Core.Interfaces.FFmpeg
@implements IDisposable
@inject IDialogService Dialog
@inject IJSRuntime JsRuntime
@inject IMediator Mediator
@inject NavigationManager NavigationManager
@inject IFFmpegSegmenterService SegmenterService
@ -74,7 +75,7 @@ @@ -74,7 +75,7 @@
</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
@if (CanPreviewChannel(context))
@if (_channelsThatCanPreview.Contains(context.Id))
{
<MudTooltip Text="Preview Channel">
<MudIconButton Icon="@Icons.Material.Filled.PlayCircle"
@ -82,9 +83,19 @@ @@ -82,9 +83,19 @@
</MudIconButton>
</MudTooltip>
}
else if (CanPreviewChannel(context) && !_ffmpegProfilesThatCanPreview[context.FFmpegProfileId])
{
<MudTooltip Text="Channel preview requires FFmpeg Profile compatible with this browser">
<div style="height: 48px; width: 48px; align-items: center; display: flex; justify-content: center">
<!--suppress CssUnresolvedCustomProperty -->
<MudIcon Icon="@Icons.Material.Filled.PlayCircle" Style="color: var(--mud-palette-error-lighten);">
</MudIcon>
</div>
</MudTooltip>
}
else
{
<MudTooltip Text="Channel preview requires playout, MPEG-TS/HLS Segmenter, and H264/AAC">
<MudTooltip Text="Channel preview requires playout, MPEG-TS/HLS Segmenter, and compatible FFmpeg Profile">
<MudIconButton Icon="@Icons.Material.Filled.PlayCircle" Disabled="true">
</MudIconButton>
</MudTooltip>
@ -127,6 +138,8 @@ @@ -127,6 +138,8 @@
private MudTable<ChannelViewModel> _table;
private List<FFmpegProfileViewModel> _ffmpegProfiles = [];
private readonly System.Collections.Generic.HashSet<int> _channelsThatCanPreview = [];
private readonly Dictionary<int, bool> _ffmpegProfilesThatCanPreview = [];
private int _rowsPerPage = 10;
@ -186,10 +199,36 @@ @@ -186,10 +199,36 @@
return false;
}
Option<FFmpegProfileViewModel> maybeProfile = Optional(_ffmpegProfiles.Find(p => p.Id == channel.FFmpegProfileId));
return true;
}
private async Task<bool> CanPreviewFFmpegProfile(int ffmpegProfileId)
{
Option<FFmpegProfileViewModel> maybeProfile = Optional(_ffmpegProfiles.Find(p => p.Id == ffmpegProfileId));
foreach (FFmpegProfileViewModel profile in maybeProfile)
{
return profile.VideoFormat is FFmpegProfileVideoFormat.H264 && profile.AudioFormat is FFmpegProfileAudioFormat.Aac;
string videoCodec = profile.VideoFormat switch
{
FFmpegProfileVideoFormat.Hevc => "hvc1.1.6.L93.B0",
FFmpegProfileVideoFormat.H264 => "avc1.4D4028",
_ => string.Empty
};
string audioCodec = profile.AudioFormat switch
{
FFmpegProfileAudioFormat.Ac3 => "ac-3",
FFmpegProfileAudioFormat.Aac => "mp4a.40.2",
_ => string.Empty
};
//Console.WriteLine($"Checking video format {videoCodec} and audio format {audioCodec}");
if (string.IsNullOrWhiteSpace(videoCodec) || string.IsNullOrWhiteSpace(audioCodec))
{
return false;
}
return await BrowserSupportsCodec($"{videoCodec}, {audioCodec}");
}
return false;
@ -197,36 +236,28 @@ @@ -197,36 +236,28 @@
private async Task PreviewChannel(ChannelViewModel channel)
{
if (!CanPreviewChannel(channel))
if (!CanPreviewChannel(channel) || !await CanPreviewFFmpegProfile(channel.FFmpegProfileId))
{
return;
}
Option<FFmpegProfileViewModel> maybeProfile = Optional(_ffmpegProfiles.Find(p => p.Id == channel.FFmpegProfileId));
foreach (FFmpegProfileViewModel profile in maybeProfile)
var uri = new UriBuilder(NavigationManager.ToAbsoluteUri(NavigationManager.Uri));
uri.Path = uri.Path.Replace("/channels", $"/iptv/channel/{channel.Number}.m3u8");
uri.Query = channel.StreamingMode switch
{
if (profile.VideoFormat == FFmpegProfileVideoFormat.Hevc)
{
return;
}
var uri = new UriBuilder(NavigationManager.ToAbsoluteUri(NavigationManager.Uri));
uri.Path = uri.Path.Replace("/channels", $"/iptv/channel/{channel.Number}.m3u8");
uri.Query = channel.StreamingMode switch
{
StreamingMode.HttpLiveStreamingSegmenterV2 => "?mode=segmenter-v2",
_ => "?mode=segmenter"
};
if (JwtHelper.IsEnabled)
{
uri.Query += $"&access_token={JwtHelper.GenerateToken()}";
}
StreamingMode.HttpLiveStreamingSegmenterV2 => "?mode=segmenter-v2",
StreamingMode.HttpLiveStreamingSegmenterFmp4 => "?mode=segmenter-fmp4",
_ => "?mode=segmenter"
};
var parameters = new DialogParameters { { "StreamUri", uri.ToString() } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraLarge };
await Dialog.ShowAsync<ChannelPreviewDialog>("Channel Preview", parameters, options);
if (JwtHelper.IsEnabled)
{
uri.Query += $"&access_token={JwtHelper.GenerateToken()}";
}
var parameters = new DialogParameters { { "StreamUri", uri.ToString() } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraLarge };
await Dialog.ShowAsync<ChannelPreviewDialog>("Channel Preview", parameters, options);
}
private async Task DeleteChannelAsync(ChannelViewModel channel)
@ -253,10 +284,16 @@ @@ -253,10 +284,16 @@
cancellationToken.ThrowIfCancellationRequested();
List<ChannelViewModel> channels = await Mediator.Send(new GetAllChannels(), cancellationToken);
IOrderedEnumerable<ChannelViewModel> sorted = channels.OrderBy(c => decimal.Parse(c.Number, CultureInfo.InvariantCulture));
// TODO: properly page this data
IOrderedEnumerable<ChannelViewModel> sorted = channels.OrderBy(c => decimal.Parse(c.Number, CultureInfo.InvariantCulture))
.Skip(state.Page * state.PageSize)
.Take(state.PageSize)
.OrderBy(c => decimal.Parse(c.Number, CultureInfo.InvariantCulture));
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
var processedChannels = new List<ChannelViewModel>();
_channelsThatCanPreview.Clear();
_ffmpegProfilesThatCanPreview.Clear();
foreach (ChannelViewModel channel in sorted)
{
Option<CultureInfo> maybeCultureInfo = allCultures.Find(ci => string.Equals(
@ -267,13 +304,23 @@ @@ -267,13 +304,23 @@
maybeCultureInfo.Match(
cultureInfo => processedChannels.Add(channel with { PreferredAudioLanguageCode = cultureInfo.EnglishName }),
() => processedChannels.Add(channel));
if (!_ffmpegProfilesThatCanPreview.TryGetValue(channel.FFmpegProfileId, out bool canPreviewFFmpegProfile))
{
canPreviewFFmpegProfile = await CanPreviewFFmpegProfile(channel.FFmpegProfileId);
_ffmpegProfilesThatCanPreview.Add(channel.FFmpegProfileId, canPreviewFFmpegProfile);
}
if (CanPreviewChannel(channel) && canPreviewFFmpegProfile)
{
_channelsThatCanPreview.Add(channel.Id);
}
}
// TODO: properly page this data
return new TableData<ChannelViewModel>
{
TotalItems = channels.Count,
Items = processedChannels.Skip(state.Page * state.PageSize).Take(state.PageSize)
Items = processedChannels
};
}
@ -281,9 +328,15 @@ @@ -281,9 +328,15 @@
{
StreamingMode.HttpLiveStreamingDirect => "HLS Direct",
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter",
StreamingMode.HttpLiveStreamingSegmenterFmp4 => "HLS Segmenter (fmp4)",
StreamingMode.HttpLiveStreamingSegmenterV2 => "HLS Segmenter V2",
StreamingMode.TransportStreamHybrid => "MPEG-TS",
_ => "MPEG-TS (Legacy)"
};
private async Task<bool> BrowserSupportsCodec(string codecString)
{
return await JsRuntime.InvokeAsync<bool>("mediaSourceSupports", codecString);
}
}

14
ErsatzTV/Pages/PlaybackTroubleshooting.razor

@ -55,6 +55,15 @@ @@ -55,6 +55,15 @@
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Streaming Mode</MudText>
</div>
<MudSelect @bind-Value="_streamingMode" For="@(() => _streamingMode)">
<MudSelectItem Value="@(StreamingMode.HttpLiveStreamingSegmenter)">HLS Segmenter</MudSelectItem>
<MudSelectItem Value="@(StreamingMode.HttpLiveStreamingSegmenterFmp4)">HLS Segmenter (fmp4)</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Subtitle</MudText>
@ -144,6 +153,7 @@ @@ -144,6 +153,7 @@
private readonly List<SubtitleViewModel> _subtitleStreams = [];
private readonly List<GraphicsElementViewModel> _graphicsElements = [];
private MediaItemInfo _info;
private StreamingMode _streamingMode = StreamingMode.HttpLiveStreamingSegmenter;
private int _ffmpegProfileId;
private IEnumerable<string> _watermarkNames = new System.Collections.Generic.HashSet<string>();
private IEnumerable<string> _graphicsElementNames = new System.Collections.Generic.HashSet<string>();
@ -213,7 +223,7 @@ @@ -213,7 +223,7 @@
{
var uri = new UriBuilder(NavigationManager.ToAbsoluteUri(NavigationManager.Uri));
uri.Path = uri.Path.Replace("/system/troubleshooting/playback", "/api/troubleshoot/playback.m3u8");
uri.Query = $"?mediaItem={MediaItemId}&ffmpegProfile={_ffmpegProfileId}&seekSeconds={_seekSeconds}";
uri.Query = $"?mediaItem={MediaItemId}&ffmpegProfile={_ffmpegProfileId}&streamingMode={(int)_streamingMode}&seekSeconds={_seekSeconds}";
foreach (string watermarkName in _watermarkNames)
{
foreach (WatermarkViewModel watermark in _watermarks.Where(wm => wm.Name == watermarkName))
@ -271,7 +281,7 @@ @@ -271,7 +281,7 @@
private async Task DownloadResults()
{
var uri = $"api/troubleshoot/playback/archive?mediaItem={MediaItemId ?? 0}&ffmpegProfile={_ffmpegProfileId}&seekSeconds={_seekSeconds}";
var uri = $"api/troubleshoot/playback/archive?mediaItem={MediaItemId ?? 0}&ffmpegProfile={_ffmpegProfileId}&streamingMode={(int)_streamingMode}&seekSeconds={_seekSeconds}";
foreach (string watermarkName in _watermarkNames)
{

7
ErsatzTV/Pages/_Host.cshtml

@ -55,6 +55,13 @@ @@ -55,6 +55,13 @@
$("h3").addClass("mud-typography mud-typography-h5");
}
function mediaSourceSupports(codecString) {
if (window.MediaSource && MediaSource.isTypeSupported) {
return MediaSource.isTypeSupported('video/mp4; codecs="' + codecString + '"');
}
return false;
}
function previewChannel(uri) {
var video = document.getElementById('video');
if (Hls.isSupported()) {

Loading…
Cancel
Save