From 7c2083d3f2606d876c0609140d02f7741a616d89 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Mon, 13 Oct 2025 11:25:56 -0500 Subject: [PATCH] add playback troubleshooting speed indicator (#2521) * more api fixes * add playback troubleshooting speed indicator --- CHANGELOG.md | 4 ++ .../Commands/UpdateFFmpegProfileHandler.cs | 44 ++++++++------ .../Queries/GetResolutionByName.cs | 5 -- .../Queries/GetResolutionByName.cs | 3 + .../Queries/GetResolutionByNameHandler.cs | 8 +-- .../PrepareTroubleshootingPlaybackHandler.cs | 6 +- .../StartTroubleshootingPlaybackHandler.cs | 26 ++++++++- ...ackTroubleshootingCompletedNotification.cs | 5 +- .../Api/FFmpegProfileController.cs | 2 +- .../Controllers/Api/ResolutionController.cs | 8 +-- ErsatzTV/Controllers/Api/VersionController.cs | 2 +- .../PlaybackTroubleshooting.razor | 58 ++++++++++++++++--- ErsatzTV/wwwroot/openapi/v1.json | 40 +++++++++++-- scripts/set-provider.sh | 4 +- scripts/update-openapi.sh | 4 ++ 15 files changed, 164 insertions(+), 55 deletions(-) delete mode 100644 ErsatzTV.Application/FFmpegProfiles/Queries/GetResolutionByName.cs create mode 100644 ErsatzTV.Application/Resolutions/Queries/GetResolutionByName.cs rename ErsatzTV.Application/{FFmpegProfiles => Resolutions}/Queries/GetResolutionByNameHandler.cs (62%) create mode 100755 scripts/update-openapi.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index bc6ca6c0d..fdd266ca9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,10 +18,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Add sequential schedule file and scripted schedule file names to playouts table - Add empty (but already up-to-date) sqlite3 database to greatly speed up initial startup for fresh installs - Add button to copy/clone block from blocks table +- Add playback speed to playback troubleshooting output + - Speed is realative to realtime (1.0x is realtime) + - Speeds < 0.9x will be colored red, between 0.9x and 1.1x colored yellow, and > 1.1x colored green ### Fixed - Fix NVIDIA startup errors on arm64 - Fix remote stream durations in playouts created using block, sequential or scripted schedules +- Fix playback troubleshooting selecting a subtitle even with no subtitle stream selected in the UI ### Changed - Do not use graphics engine for single, permanent watermark diff --git a/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs b/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs index f65887adf..1a40d9ea9 100644 --- a/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs +++ b/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs @@ -2,8 +2,6 @@ using ErsatzTV.Core.Domain; using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Interfaces.Search; -using ErsatzTV.FFmpeg; -using ErsatzTV.FFmpeg.Format; using ErsatzTV.FFmpeg.Preset; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; @@ -11,23 +9,14 @@ using Microsoft.EntityFrameworkCore; namespace ErsatzTV.Application.FFmpegProfiles; -public class - UpdateFFmpegProfileHandler : IRequestHandler> +public class UpdateFFmpegProfileHandler(IDbContextFactory dbContextFactory, ISearchTargets searchTargets) + : IRequestHandler> { - private readonly IDbContextFactory _dbContextFactory; - private readonly ISearchTargets _searchTargets; - - public UpdateFFmpegProfileHandler(IDbContextFactory dbContextFactory, ISearchTargets searchTargets) - { - _dbContextFactory = dbContextFactory; - _searchTargets = searchTargets; - } - public async Task> Handle( UpdateFFmpegProfile request, CancellationToken cancellationToken) { - await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); Validation validation = await Validate(dbContext, request, cancellationToken); return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request, cancellationToken)); } @@ -89,7 +78,7 @@ public class await dbContext.SaveChangesAsync(cancellationToken); - _searchTargets.SearchTargetsChanged(); + searchTargets.SearchTargetsChanged(); return new UpdateFFmpegProfileResult(p.Id); } @@ -98,7 +87,8 @@ public class TvContext dbContext, UpdateFFmpegProfile request, CancellationToken cancellationToken) => - (await FFmpegProfileMustExist(dbContext, request, cancellationToken), ValidateName(request), + (await FFmpegProfileMustExist(dbContext, request, cancellationToken), + await ValidateName(dbContext, request), ValidateThreadCount(request), await ResolutionMustExist(dbContext, request, cancellationToken)) .Apply((ffmpegProfileToUpdate, _, _, _) => ffmpegProfileToUpdate); @@ -111,9 +101,25 @@ public class .SelectOneAsync(p => p.Id, p => p.Id == updateFFmpegProfile.FFmpegProfileId, cancellationToken) .Map(o => o.ToValidation("FFmpegProfile does not exist.")); - private static Validation ValidateName(UpdateFFmpegProfile updateFFmpegProfile) => - updateFFmpegProfile.NotEmpty(x => x.Name) - .Bind(_ => updateFFmpegProfile.NotLongerThan(50)(x => x.Name)); + private static async Task> ValidateName( + TvContext dbContext, + UpdateFFmpegProfile updateFFmpegProfile) + { + if (updateFFmpegProfile.Name.Length > 50) + { + return BaseError.New($"FFmpeg profile name \"{updateFFmpegProfile.Name}\" is invalid"); + } + + Option maybeExisting = await dbContext.FFmpegProfiles + .AsNoTracking() + .FirstOrDefaultAsync(ff => + ff.Id != updateFFmpegProfile.FFmpegProfileId && ff.Name == updateFFmpegProfile.Name) + .Map(Optional); + + return maybeExisting.IsSome + ? BaseError.New($"An ffmpeg profile named \"{updateFFmpegProfile.Name}\" already exists in the database") + : Success(updateFFmpegProfile.Name); + } private static Validation ValidateThreadCount(UpdateFFmpegProfile updateFFmpegProfile) => updateFFmpegProfile.AtLeast(0)(p => p.ThreadCount); diff --git a/ErsatzTV.Application/FFmpegProfiles/Queries/GetResolutionByName.cs b/ErsatzTV.Application/FFmpegProfiles/Queries/GetResolutionByName.cs deleted file mode 100644 index 2e770542d..000000000 --- a/ErsatzTV.Application/FFmpegProfiles/Queries/GetResolutionByName.cs +++ /dev/null @@ -1,5 +0,0 @@ -using ErsatzTV.Core; - -namespace ErsatzTV.Application.FFmpegProfiles; - -public record GetResolutionByName(string Name) : IRequest>; diff --git a/ErsatzTV.Application/Resolutions/Queries/GetResolutionByName.cs b/ErsatzTV.Application/Resolutions/Queries/GetResolutionByName.cs new file mode 100644 index 000000000..1a2c06e05 --- /dev/null +++ b/ErsatzTV.Application/Resolutions/Queries/GetResolutionByName.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Application.Resolutions; + +public record GetResolutionByName(string Name) : IRequest>; diff --git a/ErsatzTV.Application/FFmpegProfiles/Queries/GetResolutionByNameHandler.cs b/ErsatzTV.Application/Resolutions/Queries/GetResolutionByNameHandler.cs similarity index 62% rename from ErsatzTV.Application/FFmpegProfiles/Queries/GetResolutionByNameHandler.cs rename to ErsatzTV.Application/Resolutions/Queries/GetResolutionByNameHandler.cs index 724d85160..9a43f3eb4 100644 --- a/ErsatzTV.Application/FFmpegProfiles/Queries/GetResolutionByNameHandler.cs +++ b/ErsatzTV.Application/Resolutions/Queries/GetResolutionByNameHandler.cs @@ -2,17 +2,17 @@ using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; using Microsoft.EntityFrameworkCore; -namespace ErsatzTV.Application.FFmpegProfiles; +namespace ErsatzTV.Application.Resolutions; public class GetResolutionByNameHandler(IDbContextFactory dbContextFactory) - : IRequestHandler> + : IRequestHandler> { - public async Task> Handle(GetResolutionByName request, CancellationToken cancellationToken) + public async Task> Handle(GetResolutionByName request, CancellationToken cancellationToken) { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); return await dbContext.Resolutions .AsNoTracking() .SelectOneAsync(r => r.Name, r => r.Name == request.Name, cancellationToken) - .MapT(r => r.Id); + .MapT(Mapper.ProjectToViewModel); } } diff --git a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs index 661cf2c30..57d6bc0ad 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs @@ -67,7 +67,7 @@ public class PrepareTroubleshootingPlaybackHandler( catch (Exception ex) { entityLocker.UnlockTroubleshootingPlayback(); - await mediator.Publish(new PlaybackTroubleshootingCompletedNotification(-1), cancellationToken); + await mediator.Publish(new PlaybackTroubleshootingCompletedNotification(-1, ex, Option.None), cancellationToken); logger.LogError(ex, "Error while preparing troubleshooting playback"); return BaseError.New(ex.Message); } @@ -293,6 +293,10 @@ public class PrepareTroubleshootingPlaybackHandler( return [subtitle]; } } + else if (string.IsNullOrWhiteSpace(request.StreamSelector)) + { + allSubtitles.Clear(); + } return allSubtitles; } diff --git a/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs index a8f39115f..4a56692f8 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs @@ -2,6 +2,7 @@ using System.IO.Pipelines; using System.Runtime.InteropServices; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.RegularExpressions; using CliWrap; using ErsatzTV.Core; using ErsatzTV.Core.Domain; @@ -16,7 +17,7 @@ using Serilog.Events; namespace ErsatzTV.Application.Troubleshooting; -public class StartTroubleshootingPlaybackHandler( +public partial class StartTroubleshootingPlaybackHandler( ITroubleshootingNotifier notifier, IMediator mediator, IEntityLocker entityLocker, @@ -152,8 +153,26 @@ public class StartTroubleshootingPlaybackHandler( // do nothing } + Option maybeSpeed = Option.None; + Option maybeFile = Directory.GetFiles(FileSystemLayout.TranscodeTroubleshootingFolder, "ffmpeg*.log").HeadOrNone(); + foreach (string file in maybeFile) + { + await foreach (string line in File.ReadLinesAsync(file, linkedCts.Token)) + { + Match match = FFmpegSpeed().Match(line); + if (match.Success && double.TryParse(match.Groups[1].Value, out double speed)) + { + maybeSpeed = speed; + break; + } + } + } + await mediator.Publish( - new PlaybackTroubleshootingCompletedNotification(commandResult.ExitCode), + new PlaybackTroubleshootingCompletedNotification( + commandResult.ExitCode, + Option.None, + maybeSpeed), linkedCts.Token); logger.LogDebug("Troubleshooting playback completed with exit code {ExitCode}", commandResult.ExitCode); @@ -186,4 +205,7 @@ public class StartTroubleshootingPlaybackHandler( loggingLevelSwitches.StreamingLevelSwitch.MinimumLevel = currentStreamingLevel; } } + + [GeneratedRegex(@"speed=([\d\.]+)x", RegexOptions.IgnoreCase)] + private static partial Regex FFmpegSpeed(); } diff --git a/ErsatzTV.Core/Notifications/PlaybackTroubleshootingCompletedNotification.cs b/ErsatzTV.Core/Notifications/PlaybackTroubleshootingCompletedNotification.cs index 8084fbff5..2fb79de51 100644 --- a/ErsatzTV.Core/Notifications/PlaybackTroubleshootingCompletedNotification.cs +++ b/ErsatzTV.Core/Notifications/PlaybackTroubleshootingCompletedNotification.cs @@ -2,4 +2,7 @@ using MediatR; namespace ErsatzTV.Core.Notifications; -public record PlaybackTroubleshootingCompletedNotification(int ExitCode) : INotification; +public record PlaybackTroubleshootingCompletedNotification( + int ExitCode, + Option MaybeException, + Option MaybeSpeed) : INotification; diff --git a/ErsatzTV/Controllers/Api/FFmpegProfileController.cs b/ErsatzTV/Controllers/Api/FFmpegProfileController.cs index b0256bf31..2dfda93b8 100644 --- a/ErsatzTV/Controllers/Api/FFmpegProfileController.cs +++ b/ErsatzTV/Controllers/Api/FFmpegProfileController.cs @@ -39,6 +39,6 @@ public class FFmpegProfileController(IMediator mediator) : ControllerBase public async Task DeleteProfileAsync(int id, CancellationToken cancellationToken) { Either result = await mediator.Send(new DeleteFFmpegProfile(id), cancellationToken); - return result.Match(_ => Ok(), error => Problem(error.ToString())); + return result.Match(_ => Ok(), error => Conflict(error.ToString())); } } diff --git a/ErsatzTV/Controllers/Api/ResolutionController.cs b/ErsatzTV/Controllers/Api/ResolutionController.cs index 423266646..2812747aa 100644 --- a/ErsatzTV/Controllers/Api/ResolutionController.cs +++ b/ErsatzTV/Controllers/Api/ResolutionController.cs @@ -1,4 +1,4 @@ -using ErsatzTV.Application.FFmpegProfiles; +using ErsatzTV.Application.Resolutions; using MediatR; using Microsoft.AspNetCore.Mvc; @@ -9,10 +9,10 @@ namespace ErsatzTV.Controllers.Api; public class ResolutionController(IMediator mediator) : ControllerBase { [HttpGet("/api/ffmpeg/resolution/by-name/{name}", Name="GetResolutionByName")] - public async Task> GetResolutionByName(string name, CancellationToken cancellationToken) + public async Task> GetResolutionByName(string name, CancellationToken cancellationToken) { - Option result = await mediator.Send(new GetResolutionByName(name), cancellationToken); - return result.Match>(i => Ok(i), () => NotFound()); + Option result = await mediator.Send(new GetResolutionByName(name), cancellationToken); + return result.Match>(i => Ok(i), () => NotFound()); } } diff --git a/ErsatzTV/Controllers/Api/VersionController.cs b/ErsatzTV/Controllers/Api/VersionController.cs index 5804177a8..6c144c312 100644 --- a/ErsatzTV/Controllers/Api/VersionController.cs +++ b/ErsatzTV/Controllers/Api/VersionController.cs @@ -11,7 +11,7 @@ public class VersionController static VersionController() => Version = new CombinedVersion( - 2, + 3, Assembly.GetEntryAssembly()? .GetCustomAttribute()? .InformationalVersion ?? "unknown"); diff --git a/ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor b/ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor index 372f97c79..5e655fb0d 100644 --- a/ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor +++ b/ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor @@ -152,7 +152,16 @@
- Logs + @if (_lastSpeed is not null) + { + + Logs (Speed: @(_lastSpeed.Value)x) + + } + else + { + Logs + } @@ -183,6 +192,7 @@ private int? _subtitleId; private int _seekSeconds; private bool _hasPlayed; + private double? _lastSpeed; private string _logs; [SupplyParameterFromQuery(Name = "mediaItem")] @@ -249,15 +259,16 @@ private async Task PreviewChannel() { _logs = null; + _lastSpeed = null; var baseUri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri).ToString(); string apiUri = baseUri.Replace("/system/troubleshooting/playback", "/api/troubleshoot/playback.m3u8"); var queryString = new List> { - new ("mediaItem", (MediaItemId ?? 0).ToString()), - new ("ffmpegProfile", _ffmpegProfileId.ToString()), - new ("streamingMode", ((int)_streamingMode).ToString()), - new ("seekSeconds", _seekSeconds.ToString()) + new("mediaItem", (MediaItemId ?? 0).ToString()), + new("ffmpegProfile", _ffmpegProfileId.ToString()), + new("streamingMode", ((int)_streamingMode).ToString()), + new("seekSeconds", _seekSeconds.ToString()) }; foreach (string watermarkName in _watermarkNames) @@ -324,10 +335,10 @@ { var queryString = new List> { - new ("mediaItem", (MediaItemId ?? 0).ToString()), - new ("ffmpegProfile", _ffmpegProfileId.ToString()), - new ("streamingMode", ((int)_streamingMode).ToString()), - new ("seekSeconds", _seekSeconds.ToString()) + new("mediaItem", (MediaItemId ?? 0).ToString()), + new("ffmpegProfile", _ffmpegProfileId.ToString()), + new("streamingMode", ((int)_streamingMode).ToString()), + new("seekSeconds", _seekSeconds.ToString()) }; foreach (string watermarkName in _watermarkNames) @@ -353,6 +364,12 @@ private void HandleTroubleshootingCompleted(PlaybackTroubleshootingCompletedNotification result) { _logs = null; + _lastSpeed = null; + + foreach (double speed in result.MaybeSpeed) + { + _lastSpeed = speed; + } if (result.ExitCode == 0) { @@ -369,6 +386,29 @@ _logs = File.ReadAllText(logFileName); InvokeAsync(StateHasChanged); } + else + { + foreach (var exception in result.MaybeException) + { + _logs = exception.Message + Environment.NewLine + Environment.NewLine + exception; + InvokeAsync(StateHasChanged); + } + } + } + + private static string GetSpeedClass(double speed) + { + if (speed < 0.9) + { + return "mud-error-text"; + } + + if (speed > 1.1) + { + return "mud-primary-text"; + } + + return "mud-warning-text"; } } diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index c5c9e80e5..734c4f49c 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -336,20 +336,17 @@ "content": { "text/plain": { "schema": { - "type": "integer", - "format": "int32" + "$ref": "#/components/schemas/ResolutionViewModel" } }, "application/json": { "schema": { - "type": "integer", - "format": "int32" + "$ref": "#/components/schemas/ResolutionViewModel" } }, "text/json": { "schema": { - "type": "integer", - "format": "int32" + "$ref": "#/components/schemas/ResolutionViewModel" } } } @@ -992,6 +989,37 @@ "LoudNorm" ] }, + "ResolutionViewModel": { + "required": [ + "id", + "name", + "width", + "height", + "isCustom" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "name": { + "type": "string", + "nullable": true + }, + "width": { + "type": "integer", + "format": "int32" + }, + "height": { + "type": "integer", + "format": "int32" + }, + "isCustom": { + "type": "boolean" + } + } + }, "ScalingBehavior": { "enum": [ "ScaleAndPad", diff --git a/scripts/set-provider.sh b/scripts/set-provider.sh index 1a38b43a5..1c94b4683 100755 --- a/scripts/set-provider.sh +++ b/scripts/set-provider.sh @@ -5,8 +5,8 @@ if [[ $# -eq 0 ]] ; then exit 1 fi -cd "$(git rev-parse --show-cdup)" || exit +cd "$(git rev-parse --show-toplevel)" || exit cd ErsatzTV && dotnet user-secrets set "Provider" "$1" -cd "$(git rev-parse --show-cdup)" || exit +cd "$(git rev-parse --show-toplevel)" || exit cd ErsatzTV.Scanner && dotnet user-secrets set "Provider" "$1" diff --git a/scripts/update-openapi.sh b/scripts/update-openapi.sh new file mode 100755 index 000000000..28ac1778c --- /dev/null +++ b/scripts/update-openapi.sh @@ -0,0 +1,4 @@ +#! /usr/bin/env bash + +cd "$(git rev-parse --show-toplevel)" || exit +cd ErsatzTV && dotnet build -t:GenerateOpenApiDocuments