using System.Text; using Bugsnag; using CliWrap; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; using Microsoft.Extensions.Logging; using Newtonsoft.Json; namespace ErsatzTV.Application.Streaming; public class GetLastPtsDurationHandler : IRequestHandler> { private readonly IClient _client; private readonly IConfigElementRepository _configElementRepository; private readonly ILocalFileSystem _localFileSystem; private readonly ILogger _logger; private readonly ITempFilePool _tempFilePool; public GetLastPtsDurationHandler( IClient client, ILocalFileSystem localFileSystem, ITempFilePool tempFilePool, IConfigElementRepository configElementRepository, ILogger logger) { _client = client; _localFileSystem = localFileSystem; _tempFilePool = tempFilePool; _configElementRepository = configElementRepository; _logger = logger; } public async Task> Handle( GetLastPtsDuration request, CancellationToken cancellationToken) { Validation validation = await Validate(request); return await validation.Match( parameters => Handle(parameters, cancellationToken), error => Task.FromResult>(error.Join())); } private async Task> Validate(GetLastPtsDuration request) => await ValidateFFprobePath().MapT(ffprobePath => new RequestParameters(request.ChannelNumber, ffprobePath)); private async Task> Handle( RequestParameters parameters, CancellationToken cancellationToken) { Option maybeLastSegment = GetLastSegment(parameters.ChannelNumber); foreach (FileInfo segment in maybeLastSegment) { string[] argumentList = { "-v", "0", "-show_entries", "packet=pts,duration", "-of", "compact=p=0:nk=1", // "-read_intervals", "999999", // read_intervals causes inconsistent behavior on windows segment.FullName }; string lastLine = string.Empty; Action replaceLine = s => { if (!string.IsNullOrWhiteSpace(s)) { lastLine = s.Trim(); } }; CommandResult probe = await Cli.Wrap(parameters.FFprobePath) .WithArguments(argumentList) .WithValidation(CommandResultValidation.None) .WithStandardOutputPipe(PipeTarget.ToDelegate(replaceLine)) .ExecuteAsync(cancellationToken); if (probe.ExitCode != 0) { return BaseError.New($"FFprobe at {parameters.FFprobePath} exited with code {probe.ExitCode}"); } try { return PtsAndDuration.From(lastLine); } catch (Exception ex) { _client.Notify(ex); await SaveTroubleshootingData(parameters.ChannelNumber, lastLine); } } return BaseError.New($"Failed to determine last pts duration for channel {parameters.ChannelNumber}"); } private static Option GetLastSegment(string channelNumber) { var directory = new DirectoryInfo(Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber)); return Optional(directory.GetFiles("*.ts").OrderByDescending(f => f.Name).FirstOrDefault()); } private Task> ValidateFFprobePath() => _configElementRepository.GetValue(ConfigElementKey.FFprobePath) .FilterT(File.Exists) .Map(ffprobePath => ffprobePath.ToValidation("FFprobe path does not exist on the file system")); private async Task SaveTroubleshootingData(string channelNumber, string output) { try { var directory = new DirectoryInfo(Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber)); FileInfo[] allFiles = directory.GetFiles(); string playlistFileName = Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live.m3u8"); string playlistContents = string.Empty; if (_localFileSystem.FileExists(playlistFileName)) { playlistContents = await File.ReadAllTextAsync(playlistFileName); } var data = new TroubleshootingData(allFiles, playlistContents, output); string serialized = data.Serialize(); string file = _tempFilePool.GetNextTempFile(TempFileCategory.BadTranscodeFolder); await File.WriteAllTextAsync(file, serialized); _logger.LogWarning("Transcode folder is in bad state; troubleshooting info saved to {File}", file); } catch (Exception ex) { _client.Notify(ex); } } private sealed record RequestParameters(string ChannelNumber, string FFprobePath); private sealed record TroubleshootingData(IEnumerable Files, string Playlist, string ProbeOutput) { public string Serialize() { var data = new InternalData( Files.Map(f => new FileData(f.FullName, f.Length, f.LastWriteTimeUtc)).ToList(), Convert.ToBase64String(Encoding.UTF8.GetBytes(Playlist)), Convert.ToBase64String(Encoding.UTF8.GetBytes(ProbeOutput))); return JsonConvert.SerializeObject(data); } private sealed record FileData(string FileName, long Bytes, DateTime LastWriteTimeUtc); private sealed record InternalData(List Files, string EncodedPlaylist, string EncodedProbeOutput); } }