Files
live_recorder/src/LiveRecorder.Infrastructure/Services/FfmpegService.Utils.cs
T

1145 lines
40 KiB
C#

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<string?> GetLowStoragePauseMessageAsync()
{
using var storageScope = _serviceScopeFactory.CreateScope();
var settingsService = storageScope.ServiceProvider.GetRequiredService<LiveRecorder.Application.Abstractions.Settings.ISystemSettingsService>();
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<string>();
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<string?> 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<string>? 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<string?> MaterializeRecorderSegmentSourceAsync(
string finalOutputPath,
IReadOnlyList<string> 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<string> BuildMp4FinalizeArgumentList(
string sourcePath,
string targetPath,
Mp4FinalizeStrategy strategy)
{
var arguments = new List<string>
{
"-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<string> 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<string> { "-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<string> 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 SQLite'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, CreatedAt)
VALUES
({resultId}, {recordTask.Id}, {effectiveOutputPath}, {fileSize}, {durationSeconds}, {danmakuFilePath}, {normalizedDanmakuCount}, {finalStatus}, {recordTask.ErrorMessage}, {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;
""", cancellationToken);
}
private static Task<RecordResult?> 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("<d ", StringComparison.OrdinalIgnoreCase) ||
line.Contains("<event ", StringComparison.OrdinalIgnoreCase))
{
count++;
}
}
return count;
}
private static async Task WaitForFileToStabilizeAsync(string? path, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(path))
{
return;
}
var absolutePath = NormalizeAbsolutePath(path);
if (!File.Exists(absolutePath))
{
return;
}
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(4));
long? previousLength = null;
DateTime previousWriteTimeUtc = default;
var stableChecks = 0;
try
{
while (true)
{
timeoutCts.Token.ThrowIfCancellationRequested();
var fileInfo = new FileInfo(absolutePath);
if (!fileInfo.Exists)
{
return;
}
if (previousLength == fileInfo.Length && previousWriteTimeUtc == fileInfo.LastWriteTimeUtc)
{
stableChecks++;
if (stableChecks >= 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
}
}