Browse Source

playback troubleshooting improvements (#2157)

pull/2159/head
Jason Dove 4 weeks ago committed by GitHub
parent
commit
223bdff8d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      CHANGELOG.md
  2. 59
      ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResultsHandler.cs
  3. 15
      ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs
  4. 6
      ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlayback.cs
  5. 58
      ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs
  6. 5
      ErsatzTV.Application/Troubleshooting/Queries/GetTroubleshootingInfoHandler.cs
  7. 7
      ErsatzTV.Application/Troubleshooting/TroubleshootingInfo.cs
  8. 2
      ErsatzTV.Core/FileSystemLayout.cs
  9. 5
      ErsatzTV.Core/Notifications/PlaybackTroubleshootingCompletedNotification.cs
  10. 46
      ErsatzTV/Controllers/Api/TroubleshootController.cs
  11. 29
      ErsatzTV/Pages/PlaybackTroubleshooting.razor
  12. 8
      ErsatzTV/Pages/_Host.cshtml

2
CHANGELOG.md

@ -90,7 +90,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -90,7 +90,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- The media item id (found in ETV media info, and ETV movie URLs)
- The ffmpeg profile to use
- The watermark to use (if any)
- Clicking `Play` will play the specified content using the desired settings
- Clicking `Play` will play up to 30 seconds of the specified content using the desired settings
- Clicking `Download Results` will generate a zip archive containing:
- The FFmpeg report of the playback attempt
- The media info for the content

59
ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResultsHandler.cs

@ -1,68 +1,35 @@ @@ -1,68 +1,35 @@
using System.IO.Compression;
using System.Text.Json;
using System.Text.Json.Serialization;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.Troubleshooting.Queries;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Metadata;
namespace ErsatzTV.Application.Troubleshooting;
public class ArchiveTroubleshootingResultsHandler(IMediator mediator, ILocalFileSystem localFileSystem)
public class ArchiveTroubleshootingResultsHandler(ILocalFileSystem localFileSystem)
: IRequestHandler<ArchiveTroubleshootingResults, Option<string>>
{
private static readonly JsonSerializerOptions Options = new()
{
Converters = { new JsonStringEnumConverter() },
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true
};
public async Task<Option<string>> Handle(ArchiveTroubleshootingResults request, CancellationToken cancellationToken)
public Task<Option<string>> Handle(ArchiveTroubleshootingResults request, CancellationToken cancellationToken)
{
string tempFile = Path.GetTempFileName();
using ZipArchive zipArchive = ZipFile.Open(tempFile, ZipArchiveMode.Update);
string transcodeFolder = Path.Combine(FileSystemLayout.TranscodeFolder, ".troubleshooting");
bool hasReport = false;
foreach (string file in localFileSystem.ListFiles(transcodeFolder))
var hasReport = false;
foreach (string file in localFileSystem.ListFiles(FileSystemLayout.TranscodeTroubleshootingFolder))
{
string fileName = Path.GetFileName(file);
// add to archive
if (Path.GetFileName(file).StartsWith("ffmpeg-", StringComparison.InvariantCultureIgnoreCase))
if (fileName.StartsWith("ffmpeg-", StringComparison.OrdinalIgnoreCase))
{
hasReport = true;
zipArchive.CreateEntryFromFile(file, Path.GetFileName(file));
zipArchive.CreateEntryFromFile(file, fileName);
}
}
Either<BaseError, MediaItemInfo> maybeMediaItemInfo = await mediator.Send(new GetMediaItemInfo(request.MediaItemId), cancellationToken);
foreach (MediaItemInfo info in maybeMediaItemInfo.RightToSeq())
{
string infoJson = JsonSerializer.Serialize(info, Options);
string tempMediaInfoFile = Path.GetTempFileName();
await File.WriteAllTextAsync(tempMediaInfoFile, infoJson, cancellationToken);
zipArchive.CreateEntryFromFile(tempMediaInfoFile, "media_info.json");
}
TroubleshootingInfo troubleshootingInfo = await mediator.Send(new GetTroubleshootingInfo(), cancellationToken);
string troubleshootingInfoJson = JsonSerializer.Serialize(
new
if (Path.GetExtension(file).Equals(".json", StringComparison.OrdinalIgnoreCase))
{
troubleshootingInfo.Version,
Environment = troubleshootingInfo.Environment.OrderBy(x => x.Key).ToDictionary(x => x.Key, x => x.Value),
troubleshootingInfo.Health,
troubleshootingInfo.FFmpegSettings,
troubleshootingInfo.Channels,
troubleshootingInfo.FFmpegProfiles
},
Options);
string tempTroubleshootingInfoFile = Path.GetTempFileName();
await File.WriteAllTextAsync(tempTroubleshootingInfoFile, troubleshootingInfoJson, cancellationToken);
zipArchive.CreateEntryFromFile(tempTroubleshootingInfoFile, "troubleshooting_info.json");
zipArchive.CreateEntryFromFile(file, fileName);
}
}
return hasReport ? tempFile : Option<string>.None;
return Task.FromResult(hasReport ? tempFile : Option<string>.None);
}
}

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

@ -9,6 +9,7 @@ using ErsatzTV.Core.FFmpeg; @@ -9,6 +9,7 @@ using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Infrastructure.Data;
@ -25,6 +26,7 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -25,6 +26,7 @@ public class PrepareTroubleshootingPlaybackHandler(
IEmbyPathReplacementService embyPathReplacementService,
IFFmpegProcessService ffmpegProcessService,
ILocalFileSystem localFileSystem,
IEntityLocker entityLocker,
ILogger<PrepareTroubleshootingPlaybackHandler> logger)
: IRequestHandler<PrepareTroubleshootingPlayback, Either<BaseError, Command>>
{
@ -45,10 +47,15 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -45,10 +47,15 @@ public class PrepareTroubleshootingPlaybackHandler(
string ffprobePath,
FFmpegProfile ffmpegProfile)
{
string transcodeFolder = Path.Combine(FileSystemLayout.TranscodeFolder, ".troubleshooting");
if (entityLocker.IsTroubleshootingPlaybackLocked())
{
return BaseError.New("Troubleshooting playback is locked");
}
entityLocker.LockTroubleshootingPlayback();
localFileSystem.EnsureFolderExists(transcodeFolder);
localFileSystem.EmptyFolder(transcodeFolder);
localFileSystem.EnsureFolderExists(FileSystemLayout.TranscodeTroubleshootingFolder);
localFileSystem.EmptyFolder(FileSystemLayout.TranscodeTroubleshootingFolder);
ChannelSubtitleMode subtitleMode = ChannelSubtitleMode.None;
@ -108,7 +115,7 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -108,7 +115,7 @@ public class PrepareTroubleshootingPlaybackHandler(
0,
None,
false,
transcodeFolder,
FileSystemLayout.TranscodeTroubleshootingFolder,
_ => { });
return process;

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

@ -1,5 +1,9 @@ @@ -1,5 +1,9 @@
using CliWrap;
using ErsatzTV.Application.MediaItems;
namespace ErsatzTV.Application.Troubleshooting;
public record StartTroubleshootingPlayback(Command Command) : IRequest, IFFmpegWorkerRequest;
public record StartTroubleshootingPlayback(
Command Command,
MediaItemInfo MediaItemInfo,
TroubleshootingInfo TroubleshootingInfo) : IRequest, IFFmpegWorkerRequest;

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

@ -1,33 +1,71 @@ @@ -1,33 +1,71 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using CliWrap;
using CliWrap.Buffered;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Notifications;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Troubleshooting;
public class StartTroubleshootingPlaybackHandler(
IMediator mediator,
IEntityLocker entityLocker,
ILogger<StartTroubleshootingPlaybackHandler> logger)
: IRequestHandler<StartTroubleshootingPlayback>
{
private static readonly JsonSerializerOptions Options = new()
{
Converters = { new JsonStringEnumConverter() },
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true
};
public async Task Handle(StartTroubleshootingPlayback request, CancellationToken cancellationToken)
{
logger.LogDebug("ffmpeg troubleshooting arguments {FFmpegArguments}", request.Command.Arguments);
try
{
// write media info without title
string infoJson = JsonSerializer.Serialize(request.MediaItemInfo with { Title = null }, Options);
await File.WriteAllTextAsync(
Path.Combine(FileSystemLayout.TranscodeTroubleshootingFolder, "media_info.json"),
infoJson,
cancellationToken);
BufferedCommandResult result = await request.Command
.WithValidation(CommandResultValidation.None)
.ExecuteBufferedAsync(cancellationToken);
// write troubleshooting info
string troubleshootingInfoJson = JsonSerializer.Serialize(
new
{
request.TroubleshootingInfo.Version,
Environment = request.TroubleshootingInfo.Environment.OrderBy(x => x.Key)
.ToDictionary(x => x.Key, x => x.Value),
request.TroubleshootingInfo.Health,
request.TroubleshootingInfo.FFmpegSettings,
request.TroubleshootingInfo.FFmpegProfiles,
request.TroubleshootingInfo.Watermarks
},
Options);
await File.WriteAllTextAsync(
Path.Combine(FileSystemLayout.TranscodeTroubleshootingFolder, "troubleshooting_info.json"),
troubleshootingInfoJson,
cancellationToken);
entityLocker.UnlockTroubleshootingPlayback();
logger.LogDebug("ffmpeg troubleshooting arguments {FFmpegArguments}", request.Command.Arguments);
BufferedCommandResult result = await request.Command
.WithValidation(CommandResultValidation.None)
.ExecuteBufferedAsync(cancellationToken);
logger.LogInformation("Troubleshooting playback completed with exit code {ExitCode}", result.ExitCode);
await mediator.Publish(
new PlaybackTroubleshootingCompletedNotification(result.ExitCode),
cancellationToken);
foreach (KeyValuePair<string, string> env in request.Command.EnvironmentVariables)
logger.LogDebug("Troubleshooting playback completed with exit code {ExitCode}", result.ExitCode);
}
finally
{
logger.LogInformation("{Key} => {Value}", env.Key, env.Value);
entityLocker.UnlockTroubleshootingPlayback();
}
// TODO: something with the result ???
}
}

5
ErsatzTV.Application/Troubleshooting/Queries/GetTroubleshootingInfoHandler.cs

@ -76,6 +76,10 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI @@ -76,6 +76,10 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI
.Filter(f => channelFFmpegProfiles.Contains(f.Id))
.ToList();
List<ChannelWatermark> channelWatermarks = await dbContext.ChannelWatermarks
.AsNoTracking()
.ToListAsync(cancellationToken);
string nvidiaCapabilities = null;
string qsvCapabilities = null;
string vaapiCapabilities = null;
@ -155,6 +159,7 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI @@ -155,6 +159,7 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI
ffmpegSettings,
activeFFmpegProfiles,
channels,
channelWatermarks,
nvidiaCapabilities,
qsvCapabilities,
vaapiCapabilities);

7
ErsatzTV.Application/Troubleshooting/TroubleshootingInfo.cs

@ -6,10 +6,11 @@ namespace ErsatzTV.Application.Troubleshooting; @@ -6,10 +6,11 @@ namespace ErsatzTV.Application.Troubleshooting;
public record TroubleshootingInfo(
string Version,
Dictionary<string, string> Environment,
IEnumerable<HealthCheckResultSummary> Health,
List<HealthCheckResultSummary> Health,
FFmpegSettingsViewModel FFmpegSettings,
IEnumerable<FFmpegProfile> FFmpegProfiles,
IEnumerable<Channel> Channels,
List<FFmpegProfile> FFmpegProfiles,
List<Channel> Channels,
List<ChannelWatermark> Watermarks,
string NvidiaCapabilities,
string QsvCapabilities,
string VaapiCapabilities);

2
ErsatzTV.Core/FileSystemLayout.cs

@ -8,6 +8,7 @@ public static class FileSystemLayout @@ -8,6 +8,7 @@ public static class FileSystemLayout
public static readonly string AppDataFolder;
public static readonly string TranscodeFolder;
public static readonly string TranscodeTroubleshootingFolder;
public static readonly string DataProtectionFolder;
public static readonly string LogsFolder;
@ -120,6 +121,7 @@ public static class FileSystemLayout @@ -120,6 +121,7 @@ public static class FileSystemLayout
}
TranscodeFolder = useCustomTranscodeFolder ? customTranscodeFolder : defaultTranscodeFolder;
TranscodeTroubleshootingFolder = Path.Combine(TranscodeFolder, ".troubleshooting");
DataProtectionFolder = Path.Combine(AppDataFolder, "data-protection");
LogsFolder = Path.Combine(AppDataFolder, "logs");

5
ErsatzTV.Core/Notifications/PlaybackTroubleshootingCompletedNotification.cs

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
using MediatR;
namespace ErsatzTV.Core.Notifications;
public record PlaybackTroubleshootingCompletedNotification(int ExitCode) : INotification;

46
ErsatzTV/Controllers/Api/TroubleshootController.cs

@ -1,9 +1,10 @@ @@ -1,9 +1,10 @@
using System.Threading.Channels;
using CliWrap;
using ErsatzTV.Application;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.Troubleshooting;
using ErsatzTV.Application.Troubleshooting.Queries;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Metadata;
using MediatR;
using Microsoft.AspNetCore.Mvc;
@ -12,7 +13,6 @@ namespace ErsatzTV.Controllers.Api; @@ -12,7 +13,6 @@ namespace ErsatzTV.Controllers.Api;
[ApiController]
public class TroubleshootController(
IEntityLocker entityLocker,
ChannelWriter<IFFmpegWorkerRequest> channelWriter,
ILocalFileSystem localFileSystem,
IMediator mediator) : ControllerBase
@ -28,8 +28,6 @@ public class TroubleshootController( @@ -28,8 +28,6 @@ public class TroubleshootController(
int watermark,
CancellationToken cancellationToken)
{
entityLocker.LockTroubleshootingPlayback();
Either<BaseError, Command> result = await mediator.Send(
new PrepareTroubleshootingPlayback(mediaItem, ffmpegProfile, watermark),
cancellationToken);
@ -37,21 +35,41 @@ public class TroubleshootController( @@ -37,21 +35,41 @@ public class TroubleshootController(
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))
Either<BaseError, MediaItemInfo> maybeMediaInfo = await mediator.Send(new GetMediaItemInfo(mediaItem), cancellationToken);
foreach (MediaItemInfo mediaInfo in maybeMediaInfo.RightToSeq())
{
await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken);
if (cancellationToken.IsCancellationRequested)
TroubleshootingInfo troubleshootingInfo = await mediator.Send(
new GetTroubleshootingInfo(),
cancellationToken);
// filter ffmpeg profiles
troubleshootingInfo.FFmpegProfiles.RemoveAll(p => p.Id != ffmpegProfile);
// filter watermarks
troubleshootingInfo.Watermarks.RemoveAll(p => p.Id != watermark);
await channelWriter.WriteAsync(
new StartTroubleshootingPlayback(command, mediaInfo, troubleshootingInfo),
cancellationToken);
string playlistFile = Path.Combine(
FileSystemLayout.TranscodeFolder,
".troubleshooting",
"live.m3u8");
while (!localFileSystem.FileExists(playlistFile))
{
break;
await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken);
if (cancellationToken.IsCancellationRequested)
{
break;
}
}
return Redirect("~/iptv/session/.troubleshooting/live.m3u8");
}
return Redirect("~/iptv/session/.troubleshooting/live.m3u8");
return NotFound();
},
_ => NotFound());
}

29
ErsatzTV/Pages/PlaybackTroubleshooting.razor

@ -2,11 +2,15 @@ @@ -2,11 +2,15 @@
@using ErsatzTV.Application.FFmpegProfiles
@using ErsatzTV.Application.MediaItems
@using ErsatzTV.Application.Watermarks
@using ErsatzTV.Core.Notifications
@using MediatR.Courier
@implements IDisposable
@inject IMediator Mediator
@inject NavigationManager NavigationManager
@inject IJSRuntime JsRuntime
@inject IEntityLocker Locker
@inject ICourier Courier;
@inject ISnackbar Snackbar;
<MudForm Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center">
@ -109,7 +113,11 @@ @@ -109,7 +113,11 @@
_cts.Dispose();
}
protected override void OnInitialized() => Locker.OnTroubleshootingPlaybackChanged += LockChanged;
protected override void OnInitialized()
{
Locker.OnTroubleshootingPlaybackChanged += LockChanged;
Courier.Subscribe<PlaybackTroubleshootingCompletedNotification>(HandleTroubleshootingCompleted);
}
protected override async Task OnParametersSetAsync()
{
@ -126,13 +134,14 @@ @@ -126,13 +134,14 @@
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());
await Task.Delay(TimeSpan.FromSeconds(1));
_hasPlayed = true;
}
private async Task OnMediaItemIdChanged(int? mediaItemId)
@ -162,4 +171,16 @@ @@ -162,4 +171,16 @@
await JsRuntime.InvokeVoidAsync("window.open", $"api/troubleshoot/playback/archive?mediaItem={_mediaItemId ?? 0}&ffmpegProfile={_ffmpegProfileId}&watermark={_watermarkId ?? 0}");
}
private void HandleTroubleshootingCompleted(PlaybackTroubleshootingCompletedNotification result)
{
if (result.ExitCode == 0)
{
Snackbar.Add("FFmpeg troubleshooting process exited successfully", Severity.Success);
}
else
{
Snackbar.Add($"FFmpeg troubleshooting process exited with code {result.ExitCode}", Severity.Warning);
}
}
}

8
ErsatzTV/Pages/_Host.cshtml

@ -58,6 +58,14 @@ @@ -58,6 +58,14 @@
if (Hls.isSupported()) {
var hls = new Hls({
debug: true,
manifestLoadPolicy: {
default: {
maxTimeToFirstByteMs: Infinity,
maxLoadTimeMs: 60000,
timeoutRetry: null,
errorRetry: null
},
},
});
$('#video').data('hls', hls);
hls.loadSource(uri);

Loading…
Cancel
Save