diff --git a/src/LiveRecorder.Infrastructure/Services/FfmpegService.Runtime.cs b/src/LiveRecorder.Infrastructure/Services/FfmpegService.Runtime.cs index 94efd90..cdf51d8 100644 --- a/src/LiveRecorder.Infrastructure/Services/FfmpegService.Runtime.cs +++ b/src/LiveRecorder.Infrastructure/Services/FfmpegService.Runtime.cs @@ -28,6 +28,7 @@ public sealed partial class FfmpegService return; } + runtime.RememberOutputLine(line, isError); _logger.LogDebug("ffmpeg[{SessionId}] {Line}", runtime.RecordSessionId, line); if (TryParseSegmentOpenPath(line, out var openedPath)) { @@ -921,9 +922,7 @@ public sealed partial class FfmpegService SystemLogLevel.Error, "FFmpeg", $"Recording session exited with status={session.Status}.", - toleratedNonZeroExit - ? $"exitCode={process.ExitCode}; output={effectiveOutputPath}; recorderOutput={runtime.RecorderOutputPath}; non-zero exit was tolerated because completion was requested and the output is usable" - : $"exitCode={process.ExitCode}; output={effectiveOutputPath}; recorderOutput={runtime.RecorderOutputPath}", + BuildExitLogDetail(runtime, process.ExitCode, effectiveOutputPath, toleratedNonZeroExit, finalizationError), session.LiveRoomId, session.Id, currentTask.Id); @@ -935,21 +934,60 @@ public sealed partial class FfmpegService if (session.Status == RecordSessionStatus.Failed && session.LiveRoom is not null) { + var failureDetail = BuildExitLogDetail(runtime, process.ExitCode, effectiveOutputPath, toleratedNonZeroExit: false, finalizationError); await emailNotificationService.SendExceptionAsync( "FFmpeg", "Recording session exited abnormally.", - $"exitCode={process.ExitCode}; output={effectiveOutputPath}", + failureDetail, session.LiveRoom, currentTask); await webhookNotificationService.SendExceptionAsync( "FFmpeg", "Recording session exited abnormally.", - $"exitCode={process.ExitCode}; output={effectiveOutputPath}", + failureDetail, session.LiveRoom, currentTask); } } + private static string BuildExitLogDetail( + SessionProcessRuntime runtime, + int exitCode, + string? effectiveOutputPath, + bool toleratedNonZeroExit, + string? finalizationError) + { + var parts = new List + { + $"exitCode={exitCode}", + $"output={effectiveOutputPath}", + $"recorderOutput={runtime.RecorderOutputPath}" + }; + + if (toleratedNonZeroExit) + { + parts.Add("non-zero exit was tolerated because completion was requested and the output is usable"); + } + + if (!string.IsNullOrWhiteSpace(finalizationError)) + { + parts.Add($"finalizationError={finalizationError}"); + } + + if (!string.IsNullOrWhiteSpace(runtime.LastStartupFailureLine)) + { + parts.Add($"startupFailure={runtime.LastStartupFailureLine}"); + } + + var recentOutput = runtime.GetRecentOutputSummary(); + if (!string.IsNullOrWhiteSpace(recentOutput)) + { + parts.Add($"recentOutput={recentOutput}"); + } + + return string.Join("; ", parts); + } + private async Task FinalizeCompletedSegmentAsync( Guid recordSessionId, Guid recordTaskId, @@ -1566,6 +1604,8 @@ public sealed partial class FfmpegService public Task? DanmakuPumpTask { get; set; } private List CurrentRecorderSegmentPaths { get; } = []; private object RuntimeSourceFailureSync { get; } = new(); + private object RecentOutputSync { get; } = new(); + private Queue RecentOutputLines { get; } = new(); private DateTimeOffset RuntimeSourceFailureWindowStartedAt { get; set; } private int RuntimeSourceFailureCount { get; set; } private bool RuntimeSourceFailureVerificationInProgress { get; set; } @@ -1600,6 +1640,28 @@ public sealed partial class FfmpegService public void MarkForceKilled() => WasForceKilled = true; + public void RememberOutputLine(string line, bool isError) + { + lock (RecentOutputSync) + { + RecentOutputLines.Enqueue($"{(isError ? "stderr" : "stdout")}={line.Trim()}"); + while (RecentOutputLines.Count > 8) + { + RecentOutputLines.Dequeue(); + } + } + } + + public string? GetRecentOutputSummary() + { + lock (RecentOutputSync) + { + return RecentOutputLines.Count == 0 + ? null + : string.Join(" | ", RecentOutputLines); + } + } + public void ResetCurrentRecorderSegmentPaths(string openedPath) { CurrentRecorderSegmentPaths.Clear();