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 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 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 TroubleshootPlayback( [FromQuery] int mediaItem, [FromQuery] int channel, [FromQuery] int ffmpegProfile, [FromQuery] StreamingMode streamingMode, [FromQuery] List watermark, [FromQuery] List 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 ss = seekSeconds > 0 ? seekSeconds : Option.None; Either 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 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(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 TroubleshootPlaybackArchive(CancellationToken cancellationToken) { Option 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 TroubleshootPlaybackSample(int mediaItemId, CancellationToken cancellationToken) { Option 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(); } }