1111
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user