diff --git a/CHANGELOG.md b/CHANGELOG.md index 97c400c5c..1b19c9dc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Automatically generate JWT tokens to allow channel previews of protected streams - Fix bug applying music video fallback metadata - Fix playback of media items with no audio streams +- Fix timestamp continuity in `HLS Segmenter` sessions + - This should make *some* clients happier ### Changed - Log search index updates under scanner category at debug level, to indicate a potential cause for the UI being out of date diff --git a/ErsatzTV.Application/Channels/Mapper.cs b/ErsatzTV.Application/Channels/Mapper.cs index ff7799ce3..6c8d04cd7 100644 --- a/ErsatzTV.Application/Channels/Mapper.cs +++ b/ErsatzTV.Application/Channels/Mapper.cs @@ -34,6 +34,9 @@ internal static class Mapper channel.PreferredAudioLanguageCode, GetStreamingMode(channel)); + internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) => + new(resolution.Height, resolution.Width); + private static string GetLogo(Channel channel) => Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo)) .Match(a => a.Path, string.Empty); diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelResolution.cs b/ErsatzTV.Application/Channels/Queries/GetChannelResolution.cs new file mode 100644 index 000000000..46b7d8872 --- /dev/null +++ b/ErsatzTV.Application/Channels/Queries/GetChannelResolution.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Application.Channels; + +public record GetChannelResolution(string ChannelNumber) : IRequest>; diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelResolutionHandler.cs b/ErsatzTV.Application/Channels/Queries/GetChannelResolutionHandler.cs new file mode 100644 index 000000000..5d56df3d4 --- /dev/null +++ b/ErsatzTV.Application/Channels/Queries/GetChannelResolutionHandler.cs @@ -0,0 +1,25 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Extensions; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Application.Channels; + +public class GetChannelResolutionHandler(IDbContextFactory dbContextFactory) + : IRequestHandler> +{ + public async Task> Handle( + GetChannelResolution request, + CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + Option maybeChannel = await dbContext.Channels + .AsNoTracking() + .Include(c => c.FFmpegProfile) + .ThenInclude(ff => ff.Resolution) + .SelectOneAsync(c => c.Number, c => c.Number == request.ChannelNumber); + + return maybeChannel.Map(c => Mapper.ProjectToViewModel(c.FFmpegProfile.Resolution)); + } +} diff --git a/ErsatzTV.Application/Channels/ResolutionViewModel.cs b/ErsatzTV.Application/Channels/ResolutionViewModel.cs new file mode 100644 index 000000000..8e3190f8e --- /dev/null +++ b/ErsatzTV.Application/Channels/ResolutionViewModel.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Application.Channels; + +public record ResolutionViewModel(int Height, int Width); diff --git a/ErsatzTV.Application/Streaming/HlsSessionWorker.cs b/ErsatzTV.Application/Streaming/HlsSessionWorker.cs index 4f32f352e..51dd1498c 100644 --- a/ErsatzTV.Application/Streaming/HlsSessionWorker.cs +++ b/ErsatzTV.Application/Streaming/HlsSessionWorker.cs @@ -181,7 +181,6 @@ public class HlsSessionWorker : IHlsSessionWorker _logger.LogError("Transcode folder is NOT empty!"); } - Touch(); _transcodedUntil = DateTimeOffset.Now; PlaylistStart = _transcodedUntil; @@ -577,7 +576,7 @@ public class HlsSessionWorker : IHlsSessionWorker foreach ((long pts, long duration) in queryResult.RightToSeq()) { - result = pts + duration; + result = pts + duration + 1; } return result; diff --git a/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs b/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs index 2cecac218..693e2ae50 100644 --- a/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs +++ b/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs @@ -8,6 +8,7 @@ public class OutputFormatHls : IPipelineStep private readonly Option _mediaFrameRate; private readonly bool _oneSecondGop; private readonly string _playlistPath; + private readonly bool _isFirstTranscode; private readonly string _segmentTemplate; public OutputFormatHls( @@ -15,12 +16,14 @@ public class OutputFormatHls : IPipelineStep Option mediaFrameRate, string segmentTemplate, string playlistPath, - bool oneSecondGop = false) + bool isFirstTranscode, + bool oneSecondGop) { _desiredState = desiredState; _mediaFrameRate = mediaFrameRate; _segmentTemplate = segmentTemplate; _playlistPath = playlistPath; + _isFirstTranscode = isFirstTranscode; _oneSecondGop = oneSecondGop; } @@ -38,8 +41,8 @@ public class OutputFormatHls : IPipelineStep int gop = _oneSecondGop ? frameRate : frameRate * SEGMENT_SECONDS; - return new[] - { + List result = + [ "-g", $"{gop}", "-keyint_min", $"{frameRate * SEGMENT_SECONDS}", "-force_key_frames", $"expr:gte(t,n_forced*{SEGMENT_SECONDS})", @@ -48,11 +51,28 @@ public class OutputFormatHls : IPipelineStep "-hls_list_size", "0", "-segment_list_flags", "+live", "-hls_segment_filename", - _segmentTemplate, - "-hls_flags", "program_date_time+append_list+discont_start+omit_endlist+independent_segments", - "-mpegts_flags", "+initial_discontinuity", - _playlistPath - }; + _segmentTemplate + ]; + + if (_isFirstTranscode) + { + result.AddRange( + [ + "-hls_flags", "program_date_time+append_list+omit_endlist+independent_segments", + _playlistPath + ]); + } + else + { + result.AddRange( + [ + "-hls_flags", "program_date_time+append_list+discont_start+omit_endlist+independent_segments", + "-mpegts_flags", "+initial_discontinuity", + _playlistPath + ]); + } + + return result.ToArray(); } } diff --git a/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs b/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs index 5fcbfc3c7..2e5adbd07 100644 --- a/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs +++ b/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs @@ -275,6 +275,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder videoStream.FrameRate, segmentTemplate, playlistPath, + isFirstTranscode: ffmpegState.PtsOffset == 0, ffmpegState.EncoderHardwareAccelerationMode is HardwareAccelerationMode.Qsv)); } } diff --git a/ErsatzTV/Controllers/IptvController.cs b/ErsatzTV/Controllers/IptvController.cs index de4e95582..3d5b8dde7 100644 --- a/ErsatzTV/Controllers/IptvController.cs +++ b/ErsatzTV/Controllers/IptvController.cs @@ -191,6 +191,7 @@ public class IptvController : ControllerBase switch (mode) { case "segmenter": + string multiVariantPlaylist = await GetMultiVariantPlaylist(channelNumber); _logger.LogDebug("Maybe starting ffmpeg session for channel {Channel}", channelNumber); Either result = await _mediator.Send(new StartFFmpegSession(channelNumber, false)); return result.Match( @@ -199,7 +200,7 @@ public class IptvController : ControllerBase _logger.LogDebug( "Session started; returning multi-variant playlist for channel {Channel}", channelNumber); - return Content(GetMultiVariantPlaylist(channelNumber), "application/vnd.apple.mpegurl"); + return Content(multiVariantPlaylist, "application/vnd.apple.mpegurl"); // return Redirect($"~/iptv/session/{channelNumber}/hls.m3u8"); }, error => @@ -210,7 +211,7 @@ public class IptvController : ControllerBase _logger.LogDebug( "Session is already active; returning multi-variant playlist for channel {Channel}", channelNumber); - return Content(GetMultiVariantPlaylist(channelNumber), "application/vnd.apple.mpegurl"); + return Content(multiVariantPlaylist, "application/vnd.apple.mpegurl"); // return RedirectPreserveMethod($"iptv/session/{channelNumber}/hls.m3u8"); default: _logger.LogWarning( @@ -246,12 +247,20 @@ public class IptvController : ControllerBase Right: r => new PhysicalFileResult(r.FileName, r.MimeType)); } - // TODO: include resolution here? - private string GetMultiVariantPlaylist(string channelNumber) => - $@"#EXTM3U + private async Task GetMultiVariantPlaylist(string channelNumber) + { + Option maybeResolution = await _mediator.Send(new GetChannelResolution(channelNumber)); + string resolution = string.Empty; + foreach (ResolutionViewModel res in maybeResolution) + { + resolution = $",RESOLUTION={res.Width}x{res.Height}"; + } + + return $@"#EXTM3U #EXT-X-VERSION:3 -#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=10000000 +#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=10000000{resolution} {Request.Scheme}://{Request.Host}/iptv/session/{channelNumber}/hls.m3u8{AccessTokenQuery()}"; + } private string AccessTokenQuery() => string.IsNullOrWhiteSpace(Request.Query["access_token"]) ? string.Empty