add playback troubleshooting tool (#2155)

* support media info for more content types

* add playback troubleshooting page

* reorganize playback troubleshooting

* fix watermarks and delay

* update changelog
This commit is contained in:
Jason Dove
2025-07-17 03:51:36 +00:00
committed by GitHub
parent 848b88bd2d
commit 578cdb1e14
27 changed files with 878 additions and 76 deletions
@@ -0,0 +1,86 @@
using System.Threading.Channels;
using CliWrap;
using ErsatzTV.Application;
using ErsatzTV.Application.Troubleshooting;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Metadata;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class TroubleshootController(
IEntityLocker entityLocker,
ChannelWriter<IFFmpegWorkerRequest> channelWriter,
ILocalFileSystem localFileSystem,
IMediator mediator) : ControllerBase
{
[HttpHead("api/troubleshoot/playback.m3u8")]
[HttpGet("api/troubleshoot/playback.m3u8")]
public async Task<IActionResult> TroubleshootPlayback(
[FromQuery]
int mediaItem,
[FromQuery]
int ffmpegProfile,
[FromQuery]
int watermark,
CancellationToken cancellationToken)
{
entityLocker.LockTroubleshootingPlayback();
Either<BaseError, Command> result = await mediator.Send(
new PrepareTroubleshootingPlayback(mediaItem, ffmpegProfile, watermark),
cancellationToken);
return await result.MatchAsync<IActionResult>(
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))
{
await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken);
if (cancellationToken.IsCancellationRequested)
{
break;
}
}
return Redirect("~/iptv/session/.troubleshooting/live.m3u8");
},
_ => NotFound());
}
[HttpHead("api/troubleshoot/playback/archive")]
[HttpGet("api/troubleshoot/playback/archive")]
public async Task<IActionResult> TroubleshootPlaybackArchive(
[FromQuery]
int mediaItem,
[FromQuery]
int ffmpegProfile,
[FromQuery]
int watermark,
CancellationToken cancellationToken)
{
Option<string> maybeArchivePath = await mediator.Send(
new ArchiveTroubleshootingResults(mediaItem, ffmpegProfile, watermark),
cancellationToken);
foreach (string archivePath in maybeArchivePath)
{
FileStream fs = System.IO.File.OpenRead(archivePath);
return File(
fs,
"application/zip",
$"ersatztv-troubleshooting-{DateTimeOffset.Now.ToUnixTimeSeconds()}.zip");
}
return NotFound();
}
}
@@ -0,0 +1,165 @@
@page "/system/troubleshooting/playback"
@using ErsatzTV.Application.FFmpegProfiles
@using ErsatzTV.Application.MediaItems
@using ErsatzTV.Application.Watermarks
@implements IDisposable
@inject IMediator Mediator
@inject NavigationManager NavigationManager
@inject IJSRuntime JsRuntime
@inject IEntityLocker Locker
<MudForm Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center">
<MudButton Variant="Variant.Filled"
Color="Color.Secondary"
Class="ml-6"
StartIcon="@Icons.Material.Filled.Download"
Disabled="@(!_hasPlayed || Locker.IsTroubleshootingPlaybackLocked())"
OnClick="DownloadResults">
Download Results
</MudButton>
</MudPaper>
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudText Typo="Typo.h5" Class="mb-2">Media Item</MudText>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Media Item ID</MudText>
</div>
<MudTextField T="int?" Value="_mediaItemId" ValueChanged="@(async x => await OnMediaItemIdChanged(x))" />
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Title</MudText>
</div>
<MudTextField Value="@(_info?.Title)" Disabled="true" />
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Playback Settings</MudText>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>FFmpeg Profile</MudText>
</div>
<MudSelect @bind-Value="_ffmpegProfileId" For="@(() => _ffmpegProfileId)">
@foreach (FFmpegProfileViewModel profile in _ffmpegProfiles)
{
<MudSelectItem Value="@profile.Id">@profile.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Watermark</MudText>
</div>
<MudSelect @bind-Value="_watermarkId" For="@(() => _watermarkId)" Clearable="true">
<MudSelectItem T="int?" Value="@((int?)null)">(none)</MudSelectItem>
@foreach (WatermarkViewModel watermark in _watermarks)
{
<MudSelectItem T="int?" Value="@watermark.Id">@watermark.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Preview</MudText>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"></div>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.PlayCircle"
Disabled="@(Locker.IsTroubleshootingPlaybackLocked() || _mediaItemId is null)"
OnClick="PreviewChannel">
Play
</MudButton>
</MudStack>
<div class="d-flex" style="width: 100%">
<media-controller style="aspect-ratio: 16/9; width: 100%">
<video id="video" slot="media"></video>
<media-control-bar>
<media-play-button></media-play-button>
<media-mute-button></media-mute-button>
<media-volume-range></media-volume-range>
<media-fullscreen-button></media-fullscreen-button>
</media-control-bar>
</media-controller>
<div class="d-none d-md-flex" style="width: 400px"></div>
</div>
<div class="mb-6">
<br />
<br />
</div>
</MudContainer>
</div>
</MudForm>
@code {
private readonly CancellationTokenSource _cts = new();
private List<FFmpegProfileViewModel> _ffmpegProfiles = [];
private List<WatermarkViewModel> _watermarks = [];
private int? _mediaItemId;
private MediaItemInfo _info;
private int _ffmpegProfileId;
private int? _watermarkId;
private bool _hasPlayed;
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
protected override void OnInitialized() => Locker.OnTroubleshootingPlaybackChanged += LockChanged;
protected override async Task OnParametersSetAsync()
{
_ffmpegProfiles = await Mediator.Send(new GetAllFFmpegProfiles(), _cts.Token);
if (_ffmpegProfiles.Count > 0)
{
_ffmpegProfileId = _ffmpegProfiles.Map(f => f.Id).Head();
}
_watermarks = await Mediator.Send(new GetAllWatermarks(), _cts.Token);
}
private void LockChanged(object sender, EventArgs e) => InvokeAsync(StateHasChanged);
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());
}
private async Task OnMediaItemIdChanged(int? mediaItemId)
{
_mediaItemId = mediaItemId;
_hasPlayed = false;
foreach (int id in Optional(mediaItemId))
{
Either<BaseError, MediaItemInfo> maybeInfo = await Mediator.Send(new GetMediaItemInfo(id));
foreach (MediaItemInfo info in maybeInfo.RightToSeq())
{
_info = info;
}
if (maybeInfo.IsLeft)
{
_mediaItemId = null;
}
}
StateHasChanged();
}
private async Task DownloadResults()
{
await JsRuntime.InvokeVoidAsync("window.open", $"api/troubleshoot/playback/archive?mediaItem={_mediaItemId ?? 0}&ffmpegProfile={_ffmpegProfileId}&watermark={_watermarkId ?? 0}");
}
}
+58 -43
View File
@@ -7,50 +7,65 @@
@inject IMediator Mediator
@inject IJSRuntime JsRuntime
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudExpansionPanels>
<MudExpansionPanel Text="General" Class="mb-6">
<div class="overflow-y-scroll" style="max-height: 500px">
<pre>
<code @ref="_troubleshootingView">@_troubleshootingInfo</code>
</pre>
<MudForm>
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudText Typo="Typo.h5" Class="mb-2">Playback</MudText>
<MudDivider Class="mb-6"/>
<div class="mb-6">
<MudLink Href="system/troubleshooting/playback">Playback Troubleshooting</MudLink>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Class="mt-4" OnClick="() => CopyToClipboard(_troubleshootingView)">
Copy
</MudButton>
</MudExpansionPanel>
<MudExpansionPanel Text="NVIDIA Capabilities" Class="mb-6">
<div class="overflow-y-scroll" style="max-height: 500px">
<pre>
<code @ref="_nvidiaView">@_nvidiaCapabilities</code>
</pre>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Class="mt-4" OnClick="() => CopyToClipboard(_nvidiaView)">
Copy
</MudButton>
</MudExpansionPanel>
<MudExpansionPanel Text="QSV Capabilities" Class="mb-6">
<div class="overflow-y-scroll" style="max-height: 500px">
<pre>
<code @ref="_qsvView">@_qsvCapabilities</code>
</pre>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Class="mt-4" OnClick="() => CopyToClipboard(_qsvView)">
Copy
</MudButton>
</MudExpansionPanel>
<MudExpansionPanel Text="VAAPI Capabilities">
<div class="overflow-y-scroll" style="max-height: 500px">
<pre>
<code @ref="_vaapiView">@_vaapiCapabilities</code>
</pre>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Class="mt-4" OnClick="() => CopyToClipboard(_vaapiView)">
Copy
</MudButton>
</MudExpansionPanel>
</MudExpansionPanels>
</MudContainer>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">General</MudText>
<MudDivider Class="mb-6"/>
<MudExpansionPanel Class="mb-6">
<div class="overflow-y-scroll" style="max-height: 500px">
<pre>
<code @ref="_troubleshootingView">@_troubleshootingInfo</code>
</pre>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Class="mt-4" OnClick="() => CopyToClipboard(_troubleshootingView)">
Copy
</MudButton>
</MudExpansionPanel>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">NVIDIA Capabilities</MudText>
<MudDivider Class="mb-6"/>
<MudExpansionPanel Class="mb-6">
<div class="overflow-y-scroll" style="max-height: 500px">
<pre>
<code @ref="_nvidiaView">@_nvidiaCapabilities</code>
</pre>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Class="mt-4" OnClick="() => CopyToClipboard(_nvidiaView)">
Copy
</MudButton>
</MudExpansionPanel>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">QSV Capabilities</MudText>
<MudDivider Class="mb-6"/>
<MudExpansionPanel Class="mb-6">
<div class="overflow-y-scroll" style="max-height: 500px">
<pre>
<code @ref="_qsvView">@_qsvCapabilities</code>
</pre>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Class="mt-4" OnClick="() => CopyToClipboard(_qsvView)">
Copy
</MudButton>
</MudExpansionPanel>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">VAAPI Capabilities</MudText>
<MudDivider Class="mb-6"/>
<MudExpansionPanel Class="mb-6">
<div class="overflow-y-scroll" style="max-height: 500px">
<pre>
<code @ref="_vaapiView">@_vaapiCapabilities</code>
</pre>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Class="mt-4" OnClick="() => CopyToClipboard(_vaapiView)">
Copy
</MudButton>
</MudExpansionPanel>
</MudContainer>
</div>
</MudForm>
@code {
private readonly CancellationTokenSource _cts = new();
+1 -1
View File
@@ -78,7 +78,7 @@
@code {
private readonly CancellationTokenSource _cts = new();
private List<WatermarkViewModel> _watermarks = new();
private List<WatermarkViewModel> _watermarks = [];
public void Dispose()
{
+8
View File
@@ -2,7 +2,9 @@
using Bugsnag;
using ErsatzTV.Application;
using ErsatzTV.Application.Streaming;
using ErsatzTV.Application.Troubleshooting;
using ErsatzTV.Core.Interfaces.FFmpeg;
using MediatR;
namespace ErsatzTV.Services;
@@ -47,6 +49,12 @@ public class FFmpegWorkerService : BackgroundService
_ffmpegSegmenterService.TouchChannel(parent.Name);
}
break;
case StartTroubleshootingPlayback startTroubleshootingPlayback:
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
await mediator.Send(startTroubleshootingPlayback, stoppingToken);
break;
}
}