From b36e94cc565c9634c6051f504efc35b088a783f2 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:09:11 -0500 Subject: [PATCH] fix: on-demand channel progress using next engine (#2985) --- CHANGELOG.md | 1 + ErsatzTV.Application/Playouts/Mapper.cs | 13 +++ .../Playouts/PlayoutModeViewModel.cs | 5 + .../Queries/GetPlayoutModeByChannelNumber.cs | 3 + .../GetPlayoutModeByChannelNumberHandler.cs | 22 +++++ .../Streaming/NextSessionWorker.cs | 91 +++++++++++++++++++ 6 files changed, 135 insertions(+) create mode 100644 ErsatzTV.Application/Playouts/PlayoutModeViewModel.cs create mode 100644 ErsatzTV.Application/Playouts/Queries/GetPlayoutModeByChannelNumber.cs create mode 100644 ErsatzTV.Application/Playouts/Queries/GetPlayoutModeByChannelNumberHandler.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0af908000..11d6870dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Next engine - Fix audio dropout/desync when using QSV accel and loudness normalization with certain content - Fix anamorphic content scaling (was incorrectly stretched with older builds) + - Fix on-demand channel progress (channels would not save checkpoints and would always start at the same spot) ## [26.7.1] - 2026-07-31 ### Changed diff --git a/ErsatzTV.Application/Playouts/Mapper.cs b/ErsatzTV.Application/Playouts/Mapper.cs index 679ef7900..c465dfba6 100644 --- a/ErsatzTV.Application/Playouts/Mapper.cs +++ b/ErsatzTV.Application/Playouts/Mapper.cs @@ -112,4 +112,17 @@ internal static class Mapper return string.Empty; } } + + internal static Option ProjectToModeViewModel(Option maybeChannel) + { + foreach (Channel channel in maybeChannel) + { + foreach (Playout playout in channel.Playouts.HeadOrNone()) + { + return new PlayoutModeViewModel(playout.Id, channel.PlayoutMode); + } + } + + return Option.None; + } } diff --git a/ErsatzTV.Application/Playouts/PlayoutModeViewModel.cs b/ErsatzTV.Application/Playouts/PlayoutModeViewModel.cs new file mode 100644 index 000000000..be731327b --- /dev/null +++ b/ErsatzTV.Application/Playouts/PlayoutModeViewModel.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.Playouts; + +public record PlayoutModeViewModel(int PlayoutId, ChannelPlayoutMode PlayoutMode); diff --git a/ErsatzTV.Application/Playouts/Queries/GetPlayoutModeByChannelNumber.cs b/ErsatzTV.Application/Playouts/Queries/GetPlayoutModeByChannelNumber.cs new file mode 100644 index 000000000..ccb1d996d --- /dev/null +++ b/ErsatzTV.Application/Playouts/Queries/GetPlayoutModeByChannelNumber.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Application.Playouts; + +public record GetPlayoutModeByChannelNumber(string ChannelNumber) : IRequest>; diff --git a/ErsatzTV.Application/Playouts/Queries/GetPlayoutModeByChannelNumberHandler.cs b/ErsatzTV.Application/Playouts/Queries/GetPlayoutModeByChannelNumberHandler.cs new file mode 100644 index 000000000..def9df9f1 --- /dev/null +++ b/ErsatzTV.Application/Playouts/Queries/GetPlayoutModeByChannelNumberHandler.cs @@ -0,0 +1,22 @@ +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Application.Playouts; + +public class GetPlayoutModeByChannelNumberHandler(IDbContextFactory dbContextFactory) + : IRequestHandler> +{ + public async Task> Handle( + GetPlayoutModeByChannelNumber request, + CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + return await dbContext.Channels + .AsNoTracking() + .Include(c => c.Playouts) + .SingleOrDefaultAsync(c => c.Number == request.ChannelNumber, cancellationToken) + .Map(Optional) + .Map(Mapper.ProjectToModeViewModel); + } +} diff --git a/ErsatzTV.Application/Streaming/NextSessionWorker.cs b/ErsatzTV.Application/Streaming/NextSessionWorker.cs index e4e0f5ea1..4c923f68b 100644 --- a/ErsatzTV.Application/Streaming/NextSessionWorker.cs +++ b/ErsatzTV.Application/Streaming/NextSessionWorker.cs @@ -1,6 +1,8 @@ using System.IO.Abstractions; using CliWrap; +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; @@ -19,6 +21,8 @@ public class NextSessionWorker( ILogger logger) : IHlsSessionWorker { + private readonly TimeSpan _checkpointThreshold = TimeSpan.FromMinutes(5); + private readonly SemaphoreSlim _slim = new(1, 1); private CancellationTokenSource _cancellationTokenSource; private IServiceScope _serviceScope = serviceScopeFactory.CreateScope(); @@ -26,6 +30,11 @@ public class NextSessionWorker( private string _channelNumber; private string _workingDirectory; private string _heartbeatFileName; + private DateTimeOffset _lastTouch; + private DateTimeOffset _lastCheckpoint; + private ChannelPlayoutMode _channelPlayoutMode = ChannelPlayoutMode.Continuous; + + private IMediator Mediator => _serviceScope.ServiceProvider.GetRequiredService(); void IDisposable.Dispose() { @@ -64,6 +73,8 @@ public class NextSessionWorker( public void Touch(Option fileName) { + _lastTouch = DateTimeOffset.Now; + if (!fileSystem.File.Exists(_heartbeatFileName)) { fileSystem.File.WriteAllBytes(_heartbeatFileName, []); @@ -92,6 +103,12 @@ public class NextSessionWorker( CancellationToken incomingCancellationToken) { _cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(incomingCancellationToken); + using var checkpointCts = CancellationTokenSource.CreateLinkedTokenSource(_cancellationTokenSource.Token); + Task checkpointLoop = Task.CompletedTask; + + DateTimeOffset sessionStart = DateTimeOffset.Now; + _lastTouch = sessionStart; + _lastCheckpoint = _lastTouch; try { @@ -99,6 +116,30 @@ public class NextSessionWorker( _workingDirectory = fileSystem.Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber); _heartbeatFileName = fileSystem.Path.Combine(_workingDirectory, ".heartbeat"); + Option maybePlayout = await Mediator.Send( + new GetPlayoutModeByChannelNumber(_channelNumber), + _cancellationTokenSource.Token); + + foreach (PlayoutModeViewModel playout in maybePlayout) + { + _channelPlayoutMode = playout.PlayoutMode; + + if (_channelPlayoutMode is ChannelPlayoutMode.OnDemand) + { + checkpointLoop = CheckpointLoop(checkpointCts.Token); + + await Mediator.Send( + new TimeShiftOnDemandPlayout(playout.PlayoutId, sessionStart, true), + _cancellationTokenSource.Token); + + // next reads serialized playout files rather than the database, so ensure it + // sees the time-shifted items before starting the channel process + await Mediator.Send( + new SyncNextPlayout(_channelNumber), + _cancellationTokenSource.Token); + } + } + List arguments = ["run", "--output-folder", _workingDirectory, "--number", channelNumber, "-"]; string defaultOverlayFile = fileSystem.Path.Combine( @@ -150,6 +191,25 @@ public class NextSessionWorker( } finally { + await checkpointCts.CancelAsync(); + try + { + await checkpointLoop; + } + catch (OperationCanceledException) + { + // do nothing + } + + try + { + await UpdateOnDemandCheckpoint(CancellationToken.None); + } + catch + { + // do nothing + } + try { localFileSystem.EmptyFolder(_workingDirectory); @@ -171,4 +231,35 @@ public class NextSessionWorker( await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); } } + + private async Task CheckpointLoop(CancellationToken cancellationToken) + { + using var timer = new PeriodicTimer(_checkpointThreshold); + while (await timer.WaitForNextTickAsync(cancellationToken)) + { + try + { + if (_lastTouch > _lastCheckpoint) + { + await UpdateOnDemandCheckpoint(cancellationToken); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to update on demand checkpoint for channel {Channel}", _channelNumber); + } + } + } + + private async Task UpdateOnDemandCheckpoint(CancellationToken cancellationToken) + { + if (_channelPlayoutMode is ChannelPlayoutMode.OnDemand) + { + await Mediator.Send( + new UpdateOnDemandCheckpoint(_channelNumber, _lastTouch), + cancellationToken); + } + + _lastCheckpoint = _lastTouch; + } }