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:
@@ -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}");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
@code {
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
private List<WatermarkViewModel> _watermarks = new();
|
||||
private List<WatermarkViewModel> _watermarks = [];
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user