From a59b4042c8c8c1414c22f22e107c48cee3e1a50f Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Sat, 13 Dec 2025 08:50:28 -0600 Subject: [PATCH] rework fallback filler --- CHANGELOG.md | 7 ++- .../Streaming/HlsSessionWorker.cs | 5 ++ .../Streaming/PlayoutItemProcessModel.cs | 6 ++- .../GetConcatProcessByChannelNumberHandler.cs | 3 +- .../Queries/GetErrorProcessHandler.cs | 3 +- ...layoutItemProcessByChannelNumberHandler.cs | 53 ++++++++++++------- ...GetWrappedProcessByChannelNumberHandler.cs | 3 +- .../FFmpeg/FFmpegLibraryProcessService.cs | 33 ++++++------ 8 files changed, 70 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbb58a04f..f345979ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,8 +60,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Changed - No longer round framerate to nearest integer when normalizing framerate - Allow playlists to have no items included in EPG -- Use hardware encoding for fallback filler - - Fallback filler will continue to use software decoding to avoid issues looping +- Change how fallback filler works + - Items will no longer loop; instead, a sequence of random items will be selected from the collection + - Items may still be cut as needed + - Hardware acceleration will now be used + - Items can "work ahead" (transcode faster than realtime) when less than 3 minutes in duration ## [25.9.0] - 2025-11-29 ### Added diff --git a/ErsatzTV.Application/Streaming/HlsSessionWorker.cs b/ErsatzTV.Application/Streaming/HlsSessionWorker.cs index eb3182450..370ce367d 100644 --- a/ErsatzTV.Application/Streaming/HlsSessionWorker.cs +++ b/ErsatzTV.Application/Streaming/HlsSessionWorker.cs @@ -488,6 +488,11 @@ public class HlsSessionWorker : IHlsSessionWorker foreach (PlayoutItemProcessModel processModel in result.RightAsEnumerable()) { + if (!realtime && !processModel.IsWorkingAhead) + { + _logger.LogDebug("HLS session throttling (NOT working ahead) based on playout item"); + } + await TrimAndDelete(cancellationToken); // increment discontinuity sequence and store with segment key (generated at) diff --git a/ErsatzTV.Application/Streaming/PlayoutItemProcessModel.cs b/ErsatzTV.Application/Streaming/PlayoutItemProcessModel.cs index b42b2ee28..ccb1ab51e 100644 --- a/ErsatzTV.Application/Streaming/PlayoutItemProcessModel.cs +++ b/ErsatzTV.Application/Streaming/PlayoutItemProcessModel.cs @@ -13,7 +13,8 @@ public class PlayoutItemProcessModel bool isComplete, Option segmentKey, Option mediaItemId, - Option playoutOffset) + Option playoutOffset, + bool isWorkingAhead) { Process = process; GraphicsEngineContext = graphicsEngineContext; @@ -22,6 +23,7 @@ public class PlayoutItemProcessModel IsComplete = isComplete; SegmentKey = segmentKey; MediaItemId = mediaItemId; + IsWorkingAhead = isWorkingAhead; // undo the offset applied in FFmpegProcessHandler // so we don't continually walk backward/forward in time by the offset amount @@ -49,4 +51,6 @@ public class PlayoutItemProcessModel public Option SegmentKey { get; init; } public Option MediaItemId { get; init; } + + public bool IsWorkingAhead { get; init; } } diff --git a/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumberHandler.cs index fe82a3bea..15676d376 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumberHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumberHandler.cs @@ -46,6 +46,7 @@ public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler.None, Option.None, - Option.None); + Option.None, + false); } } diff --git a/ErsatzTV.Application/Streaming/Queries/GetErrorProcessHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetErrorProcessHandler.cs index 9e280b9b7..d763c4f70 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetErrorProcessHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetErrorProcessHandler.cs @@ -42,6 +42,7 @@ public class GetErrorProcessHandler( true, request.Now.ToUnixTimeSeconds(), Option.None, - Optional(channel.PlayoutOffset)); + Optional(channel.PlayoutOffset), + !request.HlsRealtime); } } diff --git a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs index a65ba97d4..b0a1a423c 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs @@ -284,9 +284,20 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< bool isComplete = true; + bool effectiveRealtime = request.HlsRealtime; + + // only work ahead on fallback filler up to 3 minutes in duration + // since we always transcode a full fallback filler item + if (!effectiveRealtime && + playoutItemWithPath.PlayoutItem.FillerKind is FillerKind.Fallback && + duration > TimeSpan.FromMinutes(3)) + { + effectiveRealtime = true; + } + TimeSpan limit = TimeSpan.Zero; - if (!request.HlsRealtime) + if (!effectiveRealtime) { // if we are working ahead, limit to 44s (multiple of segment size) limit = TimeSpan.FromSeconds(44); @@ -319,7 +330,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< now, duration, $"DEBUG_NO_SYNC:\n{Mapper.GetDisplayTitle(playoutItemWithPath.PlayoutItem.MediaItem, Option.None)}\nFrom: {start} To: {finish}", - request.HlsRealtime, + effectiveRealtime, request.PtsOffset, channel.FFmpegProfile.VaapiDisplay, channel.FFmpegProfile.VaapiDriver, @@ -334,7 +345,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< true, effectiveNow.ToUnixTimeSeconds(), Option.None, - Optional(channel.PlayoutOffset)); + Optional(channel.PlayoutOffset), + !effectiveRealtime); } MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem.GetHeadVersion(); @@ -443,7 +455,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< channel.FFmpegProfile.VaapiDriver, channel.FFmpegProfile.VaapiDevice, Optional(channel.FFmpegProfile.QsvExtraHardwareFrames), - hlsRealtime: request.HlsRealtime, + effectiveRealtime, playoutItemWithPath.PlayoutItem.MediaItem is RemoteStream { IsLive: true } ? StreamInputKind.Live : StreamInputKind.Vod, @@ -465,7 +477,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< isComplete, effectiveNow.ToUnixTimeSeconds(), playoutItemResult.MediaItemId, - Optional(channel.PlayoutOffset)); + Optional(channel.PlayoutOffset), + !effectiveRealtime); return Right(result); } @@ -529,7 +542,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< true, now.ToUnixTimeSeconds(), Option.None, - Optional(channel.PlayoutOffset)); + Optional(channel.PlayoutOffset), + !request.HlsRealtime); case PlayoutItemDoesNotExistOnDisk: Command doesNotExistProcess = await _ffmpegProcessService.ForError( ffmpegPath, @@ -552,7 +566,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< true, now.ToUnixTimeSeconds(), Option.None, - Optional(channel.PlayoutOffset)); + Optional(channel.PlayoutOffset), + !request.HlsRealtime); default: Command errorProcess = await _ffmpegProcessService.ForError( ffmpegPath, @@ -575,7 +590,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< true, now.ToUnixTimeSeconds(), Option.None, - Optional(channel.PlayoutOffset)); + Optional(channel.PlayoutOffset), + !request.HlsRealtime); } } @@ -724,7 +740,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< break; } - // TODO: shuffle? does it really matter since we loop anyway + // get a random item MediaItem item = items[new Random().Next(items.Count)]; Option maybeDuration = await dbContext.PlayoutItems @@ -747,15 +763,14 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< .Filter(ms => ms.MediaVersionId == version.Id) .ToListAsync(cancellationToken); - DateTimeOffset finish = maybeDuration.Match( - // next playout item exists - // loop until it starts - now.Add, - // no next playout item exists - // loop for 5 minutes if less than 30s, otherwise play full item - () => version.Duration < TimeSpan.FromSeconds(30) - ? now.AddMinutes(5) - : now.Add(version.Duration)); + // always play min(duration to next item, version.Duration) + TimeSpan duration = maybeDuration.IfNone(version.Duration); + if (version.Duration < duration) + { + duration = version.Duration; + } + + DateTimeOffset finish = now.Add(duration); var playoutItem = new PlayoutItem { @@ -765,7 +780,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< Finish = finish.UtcDateTime, FillerKind = FillerKind.Fallback, InPoint = TimeSpan.Zero, - OutPoint = version.Duration, + OutPoint = duration, DisableWatermarks = !fallbackPreset.AllowWatermarks, Watermarks = [], PlayoutItemWatermarks = [], diff --git a/ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumberHandler.cs index cd88f4197..feda3a1a3 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumberHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumberHandler.cs @@ -48,6 +48,7 @@ public class GetWrappedProcessByChannelNumberHandler : FFmpegProcessHandler.None, Option.None, - Option.None); + Option.None, + false); } } diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs index 85175cac7..a7ae2f9c6 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs @@ -404,13 +404,12 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService graphicsElementContexts.AddRange(watermarks.Map(wm => new WatermarkElementContext(wm))); } - HardwareAccelerationMode decodeHwAccel = GetHardwareAccelerationMode(playbackSettings, fillerKind); - HardwareAccelerationMode encodeHwAccel = GetHardwareAccelerationMode(playbackSettings, FillerKind.None); + HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings); string videoFormat = GetVideoFormat(playbackSettings); Option maybeVideoProfile = GetVideoProfile(videoFormat, channel.FFmpegProfile.VideoProfile); Option maybeVideoPreset = GetVideoPreset( - encodeHwAccel, + hwAccel, videoFormat, channel.FFmpegProfile.VideoPreset, FFmpegLibraryHelper.MapBitDepth(channel.FFmpegProfile.BitDepth)); @@ -500,7 +499,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService var desiredState = new FrameState( playbackSettings.RealtimeOutput, - fillerKind == FillerKind.Fallback, + InfiniteLoop: false, videoFormat, maybeVideoProfile, maybeVideoPreset, @@ -549,10 +548,10 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService var ffmpegState = new FFmpegState( saveReports, - decodeHwAccel, - encodeHwAccel, - VaapiDriverName(encodeHwAccel, vaapiDriver), - VaapiDeviceName(encodeHwAccel, vaapiDevice), + hwAccel, + hwAccel, + VaapiDriverName(hwAccel, vaapiDriver), + VaapiDeviceName(hwAccel, vaapiDevice), playbackSettings.StreamSeek, finish - now, channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect, @@ -577,16 +576,16 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService _logger.LogDebug("FFmpeg desired state {FrameState}", desiredState); IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder( - encodeHwAccel, + hwAccel, videoInputFile, audioInputFile, watermarkInputFile, subtitleInputFile, Option.None, graphicsEngineInput, - VaapiDisplayName(encodeHwAccel, vaapiDisplay), - VaapiDriverName(encodeHwAccel, vaapiDriver), - VaapiDeviceName(encodeHwAccel, vaapiDevice), + VaapiDisplayName(hwAccel, vaapiDisplay), + VaapiDriverName(hwAccel, vaapiDriver), + VaapiDeviceName(hwAccel, vaapiDevice), await customReportsFolder.IfNoneAsync(FileSystemLayout.FFmpegReportsFolder), FileSystemLayout.FontsCacheFolder, ffmpegPath); @@ -701,7 +700,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService var desiredState = new FrameState( playbackSettings.RealtimeOutput, - false, + InfiniteLoop: false, videoFormat, GetVideoProfile(videoFormat, channel.FFmpegProfile.VideoProfile), VideoPreset.Unset, @@ -790,7 +789,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService var videoInputFile = new VideoInputFile(videoPath, new List { ffmpegVideoStream }); // TODO: ignore accel if this already failed once - HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings, FillerKind.None); + HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings); _logger.LogDebug("HW accel mode: {HwAccel}", hwAccel); var ffmpegState = new FFmpegState( @@ -1188,12 +1187,10 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService .ForAccelAndFormat(hardwareAccelerationMode, videoFormat, bitDepth) .Find(p => string.Equals(p, videoPreset, StringComparison.OrdinalIgnoreCase)); - private static HardwareAccelerationMode GetHardwareAccelerationMode( - FFmpegPlaybackSettings playbackSettings, - FillerKind fillerKind) => + private static HardwareAccelerationMode GetHardwareAccelerationMode(FFmpegPlaybackSettings playbackSettings) => playbackSettings.HardwareAcceleration switch { - _ when fillerKind == FillerKind.Fallback => HardwareAccelerationMode.None, + //_ when fillerKind == FillerKind.Fallback => HardwareAccelerationMode.None, HardwareAccelerationKind.Nvenc => HardwareAccelerationMode.Nvenc, HardwareAccelerationKind.Qsv => HardwareAccelerationMode.Qsv, HardwareAccelerationKind.Vaapi => HardwareAccelerationMode.Vaapi,