add playback troubleshooting speed indicator (#2521)
* more api fixes * add playback troubleshooting speed indicator
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<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
|
||||
|
||||
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<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);
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
public record GetResolutionByName(string Name) : IRequest<Option<int>>;
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Resolutions;
|
||||
|
||||
public record GetResolutionByName(string Name) : IRequest<Option<ResolutionViewModel>>;
|
||||
+4
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -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(
|
||||
return [subtitle];
|
||||
}
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(request.StreamSelector))
|
||||
{
|
||||
allSubtitles.Clear();
|
||||
}
|
||||
|
||||
return allSubtitles;
|
||||
}
|
||||
|
||||
+24
-2
@@ -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<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(
|
||||
loggingLevelSwitches.StreamingLevelSwitch.MinimumLevel = currentStreamingLevel;
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"speed=([\d\.]+)x", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex FFmpegSpeed();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ public class VersionController
|
||||
|
||||
static VersionController() =>
|
||||
Version = new CombinedVersion(
|
||||
2,
|
||||
3,
|
||||
Assembly.GetEntryAssembly()?
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
|
||||
.InformationalVersion ?? "unknown");
|
||||
|
||||
@@ -152,7 +152,16 @@
|
||||
</media-controller>
|
||||
<div class="d-none d-md-flex" style="width: 400px"></div>
|
||||
</div>
|
||||
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Logs</MudText>
|
||||
@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 @@
|
||||
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<KeyValuePair<string, string>>
|
||||
{
|
||||
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<KeyValuePair<string, string>>
|
||||
{
|
||||
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";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#! /usr/bin/env bash
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)" || exit
|
||||
cd ErsatzTV && dotnet build -t:GenerateOpenApiDocuments
|
||||
Reference in New Issue
Block a user