diff --git a/ErsatzTV.Application/Streaming/HlsSessionWorker.cs b/ErsatzTV.Application/Streaming/HlsSessionWorker.cs index 527676d34..884129be8 100644 --- a/ErsatzTV.Application/Streaming/HlsSessionWorker.cs +++ b/ErsatzTV.Application/Streaming/HlsSessionWorker.cs @@ -463,6 +463,8 @@ public class HlsSessionWorker : IHlsSessionWorker try { + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var processWithPipe = process; foreach (var graphicsEngineContext in processModel.GraphicsEngineContext) { @@ -474,13 +476,13 @@ public class HlsSessionWorker : IHlsSessionWorker _ = _graphicsEngine.Run( graphicsEngineContext, pipe.Writer, - cancellationToken); + linkedCts.Token); } CommandResult commandResult = await processWithPipe .WithStandardErrorPipe(PipeTarget.ToStringBuilder(stdErrBuffer)) .WithValidation(CommandResultValidation.None) - .ExecuteAsync(cancellationToken); + .ExecuteAsync(linkedCts.Token); if (commandResult.ExitCode == 0) { @@ -493,6 +495,8 @@ public class HlsSessionWorker : IHlsSessionWorker } else { + await linkedCts.CancelAsync(); + // detect the non-zero exit code and transcode the ffmpeg error message instead string errorMessage = stdErrBuffer.ToString(); if (string.IsNullOrWhiteSpace(errorMessage)) @@ -515,6 +519,7 @@ public class HlsSessionWorker : IHlsSessionWorker processModel.MaybeDuration, processModel.Until, errorMessage), + // ReSharper disable once PossiblyMistakenUseOfCancellationToken cancellationToken); foreach (PlayoutItemProcessModel errorProcessModel in maybeOfflineProcess.RightAsEnumerable()) @@ -527,6 +532,7 @@ public class HlsSessionWorker : IHlsSessionWorker commandResult = await errorProcess .WithValidation(CommandResultValidation.None) + // ReSharper disable once PossiblyMistakenUseOfCancellationToken .ExecuteBufferedAsync(Encoding.UTF8, cancellationToken); if (commandResult.ExitCode == 0) diff --git a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlayback.cs b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlayback.cs index 84a3f1a41..fc69bf5ab 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlayback.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlayback.cs @@ -1,5 +1,5 @@ -using CliWrap; using ErsatzTV.Core; +using ErsatzTV.Core.Interfaces.FFmpeg; namespace ErsatzTV.Application.Troubleshooting; @@ -9,4 +9,4 @@ public record PrepareTroubleshootingPlayback( int WatermarkId, int? SubtitleId, bool StartFromBeginning) - : IRequest>; + : IRequest>; diff --git a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs index d223d6d7e..7181bafd0 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs @@ -29,9 +29,9 @@ public class PrepareTroubleshootingPlaybackHandler( ILocalFileSystem localFileSystem, IEntityLocker entityLocker, ILogger logger) - : IRequestHandler> + : IRequestHandler> { - public async Task> Handle(PrepareTroubleshootingPlayback request, CancellationToken cancellationToken) + public async Task> Handle(PrepareTroubleshootingPlayback request, CancellationToken cancellationToken) { try { @@ -39,7 +39,7 @@ public class PrepareTroubleshootingPlaybackHandler( Validation> validation = await Validate(dbContext, request); return await validation.Match( tuple => GetProcess(dbContext, request, tuple.Item1, tuple.Item2, tuple.Item3, tuple.Item4), - error => Task.FromResult>(error.Join())); + error => Task.FromResult>(error.Join())); } catch (Exception ex) { @@ -49,7 +49,7 @@ public class PrepareTroubleshootingPlaybackHandler( } } - private async Task> GetProcess( + private async Task> GetProcess( TvContext dbContext, PrepareTroubleshootingPlayback request, MediaItem mediaItem, @@ -150,9 +150,7 @@ public class PrepareTroubleshootingPlaybackHandler( FileSystemLayout.TranscodeTroubleshootingFolder, _ => { }); - // TODO: graphics engine? - - return playoutItemResult.Process; + return playoutItemResult; } private static async Task> GetSelectedSubtitle(MediaItem mediaItem, PrepareTroubleshootingPlayback request) diff --git a/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlayback.cs b/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlayback.cs index eb25accb9..d1ccebf65 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlayback.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlayback.cs @@ -1,9 +1,10 @@ -using CliWrap; using ErsatzTV.Application.MediaItems; +using ErsatzTV.Core.Interfaces.FFmpeg; namespace ErsatzTV.Application.Troubleshooting; public record StartTroubleshootingPlayback( - Command Command, + Guid SessionId, + PlayoutItemResult PlayoutItemResult, MediaItemInfo MediaItemInfo, TroubleshootingInfo TroubleshootingInfo) : IRequest, IFFmpegWorkerRequest; diff --git a/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs index 6758c777e..6ce3675c1 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs @@ -1,11 +1,13 @@ +using System.IO.Pipelines; using System.Runtime.InteropServices; using System.Text.Json; using System.Text.Json.Serialization; using CliWrap; -using CliWrap.Buffered; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Core.Interfaces.Streaming; +using ErsatzTV.Core.Interfaces.Troubleshooting; using ErsatzTV.Core.Notifications; using ErsatzTV.FFmpeg.Runtime; using Microsoft.Extensions.Logging; @@ -13,9 +15,11 @@ using Microsoft.Extensions.Logging; namespace ErsatzTV.Application.Troubleshooting; public class StartTroubleshootingPlaybackHandler( + ITroubleshootingNotifier notifier, IMediator mediator, IEntityLocker entityLocker, IRuntimeInfo runtimeInfo, + IGraphicsEngine graphicsEngine, ILogger logger) : IRequestHandler { @@ -83,17 +87,56 @@ public class StartTroubleshootingPlaybackHandler( cancellationToken); } - logger.LogDebug("ffmpeg troubleshooting arguments {FFmpegArguments}", request.Command.Arguments); + logger.LogDebug("ffmpeg troubleshooting arguments {FFmpegArguments}", request.PlayoutItemResult.Process.Arguments); - BufferedCommandResult result = await request.Command - .WithValidation(CommandResultValidation.None) - .ExecuteBufferedAsync(cancellationToken); + var maybePipe = Option.None; - await mediator.Publish( - new PlaybackTroubleshootingCompletedNotification(result.ExitCode), - cancellationToken); + try + { + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - logger.LogDebug("Troubleshooting playback completed with exit code {ExitCode}", result.ExitCode); + var processWithPipe = request.PlayoutItemResult.Process; + foreach (var graphicsEngineContext in request.PlayoutItemResult.GraphicsEngineContext) + { + var pipe = new Pipe(); + maybePipe = pipe; + processWithPipe = processWithPipe.WithStandardInputPipe(PipeSource.FromStream(pipe.Reader.AsStream())); + + // fire and forget graphics engine task + _ = graphicsEngine.Run( + graphicsEngineContext, + pipe.Writer, + linkedCts.Token); + } + + CommandResult commandResult = await processWithPipe + .WithStandardErrorPipe(PipeTarget.Null) + .WithValidation(CommandResultValidation.None) + .ExecuteAsync(linkedCts.Token); + + await mediator.Publish( + new PlaybackTroubleshootingCompletedNotification(commandResult.ExitCode), + linkedCts.Token); + + logger.LogDebug("Troubleshooting playback completed with exit code {ExitCode}", commandResult.ExitCode); + + if (commandResult.ExitCode != 0) + { + await linkedCts.CancelAsync(); + notifier.NotifyFailed(request.SessionId); + } + } + catch (Exception e) + { + Console.WriteLine(e); + } + finally + { + foreach (var pipe in maybePipe) + { + await pipe.Writer.CompleteAsync(); + } + } } finally { diff --git a/ErsatzTV.Core/Interfaces/Troubleshooting/ITroubleshootingNotifier.cs b/ErsatzTV.Core/Interfaces/Troubleshooting/ITroubleshootingNotifier.cs new file mode 100644 index 000000000..428f1220b --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Troubleshooting/ITroubleshootingNotifier.cs @@ -0,0 +1,10 @@ +namespace ErsatzTV.Core.Interfaces.Troubleshooting; + +public interface ITroubleshootingNotifier +{ + bool IsFailed(Guid sessionId); + + void NotifyFailed(Guid sessionId); + + void RemoveSession(Guid sessionId); +} diff --git a/ErsatzTV.Core/Troubleshooting/TroubleshootingNotifier.cs b/ErsatzTV.Core/Troubleshooting/TroubleshootingNotifier.cs new file mode 100644 index 000000000..d7999dd34 --- /dev/null +++ b/ErsatzTV.Core/Troubleshooting/TroubleshootingNotifier.cs @@ -0,0 +1,24 @@ +using System.Collections.Concurrent; +using ErsatzTV.Core.Interfaces.Troubleshooting; + +namespace ErsatzTV.Core.Troubleshooting; + +public class TroubleshootingNotifier : ITroubleshootingNotifier +{ + private readonly ConcurrentDictionary _failedSessions = new(); + + public bool IsFailed(Guid sessionId) + { + return _failedSessions.TryGetValue(sessionId, out _); + } + + public void NotifyFailed(Guid sessionId) + { + _failedSessions[sessionId] = true; + } + + public void RemoveSession(Guid sessionId) + { + _failedSessions.TryRemove(sessionId, out _); + } +} \ No newline at end of file diff --git a/ErsatzTV.FFmpeg/Filter/ComplexFilter.cs b/ErsatzTV.FFmpeg/Filter/ComplexFilter.cs index 2fc3004bf..561c5df52 100644 --- a/ErsatzTV.FFmpeg/Filter/ComplexFilter.cs +++ b/ErsatzTV.FFmpeg/Filter/ComplexFilter.cs @@ -91,7 +91,7 @@ public class ComplexFilter : IPipelineStep } } - foreach ((string path, _) in _maybeSubtitleInputFile) + foreach ((string path, _) in _maybeSubtitleInputFile.Filter(s => s.IsImageBased)) { if (!distinctPaths.Contains(path)) { @@ -149,7 +149,7 @@ public class ComplexFilter : IPipelineStep } foreach (SubtitleInputFile subtitleInputFile in _maybeSubtitleInputFile.Filter(s => - s.Method == SubtitleMethod.Burn)) + s is { IsImageBased: true, Method: SubtitleMethod.Burn })) { int inputIndex = distinctPaths.IndexOf(subtitleInputFile.Path); foreach ((int index, _, _) in subtitleInputFile.Streams) diff --git a/ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs b/ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs index eb699450d..c02ca0670 100644 --- a/ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs +++ b/ErsatzTV.Infrastructure/Streaming/GraphicsEngine.cs @@ -87,6 +87,10 @@ public class GraphicsEngine(ILogger logger) : IGraphicsEngine frameCount++; } } + catch (Exception) + { + // do nothing; don't want to throw on a background task + } finally { await pipeWriter.CompleteAsync(); diff --git a/ErsatzTV/Controllers/Api/TroubleshootController.cs b/ErsatzTV/Controllers/Api/TroubleshootController.cs index 511e5c6b1..3a3fb637d 100644 --- a/ErsatzTV/Controllers/Api/TroubleshootController.cs +++ b/ErsatzTV/Controllers/Api/TroubleshootController.cs @@ -1,11 +1,12 @@ using System.Threading.Channels; -using CliWrap; using ErsatzTV.Application; using ErsatzTV.Application.MediaItems; using ErsatzTV.Application.Troubleshooting; using ErsatzTV.Application.Troubleshooting.Queries; using ErsatzTV.Core; +using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Troubleshooting; using MediatR; using Microsoft.AspNetCore.Mvc; @@ -15,6 +16,7 @@ namespace ErsatzTV.Controllers.Api; public class TroubleshootController( ChannelWriter channelWriter, ILocalFileSystem localFileSystem, + ITroubleshootingNotifier notifier, IMediator mediator) : ControllerBase { [HttpHead("api/troubleshoot/playback.m3u8")] @@ -32,50 +34,75 @@ public class TroubleshootController( bool startFromBeginning, CancellationToken cancellationToken) { - Either result = await mediator.Send( - new PrepareTroubleshootingPlayback(mediaItem, ffmpegProfile, watermark, subtitleId, startFromBeginning), - cancellationToken); + try + { + Either result = await mediator.Send( + new PrepareTroubleshootingPlayback(mediaItem, ffmpegProfile, watermark, subtitleId, startFromBeginning), + cancellationToken); - return await result.MatchAsync( - async command => + if (result.IsLeft) { - Either maybeMediaInfo = await mediator.Send(new GetMediaItemInfo(mediaItem), cancellationToken); + return NotFound(); + } + + foreach (var playoutItemResult in result.RightToSeq()) + { + Either maybeMediaInfo = + await mediator.Send(new GetMediaItemInfo(mediaItem), cancellationToken); foreach (MediaItemInfo mediaInfo in maybeMediaInfo.RightToSeq()) { - TroubleshootingInfo troubleshootingInfo = await mediator.Send( - new GetTroubleshootingInfo(), - cancellationToken); + var sessionId = Guid.NewGuid(); - // filter ffmpeg profiles - troubleshootingInfo.FFmpegProfiles.RemoveAll(p => p.Id != ffmpegProfile); - - // filter watermarks - troubleshootingInfo.Watermarks.RemoveAll(p => p.Id != watermark); - - await channelWriter.WriteAsync( - new StartTroubleshootingPlayback(command, mediaInfo, troubleshootingInfo), - cancellationToken); - - string playlistFile = Path.Combine( - FileSystemLayout.TranscodeFolder, - ".troubleshooting", - "live.m3u8"); - - while (!localFileSystem.FileExists(playlistFile)) + try { - await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken); - if (cancellationToken.IsCancellationRequested) + TroubleshootingInfo troubleshootingInfo = await mediator.Send( + new GetTroubleshootingInfo(), + cancellationToken); + + // filter ffmpeg profiles + troubleshootingInfo.FFmpegProfiles.RemoveAll(p => p.Id != ffmpegProfile); + + // filter watermarks + troubleshootingInfo.Watermarks.RemoveAll(p => p.Id != watermark); + + await channelWriter.WriteAsync( + new StartTroubleshootingPlayback(sessionId, playoutItemResult, mediaInfo, + troubleshootingInfo), + cancellationToken); + + string playlistFile = Path.Combine( + FileSystemLayout.TranscodeFolder, + ".troubleshooting", + "live.m3u8"); + + while (!localFileSystem.FileExists(playlistFile)) { - break; + await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken); + if (cancellationToken.IsCancellationRequested || notifier.IsFailed(sessionId)) + { + break; + } } + + if (!notifier.IsFailed(sessionId)) + { + return Redirect("~/iptv/session/.troubleshooting/live.m3u8"); + } + + } + finally + { + notifier.RemoveSession(sessionId); } - - return Redirect("~/iptv/session/.troubleshooting/live.m3u8"); } + } + } + catch (Exception) + { + // do nothing + } - return NotFound(); - }, - _ => NotFound()); + return NotFound(); } [HttpHead("api/troubleshoot/playback/archive")] diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 027644e9f..3f8ae0bb9 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -31,6 +31,7 @@ using ErsatzTV.Core.Interfaces.Scripting; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Interfaces.Streaming; using ErsatzTV.Core.Interfaces.Trakt; +using ErsatzTV.Core.Interfaces.Troubleshooting; using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Plex; @@ -39,6 +40,7 @@ using ErsatzTV.Core.Scheduling.BlockScheduling; using ErsatzTV.Core.Scheduling.YamlScheduling; using ErsatzTV.Core.Search; using ErsatzTV.Core.Trakt; +using ErsatzTV.Core.Troubleshooting; using ErsatzTV.FFmpeg.Capabilities; using ErsatzTV.FFmpeg.Pipeline; using ErsatzTV.FFmpeg.Runtime; @@ -614,6 +616,7 @@ public class Startup services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); if (SearchHelper.IsElasticSearchEnabled) {