feat: improve recording automation and task workflows
This commit is contained in:
@@ -1,18 +1,32 @@
|
||||
using System.Diagnostics;
|
||||
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 static async Task<(string OutputPath, string? ErrorMessage)> TryFinalizeMp4Async(
|
||||
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)
|
||||
string targetPath,
|
||||
double? expectedDurationSeconds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(sourcePath))
|
||||
{
|
||||
@@ -28,24 +42,286 @@ public sealed partial class FfmpegService
|
||||
File.Delete(tempPath);
|
||||
}
|
||||
|
||||
var remuxProcess = new Process
|
||||
async Task<string?> GetLowStoragePauseMessageAsync()
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
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;
|
||||
var lastReportedWholePercent = -1;
|
||||
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)
|
||||
{
|
||||
FileName = ffmpegPath,
|
||||
Arguments = $"-hide_banner -y -i {Quote(sourcePath)} -c copy -movflags +faststart {Quote(tempPath)}",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardOutput = true,
|
||||
CreateNoWindow = true
|
||||
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 wholePercent = progressPercent.HasValue ? (int)Math.Floor(progressPercent.Value) : -1;
|
||||
if (stageOverride is null && wholePercent == lastReportedWholePercent)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lastReportedWholePercent = wholePercent;
|
||||
SetPostProcessState(
|
||||
recordSessionId,
|
||||
recordTaskId,
|
||||
stageOverride ?? activeStage,
|
||||
progressPercent,
|
||||
detail ?? $"Optimizing MP4 index for {Path.GetFileName(targetPath)}");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
remuxProcess.Start();
|
||||
await remuxProcess.WaitForExitAsync();
|
||||
|
||||
if (remuxProcess.ExitCode == 0 && File.Exists(tempPath))
|
||||
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;
|
||||
lastReportedWholePercent = -1;
|
||||
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);
|
||||
@@ -60,6 +336,7 @@ public sealed partial class FfmpegService
|
||||
File.Delete(sourcePath);
|
||||
}
|
||||
|
||||
SetPostProcessState(recordSessionId, recordTaskId, "Completed", 100, "MP4 seek index is ready");
|
||||
return (targetPath, null);
|
||||
}
|
||||
|
||||
@@ -69,7 +346,285 @@ public sealed partial class FfmpegService
|
||||
}
|
||||
|
||||
var fallbackPath = File.Exists(targetPath) ? targetPath : sourcePath;
|
||||
return (fallbackPath, "The MP4 file could not be finalized into a seekable output.");
|
||||
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(
|
||||
@@ -83,6 +638,8 @@ public sealed partial class FfmpegService
|
||||
int readWriteTimeoutMilliseconds,
|
||||
int segmentDurationMinutes,
|
||||
StreamInputHeaders? inputHeaders,
|
||||
string? selectedProtocol,
|
||||
string? selectedVideoCodec,
|
||||
FfmpegInputOptionProfile inputOptionProfile)
|
||||
{
|
||||
var arguments = new List<string> { "-hide_banner", "-y" };
|
||||
@@ -107,7 +664,9 @@ public sealed partial class FfmpegService
|
||||
}
|
||||
}
|
||||
|
||||
if (enableReconnect && inputOptionProfile == FfmpegInputOptionProfile.Baseline)
|
||||
if (enableReconnect &&
|
||||
inputOptionProfile == FfmpegInputOptionProfile.Baseline &&
|
||||
ShouldEnableReconnect(streamUrl, selectedProtocol))
|
||||
{
|
||||
arguments.AddRange(
|
||||
[
|
||||
@@ -121,19 +680,30 @@ public sealed partial class FfmpegService
|
||||
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", outputFormat == RecordOutputFormat.Ts ? "mpegts" : "mp4"
|
||||
"-segment_format", segmentFormat
|
||||
]);
|
||||
|
||||
if (outputFormat == RecordOutputFormat.Mp4)
|
||||
if (!useIntermediateTransportStream && outputFormat == RecordOutputFormat.Mp4)
|
||||
{
|
||||
arguments.AddRange(["-segment_format_options", $"movflags={BuildSegmentedMp4MovFlags()}"]);
|
||||
}
|
||||
@@ -173,6 +743,35 @@ public sealed partial class FfmpegService
|
||||
]
|
||||
};
|
||||
|
||||
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))
|
||||
@@ -215,7 +814,7 @@ public sealed partial class FfmpegService
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void UpsertRecordResult(
|
||||
private static async Task UpsertRecordResultAsync(
|
||||
RecordTask recordTask,
|
||||
LiveRecorderDbContext dbContext,
|
||||
string? effectiveOutputPath,
|
||||
@@ -223,71 +822,122 @@ public sealed partial class FfmpegService
|
||||
double? durationSeconds,
|
||||
string? danmakuFilePath,
|
||||
int danmakuMessageCount,
|
||||
DateTimeOffset endedAt)
|
||||
DateTimeOffset endedAt,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(effectiveOutputPath))
|
||||
{
|
||||
effectiveOutputPath = recordTask.OutputFilePath ?? string.Empty;
|
||||
}
|
||||
|
||||
if (recordTask.Result is null)
|
||||
{
|
||||
dbContext.RecordResults.Add(new RecordResult(
|
||||
recordTask.Id,
|
||||
effectiveOutputPath,
|
||||
fileSize,
|
||||
durationSeconds,
|
||||
danmakuFilePath,
|
||||
danmakuMessageCount,
|
||||
recordTask.Status,
|
||||
recordTask.ErrorMessage,
|
||||
endedAt));
|
||||
return;
|
||||
}
|
||||
var resultId = Guid.NewGuid();
|
||||
var normalizedDanmakuCount = Math.Max(0, danmakuMessageCount);
|
||||
var finalStatus = (int)recordTask.Status;
|
||||
|
||||
recordTask.Result.Update(
|
||||
effectiveOutputPath,
|
||||
fileSize,
|
||||
durationSeconds,
|
||||
danmakuFilePath,
|
||||
danmakuMessageCount,
|
||||
recordTask.Status,
|
||||
recordTask.ErrorMessage);
|
||||
// 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+frag_keyframe+empty_moov+default_base_moof";
|
||||
"+faststart";
|
||||
|
||||
private static string GetRecorderOutputPath(
|
||||
string finalOutputPath,
|
||||
RecordOutputFormat outputFormat,
|
||||
RecordSaveMode saveMode)
|
||||
{
|
||||
if (saveMode == RecordSaveMode.SingleFile && outputFormat == RecordOutputFormat.Mp4)
|
||||
if (outputFormat != RecordOutputFormat.Mp4)
|
||||
{
|
||||
return finalOutputPath;
|
||||
}
|
||||
|
||||
if (saveMode == RecordSaveMode.SingleFile)
|
||||
{
|
||||
return Path.Combine(
|
||||
Path.GetDirectoryName(finalOutputPath)!,
|
||||
$"{Path.GetFileNameWithoutExtension(finalOutputPath)}.recording.ts");
|
||||
}
|
||||
|
||||
return finalOutputPath;
|
||||
return Path.ChangeExtension(finalOutputPath, ".ts");
|
||||
}
|
||||
|
||||
private static bool ShouldUseIntermediateTransportStream(
|
||||
string outputPath,
|
||||
RecordOutputFormat outputFormat,
|
||||
RecordSaveMode saveMode) =>
|
||||
saveMode == RecordSaveMode.SingleFile &&
|
||||
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)
|
||||
@@ -376,13 +1026,95 @@ public sealed partial class FfmpegService
|
||||
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 string Quote(string value) => $"\"{value.Replace("\"", "\\\"", StringComparison.Ordinal)}\"";
|
||||
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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user