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,380 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Text.RegularExpressions;
using LiveRecorder.Application.Abstractions.Logging;
using LiveRecorder.Application.Abstractions.Notifications;
using LiveRecorder.Application.Abstractions.Platforms;
using LiveRecorder.Application.Abstractions.Recording;
using LiveRecorder.Application.Abstractions.Settings;
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 : IFfmpegService
{
private static readonly Regex SegmentOpeningRegex = new(
"""Opening '([^']+)' for writing""",
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
private readonly ConcurrentDictionary<Guid, SessionProcessRuntime> _processes = new();
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly ILogger<FfmpegService> _logger;
public FfmpegService(
IServiceScopeFactory serviceScopeFactory,
ILogger<FfmpegService> logger)
{
_serviceScopeFactory = serviceScopeFactory;
_logger = logger;
}
public bool IsRunning(Guid recordSessionId) => _processes.ContainsKey(recordSessionId);
public Task CompleteAsync(Guid recordSessionId, CancellationToken cancellationToken = default) =>
RequestStopAsync(recordSessionId, markAsCompletedOnExit: true, cancellationToken);
public Task StartAsync(
RecordSession recordSession,
RecordTask initialTask,
StreamUrlResult streamUrlResult,
CancellationToken cancellationToken = default) =>
StartInternalAsync(
recordSession,
initialTask,
streamUrlResult,
inputOptionProfile: FfmpegInputOptionProfile.Baseline,
hasRetriedWithCompatibilityProfile: false,
hasRetriedWithRefreshedStream: false,
retryAttemptCount: 0,
cancellationToken);
private async Task StartInternalAsync(
RecordSession recordSession,
RecordTask initialTask,
StreamUrlResult streamUrlResult,
FfmpegInputOptionProfile inputOptionProfile,
bool hasRetriedWithCompatibilityProfile,
bool hasRetriedWithRefreshedStream,
int retryAttemptCount,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(recordSession);
ArgumentNullException.ThrowIfNull(initialTask);
ArgumentNullException.ThrowIfNull(streamUrlResult);
if (string.IsNullOrWhiteSpace(streamUrlResult.SelectedUrl) || string.IsNullOrWhiteSpace(recordSession.OutputPathPattern))
{
throw new InvalidOperationException("Recording session is missing stream URL or output path.");
}
using var settingsScope = _serviceScopeFactory.CreateScope();
var settingsService = settingsScope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
var settings = await settingsService.GetAsync(cancellationToken);
var outputPathPattern = Path.IsPathRooted(recordSession.OutputPathPattern)
? recordSession.OutputPathPattern
: Path.GetFullPath(recordSession.OutputPathPattern, AppContext.BaseDirectory);
var recorderOutputPath = GetRecorderOutputPath(outputPathPattern, recordSession.OutputFormat, recordSession.SaveMode);
Directory.CreateDirectory(Path.GetDirectoryName(recorderOutputPath)!);
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = settings.FfmpegPath,
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
},
EnableRaisingEvents = true
};
var runtime = new SessionProcessRuntime(
recordSession.Id,
recordSession.LiveRoomId,
streamUrlResult.SelectedUrl,
outputPathPattern,
recorderOutputPath,
recordSession.OutputFormat,
recordSession.SaveMode,
initialTask.Id,
Math.Max(1, initialTask.SegmentIndex),
initialTask.OutputFilePath ?? outputPathPattern,
streamUrlResult.SelectedQuality,
streamUrlResult.SelectedProtocol,
streamUrlResult.InputHeaders,
inputOptionProfile,
hasRetriedWithCompatibilityProfile,
hasRetriedWithRefreshedStream,
retryAttemptCount);
foreach (var argument in BuildArgumentList(
streamUrlResult.SelectedUrl,
recorderOutputPath,
recordSession.OutputFormat,
recordSession.SaveMode,
settings.RecordingTemplate,
settings.EnableAutoReconnect,
settings.ReconnectDelayMaxSeconds,
settings.ReadWriteTimeoutMilliseconds,
settings.SegmentDurationMinutes,
streamUrlResult.InputHeaders,
inputOptionProfile))
{
process.StartInfo.ArgumentList.Add(argument);
}
process.OutputDataReceived += (_, args) => _ = HandleProcessOutputAsync(runtime, args.Data, isError: false);
process.ErrorDataReceived += (_, args) => _ = HandleProcessOutputAsync(runtime, args.Data, isError: true);
process.Exited += (_, _) => _ = HandleProcessExitedAsync(runtime, process);
if (!process.Start())
{
throw new InvalidOperationException("ffmpeg failed to start.");
}
runtime.AttachProcess(process);
if (!_processes.TryAdd(recordSession.Id, runtime))
{
process.Kill(true);
process.Dispose();
throw new InvalidOperationException("A running ffmpeg process already exists for the recording session.");
}
await PersistProcessBindingAsync(recordSession.Id, initialTask.Id, process.Id, cancellationToken);
await PersistStartupProfileAsync(runtime, cancellationToken);
await StartDanmakuAsync(runtime, initialTask, cancellationToken);
process.BeginOutputReadLine();
process.BeginErrorReadLine();
}
public Task StopAsync(Guid recordSessionId, CancellationToken cancellationToken = default) =>
RequestStopAsync(recordSessionId, markAsCompletedOnExit: false, cancellationToken);
public async Task<bool> StopAndWaitAsync(
Guid recordSessionId,
bool markAsCompletedOnExit,
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
if (!_processes.TryGetValue(recordSessionId, out var runtime))
{
return true;
}
await RequestStopAsync(recordSessionId, markAsCompletedOnExit, cancellationToken);
return await WaitForExitAsync(runtime, timeout, cancellationToken);
}
public async Task<bool> KillAndWaitAsync(
Guid recordSessionId,
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
if (!_processes.TryGetValue(recordSessionId, out var runtime))
{
return true;
}
var process = runtime.Process;
if (process is not null)
{
try
{
if (!process.HasExited)
{
process.Kill(true);
}
}
catch (InvalidOperationException)
{
}
}
return await WaitForExitAsync(runtime, timeout, cancellationToken);
}
public async Task<bool> TryReconcileInactiveSessionAsync(Guid recordSessionId, CancellationToken cancellationToken = default)
{
if (IsRunning(recordSessionId))
{
return false;
}
using var scope = _serviceScopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
var settingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
var recordSession = await dbContext.RecordSessions
.Include(item => item.LiveRoom)
.Include(item => item.RecordTasks)
.ThenInclude(item => item.Result)
.FirstOrDefaultAsync(item => item.Id == recordSessionId, cancellationToken);
if (recordSession is null || !IsActiveSessionStatus(recordSession.Status))
{
return false;
}
var settings = await settingsService.GetAsync(cancellationToken);
var endedAt = DateTimeOffset.UtcNow;
var tasks = recordSession.RecordTasks
.OrderBy(static item => item.SegmentIndex)
.ThenBy(static item => item.CreatedAt)
.ToList();
var anyUsableOutput = false;
string? finalizationError = null;
foreach (var task in tasks.Where(item => IsActiveTaskStatus(item.Status)))
{
var effectiveOutputPath = task.OutputFilePath ?? string.Empty;
if (recordSession.SaveMode == RecordSaveMode.SingleFile &&
recordSession.OutputFormat == RecordOutputFormat.Mp4 &&
!string.IsNullOrWhiteSpace(recordSession.OutputPathPattern))
{
var finalOutputPath = Path.IsPathRooted(recordSession.OutputPathPattern)
? recordSession.OutputPathPattern
: Path.GetFullPath(recordSession.OutputPathPattern, AppContext.BaseDirectory);
var recorderOutputPath = GetRecorderOutputPath(finalOutputPath, recordSession.OutputFormat, recordSession.SaveMode);
if (File.Exists(recorderOutputPath))
{
var finalizationResult = await TryFinalizeMp4Async(settings.FfmpegPath, recorderOutputPath, finalOutputPath);
effectiveOutputPath = finalizationResult.OutputPath;
finalizationError ??= finalizationResult.ErrorMessage;
}
}
var fileSize = CalculateFileSize(effectiveOutputPath);
var danmakuPath = task.Result?.DanmakuFilePath ?? GuessDanmakuPath(task.OutputFilePath);
var danmakuCount = CountDanmakuMessages(danmakuPath);
var durationSeconds = task.StartedAt.HasValue
? (double?)Math.Max(0, (endedAt - task.StartedAt.Value).TotalSeconds)
: null;
if (!string.IsNullOrWhiteSpace(finalizationError))
{
task.MarkFailed(finalizationError, endedAt);
}
else if (HasUsableOutput(effectiveOutputPath, fileSize))
{
task.MarkCompleted(endedAt, durationSeconds);
anyUsableOutput = true;
}
else
{
task.MarkStopped(endedAt, durationSeconds, "Recording process was no longer running when the session was reconciled.");
}
UpsertRecordResult(task, dbContext, effectiveOutputPath, fileSize, durationSeconds, danmakuPath, danmakuCount, endedAt);
}
recordSession.SyncSegmentCount(tasks.Count, endedAt);
if (!string.IsNullOrWhiteSpace(finalizationError))
{
recordSession.MarkFailed(finalizationError, endedAt);
}
else if (anyUsableOutput)
{
recordSession.MarkCompleted(endedAt);
}
else
{
recordSession.MarkStopped(endedAt, "Recording process was no longer running when the session was reconciled.");
}
await dbContext.SaveChangesAsync(cancellationToken);
return true;
}
private async Task PersistStartupProfileAsync(SessionProcessRuntime runtime, CancellationToken cancellationToken)
{
using var scope = _serviceScopeFactory.CreateScope();
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
await logService.WriteAsync(
SystemLogLevel.Info,
"FFmpeg",
$"ffmpeg input profile={runtime.InputOptionProfile}.",
detail: $"stream={runtime.SelectedProtocol}:{runtime.SelectedQuality}; compatibilityRetry={runtime.HasRetriedWithCompatibilityProfile}; refreshRetry={runtime.HasRetriedWithRefreshedStream}",
liveRoomId: runtime.LiveRoomId,
recordSessionId: runtime.RecordSessionId,
recordTaskId: runtime.CurrentTaskId,
cancellationToken: cancellationToken);
}
private static async Task<bool> WaitForExitAsync(
SessionProcessRuntime runtime,
TimeSpan timeout,
CancellationToken cancellationToken)
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var delayTask = Task.Delay(timeout, timeoutCts.Token);
var completed = await Task.WhenAny(runtime.ExitCompletion.Task, delayTask);
if (completed == runtime.ExitCompletion.Task)
{
timeoutCts.Cancel();
await runtime.ExitCompletion.Task;
return true;
}
return false;
}
private async Task RequestStopAsync(Guid recordSessionId, bool markAsCompletedOnExit, CancellationToken cancellationToken)
{
if (!_processes.TryGetValue(recordSessionId, out var runtime))
{
return;
}
runtime.MarkStopRequested(markAsCompletedOnExit);
var process = runtime.Process;
if (process is null)
{
return;
}
try
{
if (process.HasExited)
{
return;
}
await process.StandardInput.WriteLineAsync("q");
await process.StandardInput.FlushAsync();
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(12));
try
{
await process.WaitForExitAsync(timeoutCts.Token);
}
catch (OperationCanceledException)
{
if (!process.HasExited)
{
process.Kill(true);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Stop ffmpeg process failed for session {RecordSessionId}", recordSessionId);
if (!process.HasExited)
{
process.Kill(true);
}
}
}
}