enable graphics engine in playback troubleshooting (#2274)

* enable graphics engine in playback troubleshooting

* fix text subtitles with graphics engine (watermarks)
This commit is contained in:
Jason Dove
2025-08-07 18:37:55 +00:00
committed by GitHub
parent c7fcaf8886
commit f2b6f5b919
11 changed files with 174 additions and 58 deletions
@@ -463,6 +463,8 @@ public class HlsSessionWorker : IHlsSessionWorker
try try
{ {
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var processWithPipe = process; var processWithPipe = process;
foreach (var graphicsEngineContext in processModel.GraphicsEngineContext) foreach (var graphicsEngineContext in processModel.GraphicsEngineContext)
{ {
@@ -474,13 +476,13 @@ public class HlsSessionWorker : IHlsSessionWorker
_ = _graphicsEngine.Run( _ = _graphicsEngine.Run(
graphicsEngineContext, graphicsEngineContext,
pipe.Writer, pipe.Writer,
cancellationToken); linkedCts.Token);
} }
CommandResult commandResult = await processWithPipe CommandResult commandResult = await processWithPipe
.WithStandardErrorPipe(PipeTarget.ToStringBuilder(stdErrBuffer)) .WithStandardErrorPipe(PipeTarget.ToStringBuilder(stdErrBuffer))
.WithValidation(CommandResultValidation.None) .WithValidation(CommandResultValidation.None)
.ExecuteAsync(cancellationToken); .ExecuteAsync(linkedCts.Token);
if (commandResult.ExitCode == 0) if (commandResult.ExitCode == 0)
{ {
@@ -493,6 +495,8 @@ public class HlsSessionWorker : IHlsSessionWorker
} }
else else
{ {
await linkedCts.CancelAsync();
// detect the non-zero exit code and transcode the ffmpeg error message instead // detect the non-zero exit code and transcode the ffmpeg error message instead
string errorMessage = stdErrBuffer.ToString(); string errorMessage = stdErrBuffer.ToString();
if (string.IsNullOrWhiteSpace(errorMessage)) if (string.IsNullOrWhiteSpace(errorMessage))
@@ -515,6 +519,7 @@ public class HlsSessionWorker : IHlsSessionWorker
processModel.MaybeDuration, processModel.MaybeDuration,
processModel.Until, processModel.Until,
errorMessage), errorMessage),
// ReSharper disable once PossiblyMistakenUseOfCancellationToken
cancellationToken); cancellationToken);
foreach (PlayoutItemProcessModel errorProcessModel in maybeOfflineProcess.RightAsEnumerable()) foreach (PlayoutItemProcessModel errorProcessModel in maybeOfflineProcess.RightAsEnumerable())
@@ -527,6 +532,7 @@ public class HlsSessionWorker : IHlsSessionWorker
commandResult = await errorProcess commandResult = await errorProcess
.WithValidation(CommandResultValidation.None) .WithValidation(CommandResultValidation.None)
// ReSharper disable once PossiblyMistakenUseOfCancellationToken
.ExecuteBufferedAsync(Encoding.UTF8, cancellationToken); .ExecuteBufferedAsync(Encoding.UTF8, cancellationToken);
if (commandResult.ExitCode == 0) if (commandResult.ExitCode == 0)
@@ -1,5 +1,5 @@
using CliWrap;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.FFmpeg;
namespace ErsatzTV.Application.Troubleshooting; namespace ErsatzTV.Application.Troubleshooting;
@@ -9,4 +9,4 @@ public record PrepareTroubleshootingPlayback(
int WatermarkId, int WatermarkId,
int? SubtitleId, int? SubtitleId,
bool StartFromBeginning) bool StartFromBeginning)
: IRequest<Either<BaseError, Command>>; : IRequest<Either<BaseError, PlayoutItemResult>>;
@@ -29,9 +29,9 @@ public class PrepareTroubleshootingPlaybackHandler(
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
IEntityLocker entityLocker, IEntityLocker entityLocker,
ILogger<PrepareTroubleshootingPlaybackHandler> logger) ILogger<PrepareTroubleshootingPlaybackHandler> logger)
: IRequestHandler<PrepareTroubleshootingPlayback, Either<BaseError, Command>> : IRequestHandler<PrepareTroubleshootingPlayback, Either<BaseError, PlayoutItemResult>>
{ {
public async Task<Either<BaseError, Command>> Handle(PrepareTroubleshootingPlayback request, CancellationToken cancellationToken) public async Task<Either<BaseError, PlayoutItemResult>> Handle(PrepareTroubleshootingPlayback request, CancellationToken cancellationToken)
{ {
try try
{ {
@@ -39,7 +39,7 @@ public class PrepareTroubleshootingPlaybackHandler(
Validation<BaseError, Tuple<MediaItem, string, string, FFmpegProfile>> validation = await Validate(dbContext, request); Validation<BaseError, Tuple<MediaItem, string, string, FFmpegProfile>> validation = await Validate(dbContext, request);
return await validation.Match( return await validation.Match(
tuple => GetProcess(dbContext, request, tuple.Item1, tuple.Item2, tuple.Item3, tuple.Item4), tuple => GetProcess(dbContext, request, tuple.Item1, tuple.Item2, tuple.Item3, tuple.Item4),
error => Task.FromResult<Either<BaseError, Command>>(error.Join())); error => Task.FromResult<Either<BaseError, PlayoutItemResult>>(error.Join()));
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -49,7 +49,7 @@ public class PrepareTroubleshootingPlaybackHandler(
} }
} }
private async Task<Either<BaseError, Command>> GetProcess( private async Task<Either<BaseError, PlayoutItemResult>> GetProcess(
TvContext dbContext, TvContext dbContext,
PrepareTroubleshootingPlayback request, PrepareTroubleshootingPlayback request,
MediaItem mediaItem, MediaItem mediaItem,
@@ -150,9 +150,7 @@ public class PrepareTroubleshootingPlaybackHandler(
FileSystemLayout.TranscodeTroubleshootingFolder, FileSystemLayout.TranscodeTroubleshootingFolder,
_ => { }); _ => { });
// TODO: graphics engine? return playoutItemResult;
return playoutItemResult.Process;
} }
private static async Task<List<Subtitle>> GetSelectedSubtitle(MediaItem mediaItem, PrepareTroubleshootingPlayback request) private static async Task<List<Subtitle>> GetSelectedSubtitle(MediaItem mediaItem, PrepareTroubleshootingPlayback request)
@@ -1,9 +1,10 @@
using CliWrap;
using ErsatzTV.Application.MediaItems; using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Interfaces.FFmpeg;
namespace ErsatzTV.Application.Troubleshooting; namespace ErsatzTV.Application.Troubleshooting;
public record StartTroubleshootingPlayback( public record StartTroubleshootingPlayback(
Command Command, Guid SessionId,
PlayoutItemResult PlayoutItemResult,
MediaItemInfo MediaItemInfo, MediaItemInfo MediaItemInfo,
TroubleshootingInfo TroubleshootingInfo) : IRequest, IFFmpegWorkerRequest; TroubleshootingInfo TroubleshootingInfo) : IRequest, IFFmpegWorkerRequest;
@@ -1,11 +1,13 @@
using System.IO.Pipelines;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using CliWrap; using CliWrap;
using CliWrap.Buffered;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Core.Interfaces.Troubleshooting;
using ErsatzTV.Core.Notifications; using ErsatzTV.Core.Notifications;
using ErsatzTV.FFmpeg.Runtime; using ErsatzTV.FFmpeg.Runtime;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -13,9 +15,11 @@ using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Troubleshooting; namespace ErsatzTV.Application.Troubleshooting;
public class StartTroubleshootingPlaybackHandler( public class StartTroubleshootingPlaybackHandler(
ITroubleshootingNotifier notifier,
IMediator mediator, IMediator mediator,
IEntityLocker entityLocker, IEntityLocker entityLocker,
IRuntimeInfo runtimeInfo, IRuntimeInfo runtimeInfo,
IGraphicsEngine graphicsEngine,
ILogger<StartTroubleshootingPlaybackHandler> logger) ILogger<StartTroubleshootingPlaybackHandler> logger)
: IRequestHandler<StartTroubleshootingPlayback> : IRequestHandler<StartTroubleshootingPlayback>
{ {
@@ -83,17 +87,56 @@ public class StartTroubleshootingPlaybackHandler(
cancellationToken); 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 var maybePipe = Option<Pipe>.None;
.WithValidation(CommandResultValidation.None)
.ExecuteBufferedAsync(cancellationToken);
await mediator.Publish( try
new PlaybackTroubleshootingCompletedNotification(result.ExitCode), {
cancellationToken); 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 finally
{ {
@@ -0,0 +1,10 @@
namespace ErsatzTV.Core.Interfaces.Troubleshooting;
public interface ITroubleshootingNotifier
{
bool IsFailed(Guid sessionId);
void NotifyFailed(Guid sessionId);
void RemoveSession(Guid sessionId);
}
@@ -0,0 +1,24 @@
using System.Collections.Concurrent;
using ErsatzTV.Core.Interfaces.Troubleshooting;
namespace ErsatzTV.Core.Troubleshooting;
public class TroubleshootingNotifier : ITroubleshootingNotifier
{
private readonly ConcurrentDictionary<Guid, bool> _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 _);
}
}
+2 -2
View File
@@ -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)) if (!distinctPaths.Contains(path))
{ {
@@ -149,7 +149,7 @@ public class ComplexFilter : IPipelineStep
} }
foreach (SubtitleInputFile subtitleInputFile in _maybeSubtitleInputFile.Filter(s => foreach (SubtitleInputFile subtitleInputFile in _maybeSubtitleInputFile.Filter(s =>
s.Method == SubtitleMethod.Burn)) s is { IsImageBased: true, Method: SubtitleMethod.Burn }))
{ {
int inputIndex = distinctPaths.IndexOf(subtitleInputFile.Path); int inputIndex = distinctPaths.IndexOf(subtitleInputFile.Path);
foreach ((int index, _, _) in subtitleInputFile.Streams) foreach ((int index, _, _) in subtitleInputFile.Streams)
@@ -87,6 +87,10 @@ public class GraphicsEngine(ILogger<GraphicsEngine> logger) : IGraphicsEngine
frameCount++; frameCount++;
} }
} }
catch (Exception)
{
// do nothing; don't want to throw on a background task
}
finally finally
{ {
await pipeWriter.CompleteAsync(); await pipeWriter.CompleteAsync();
@@ -1,11 +1,12 @@
using System.Threading.Channels; using System.Threading.Channels;
using CliWrap;
using ErsatzTV.Application; using ErsatzTV.Application;
using ErsatzTV.Application.MediaItems; using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.Troubleshooting; using ErsatzTV.Application.Troubleshooting;
using ErsatzTV.Application.Troubleshooting.Queries; using ErsatzTV.Application.Troubleshooting.Queries;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Troubleshooting;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -15,6 +16,7 @@ namespace ErsatzTV.Controllers.Api;
public class TroubleshootController( public class TroubleshootController(
ChannelWriter<IFFmpegWorkerRequest> channelWriter, ChannelWriter<IFFmpegWorkerRequest> channelWriter,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
ITroubleshootingNotifier notifier,
IMediator mediator) : ControllerBase IMediator mediator) : ControllerBase
{ {
[HttpHead("api/troubleshoot/playback.m3u8")] [HttpHead("api/troubleshoot/playback.m3u8")]
@@ -32,50 +34,75 @@ public class TroubleshootController(
bool startFromBeginning, bool startFromBeginning,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
Either<BaseError, Command> result = await mediator.Send( try
new PrepareTroubleshootingPlayback(mediaItem, ffmpegProfile, watermark, subtitleId, startFromBeginning), {
cancellationToken); Either<BaseError, PlayoutItemResult> result = await mediator.Send(
new PrepareTroubleshootingPlayback(mediaItem, ffmpegProfile, watermark, subtitleId, startFromBeginning),
cancellationToken);
return await result.MatchAsync<IActionResult>( if (result.IsLeft)
async command =>
{ {
Either<BaseError, MediaItemInfo> maybeMediaInfo = await mediator.Send(new GetMediaItemInfo(mediaItem), cancellationToken); return NotFound();
}
foreach (var playoutItemResult in result.RightToSeq())
{
Either<BaseError, MediaItemInfo> maybeMediaInfo =
await mediator.Send(new GetMediaItemInfo(mediaItem), cancellationToken);
foreach (MediaItemInfo mediaInfo in maybeMediaInfo.RightToSeq()) foreach (MediaItemInfo mediaInfo in maybeMediaInfo.RightToSeq())
{ {
TroubleshootingInfo troubleshootingInfo = await mediator.Send( var sessionId = Guid.NewGuid();
new GetTroubleshootingInfo(),
cancellationToken);
// filter ffmpeg profiles try
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))
{ {
await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken); TroubleshootingInfo troubleshootingInfo = await mediator.Send(
if (cancellationToken.IsCancellationRequested) 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(); return NotFound();
},
_ => NotFound());
} }
[HttpHead("api/troubleshoot/playback/archive")] [HttpHead("api/troubleshoot/playback/archive")]
+3
View File
@@ -31,6 +31,7 @@ using ErsatzTV.Core.Interfaces.Scripting;
using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Interfaces.Streaming; using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Core.Interfaces.Trakt; using ErsatzTV.Core.Interfaces.Trakt;
using ErsatzTV.Core.Interfaces.Troubleshooting;
using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Plex; using ErsatzTV.Core.Plex;
@@ -39,6 +40,7 @@ using ErsatzTV.Core.Scheduling.BlockScheduling;
using ErsatzTV.Core.Scheduling.YamlScheduling; using ErsatzTV.Core.Scheduling.YamlScheduling;
using ErsatzTV.Core.Search; using ErsatzTV.Core.Search;
using ErsatzTV.Core.Trakt; using ErsatzTV.Core.Trakt;
using ErsatzTV.Core.Troubleshooting;
using ErsatzTV.FFmpeg.Capabilities; using ErsatzTV.FFmpeg.Capabilities;
using ErsatzTV.FFmpeg.Pipeline; using ErsatzTV.FFmpeg.Pipeline;
using ErsatzTV.FFmpeg.Runtime; using ErsatzTV.FFmpeg.Runtime;
@@ -614,6 +616,7 @@ public class Startup
services.AddSingleton<ISearchTargets, SearchTargets>(); services.AddSingleton<ISearchTargets, SearchTargets>();
services.AddSingleton<ISmartCollectionCache, SmartCollectionCache>(); services.AddSingleton<ISmartCollectionCache, SmartCollectionCache>();
services.AddSingleton<SearchQueryParser>(); services.AddSingleton<SearchQueryParser>();
services.AddSingleton<ITroubleshootingNotifier, TroubleshootingNotifier>();
if (SearchHelper.IsElasticSearchEnabled) if (SearchHelper.IsElasticSearchEnabled)
{ {