From b46de508011262f2b39767b033732c8ecb5c2d7e Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Tue, 30 Sep 2025 10:04:02 -0500 Subject: [PATCH] add hls segmenter fmp4 streaming mode (#2468) * add streaming mode segmenter fmp4 * allow hevc channel preview --- CHANGELOG.md | 6 + ErsatzTV.Application/Channels/Mapper.cs | 1 + .../Queries/GetChannelPlaylistHandler.cs | 4 + .../Commands/StartFFmpegSessionHandler.cs | 12 ++ .../Streaming/HlsSessionWorker.cs | 79 +++++++++--- .../Streaming/HlsSessionWorkerV2.cs | 3 +- .../Streaming/Queries/FFmpegProcessHandler.cs | 11 +- .../Streaming/Queries/FFmpegProcessRequest.cs | 3 +- .../GetConcatProcessByChannelNumber.cs | 6 +- ...etConcatSegmenterProcessByChannelNumber.cs | 6 +- .../Streaming/Queries/GetErrorProcess.cs | 6 +- .../GetPlayoutItemProcessByChannelNumber.cs | 6 +- .../GetWrappedProcessByChannelNumber.cs | 6 +- .../Commands/ArchiveTroubleshootingResults.cs | 3 + .../PrepareTroubleshootingPlayback.cs | 2 + .../PrepareTroubleshootingPlaybackHandler.cs | 2 +- .../FFmpegPlaybackSettingsCalculatorTests.cs | 42 +++++++ .../FFmpeg/HlsPlaylistFilterTests.cs | 76 ++++++++--- ErsatzTV.Core/Domain/StreamingMode.cs | 4 +- .../FFmpeg/FFmpegLibraryProcessService.cs | 28 +++-- .../FFmpegPlaybackSettingsCalculator.cs | 4 + ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs | 119 ++++++++++++++---- ErsatzTV.Core/FFmpeg/IHlsPlaylistFilter.cs | 9 +- ErsatzTV.Core/Iptv/ChannelPlaylist.cs | 1 + .../OutputFormat/OutputFormatHls.cs | 29 ++++- .../OutputFormat/OutputFormatKind.cs | 1 + .../OutputOption/HlsDirectMp4OutputOptions.cs | 9 ++ .../OutputOption/Mp4OutputOptions.cs | 6 +- .../Pipeline/PipelineBuilderBase.cs | 10 +- .../Controllers/Api/TroubleshootController.cs | 8 +- ErsatzTV/Controllers/InternalController.cs | 3 +- ErsatzTV/Controllers/IptvController.cs | 4 + ErsatzTV/Pages/ChannelEditor.razor | 1 + ErsatzTV/Pages/Channels.razor | 113 ++++++++++++----- ErsatzTV/Pages/PlaybackTroubleshooting.razor | 14 ++- ErsatzTV/Pages/_Host.cshtml | 7 ++ 36 files changed, 514 insertions(+), 130 deletions(-) create mode 100644 ErsatzTV.FFmpeg/OutputOption/HlsDirectMp4OutputOptions.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 079f27c2e..780f8fc75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/ErsatzTV.Application/Channels/Mapper.cs b/ErsatzTV.Application/Channels/Mapper.cs index d585084ed..4f702abf4 100644 --- a/ErsatzTV.Application/Channels/Mapper.cs +++ b/ErsatzTV.Application/Channels/Mapper.cs @@ -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)) }; diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelPlaylistHandler.cs b/ErsatzTV.Application/Channels/Queries/GetChannelPlaylistHandler.cs index bffec02ce..cbb9e719e 100644 --- a/ErsatzTV.Application/Channels/Queries/GetChannelPlaylistHandler.cs +++ b/ErsatzTV.Application/Channels/Queries/GetChannelPlaylistHandler.cs @@ -38,6 +38,10 @@ public class GetChannelPlaylistHandler : IRequestHandler new HlsSessionWorker( + _serviceScopeFactory, + OutputFormatKind.HlsMp4, + _graphicsEngine, + _client, + _hlsPlaylistFilter, + _configElementRepository, + _localFileSystem, + _sessionWorkerLogger, + targetFramerate), _ => new HlsSessionWorker( _serviceScopeFactory, + OutputFormatKind.Hls, _graphicsEngine, _client, _hlsPlaylistFilter, diff --git a/ErsatzTV.Application/Streaming/HlsSessionWorker.cs b/ErsatzTV.Application/Streaming/HlsSessionWorker.cs index 0cd3da516..4c9f20211 100644 --- a/ErsatzTV.Application/Streaming/HlsSessionWorker.cs +++ b/ErsatzTV.Application/Streaming/HlsSessionWorker.cs @@ -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 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 public HlsSessionWorker( IServiceScopeFactory serviceScopeFactory, + OutputFormatKind outputFormat, IGraphicsEngine graphicsEngine, IClient client, IHlsPlaylistFilter hlsPlaylistFilter, @@ -58,6 +61,7 @@ public class HlsSessionWorker : IHlsSessionWorker { _serviceScope = serviceScopeFactory.CreateScope(); _mediator = _serviceScope.ServiceProvider.GetRequiredService(); + _outputFormat = outputFormat; _graphicsEngine = graphicsEngine; _client = client; _hlsPlaylistFilter = hlsPlaylistFilter; @@ -110,7 +114,12 @@ public class HlsSessionWorker : IHlsSessionWorker Option 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 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 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 Either 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 { // 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 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.None; }) + .Somes() .ToList(); var toDelete = allSegments.Filter(s => s.SequenceNumber < trimResult.Sequence).ToList(); @@ -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 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 } } + private List 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 GetPtsOffset(string channelNumber, CancellationToken cancellationToken) { await _slim.WaitAsync(cancellationToken); @@ -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); } diff --git a/ErsatzTV.Application/Streaming/HlsSessionWorkerV2.cs b/ErsatzTV.Application/Streaming/HlsSessionWorkerV2.cs index 5d6ab17d2..b42f75a35 100644 --- a/ErsatzTV.Application/Streaming/HlsSessionWorkerV2.cs +++ b/ErsatzTV.Application/Streaming/HlsSessionWorkerV2.cs @@ -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 var request = new GetPlayoutItemProcessByChannelNumber( _channelNumber, - "segmenter-v2", + StreamingMode.HttpLiveStreamingSegmenterV2, _transcodedUntil, startAtZero, realtime, diff --git a/ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs b/ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs index 3920b673b..60bd4fe2d 100644 --- a/ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs @@ -61,16 +61,7 @@ public abstract class FFmpegProcessHandler : IRequestHandler 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($"Channel number {request.ChannelNumber} does not exist.")); diff --git a/ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs b/ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs index e61c5864f..734373596 100644 --- a/ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs +++ b/ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs @@ -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, diff --git a/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs b/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs index 5abb8e0a6..7955337ea 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs @@ -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, diff --git a/ErsatzTV.Application/Streaming/Queries/GetConcatSegmenterProcessByChannelNumber.cs b/ErsatzTV.Application/Streaming/Queries/GetConcatSegmenterProcessByChannelNumber.cs index c22a9b049..a38f25a11 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetConcatSegmenterProcessByChannelNumber.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetConcatSegmenterProcessByChannelNumber.cs @@ -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, diff --git a/ErsatzTV.Application/Streaming/Queries/GetErrorProcess.cs b/ErsatzTV.Application/Streaming/Queries/GetErrorProcess.cs index 7632676ef..8a2e5779e 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetErrorProcess.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetErrorProcess.cs @@ -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 MaybeDuration, diff --git a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs index 7b87c99f4..1b8b19257 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs @@ -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, diff --git a/ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumber.cs b/ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumber.cs index 23ca34fcd..54e850371 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumber.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumber.cs @@ -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 string accessToken, string channelNumber) : base( channelNumber, - "ts", + StreamingMode.TransportStreamHybrid, DateTimeOffset.Now, false, true, diff --git a/ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResults.cs b/ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResults.cs index 3f8b14165..399a66db7 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResults.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResults.cs @@ -1,8 +1,11 @@ +using ErsatzTV.Core.Domain; + namespace ErsatzTV.Application.Troubleshooting; public record ArchiveTroubleshootingResults( int MediaItemId, int FFmpegProfileId, + StreamingMode StreamingMode, List WatermarkIds, List GraphicsElementIds, Option SeekSeconds) diff --git a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlayback.cs b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlayback.cs index 089f21fcd..52889a0ee 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlayback.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlayback.cs @@ -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 WatermarkIds, diff --git a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs index 94d82137d..44a946dc9 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs @@ -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 diff --git a/ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsCalculatorTests.cs b/ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsCalculatorTests.cs index 08274dc9f..cf63a1930 100644 --- a/ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsCalculatorTests.cs +++ b/ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsCalculatorTests.cs @@ -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 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() { diff --git a/ErsatzTV.Core.Tests/FFmpeg/HlsPlaylistFilterTests.cs b/ErsatzTV.Core.Tests/FFmpeg/HlsPlaylistFilterTests.cs index e70e16553..1778ac37d 100644 --- a/ErsatzTV.Core.Tests/FFmpeg/HlsPlaylistFilterTests.cs +++ b/ErsatzTV.Core.Tests/FFmpeg/HlsPlaylistFilterTests.cs @@ -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 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 #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 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 #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 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 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); 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 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 #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 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 #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 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 #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 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 // 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 diff --git a/ErsatzTV.Core/Domain/StreamingMode.cs b/ErsatzTV.Core/Domain/StreamingMode.cs index 208c083d8..8985a3e62 100644 --- a/ErsatzTV.Core/Domain/StreamingMode.cs +++ b/ErsatzTV.Core/Domain/StreamingMode.cs @@ -8,5 +8,7 @@ public enum StreamingMode // HttpLiveStreamingHybrid = 3, HttpLiveStreamingSegmenter = 4, TransportStreamHybrid = 5, - HttpLiveStreamingSegmenterV2 = 6 + HttpLiveStreamingSegmenterV2 = 6, + + HttpLiveStreamingSegmenterFmp4 = 100 } diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs index 6db3c45d2..2f87c45b9 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs @@ -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 channel.FFmpegProfile.VideoPreset, FFmpegLibraryHelper.MapBitDepth(channel.FFmpegProfile.BitDepth)); - Option hlsPlaylistPath = outputFormat == OutputFormatKind.Hls + Option hlsPlaylistPath = outputFormat is OutputFormatKind.Hls or OutputFormatKind.HlsMp4 ? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live.m3u8") : Option.None; - Option hlsSegmentTemplate = outputFormat == OutputFormatKind.Hls - ? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live%06d.ts") - : Option.None; + Option 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.None + }; FrameSize scaledSize = ffmpegVideoStream.SquarePixelFrameSize( new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height)); @@ -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 hlsPlaylistPath = outputFormat == OutputFormatKind.Hls + Option hlsPlaylistPath = outputFormat is OutputFormatKind.Hls or OutputFormatKind.HlsMp4 ? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live.m3u8") : Option.None; - Option hlsSegmentTemplate = outputFormat == OutputFormatKind.Hls - ? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live%06d.ts") - : Option.None; + Option 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.None + }; string videoPath = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "background.png"); diff --git a/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs b/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs index 5e02b9c9d..1539ddd52 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs @@ -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 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 RealtimeOutput = streamingMode switch { StreamingMode.HttpLiveStreamingSegmenter => hlsRealtime, + StreamingMode.HttpLiveStreamingSegmenterFmp4 => hlsRealtime, StreamingMode.HttpLiveStreamingSegmenterV2 => hlsRealtime, _ => true }, diff --git a/ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs b/ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs index c261410c7..f71be1941 100644 --- a/ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs +++ b/ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs @@ -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 } public TrimPlaylistResult TrimPlaylist( + OutputFormatKind outputFormat, DateTimeOffset playlistStart, DateTimeOffset filterBefore, + List inits, string[] lines, int maxSegments = 10, bool endWithDiscontinuity = false) { try { - List items = new(); + List 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 { 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 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 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 _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 } public TrimPlaylistResult TrimPlaylistWithDiscontinuity( + OutputFormatKind outputFormat, DateTimeOffset playlistStart, DateTimeOffset filterBefore, + List inits, string[] lines) => - TrimPlaylist(playlistStart, filterBefore, lines, int.MaxValue, true); + TrimPlaylist(outputFormat, playlistStart, filterBefore, inits, lines, int.MaxValue, true); - private static Tuple GeneratePlaylist( + private static Tuple GeneratePlaylist( + OutputFormatKind outputFormat, List items, + List inits, DateTimeOffset filterBefore, + int targetDuration, int discontinuitySequence, int maxSegments) { @@ -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 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 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 if (items[i + 1] is PlaylistSegment nextSegment && allSegments.Head() != nextSegment) { output.AppendLine("#EXT-X-DISCONTINUITY"); + + if (outputFormat is OutputFormatKind.HlsMp4) + { + Option 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 .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); diff --git a/ErsatzTV.Core/FFmpeg/IHlsPlaylistFilter.cs b/ErsatzTV.Core/FFmpeg/IHlsPlaylistFilter.cs index b498c72a2..20ef30127 100644 --- a/ErsatzTV.Core/FFmpeg/IHlsPlaylistFilter.cs +++ b/ErsatzTV.Core/FFmpeg/IHlsPlaylistFilter.cs @@ -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 inits, string[] lines, int maxSegments = 10, bool endWithDiscontinuity = false); TrimPlaylistResult TrimPlaylistWithDiscontinuity( + OutputFormatKind outputFormat, DateTimeOffset playlistStart, DateTimeOffset filterBefore, + List inits, string[] lines); } diff --git a/ErsatzTV.Core/Iptv/ChannelPlaylist.cs b/ErsatzTV.Core/Iptv/ChannelPlaylist.cs index 15849c61e..7219ce6a9 100644 --- a/ErsatzTV.Core/Iptv/ChannelPlaylist.cs +++ b/ErsatzTV.Core/Iptv/ChannelPlaylist.cs @@ -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}" diff --git a/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs b/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs index ddedb438b..552be441c 100644 --- a/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs +++ b/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs @@ -10,6 +10,7 @@ public class OutputFormatHls : IPipelineStep private readonly bool _isFirstTranscode; private readonly bool _isTroubleshooting; private readonly Option _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 public OutputFormatHls( FrameState desiredState, Option mediaFrameRate, + OutputFormatKind outputFormat, string segmentTemplate, string playlistPath, bool isFirstTranscode, @@ -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 _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 { 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 ]); diff --git a/ErsatzTV.FFmpeg/OutputFormat/OutputFormatKind.cs b/ErsatzTV.FFmpeg/OutputFormat/OutputFormatKind.cs index 1d6ffc7ad..8c1b04d8a 100644 --- a/ErsatzTV.FFmpeg/OutputFormat/OutputFormatKind.cs +++ b/ErsatzTV.FFmpeg/OutputFormat/OutputFormatKind.cs @@ -7,6 +7,7 @@ public enum OutputFormatKind MpegTs, Mp4, Hls, + HlsMp4, Nut } diff --git a/ErsatzTV.FFmpeg/OutputOption/HlsDirectMp4OutputOptions.cs b/ErsatzTV.FFmpeg/OutputOption/HlsDirectMp4OutputOptions.cs new file mode 100644 index 000000000..ff7efb779 --- /dev/null +++ b/ErsatzTV.FFmpeg/OutputOption/HlsDirectMp4OutputOptions.cs @@ -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" + ]; +} diff --git a/ErsatzTV.FFmpeg/OutputOption/Mp4OutputOptions.cs b/ErsatzTV.FFmpeg/OutputOption/Mp4OutputOptions.cs index f4f739ab0..d3ccd7f30 100644 --- a/ErsatzTV.FFmpeg/OutputOption/Mp4OutputOptions.cs +++ b/ErsatzTV.FFmpeg/OutputOption/Mp4OutputOptions.cs @@ -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" + ]; } diff --git a/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs b/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs index d34fe5f7e..b0f93dbc6 100644 --- a/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs +++ b/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs @@ -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 } } - 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 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 new OutputFormatHls( desiredState, videoStream.FrameRate, + ffmpegState.OutputFormat, segmentTemplate, playlistPath, ffmpegState.PtsOffset == 0, diff --git a/ErsatzTV/Controllers/Api/TroubleshootController.cs b/ErsatzTV/Controllers/Api/TroubleshootController.cs index a5ee0de11..75132b244 100644 --- a/ErsatzTV/Controllers/Api/TroubleshootController.cs +++ b/ErsatzTV/Controllers/Api/TroubleshootController.cs @@ -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( [FromQuery] int ffmpegProfile, [FromQuery] + StreamingMode streamingMode, + [FromQuery] List watermark, [FromQuery] List graphicsElement, @@ -42,6 +45,7 @@ public class TroubleshootController( Either result = await mediator.Send( new PrepareTroubleshootingPlayback( + streamingMode, mediaItem, ffmpegProfile, watermark, @@ -125,6 +129,8 @@ public class TroubleshootController( [FromQuery] int ffmpegProfile, [FromQuery] + StreamingMode streamingMode, + [FromQuery] List watermark, [FromQuery] List graphicsElement, @@ -135,7 +141,7 @@ public class TroubleshootController( Option ss = seekSeconds > 0 ? seekSeconds : Option.None; Option 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) diff --git a/ErsatzTV/Controllers/InternalController.cs b/ErsatzTV/Controllers/InternalController.cs index 196206e93..e569be727 100644 --- a/ErsatzTV/Controllers/InternalController.cs +++ b/ErsatzTV/Controllers/InternalController.cs @@ -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 { var request = new GetPlayoutItemProcessByChannelNumber( channelNumber, - mode, + StreamingMode.TransportStream, DateTimeOffset.Now, false, true, diff --git a/ErsatzTV/Controllers/IptvController.cs b/ErsatzTV/Controllers/IptvController.cs index 75148a8ee..26f2e1f14 100644 --- a/ErsatzTV/Controllers/IptvController.cs +++ b/ErsatzTV/Controllers/IptvController.cs @@ -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 switch (mode) { case "segmenter": + case "segmenter-fmp4": case "segmenter-v2": _logger.LogDebug( "Maybe starting ffmpeg session for channel {Channel}, mode {Mode}", diff --git a/ErsatzTV/Pages/ChannelEditor.razor b/ErsatzTV/Pages/ChannelEditor.razor index e58e17458..830ea67d8 100644 --- a/ErsatzTV/Pages/ChannelEditor.razor +++ b/ErsatzTV/Pages/ChannelEditor.razor @@ -140,6 +140,7 @@ else MPEG-TS (Legacy) HLS Direct HLS Segmenter + HLS Segmenter (fmp4) HLS Segmenter V2 diff --git a/ErsatzTV/Pages/Channels.razor b/ErsatzTV/Pages/Channels.razor index f6d972c85..34c1e81f1 100644 --- a/ErsatzTV/Pages/Channels.razor +++ b/ErsatzTV/Pages/Channels.razor @@ -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 @@
- @if (CanPreviewChannel(context)) + @if (_channelsThatCanPreview.Contains(context.Id)) { } + else if (CanPreviewChannel(context) && !_ffmpegProfilesThatCanPreview[context.FFmpegProfileId]) + { + +
+ + + +
+
+ } else { - + @@ -127,6 +138,8 @@ private MudTable _table; private List _ffmpegProfiles = []; + private readonly System.Collections.Generic.HashSet _channelsThatCanPreview = []; + private readonly Dictionary _ffmpegProfilesThatCanPreview = []; private int _rowsPerPage = 10; @@ -186,10 +199,36 @@ return false; } - Option maybeProfile = Optional(_ffmpegProfiles.Find(p => p.Id == channel.FFmpegProfileId)); + return true; + } + + private async Task CanPreviewFFmpegProfile(int ffmpegProfileId) + { + Option 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 @@ private async Task PreviewChannel(ChannelViewModel channel) { - if (!CanPreviewChannel(channel)) + if (!CanPreviewChannel(channel) || !await CanPreviewFFmpegProfile(channel.FFmpegProfileId)) { return; } - Option 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("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("Channel Preview", parameters, options); } private async Task DeleteChannelAsync(ChannelViewModel channel) @@ -253,10 +284,16 @@ cancellationToken.ThrowIfCancellationRequested(); List channels = await Mediator.Send(new GetAllChannels(), cancellationToken); - IOrderedEnumerable sorted = channels.OrderBy(c => decimal.Parse(c.Number, CultureInfo.InvariantCulture)); + // TODO: properly page this data + IOrderedEnumerable 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(); + _channelsThatCanPreview.Clear(); + _ffmpegProfilesThatCanPreview.Clear(); foreach (ChannelViewModel channel in sorted) { Option maybeCultureInfo = allCultures.Find(ci => string.Equals( @@ -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 { TotalItems = channels.Count, - Items = processedChannels.Skip(state.Page * state.PageSize).Take(state.PageSize) + Items = processedChannels }; } @@ -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 BrowserSupportsCodec(string codecString) + { + return await JsRuntime.InvokeAsync("mediaSourceSupports", codecString); + } + } diff --git a/ErsatzTV/Pages/PlaybackTroubleshooting.razor b/ErsatzTV/Pages/PlaybackTroubleshooting.razor index 802ae99cb..92ba1f518 100644 --- a/ErsatzTV/Pages/PlaybackTroubleshooting.razor +++ b/ErsatzTV/Pages/PlaybackTroubleshooting.razor @@ -55,6 +55,15 @@ } + +
+ Streaming Mode +
+ + HLS Segmenter + HLS Segmenter (fmp4) + +
Subtitle @@ -144,6 +153,7 @@ private readonly List _subtitleStreams = []; private readonly List _graphicsElements = []; private MediaItemInfo _info; + private StreamingMode _streamingMode = StreamingMode.HttpLiveStreamingSegmenter; private int _ffmpegProfileId; private IEnumerable _watermarkNames = new System.Collections.Generic.HashSet(); private IEnumerable _graphicsElementNames = new System.Collections.Generic.HashSet(); @@ -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 @@ 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) { diff --git a/ErsatzTV/Pages/_Host.cshtml b/ErsatzTV/Pages/_Host.cshtml index aba7a55c7..e192b25bd 100644 --- a/ErsatzTV/Pages/_Host.cshtml +++ b/ErsatzTV/Pages/_Host.cshtml @@ -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()) {