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