Files
ersatztv/ErsatzTV/Controllers/Api/TroubleshootController.cs
T
timothyandClaude Fable 5 24a26cbe34
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m37s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix: address #165 review findings (stale-response guard, security-test coverage, route/help-text nits)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 13:55:05 +02:00

263 lines
9.5 KiB
C#

using System.IO.Abstractions;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.Troubleshooting;
using ErsatzTV.Application.Troubleshooting.Queries;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Troubleshooting;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Troubleshooting;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Serilog.Context;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class TroubleshootController(
ChannelWriter<IFFmpegWorkerRequest> channelWriter,
IFileSystem fileSystem,
IConfigElementRepository configElementRepository,
ITroubleshootingNotifier notifier,
IMediator mediator) : ControllerBase
{
private static readonly JsonSerializerOptions GeneralJsonOptions = new()
{
Converters = { new JsonStringEnumConverter() },
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true
};
[HttpGet("api/troubleshoot/info", Name = "GetTroubleshootingInfo")]
[Tags("Troubleshooting")]
[EndpointSummary("Get troubleshooting diagnostic info")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(TroubleshootingInfoResponseModel), StatusCodes.Status200OK)]
public async Task<TroubleshootingInfoResponseModel> GetInfo(CancellationToken cancellationToken)
{
TroubleshootingInfo info = await mediator.Send(new GetTroubleshootingInfo(), cancellationToken);
// mirrors the "General" tab JSON blob built by Pages/Troubleshooting/Troubleshooting.razor
string generalJson = JsonSerializer.Serialize(
new
{
info.Version,
Environment = info.Environment.OrderBy(x => x.Key).ToDictionary(x => x.Key, x => x.Value),
info.Cpus,
info.VideoControllers,
info.Health,
info.FFmpegSettings,
AviSynth = new
{
Demuxer = info.AviSynthDemuxer,
Installed = info.AviSynthInstalled
},
info.Channels,
info.FFmpegProfiles
},
GeneralJsonOptions);
return new TroubleshootingInfoResponseModel(
generalJson,
info.NvidiaCapabilities,
info.QsvCapabilities,
info.VaapiCapabilities,
info.VideoToolboxCapabilities);
}
[HttpHead("api/troubleshoot/playback.m3u8")]
[HttpGet("api/troubleshoot/playback.m3u8")]
[Tags("Troubleshooting")]
[EndpointSummary("Start a troubleshooting playback session")]
[EndpointGroupName("general")]
public async Task<IActionResult> TroubleshootPlayback(
[FromQuery]
int mediaItem,
[FromQuery]
int channel,
[FromQuery]
int ffmpegProfile,
[FromQuery]
StreamingMode streamingMode,
[FromQuery]
List<int> watermark,
[FromQuery]
List<int> graphicsElement,
[FromQuery]
string streamSelector,
[FromQuery]
int? subtitleId,
[FromQuery]
int seekSeconds,
[FromQuery]
DateTimeOffset? start,
CancellationToken cancellationToken)
{
var sessionId = Guid.NewGuid();
using var logContext = LogContext.PushProperty(InMemoryLogService.CorrelationIdKey, sessionId);
try
{
Option<int> ss = seekSeconds > 0 ? seekSeconds : Option<int>.None;
Either<BaseError, PlayoutItemResult> result = await mediator.Send(
new PrepareTroubleshootingPlayback(
sessionId,
streamingMode,
mediaItem,
channel,
ffmpegProfile,
streamSelector,
watermark,
graphicsElement,
subtitleId,
ss,
Optional(start)),
cancellationToken);
if (result.IsLeft)
{
return NotFound();
}
foreach (PlayoutItemResult playoutItemResult in result.RightToSeq())
{
Either<BaseError, MediaItemInfo> maybeMediaInfo =
await mediator.Send(
new GetMediaItemInfo(await playoutItemResult.MediaItemId.IfNoneAsync(0)),
cancellationToken);
try
{
TroubleshootingInfo troubleshootingInfo = await mediator.Send(
new GetTroubleshootingInfo(),
cancellationToken);
// filter ffmpeg profiles
troubleshootingInfo.FFmpegProfiles.RemoveAll(p => p.Id != ffmpegProfile);
// filter watermarks
troubleshootingInfo.Watermarks.RemoveAll(p => !watermark.Contains(p.Id));
await channelWriter.WriteAsync(
new StartTroubleshootingPlayback(
sessionId,
streamSelector,
playoutItemResult,
maybeMediaInfo.ToOption(),
troubleshootingInfo),
cancellationToken);
string playlistFile = Path.Combine(FileSystemLayout.TranscodeTroubleshootingFolder, "live.m3u8");
while (!fileSystem.File.Exists(playlistFile))
{
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
if (cancellationToken.IsCancellationRequested || notifier.IsFailed(sessionId))
{
break;
}
}
int initialSegmentCount = await configElementRepository
.GetValue<int>(ConfigElementKey.FFmpegInitialSegmentCount, cancellationToken)
.Map(maybeCount => maybeCount.Match(c => c, () => 1));
initialSegmentCount = Math.Max(initialSegmentCount, 2);
bool hasSegments = false;
while (!hasSegments)
{
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
string[] segmentFiles = streamingMode switch
{
// StreamingMode.HttpLiveStreamingSegmenter => Directory.GetFiles(
// FileSystemLayout.TranscodeTroubleshootingFolder,
// "*.m4s"),
_ => Directory.GetFiles(FileSystemLayout.TranscodeTroubleshootingFolder, "*.ts")
};
if (segmentFiles.Length >= initialSegmentCount)
{
hasSegments = true;
}
}
if (!notifier.IsFailed(sessionId))
{
return Redirect("~/iptv/session/.troubleshooting/live.m3u8");
}
}
finally
{
notifier.RemoveSession(sessionId);
}
}
}
catch (Exception)
{
// do nothing
}
return NotFound();
}
[HttpHead("api/troubleshoot/playback/archive")]
[HttpGet("api/troubleshoot/playback/archive")]
[Tags("Troubleshooting")]
[EndpointSummary("Download the last troubleshooting playback session archive")]
[EndpointGroupName("general")]
public async Task<IActionResult> TroubleshootPlaybackArchive(CancellationToken cancellationToken)
{
Option<string> maybeArchivePath = await mediator.Send(new ArchiveTroubleshootingResults(), cancellationToken);
foreach (string archivePath in maybeArchivePath)
{
var fs = new FileStream(
archivePath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
4096,
FileOptions.DeleteOnClose);
return File(
fs,
"application/zip",
$"ersatztv-troubleshooting-{DateTimeOffset.Now.ToUnixTimeSeconds()}.zip");
}
return NotFound();
}
[HttpHead("api/troubleshoot/playback/sample/{mediaItemId:int}")]
[HttpGet("api/troubleshoot/playback/sample/{mediaItemId:int}")]
[Tags("Troubleshooting")]
[EndpointSummary("Download a media sample archive for troubleshooting")]
[EndpointGroupName("general")]
public async Task<IActionResult> TroubleshootPlaybackSample(int mediaItemId, CancellationToken cancellationToken)
{
Option<string> maybeArchivePath = await mediator.Send(new ArchiveMediaSample(mediaItemId), cancellationToken);
foreach (string archivePath in maybeArchivePath)
{
var fs = new FileStream(
archivePath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
4096,
FileOptions.DeleteOnClose);
return File(
fs,
"application/zip",
$"ersatztv-media-sample-{DateTimeOffset.Now.ToUnixTimeSeconds()}.zip");
}
return NotFound();
}
}