From 223bdff8d60c08acdb4e6c7dd82583c806e9bf7e Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Thu, 17 Jul 2025 14:53:11 +0000 Subject: [PATCH] playback troubleshooting improvements (#2157) --- CHANGELOG.md | 2 +- .../ArchiveTroubleshootingResultsHandler.cs | 59 ++++--------------- .../PrepareTroubleshootingPlaybackHandler.cs | 15 +++-- .../Commands/StartTroubleshootingPlayback.cs | 6 +- .../StartTroubleshootingPlaybackHandler.cs | 58 ++++++++++++++---- .../Queries/GetTroubleshootingInfoHandler.cs | 5 ++ .../Troubleshooting/TroubleshootingInfo.cs | 7 ++- ErsatzTV.Core/FileSystemLayout.cs | 2 + ...ackTroubleshootingCompletedNotification.cs | 5 ++ .../Controllers/Api/TroubleshootController.cs | 46 ++++++++++----- ErsatzTV/Pages/PlaybackTroubleshooting.razor | 29 +++++++-- ErsatzTV/Pages/_Host.cshtml | 8 +++ 12 files changed, 159 insertions(+), 83 deletions(-) create mode 100644 ErsatzTV.Core/Notifications/PlaybackTroubleshootingCompletedNotification.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 76467b7d..31d0b8d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,7 +90,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - The media item id (found in ETV media info, and ETV movie URLs) - The ffmpeg profile to use - The watermark to use (if any) - - Clicking `Play` will play the specified content using the desired settings + - Clicking `Play` will play up to 30 seconds of the specified content using the desired settings - Clicking `Download Results` will generate a zip archive containing: - The FFmpeg report of the playback attempt - The media info for the content diff --git a/ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResultsHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResultsHandler.cs index 69cdac9b..f49c0573 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResultsHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResultsHandler.cs @@ -1,68 +1,35 @@ using System.IO.Compression; -using System.Text.Json; -using System.Text.Json.Serialization; -using ErsatzTV.Application.MediaItems; -using ErsatzTV.Application.Troubleshooting.Queries; using ErsatzTV.Core; using ErsatzTV.Core.Interfaces.Metadata; namespace ErsatzTV.Application.Troubleshooting; -public class ArchiveTroubleshootingResultsHandler(IMediator mediator, ILocalFileSystem localFileSystem) +public class ArchiveTroubleshootingResultsHandler(ILocalFileSystem localFileSystem) : IRequestHandler> { - private static readonly JsonSerializerOptions Options = new() - { - Converters = { new JsonStringEnumConverter() }, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - WriteIndented = true - }; - - public async Task> Handle(ArchiveTroubleshootingResults request, CancellationToken cancellationToken) + public Task> Handle(ArchiveTroubleshootingResults request, CancellationToken cancellationToken) { string tempFile = Path.GetTempFileName(); using ZipArchive zipArchive = ZipFile.Open(tempFile, ZipArchiveMode.Update); - string transcodeFolder = Path.Combine(FileSystemLayout.TranscodeFolder, ".troubleshooting"); - - bool hasReport = false; - foreach (string file in localFileSystem.ListFiles(transcodeFolder)) + var hasReport = false; + foreach (string file in localFileSystem.ListFiles(FileSystemLayout.TranscodeTroubleshootingFolder)) { + string fileName = Path.GetFileName(file); + // add to archive - if (Path.GetFileName(file).StartsWith("ffmpeg-", StringComparison.InvariantCultureIgnoreCase)) + if (fileName.StartsWith("ffmpeg-", StringComparison.OrdinalIgnoreCase)) { hasReport = true; - zipArchive.CreateEntryFromFile(file, Path.GetFileName(file)); + zipArchive.CreateEntryFromFile(file, fileName); } - } - - Either maybeMediaItemInfo = await mediator.Send(new GetMediaItemInfo(request.MediaItemId), cancellationToken); - foreach (MediaItemInfo info in maybeMediaItemInfo.RightToSeq()) - { - string infoJson = JsonSerializer.Serialize(info, Options); - string tempMediaInfoFile = Path.GetTempFileName(); - await File.WriteAllTextAsync(tempMediaInfoFile, infoJson, cancellationToken); - zipArchive.CreateEntryFromFile(tempMediaInfoFile, "media_info.json"); - } - - TroubleshootingInfo troubleshootingInfo = await mediator.Send(new GetTroubleshootingInfo(), cancellationToken); - string troubleshootingInfoJson = JsonSerializer.Serialize( - new + if (Path.GetExtension(file).Equals(".json", StringComparison.OrdinalIgnoreCase)) { - troubleshootingInfo.Version, - Environment = troubleshootingInfo.Environment.OrderBy(x => x.Key).ToDictionary(x => x.Key, x => x.Value), - troubleshootingInfo.Health, - troubleshootingInfo.FFmpegSettings, - troubleshootingInfo.Channels, - troubleshootingInfo.FFmpegProfiles - }, - Options); - - string tempTroubleshootingInfoFile = Path.GetTempFileName(); - await File.WriteAllTextAsync(tempTroubleshootingInfoFile, troubleshootingInfoJson, cancellationToken); - zipArchive.CreateEntryFromFile(tempTroubleshootingInfoFile, "troubleshooting_info.json"); + zipArchive.CreateEntryFromFile(file, fileName); + } + } - return hasReport ? tempFile : Option.None; + return Task.FromResult(hasReport ? tempFile : Option.None); } } diff --git a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs index cdb34fb2..f05ceab0 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs @@ -9,6 +9,7 @@ using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.Jellyfin; +using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Infrastructure.Data; @@ -25,6 +26,7 @@ public class PrepareTroubleshootingPlaybackHandler( IEmbyPathReplacementService embyPathReplacementService, IFFmpegProcessService ffmpegProcessService, ILocalFileSystem localFileSystem, + IEntityLocker entityLocker, ILogger logger) : IRequestHandler> { @@ -45,10 +47,15 @@ public class PrepareTroubleshootingPlaybackHandler( string ffprobePath, FFmpegProfile ffmpegProfile) { - string transcodeFolder = Path.Combine(FileSystemLayout.TranscodeFolder, ".troubleshooting"); + if (entityLocker.IsTroubleshootingPlaybackLocked()) + { + return BaseError.New("Troubleshooting playback is locked"); + } + + entityLocker.LockTroubleshootingPlayback(); - localFileSystem.EnsureFolderExists(transcodeFolder); - localFileSystem.EmptyFolder(transcodeFolder); + localFileSystem.EnsureFolderExists(FileSystemLayout.TranscodeTroubleshootingFolder); + localFileSystem.EmptyFolder(FileSystemLayout.TranscodeTroubleshootingFolder); ChannelSubtitleMode subtitleMode = ChannelSubtitleMode.None; @@ -108,7 +115,7 @@ public class PrepareTroubleshootingPlaybackHandler( 0, None, false, - transcodeFolder, + FileSystemLayout.TranscodeTroubleshootingFolder, _ => { }); return process; diff --git a/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlayback.cs b/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlayback.cs index 3be0e72e..eb25accb 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlayback.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlayback.cs @@ -1,5 +1,9 @@ using CliWrap; +using ErsatzTV.Application.MediaItems; namespace ErsatzTV.Application.Troubleshooting; -public record StartTroubleshootingPlayback(Command Command) : IRequest, IFFmpegWorkerRequest; +public record StartTroubleshootingPlayback( + Command Command, + MediaItemInfo MediaItemInfo, + TroubleshootingInfo TroubleshootingInfo) : IRequest, IFFmpegWorkerRequest; diff --git a/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs index 6ede876c..5b9d0678 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs @@ -1,33 +1,71 @@ +using System.Text.Json; +using System.Text.Json.Serialization; using CliWrap; using CliWrap.Buffered; +using ErsatzTV.Core; using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Core.Notifications; using Microsoft.Extensions.Logging; namespace ErsatzTV.Application.Troubleshooting; public class StartTroubleshootingPlaybackHandler( + IMediator mediator, IEntityLocker entityLocker, ILogger logger) : IRequestHandler { + private static readonly JsonSerializerOptions Options = new() + { + Converters = { new JsonStringEnumConverter() }, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true + }; + public async Task Handle(StartTroubleshootingPlayback request, CancellationToken cancellationToken) { - logger.LogDebug("ffmpeg troubleshooting arguments {FFmpegArguments}", request.Command.Arguments); + try + { + // write media info without title + string infoJson = JsonSerializer.Serialize(request.MediaItemInfo with { Title = null }, Options); + await File.WriteAllTextAsync( + Path.Combine(FileSystemLayout.TranscodeTroubleshootingFolder, "media_info.json"), + infoJson, + cancellationToken); - BufferedCommandResult result = await request.Command - .WithValidation(CommandResultValidation.None) - .ExecuteBufferedAsync(cancellationToken); + // write troubleshooting info + string troubleshootingInfoJson = JsonSerializer.Serialize( + new + { + request.TroubleshootingInfo.Version, + Environment = request.TroubleshootingInfo.Environment.OrderBy(x => x.Key) + .ToDictionary(x => x.Key, x => x.Value), + request.TroubleshootingInfo.Health, + request.TroubleshootingInfo.FFmpegSettings, + request.TroubleshootingInfo.FFmpegProfiles, + request.TroubleshootingInfo.Watermarks + }, + Options); + await File.WriteAllTextAsync( + Path.Combine(FileSystemLayout.TranscodeTroubleshootingFolder, "troubleshooting_info.json"), + troubleshootingInfoJson, + cancellationToken); - entityLocker.UnlockTroubleshootingPlayback(); + logger.LogDebug("ffmpeg troubleshooting arguments {FFmpegArguments}", request.Command.Arguments); + BufferedCommandResult result = await request.Command + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync(cancellationToken); - logger.LogInformation("Troubleshooting playback completed with exit code {ExitCode}", result.ExitCode); + await mediator.Publish( + new PlaybackTroubleshootingCompletedNotification(result.ExitCode), + cancellationToken); - foreach (KeyValuePair env in request.Command.EnvironmentVariables) + logger.LogDebug("Troubleshooting playback completed with exit code {ExitCode}", result.ExitCode); + } + finally { - logger.LogInformation("{Key} => {Value}", env.Key, env.Value); + entityLocker.UnlockTroubleshootingPlayback(); } - - // TODO: something with the result ??? } } diff --git a/ErsatzTV.Application/Troubleshooting/Queries/GetTroubleshootingInfoHandler.cs b/ErsatzTV.Application/Troubleshooting/Queries/GetTroubleshootingInfoHandler.cs index 7d516356..6dbe7fc5 100644 --- a/ErsatzTV.Application/Troubleshooting/Queries/GetTroubleshootingInfoHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Queries/GetTroubleshootingInfoHandler.cs @@ -76,6 +76,10 @@ public class GetTroubleshootingInfoHandler : IRequestHandler channelFFmpegProfiles.Contains(f.Id)) .ToList(); + List channelWatermarks = await dbContext.ChannelWatermarks + .AsNoTracking() + .ToListAsync(cancellationToken); + string nvidiaCapabilities = null; string qsvCapabilities = null; string vaapiCapabilities = null; @@ -155,6 +159,7 @@ public class GetTroubleshootingInfoHandler : IRequestHandler Environment, - IEnumerable Health, + List Health, FFmpegSettingsViewModel FFmpegSettings, - IEnumerable FFmpegProfiles, - IEnumerable Channels, + List FFmpegProfiles, + List Channels, + List Watermarks, string NvidiaCapabilities, string QsvCapabilities, string VaapiCapabilities); diff --git a/ErsatzTV.Core/FileSystemLayout.cs b/ErsatzTV.Core/FileSystemLayout.cs index 793f8c52..1107607b 100644 --- a/ErsatzTV.Core/FileSystemLayout.cs +++ b/ErsatzTV.Core/FileSystemLayout.cs @@ -8,6 +8,7 @@ public static class FileSystemLayout public static readonly string AppDataFolder; public static readonly string TranscodeFolder; + public static readonly string TranscodeTroubleshootingFolder; public static readonly string DataProtectionFolder; public static readonly string LogsFolder; @@ -120,6 +121,7 @@ public static class FileSystemLayout } TranscodeFolder = useCustomTranscodeFolder ? customTranscodeFolder : defaultTranscodeFolder; + TranscodeTroubleshootingFolder = Path.Combine(TranscodeFolder, ".troubleshooting"); DataProtectionFolder = Path.Combine(AppDataFolder, "data-protection"); LogsFolder = Path.Combine(AppDataFolder, "logs"); diff --git a/ErsatzTV.Core/Notifications/PlaybackTroubleshootingCompletedNotification.cs b/ErsatzTV.Core/Notifications/PlaybackTroubleshootingCompletedNotification.cs new file mode 100644 index 00000000..8084fbff --- /dev/null +++ b/ErsatzTV.Core/Notifications/PlaybackTroubleshootingCompletedNotification.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace ErsatzTV.Core.Notifications; + +public record PlaybackTroubleshootingCompletedNotification(int ExitCode) : INotification; diff --git a/ErsatzTV/Controllers/Api/TroubleshootController.cs b/ErsatzTV/Controllers/Api/TroubleshootController.cs index b0770f98..2f56515d 100644 --- a/ErsatzTV/Controllers/Api/TroubleshootController.cs +++ b/ErsatzTV/Controllers/Api/TroubleshootController.cs @@ -1,9 +1,10 @@ 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.Locking; using ErsatzTV.Core.Interfaces.Metadata; using MediatR; using Microsoft.AspNetCore.Mvc; @@ -12,7 +13,6 @@ namespace ErsatzTV.Controllers.Api; [ApiController] public class TroubleshootController( - IEntityLocker entityLocker, ChannelWriter channelWriter, ILocalFileSystem localFileSystem, IMediator mediator) : ControllerBase @@ -28,8 +28,6 @@ public class TroubleshootController( int watermark, CancellationToken cancellationToken) { - entityLocker.LockTroubleshootingPlayback(); - Either result = await mediator.Send( new PrepareTroubleshootingPlayback(mediaItem, ffmpegProfile, watermark), cancellationToken); @@ -37,21 +35,41 @@ public class TroubleshootController( return await result.MatchAsync( async command => { - await channelWriter.WriteAsync(new StartTroubleshootingPlayback(command), CancellationToken.None); - string playlistFile = Path.Combine(FileSystemLayout.TranscodeFolder, ".troubleshooting", "live.m3u8"); - - DateTimeOffset start = DateTimeOffset.Now; - while (!localFileSystem.FileExists(playlistFile) && - DateTimeOffset.Now - start < TimeSpan.FromSeconds(15)) + Either maybeMediaInfo = await mediator.Send(new GetMediaItemInfo(mediaItem), cancellationToken); + foreach (MediaItemInfo mediaInfo in maybeMediaInfo.RightToSeq()) { - 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(command, 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) + { + break; + } } + + return Redirect("~/iptv/session/.troubleshooting/live.m3u8"); } - return Redirect("~/iptv/session/.troubleshooting/live.m3u8"); + return NotFound(); }, _ => NotFound()); } diff --git a/ErsatzTV/Pages/PlaybackTroubleshooting.razor b/ErsatzTV/Pages/PlaybackTroubleshooting.razor index ed71282d..090fe668 100644 --- a/ErsatzTV/Pages/PlaybackTroubleshooting.razor +++ b/ErsatzTV/Pages/PlaybackTroubleshooting.razor @@ -2,11 +2,15 @@ @using ErsatzTV.Application.FFmpegProfiles @using ErsatzTV.Application.MediaItems @using ErsatzTV.Application.Watermarks +@using ErsatzTV.Core.Notifications +@using MediatR.Courier @implements IDisposable @inject IMediator Mediator @inject NavigationManager NavigationManager @inject IJSRuntime JsRuntime @inject IEntityLocker Locker +@inject ICourier Courier; +@inject ISnackbar Snackbar; @@ -109,7 +113,11 @@ _cts.Dispose(); } - protected override void OnInitialized() => Locker.OnTroubleshootingPlaybackChanged += LockChanged; + protected override void OnInitialized() + { + Locker.OnTroubleshootingPlaybackChanged += LockChanged; + Courier.Subscribe(HandleTroubleshootingCompleted); + } protected override async Task OnParametersSetAsync() { @@ -126,13 +134,14 @@ private async Task PreviewChannel() { - Locker.LockTroubleshootingPlayback(); - _hasPlayed = true; - var uri = new UriBuilder(NavigationManager.ToAbsoluteUri(NavigationManager.Uri)); uri.Path = uri.Path.Replace("/system/troubleshooting/playback", "/api/troubleshoot/playback.m3u8"); uri.Query = $"?mediaItem={_mediaItemId}&ffmpegProfile={_ffmpegProfileId}&watermark={_watermarkId ?? 0}"; await JsRuntime.InvokeVoidAsync("previewChannel", uri.ToString()); + + await Task.Delay(TimeSpan.FromSeconds(1)); + + _hasPlayed = true; } private async Task OnMediaItemIdChanged(int? mediaItemId) @@ -162,4 +171,16 @@ await JsRuntime.InvokeVoidAsync("window.open", $"api/troubleshoot/playback/archive?mediaItem={_mediaItemId ?? 0}&ffmpegProfile={_ffmpegProfileId}&watermark={_watermarkId ?? 0}"); } + private void HandleTroubleshootingCompleted(PlaybackTroubleshootingCompletedNotification result) + { + if (result.ExitCode == 0) + { + Snackbar.Add("FFmpeg troubleshooting process exited successfully", Severity.Success); + } + else + { + Snackbar.Add($"FFmpeg troubleshooting process exited with code {result.ExitCode}", Severity.Warning); + } + } + } \ No newline at end of file diff --git a/ErsatzTV/Pages/_Host.cshtml b/ErsatzTV/Pages/_Host.cshtml index 36a11777..e6ca5b2c 100644 --- a/ErsatzTV/Pages/_Host.cshtml +++ b/ErsatzTV/Pages/_Host.cshtml @@ -58,6 +58,14 @@ if (Hls.isSupported()) { var hls = new Hls({ debug: true, + manifestLoadPolicy: { + default: { + maxTimeToFirstByteMs: Infinity, + maxLoadTimeMs: 60000, + timeoutRetry: null, + errorRetry: null + }, + }, }); $('#video').data('hls', hls); hls.loadSource(uri);