Browse Source

add playback troubleshooting speed indicator (#2521)

* more api fixes

* add playback troubleshooting speed indicator
pull/2522/head
Jason Dove 3 months ago committed by GitHub
parent
commit
7c2083d3f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 44
      ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs
  3. 5
      ErsatzTV.Application/FFmpegProfiles/Queries/GetResolutionByName.cs
  4. 3
      ErsatzTV.Application/Resolutions/Queries/GetResolutionByName.cs
  5. 8
      ErsatzTV.Application/Resolutions/Queries/GetResolutionByNameHandler.cs
  6. 6
      ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs
  7. 26
      ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs
  8. 5
      ErsatzTV.Core/Notifications/PlaybackTroubleshootingCompletedNotification.cs
  9. 2
      ErsatzTV/Controllers/Api/FFmpegProfileController.cs
  10. 8
      ErsatzTV/Controllers/Api/ResolutionController.cs
  11. 2
      ErsatzTV/Controllers/Api/VersionController.cs
  12. 40
      ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor
  13. 40
      ErsatzTV/wwwroot/openapi/v1.json
  14. 4
      scripts/set-provider.sh
  15. 4
      scripts/update-openapi.sh

4
CHANGELOG.md

@ -18,10 +18,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -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

44
ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs

@ -2,8 +2,6 @@ @@ -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; @@ -11,23 +9,14 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.FFmpegProfiles;
public class
UpdateFFmpegProfileHandler : IRequestHandler<UpdateFFmpegProfile, Either<BaseError, UpdateFFmpegProfileResult>>
public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
: IRequestHandler<UpdateFFmpegProfile, Either<BaseError, UpdateFFmpegProfileResult>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ISearchTargets _searchTargets;
public UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
{
_dbContextFactory = dbContextFactory;
_searchTargets = searchTargets;
}
public async Task<Either<BaseError, UpdateFFmpegProfileResult>> Handle(
UpdateFFmpegProfile request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request, cancellationToken));
}
@ -89,7 +78,7 @@ public class @@ -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 @@ -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 @@ -111,9 +101,25 @@ public class
.SelectOneAsync(p => p.Id, p => p.Id == updateFFmpegProfile.FFmpegProfileId, cancellationToken)
.Map(o => o.ToValidation<BaseError>("FFmpegProfile does not exist."));
private static Validation<BaseError, string> ValidateName(UpdateFFmpegProfile updateFFmpegProfile) =>
updateFFmpegProfile.NotEmpty(x => x.Name)
.Bind(_ => updateFFmpegProfile.NotLongerThan(50)(x => x.Name));
private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext,
UpdateFFmpegProfile updateFFmpegProfile)
{
if (updateFFmpegProfile.Name.Length > 50)
{
return BaseError.New($"FFmpeg profile name \"{updateFFmpegProfile.Name}\" is invalid");
}
Option<FFmpegProfile> 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<BaseError, string>(updateFFmpegProfile.Name);
}
private static Validation<BaseError, int> ValidateThreadCount(UpdateFFmpegProfile updateFFmpegProfile) =>
updateFFmpegProfile.AtLeast(0)(p => p.ThreadCount);

5
ErsatzTV.Application/FFmpegProfiles/Queries/GetResolutionByName.cs

@ -1,5 +0,0 @@ @@ -1,5 +0,0 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.FFmpegProfiles;
public record GetResolutionByName(string Name) : IRequest<Option<int>>;

3
ErsatzTV.Application/Resolutions/Queries/GetResolutionByName.cs

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Resolutions;
public record GetResolutionByName(string Name) : IRequest<Option<ResolutionViewModel>>;

8
ErsatzTV.Application/FFmpegProfiles/Queries/GetResolutionByNameHandler.cs → ErsatzTV.Application/Resolutions/Queries/GetResolutionByNameHandler.cs

@ -2,17 +2,17 @@ using ErsatzTV.Infrastructure.Data; @@ -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<TvContext> dbContextFactory)
: IRequestHandler<GetResolutionByName, Option<int>>
: IRequestHandler<GetResolutionByName, Option<ResolutionViewModel>>
{
public async Task<Option<int>> Handle(GetResolutionByName request, CancellationToken cancellationToken)
public async Task<Option<ResolutionViewModel>> 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);
}
}

6
ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs

@ -67,7 +67,7 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -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<double>.None), cancellationToken);
logger.LogError(ex, "Error while preparing troubleshooting playback");
return BaseError.New(ex.Message);
}
@ -293,6 +293,10 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -293,6 +293,10 @@ public class PrepareTroubleshootingPlaybackHandler(
return [subtitle];
}
}
else if (string.IsNullOrWhiteSpace(request.StreamSelector))
{
allSubtitles.Clear();
}
return allSubtitles;
}

26
ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs

@ -2,6 +2,7 @@ using System.IO.Pipelines; @@ -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; @@ -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( @@ -152,8 +153,26 @@ public class StartTroubleshootingPlaybackHandler(
// do nothing
}
Option<double> maybeSpeed = Option<double>.None;
Option<string> 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<Exception>.None,
maybeSpeed),
linkedCts.Token);
logger.LogDebug("Troubleshooting playback completed with exit code {ExitCode}", commandResult.ExitCode);
@ -186,4 +205,7 @@ public class StartTroubleshootingPlaybackHandler( @@ -186,4 +205,7 @@ public class StartTroubleshootingPlaybackHandler(
loggingLevelSwitches.StreamingLevelSwitch.MinimumLevel = currentStreamingLevel;
}
}
[GeneratedRegex(@"speed=([\d\.]+)x", RegexOptions.IgnoreCase)]
private static partial Regex FFmpegSpeed();
}

5
ErsatzTV.Core/Notifications/PlaybackTroubleshootingCompletedNotification.cs

@ -2,4 +2,7 @@ using MediatR; @@ -2,4 +2,7 @@ using MediatR;
namespace ErsatzTV.Core.Notifications;
public record PlaybackTroubleshootingCompletedNotification(int ExitCode) : INotification;
public record PlaybackTroubleshootingCompletedNotification(
int ExitCode,
Option<Exception> MaybeException,
Option<double> MaybeSpeed) : INotification;

2
ErsatzTV/Controllers/Api/FFmpegProfileController.cs

@ -39,6 +39,6 @@ public class FFmpegProfileController(IMediator mediator) : ControllerBase @@ -39,6 +39,6 @@ public class FFmpegProfileController(IMediator mediator) : ControllerBase
public async Task<IActionResult> DeleteProfileAsync(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteFFmpegProfile(id), cancellationToken);
return result.Match<IActionResult>(_ => Ok(), error => Problem(error.ToString()));
return result.Match<IActionResult>(_ => Ok(), error => Conflict(error.ToString()));
}
}

8
ErsatzTV/Controllers/Api/ResolutionController.cs

@ -1,4 +1,4 @@ @@ -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; @@ -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<ActionResult<int>> GetResolutionByName(string name, CancellationToken cancellationToken)
public async Task<ActionResult<ResolutionViewModel>> GetResolutionByName(string name, CancellationToken cancellationToken)
{
Option<int> result = await mediator.Send(new GetResolutionByName(name), cancellationToken);
return result.Match<ActionResult<int>>(i => Ok(i), () => NotFound());
Option<ResolutionViewModel> result = await mediator.Send(new GetResolutionByName(name), cancellationToken);
return result.Match<ActionResult<ResolutionViewModel>>(i => Ok(i), () => NotFound());
}
}

2
ErsatzTV/Controllers/Api/VersionController.cs

@ -11,7 +11,7 @@ public class VersionController @@ -11,7 +11,7 @@ public class VersionController
static VersionController() =>
Version = new CombinedVersion(
2,
3,
Assembly.GetEntryAssembly()?
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
.InformationalVersion ?? "unknown");

40
ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor

@ -152,7 +152,16 @@ @@ -152,7 +152,16 @@
</media-controller>
<div class="d-none d-md-flex" style="width: 400px"></div>
</div>
@if (_lastSpeed is not null)
{
<MudText Typo="Typo.h5" Class="mt-10 mb-2">
Logs <span class="@GetSpeedClass(_lastSpeed.Value)">(Speed: @(_lastSpeed.Value)x)</span>
</MudText>
}
else
{
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Logs</MudText>
}
<MudDivider Class="mb-6"/>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="gap-md-8 mb-5">
<MudTextField @bind-Value="_logs" ReadOnly="true" Lines="20" Variant="Variant.Outlined" />
@ -183,6 +192,7 @@ @@ -183,6 +192,7 @@
private int? _subtitleId;
private int _seekSeconds;
private bool _hasPlayed;
private double? _lastSpeed;
private string _logs;
[SupplyParameterFromQuery(Name = "mediaItem")]
@ -249,6 +259,7 @@ @@ -249,6 +259,7 @@
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");
@ -353,6 +364,12 @@ @@ -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 @@ @@ -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";
}
}

40
ErsatzTV/wwwroot/openapi/v1.json

@ -336,20 +336,17 @@ @@ -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 @@ @@ -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",

4
scripts/set-provider.sh

@ -5,8 +5,8 @@ if [[ $# -eq 0 ]] ; then @@ -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"

4
scripts/update-openapi.sh

@ -0,0 +1,4 @@ @@ -0,0 +1,4 @@
#! /usr/bin/env bash
cd "$(git rev-parse --show-toplevel)" || exit
cd ErsatzTV && dotnet build -t:GenerateOpenApiDocuments
Loading…
Cancel
Save