using System.Diagnostics; using System.Globalization; using System.Text; using LiveRecorder.Application.Abstractions.Platforms; using LiveRecorder.Domain.Entities; using LiveRecorder.Domain.Enums; using LiveRecorder.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace LiveRecorder.Infrastructure.Services; public sealed partial class FfmpegService { private const string LowStoragePauseErrorPrefix = "MP4 finalization paused because storage is below threshold."; private static readonly TimeSpan Mp4FinalizeInactivityTimeout = TimeSpan.FromMinutes(10); private static readonly TimeSpan Mp4FinalizePollInterval = TimeSpan.FromSeconds(1); private async Task<(string OutputPath, string? ErrorMessage)> TryFinalizeMp4Async( string ffmpegPath, int maxConcurrentTranscodeTasks, int mp4FinalizeTimeoutMinutes, Guid recordSessionId, Guid recordTaskId, string sourcePath, string targetPath, double? expectedDurationSeconds, CancellationToken cancellationToken) { if (!File.Exists(sourcePath)) { return (File.Exists(targetPath) ? targetPath : sourcePath, "The intermediate recording file was not found for MP4 finalization."); } var tempPath = Path.Combine( Path.GetDirectoryName(targetPath)!, $"{Path.GetFileNameWithoutExtension(targetPath)}.remux{Path.GetExtension(targetPath)}"); if (File.Exists(tempPath)) { File.Delete(tempPath); } async Task GetLowStoragePauseMessageAsync() { using var storageScope = _serviceScopeFactory.CreateScope(); var settingsService = storageScope.ServiceProvider.GetRequiredService(); var settings = await settingsService.GetAsync(cancellationToken); var sourceSizeBytes = new FileInfo(sourcePath).Length; var storageCheck = _storageGuardService.CheckCanStartOrResume(settings, sourceSizeBytes); return storageCheck.HasEnoughSpace ? null : $"{LowStoragePauseErrorPrefix} {storageCheck.Message}"; } var lowStoragePauseMessage = await GetLowStoragePauseMessageAsync(); if (!string.IsNullOrWhiteSpace(lowStoragePauseMessage)) { SetPostProcessState(recordSessionId, recordTaskId, "Paused: Low storage", null, lowStoragePauseMessage); return (sourcePath, lowStoragePauseMessage); } SetPostProcessState( recordSessionId, recordTaskId, "Queued", null, $"Waiting for an available ffmpeg transcode slot (max {Math.Clamp(maxConcurrentTranscodeTasks, 1, 16)})."); using var transcodeSlot = await AcquireTranscodeSlotAsync(maxConcurrentTranscodeTasks, cancellationToken); lowStoragePauseMessage = await GetLowStoragePauseMessageAsync(); if (!string.IsNullOrWhiteSpace(lowStoragePauseMessage)) { SetPostProcessState(recordSessionId, recordTaskId, "Paused: Low storage", null, lowStoragePauseMessage); return (sourcePath, lowStoragePauseMessage); } var stderrLines = new Queue(); var progressSync = new object(); double? processedSeconds = null; double? lastReportedProgressPercent = null; string? lastReportedStage = null; string? lastReportedDetail = null; var attemptStartedAt = DateTimeOffset.UtcNow; var activeStage = "Finalizing MP4"; var maxDuration = TimeSpan.FromMinutes(Math.Clamp(mp4FinalizeTimeoutMinutes, 1, 1440)); long lastActivityTicks = attemptStartedAt.UtcTicks; void TouchActivity() { System.Threading.Interlocked.Exchange(ref lastActivityTicks, DateTimeOffset.UtcNow.UtcTicks); } void ReportProgress(double? seconds, string? stageOverride = null) { lock (progressSync) { TouchActivity(); if (seconds.HasValue) { processedSeconds = seconds; } double? progressPercent = null; string? detail = null; if (expectedDurationSeconds.HasValue && expectedDurationSeconds.Value > 0 && processedSeconds.HasValue) { progressPercent = Math.Clamp(processedSeconds.Value / expectedDurationSeconds.Value * 100d, 0d, 99d); detail = $"Processed {processedSeconds.Value:F1}s / {expectedDurationSeconds.Value:F1}s"; } else if (processedSeconds.HasValue) { detail = $"Processed {processedSeconds.Value:F1}s"; } var effectiveStage = stageOverride ?? activeStage; var normalizedProgressPercent = progressPercent.HasValue ? Math.Round(progressPercent.Value, 1, MidpointRounding.AwayFromZero) : (double?)null; var effectiveDetail = detail ?? $"Optimizing MP4 index for {Path.GetFileName(targetPath)}"; if (string.Equals(effectiveStage, lastReportedStage, StringComparison.Ordinal) && string.Equals(effectiveDetail, lastReportedDetail, StringComparison.Ordinal) && Nullable.Equals(normalizedProgressPercent, lastReportedProgressPercent)) { return; } lastReportedStage = effectiveStage; lastReportedDetail = effectiveDetail; lastReportedProgressPercent = normalizedProgressPercent; SetPostProcessState( recordSessionId, recordTaskId, effectiveStage, normalizedProgressPercent, effectiveDetail); } } string? GetErrorDetail() { lock (stderrLines) { return stderrLines.Count == 0 ? null : string.Join(" | ", stderrLines); } } void ClearErrorDetail() { lock (stderrLines) { stderrLines.Clear(); } } async Task RunFinalizeAttemptAsync( Mp4FinalizeStrategy strategy, string stage, string detail) { if (File.Exists(tempPath)) { File.Delete(tempPath); } ClearErrorDetail(); activeStage = stage; processedSeconds = null; lastReportedProgressPercent = null; lastReportedStage = null; lastReportedDetail = null; attemptStartedAt = DateTimeOffset.UtcNow; System.Threading.Interlocked.Exchange(ref lastActivityTicks, attemptStartedAt.UtcTicks); SetPostProcessState(recordSessionId, recordTaskId, stage, 0, detail); using var finalizeProcess = new Process { StartInfo = new ProcessStartInfo { FileName = ffmpegPath, UseShellExecute = false, RedirectStandardError = true, RedirectStandardOutput = true, CreateNoWindow = true } }; foreach (var argument in BuildMp4FinalizeArgumentList(sourcePath, tempPath, strategy)) { finalizeProcess.StartInfo.ArgumentList.Add(argument); } finalizeProcess.OutputDataReceived += (_, args) => { if (string.IsNullOrWhiteSpace(args.Data)) { return; } TouchActivity(); if (TryParseFfmpegProgressSeconds(args.Data, out var seconds)) { ReportProgress(seconds); return; } if (args.Data.StartsWith("progress=", StringComparison.OrdinalIgnoreCase)) { ReportProgress(processedSeconds); } }; finalizeProcess.ErrorDataReceived += (_, args) => { if (string.IsNullOrWhiteSpace(args.Data)) { return; } TouchActivity(); _logger.LogDebug("ffmpeg-postprocess[{RecordTaskId}] {Line}", recordTaskId, args.Data); lock (stderrLines) { stderrLines.Enqueue(args.Data.Trim()); while (stderrLines.Count > 10) { stderrLines.Dequeue(); } } }; try { finalizeProcess.Start(); finalizeProcess.BeginOutputReadLine(); finalizeProcess.BeginErrorReadLine(); while (!finalizeProcess.HasExited) { cancellationToken.ThrowIfCancellationRequested(); var now = DateTimeOffset.UtcNow; var lastActivityAt = new DateTimeOffset(System.Threading.Interlocked.Read(ref lastActivityTicks), TimeSpan.Zero); if (now - attemptStartedAt > maxDuration) { throw new TimeoutException($"MP4 finalization exceeded the maximum allowed duration of {maxDuration.TotalMinutes:F0} minutes."); } if (now - lastActivityAt > Mp4FinalizeInactivityTimeout) { throw new TimeoutException($"MP4 finalization did not report progress for more than {Mp4FinalizeInactivityTimeout.TotalMinutes:F0} minutes."); } await Task.Delay(Mp4FinalizePollInterval, cancellationToken); } await finalizeProcess.WaitForExitAsync(cancellationToken); } catch (TimeoutException ex) { try { if (!finalizeProcess.HasExited) { finalizeProcess.Kill(true); } } catch (Exception killEx) { _logger.LogWarning(killEx, "Timed-out MP4 finalization process could not be killed for task {RecordTaskId}", recordTaskId); } return ex.Message; } catch (Exception ex) { try { if (!finalizeProcess.HasExited) { finalizeProcess.Kill(true); } } catch (Exception killEx) { _logger.LogWarning(killEx, "Failed MP4 finalization process could not be killed for task {RecordTaskId}", recordTaskId); } return ex.Message; } if (finalizeProcess.ExitCode == 0 && File.Exists(tempPath)) { return null; } if (File.Exists(tempPath)) { File.Delete(tempPath); } return GetErrorDetail() ?? $"ffmpeg exited with code {finalizeProcess.ExitCode}."; } var finalizationError = await RunFinalizeAttemptAsync( Mp4FinalizeStrategy.StreamCopy, "Finalizing MP4", $"Optimizing MP4 index for {Path.GetFileName(targetPath)}"); if (IsNoSpaceLeftError(finalizationError)) { finalizationError = $"{LowStoragePauseErrorPrefix} {finalizationError}"; } if (!string.IsNullOrWhiteSpace(finalizationError) && IsRepairableMp4FinalizeError(finalizationError)) { var repairError = await RunFinalizeAttemptAsync( Mp4FinalizeStrategy.RepairTranscode, "Repairing MP4", $"Repairing stream metadata for {Path.GetFileName(targetPath)}"); finalizationError = string.IsNullOrWhiteSpace(repairError) ? null : IsNoSpaceLeftError(repairError) ? $"{LowStoragePauseErrorPrefix} {repairError}" : $"{finalizationError} | fallback repair failed: {repairError}"; } if (string.IsNullOrWhiteSpace(finalizationError) && File.Exists(tempPath)) { ReportProgress(expectedDurationSeconds, "Writing MP4 index"); if (File.Exists(targetPath)) { File.Replace(tempPath, targetPath, null, ignoreMetadataErrors: true); } else { File.Move(tempPath, targetPath); } if (!string.Equals(sourcePath, targetPath, StringComparison.OrdinalIgnoreCase) && File.Exists(sourcePath)) { File.Delete(sourcePath); } SetPostProcessState(recordSessionId, recordTaskId, "Completed", 100, "MP4 seek index is ready"); return (targetPath, null); } if (File.Exists(tempPath)) { File.Delete(tempPath); } var fallbackPath = File.Exists(targetPath) ? targetPath : sourcePath; string? errorDetail; lock (stderrLines) { errorDetail = stderrLines.Count == 0 ? null : string.Join(" | ", stderrLines); } return ( fallbackPath, string.IsNullOrWhiteSpace(finalizationError ?? errorDetail) ? "The MP4 file could not be finalized into a seekable output." : $"The MP4 file could not be finalized into a seekable output. {finalizationError ?? errorDetail}"); } private async Task<(string OutputPath, string? ErrorMessage)> TryFinalizeTaskOutputAsync( string ffmpegPath, int maxConcurrentTranscodeTasks, int mp4FinalizeTimeoutMinutes, RecordSession recordSession, RecordTask recordTask, double? expectedDurationSeconds, CancellationToken cancellationToken = default) => await TryFinalizeTaskOutputAsync( ffmpegPath, maxConcurrentTranscodeTasks, mp4FinalizeTimeoutMinutes, recordSession, recordTask, expectedDurationSeconds, recorderSegmentPaths: null, cancellationToken); private async Task<(string OutputPath, string? ErrorMessage)> TryFinalizeTaskOutputAsync( string ffmpegPath, int maxConcurrentTranscodeTasks, int mp4FinalizeTimeoutMinutes, RecordSession recordSession, RecordTask recordTask, double? expectedDurationSeconds, IReadOnlyList? recorderSegmentPaths, CancellationToken cancellationToken = default) { var fallbackOutputPath = recordTask.OutputFilePath ?? string.Empty; if (recordSession.OutputFormat != RecordOutputFormat.Mp4) { return (fallbackOutputPath, null); } if (recordSession.SaveMode == RecordSaveMode.SingleFile) { if (string.IsNullOrWhiteSpace(recordSession.OutputPathPattern)) { return (fallbackOutputPath, null); } var finalOutputPath = NormalizeAbsolutePath(recordSession.OutputPathPattern); var recorderOutputPath = GetRecorderOutputPath(finalOutputPath, recordSession.OutputFormat, recordSession.SaveMode); if (!File.Exists(recorderOutputPath)) { return (File.Exists(finalOutputPath) ? finalOutputPath : fallbackOutputPath, null); } return await TryFinalizeMp4Async( ffmpegPath, maxConcurrentTranscodeTasks, mp4FinalizeTimeoutMinutes, recordSession.Id, recordTask.Id, recorderOutputPath, finalOutputPath, expectedDurationSeconds, cancellationToken); } if (string.IsNullOrWhiteSpace(recordTask.OutputFilePath)) { return (fallbackOutputPath, null); } var finalSegmentOutputPath = NormalizeAbsolutePath(recordTask.OutputFilePath); var normalizedRecorderSegmentPaths = recorderSegmentPaths? .Where(static item => !string.IsNullOrWhiteSpace(item)) .Select(NormalizeAbsolutePath) .Distinct(StringComparer.OrdinalIgnoreCase) .Where(File.Exists) .ToArray(); if (normalizedRecorderSegmentPaths is null || normalizedRecorderSegmentPaths.Length == 0) { normalizedRecorderSegmentPaths = [ NormalizeAbsolutePath( GetRecorderOutputPath(finalSegmentOutputPath, recordSession.OutputFormat, recordSession.SaveMode)) ]; } normalizedRecorderSegmentPaths = normalizedRecorderSegmentPaths .Where(File.Exists) .ToArray(); if (normalizedRecorderSegmentPaths.Length == 0) { return (File.Exists(finalSegmentOutputPath) ? finalSegmentOutputPath : recordTask.OutputFilePath, null); } string? materializedSourcePath = null; var cleanupMaterializedSource = false; var preserveMaterializedSource = false; try { materializedSourcePath = await MaterializeRecorderSegmentSourceAsync( finalSegmentOutputPath, normalizedRecorderSegmentPaths, cancellationToken); if (string.IsNullOrWhiteSpace(materializedSourcePath)) { return (File.Exists(finalSegmentOutputPath) ? finalSegmentOutputPath : recordTask.OutputFilePath, null); } cleanupMaterializedSource = !string.Equals(materializedSourcePath, normalizedRecorderSegmentPaths[0], StringComparison.OrdinalIgnoreCase); var finalizationResult = await TryFinalizeMp4Async( ffmpegPath, maxConcurrentTranscodeTasks, mp4FinalizeTimeoutMinutes, recordSession.Id, recordTask.Id, materializedSourcePath, finalSegmentOutputPath, expectedDurationSeconds, cancellationToken); preserveMaterializedSource = IsLowStoragePauseError(finalizationResult.ErrorMessage); return finalizationResult; } finally { if (cleanupMaterializedSource && !preserveMaterializedSource && !string.IsNullOrWhiteSpace(materializedSourcePath) && File.Exists(materializedSourcePath)) { File.Delete(materializedSourcePath); } } } private static async Task MaterializeRecorderSegmentSourceAsync( string finalOutputPath, IReadOnlyList recorderSegmentPaths, CancellationToken cancellationToken) { if (recorderSegmentPaths.Count == 0) { return null; } if (recorderSegmentPaths.Count == 1) { return recorderSegmentPaths[0]; } var combinedPath = Path.Combine( Path.GetDirectoryName(finalOutputPath)!, $"{Path.GetFileNameWithoutExtension(finalOutputPath)}.concat.ts"); if (File.Exists(combinedPath)) { File.Delete(combinedPath); } await using var outputStream = new FileStream( combinedPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, bufferSize: 1024 * 128, useAsync: true); foreach (var path in recorderSegmentPaths) { await using var inputStream = new FileStream( path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, bufferSize: 1024 * 128, useAsync: true); await inputStream.CopyToAsync(outputStream, 1024 * 128, cancellationToken); } await outputStream.FlushAsync(cancellationToken); return combinedPath; } private static IReadOnlyList BuildMp4FinalizeArgumentList( string sourcePath, string targetPath, Mp4FinalizeStrategy strategy) { var arguments = new List { "-hide_banner", "-y", "-nostats", "-progress", "pipe:1", "-analyzeduration", "100M", "-probesize", "100M", "-fflags", "+genpts+igndts+discardcorrupt", "-err_detect", "ignore_err", "-i", sourcePath, "-map", "0:v:0", "-map", "0:a:0?", "-dn", "-sn" }; if (strategy == Mp4FinalizeStrategy.RepairTranscode) { arguments.AddRange( [ "-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-c:a", "aac", "-b:a", "128k" ]); } else { arguments.AddRange(["-c", "copy"]); } arguments.AddRange( [ "-movflags", "+faststart", "-avoid_negative_ts", "make_zero", targetPath ]); return arguments; } private static bool IsRepairableMp4FinalizeError(string errorDetail) { if (IsLowStoragePauseError(errorDetail)) { return false; } return errorDetail.Contains("dimensions not set", StringComparison.OrdinalIgnoreCase) || errorDetail.Contains("Could not write header", StringComparison.OrdinalIgnoreCase) || errorDetail.Contains("incorrect codec parameters", StringComparison.OrdinalIgnoreCase) || errorDetail.Contains("codec parameters", StringComparison.OrdinalIgnoreCase) || errorDetail.Contains("Invalid data found when processing input", StringComparison.OrdinalIgnoreCase) || errorDetail.Contains("Error initializing output stream", StringComparison.OrdinalIgnoreCase); } private static bool IsLowStoragePauseError(string? errorDetail) => !string.IsNullOrWhiteSpace(errorDetail) && errorDetail.Contains(LowStoragePauseErrorPrefix, StringComparison.OrdinalIgnoreCase); private static bool IsNoSpaceLeftError(string? errorDetail) => !string.IsNullOrWhiteSpace(errorDetail) && (errorDetail.Contains("No space left on device", StringComparison.OrdinalIgnoreCase) || errorDetail.Contains("database or disk is full", StringComparison.OrdinalIgnoreCase)); private enum Mp4FinalizeStrategy { StreamCopy, RepairTranscode } private static IReadOnlyList BuildArgumentList( string streamUrl, string outputFilePath, RecordOutputFormat outputFormat, RecordSaveMode saveMode, RecordingTemplateType recordingTemplate, bool enableReconnect, int reconnectDelayMaxSeconds, int readWriteTimeoutMilliseconds, int segmentDurationMinutes, StreamInputHeaders? inputHeaders, string? selectedProtocol, string? selectedVideoCodec, FfmpegInputOptionProfile inputOptionProfile) { var arguments = new List { "-hide_banner", "-y" }; var useIntermediateTransportStream = ShouldUseIntermediateTransportStream(outputFilePath, outputFormat, saveMode); if (IsHttpInput(streamUrl)) { if (!string.IsNullOrWhiteSpace(inputHeaders?.UserAgent)) { arguments.AddRange(["-user_agent", inputHeaders.UserAgent]); } if (!string.IsNullOrWhiteSpace(inputHeaders?.Referer)) { arguments.AddRange(["-referer", inputHeaders.Referer]); } var customHeaders = BuildCustomHeaderArgument(inputHeaders); if (!string.IsNullOrWhiteSpace(customHeaders)) { arguments.AddRange(["-headers", customHeaders]); } } if (enableReconnect && inputOptionProfile == FfmpegInputOptionProfile.Baseline && ShouldEnableReconnect(streamUrl, selectedProtocol)) { arguments.AddRange( [ "-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_at_eof", "1", "-reconnect_delay_max", reconnectDelayMaxSeconds.ToString() ]); } arguments.AddRange(["-rw_timeout", readWriteTimeoutMilliseconds.ToString(), "-fflags", "+discardcorrupt+genpts", "-i", streamUrl]); arguments.AddRange(BuildCodecArguments(recordingTemplate)); var writesTransportStream = useIntermediateTransportStream || outputFormat == RecordOutputFormat.Ts; var bitstreamFilter = ResolveTransportStreamBitstreamFilter(recordingTemplate, writesTransportStream, selectedVideoCodec); if (!string.IsNullOrWhiteSpace(bitstreamFilter)) { arguments.AddRange(["-bsf:v", bitstreamFilter]); } if (saveMode == RecordSaveMode.Segmented) { var segmentFormat = writesTransportStream ? "mpegts" : "mp4"; arguments.AddRange( [ "-f", "segment", "-segment_start_number", "1", "-segment_time", Math.Max(60, segmentDurationMinutes * 60).ToString(), "-break_non_keyframes", "0", "-reset_timestamps", "1", "-strftime", "0", "-segment_format", segmentFormat ]); if (!useIntermediateTransportStream && outputFormat == RecordOutputFormat.Mp4) { arguments.AddRange(["-segment_format_options", $"movflags={BuildSegmentedMp4MovFlags()}"]); } } else if (useIntermediateTransportStream) { arguments.AddRange(["-f", "mpegts"]); } else if (outputFormat == RecordOutputFormat.Mp4) { arguments.AddRange(["-movflags", BuildSingleFileMp4MovFlags()]); } arguments.Add(outputFilePath); return arguments; } private static IReadOnlyList BuildCodecArguments(RecordingTemplateType recordingTemplate) => recordingTemplate switch { RecordingTemplateType.BalancedMp4 => [ "-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-c:a", "aac", "-b:a", "128k" ], RecordingTemplateType.ArchiveTs => [ "-map", "0", "-c", "copy" ], _ => [ "-c", "copy" ] }; private static string? ResolveTransportStreamBitstreamFilter( RecordingTemplateType recordingTemplate, bool writesTransportStream, string? selectedVideoCodec) { if (!writesTransportStream || !UsesStreamCopy(recordingTemplate) || string.IsNullOrWhiteSpace(selectedVideoCodec)) { return null; } var normalizedCodec = selectedVideoCodec.Trim(); if (normalizedCodec.Contains("h264", StringComparison.OrdinalIgnoreCase) || normalizedCodec.Contains("avc", StringComparison.OrdinalIgnoreCase)) { return "h264_mp4toannexb"; } if (normalizedCodec.Contains("hevc", StringComparison.OrdinalIgnoreCase) || normalizedCodec.Contains("h265", StringComparison.OrdinalIgnoreCase)) { return "hevc_mp4toannexb"; } return null; } private static bool UsesStreamCopy(RecordingTemplateType recordingTemplate) => recordingTemplate is RecordingTemplateType.StreamCopy or RecordingTemplateType.ArchiveTs; private static long? CalculateFileSize(string? outputPath) { if (string.IsNullOrWhiteSpace(outputPath)) { return null; } if (File.Exists(outputPath)) { return new FileInfo(outputPath).Length; } if (Directory.Exists(outputPath)) { return new DirectoryInfo(outputPath) .EnumerateFiles("*", SearchOption.TopDirectoryOnly) .Sum(static file => file.Length); } return null; } private static bool HasUsableOutput(string? outputPath, long? fileSize) { if (string.IsNullOrWhiteSpace(outputPath)) { return false; } if (File.Exists(outputPath)) { return fileSize.GetValueOrDefault() > 0; } if (Directory.Exists(outputPath)) { return Directory.EnumerateFiles(outputPath, "*", SearchOption.TopDirectoryOnly).Any(); } return false; } private static async Task UpsertRecordResultAsync( RecordTask recordTask, LiveRecorderDbContext dbContext, string? effectiveOutputPath, long? fileSize, double? durationSeconds, string? danmakuFilePath, int danmakuMessageCount, DateTimeOffset endedAt, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(effectiveOutputPath)) { effectiveOutputPath = recordTask.OutputFilePath ?? string.Empty; } var resultId = Guid.NewGuid(); var normalizedDanmakuCount = Math.Max(0, danmakuMessageCount); var finalStatus = (int)recordTask.Status; // Multiple background paths can reconcile the same segment after ffmpeg exits. // Use the database's atomic upsert instead of EF Add-or-Update to avoid // RecordTaskId unique constraint races between scoped DbContext instances. await dbContext.Database.ExecuteSqlInterpolatedAsync($""" INSERT INTO "RecordResults" ("Id", "RecordTaskId", "FilePath", "FileSizeBytes", "DurationSeconds", "DanmakuFilePath", "DanmakuMessageCount", "FinalStatus", "ErrorMessage", "UploadStatus", "DeletedLocalFilesAfterUpload", "CreatedAt") VALUES ({resultId}, {recordTask.Id}, {effectiveOutputPath}, {fileSize}, {durationSeconds}, {danmakuFilePath}, {normalizedDanmakuCount}, {finalStatus}, {recordTask.ErrorMessage}, {(int)RecordArtifactUploadStatus.NotUploaded}, {false}, {endedAt}) ON CONFLICT("RecordTaskId") DO UPDATE SET "FilePath" = excluded."FilePath", "FileSizeBytes" = excluded."FileSizeBytes", "DurationSeconds" = excluded."DurationSeconds", "DanmakuFilePath" = excluded."DanmakuFilePath", "DanmakuMessageCount" = excluded."DanmakuMessageCount", "FinalStatus" = excluded."FinalStatus", "ErrorMessage" = excluded."ErrorMessage", "UploadStatus" = COALESCE("RecordResults"."UploadStatus", excluded."UploadStatus"), "DeletedLocalFilesAfterUpload" = COALESCE("RecordResults"."DeletedLocalFilesAfterUpload", excluded."DeletedLocalFilesAfterUpload"); """, cancellationToken); } private static Task LoadRecordResultAsync( LiveRecorderDbContext dbContext, Guid recordTaskId, CancellationToken cancellationToken = default) => dbContext.RecordResults .AsNoTracking() .FirstOrDefaultAsync(item => item.RecordTaskId == recordTaskId, cancellationToken); private static string BuildSingleFileMp4MovFlags() => "+faststart+frag_keyframe+empty_moov+default_base_moof"; private static string BuildSegmentedMp4MovFlags() => "+faststart"; private static string GetRecorderOutputPath( string finalOutputPath, RecordOutputFormat outputFormat, RecordSaveMode saveMode) { if (outputFormat != RecordOutputFormat.Mp4) { return finalOutputPath; } if (saveMode == RecordSaveMode.SingleFile) { return Path.Combine( Path.GetDirectoryName(finalOutputPath)!, $"{Path.GetFileNameWithoutExtension(finalOutputPath)}.recording.ts"); } return Path.ChangeExtension(finalOutputPath, ".ts"); } private static bool ShouldUseIntermediateTransportStream( string outputPath, RecordOutputFormat outputFormat, RecordSaveMode saveMode) => outputFormat == RecordOutputFormat.Mp4 && (saveMode == RecordSaveMode.SingleFile || saveMode == RecordSaveMode.Segmented) && outputPath.EndsWith(".ts", StringComparison.OrdinalIgnoreCase); private static string ResolveSegmentOutputPath( string outputPathPattern, RecordSaveMode saveMode, int segmentIndex) { if (saveMode != RecordSaveMode.Segmented) { return outputPathPattern; } return outputPathPattern.Replace("%05d", $"{Math.Max(1, segmentIndex):D5}", StringComparison.OrdinalIgnoreCase); } private static string ResolveRecorderSegmentOutputPath( string outputPathPattern, RecordOutputFormat outputFormat, RecordSaveMode saveMode, int segmentIndex) => NormalizeAbsolutePath(GetRecorderOutputPath( ResolveSegmentOutputPath(outputPathPattern, saveMode, segmentIndex), outputFormat, saveMode)); private static bool IsHttpInput(string streamUrl) => streamUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || streamUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase); private static bool ShouldEnableReconnect(string streamUrl, string? selectedProtocol) { if (!string.IsNullOrWhiteSpace(selectedProtocol) && selectedProtocol.Equals("hls", StringComparison.OrdinalIgnoreCase)) { return false; } return !streamUrl.Contains(".m3u8", StringComparison.OrdinalIgnoreCase); } private static string NormalizeAbsolutePath(string path) => Path.IsPathRooted(path) ? path : Path.GetFullPath(path, AppContext.BaseDirectory); private static string? BuildCustomHeaderArgument(StreamInputHeaders? inputHeaders) { if (inputHeaders is null) { return null; } var builder = new StringBuilder(); if (!string.IsNullOrWhiteSpace(inputHeaders.Cookie)) { builder.Append("Cookie: "); builder.Append(inputHeaders.Cookie.Trim()); builder.Append("\r\n"); } if (inputHeaders.AdditionalHeaders is not null) { foreach (var pair in inputHeaders.AdditionalHeaders.Where(static pair => !string.IsNullOrWhiteSpace(pair.Key))) { builder.Append(pair.Key.Trim()); builder.Append(": "); builder.Append(pair.Value?.Trim() ?? string.Empty); builder.Append("\r\n"); } } return builder.Length == 0 ? null : builder.ToString(); } private static bool TryParseSegmentOpenPath(string line, out string openedPath) { var match = SegmentOpeningRegex.Match(line); if (match.Success) { openedPath = match.Groups[1].Value; return true; } openedPath = string.Empty; return false; } private static int? ExtractSegmentIndex(string openedPath) { var fileName = Path.GetFileNameWithoutExtension(openedPath); var lastUnderscore = fileName.LastIndexOf('_'); if (lastUnderscore < 0 || lastUnderscore == fileName.Length - 1) { return null; } var suffix = fileName[(lastUnderscore + 1)..]; return int.TryParse(suffix, out var value) ? value : null; } private static string? GuessDanmakuPath(string? outputFilePath) { if (string.IsNullOrWhiteSpace(outputFilePath)) { return null; } var fullPath = Path.IsPathRooted(outputFilePath) ? outputFilePath : Path.GetFullPath(outputFilePath, AppContext.BaseDirectory); return SessionDanmakuXmlRecorder.GetDanmakuFilePath(fullPath); } private static int CountDanmakuMessages(string? danmakuPath) { if (string.IsNullOrWhiteSpace(danmakuPath) || !File.Exists(danmakuPath)) { return 0; } var count = 0; foreach (var line in File.ReadLines(danmakuPath)) { if (line.Contains("= 2) { return; } } else { previousLength = fileInfo.Length; previousWriteTimeUtc = fileInfo.LastWriteTimeUtc; stableChecks = 0; } await Task.Delay(250, timeoutCts.Token); } } catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) { } } private static bool IsActiveTaskStatus(RecordTaskStatus status) => status is RecordTaskStatus.Starting or RecordTaskStatus.Running or RecordTaskStatus.Stopping; private static bool IsActiveSessionStatus(RecordSessionStatus status) => status is RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping; private static bool TryParseFfmpegProgressSeconds(string line, out double seconds) { if (line.StartsWith("out_time=", StringComparison.OrdinalIgnoreCase)) { if (TimeSpan.TryParse( line["out_time=".Length..], CultureInfo.InvariantCulture, out var timeSpan)) { seconds = Math.Max(0, timeSpan.TotalSeconds); return true; } } if (line.StartsWith("out_time_ms=", StringComparison.OrdinalIgnoreCase) || line.StartsWith("out_time_us=", StringComparison.OrdinalIgnoreCase)) { var raw = line[(line.IndexOf('=') + 1)..]; if (long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) { seconds = Math.Max(0, value / 1_000_000d); return true; } } seconds = 0; return false; } private enum FfmpegInputOptionProfile { Baseline = 0, Minimal = 1 } private enum StartupFailureKind { None = 0, InputOptionCompatibility = 1, StreamHandshake = 2 } }