1147 lines
40 KiB
C#
1147 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 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<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
|
|
}
|
|
}
|