This commit is contained in:
2026-04-16 15:19:48 +08:00
commit 1c892259a9
127 changed files with 17390 additions and 0 deletions
@@ -0,0 +1,399 @@
using System.Diagnostics;
using System.Text;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
using LiveRecorder.Infrastructure.Persistence;
namespace LiveRecorder.Infrastructure.Services;
public sealed partial class FfmpegService
{
private static async Task<(string OutputPath, string? ErrorMessage)> TryFinalizeMp4Async(
string ffmpegPath,
string sourcePath,
string targetPath)
{
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);
}
var remuxProcess = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = ffmpegPath,
Arguments = $"-hide_banner -y -i {Quote(sourcePath)} -c copy -movflags +faststart {Quote(tempPath)}",
UseShellExecute = false,
RedirectStandardError = true,
RedirectStandardOutput = true,
CreateNoWindow = true
}
};
remuxProcess.Start();
await remuxProcess.WaitForExitAsync();
if (remuxProcess.ExitCode == 0 && File.Exists(tempPath))
{
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);
}
return (targetPath, null);
}
if (File.Exists(tempPath))
{
File.Delete(tempPath);
}
var fallbackPath = File.Exists(targetPath) ? targetPath : sourcePath;
return (fallbackPath, "The MP4 file could not be finalized into a seekable output.");
}
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,
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)
{
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));
if (saveMode == RecordSaveMode.Segmented)
{
arguments.AddRange(
[
"-f", "segment",
"-segment_start_number", "1",
"-segment_time", Math.Max(60, segmentDurationMinutes * 60).ToString(),
"-reset_timestamps", "1",
"-strftime", "0",
"-segment_format", outputFormat == RecordOutputFormat.Ts ? "mpegts" : "mp4"
]);
if (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 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 void UpsertRecordResult(
RecordTask recordTask,
LiveRecorderDbContext dbContext,
string? effectiveOutputPath,
long? fileSize,
double? durationSeconds,
string? danmakuFilePath,
int danmakuMessageCount,
DateTimeOffset endedAt)
{
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;
}
recordTask.Result.Update(
effectiveOutputPath,
fileSize,
durationSeconds,
danmakuFilePath,
danmakuMessageCount,
recordTask.Status,
recordTask.ErrorMessage);
}
private static string BuildSingleFileMp4MovFlags() =>
"+faststart+frag_keyframe+empty_moov+default_base_moof";
private static string BuildSegmentedMp4MovFlags() =>
"+faststart+frag_keyframe+empty_moov+default_base_moof";
private static string GetRecorderOutputPath(
string finalOutputPath,
RecordOutputFormat outputFormat,
RecordSaveMode saveMode)
{
if (saveMode == RecordSaveMode.SingleFile && outputFormat == RecordOutputFormat.Mp4)
{
return Path.Combine(
Path.GetDirectoryName(finalOutputPath)!,
$"{Path.GetFileNameWithoutExtension(finalOutputPath)}.recording.ts");
}
return finalOutputPath;
}
private static bool ShouldUseIntermediateTransportStream(
string outputPath,
RecordOutputFormat outputFormat,
RecordSaveMode saveMode) =>
saveMode == RecordSaveMode.SingleFile &&
outputFormat == RecordOutputFormat.Mp4 &&
outputPath.EndsWith(".ts", StringComparison.OrdinalIgnoreCase);
private static bool IsHttpInput(string streamUrl) =>
streamUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
streamUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase);
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 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 enum FfmpegInputOptionProfile
{
Baseline = 0,
Minimal = 1
}
private enum StartupFailureKind
{
None = 0,
InputOptionCompatibility = 1,
StreamHandshake = 2
}
}