feat: improve recording recovery and upload workflow
This commit is contained in:
@@ -21,6 +21,7 @@ namespace LiveRecorder.Infrastructure.Services;
|
||||
|
||||
public sealed partial class FfmpegService : IFfmpegService
|
||||
{
|
||||
private const string ArtifactRepairMarker = "[artifact-repair]";
|
||||
private static readonly Regex SegmentOpeningRegex = new(
|
||||
"""Opening '([^']+)' for writing""",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
|
||||
@@ -32,6 +33,7 @@ public sealed partial class FfmpegService : IFfmpegService
|
||||
private static readonly TimeSpan RuntimeFailureBaseBackoff = TimeSpan.FromSeconds(15);
|
||||
|
||||
private readonly ConcurrentDictionary<Guid, SessionProcessRuntime> _processes = new();
|
||||
private readonly ConcurrentDictionary<Guid, SessionTransitionRuntime> _sessionTransitions = new();
|
||||
private readonly ConcurrentDictionary<Guid, PostProcessRuntimeEntry> _postProcessStates = new();
|
||||
private readonly ConcurrentDictionary<Guid, Process> _postProcessProcesses = new();
|
||||
private readonly ConcurrentDictionary<Guid, RoomStartupFailureState> _roomStartupFailureStates = new();
|
||||
@@ -62,7 +64,16 @@ public sealed partial class FfmpegService : IFfmpegService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public bool IsRunning(Guid recordSessionId) => _processes.ContainsKey(recordSessionId);
|
||||
public bool IsRunning(Guid recordSessionId) =>
|
||||
IsSessionRuntimeActive(
|
||||
_processes.ContainsKey(recordSessionId),
|
||||
_sessionTransitions.ContainsKey(recordSessionId));
|
||||
|
||||
internal static bool IsSessionRuntimeActive(bool hasProcess, bool hasTransition) =>
|
||||
hasProcess || hasTransition;
|
||||
|
||||
internal static bool ShouldReuseRecoveryTask(bool hasMedia, RecordTaskStatus status) =>
|
||||
!hasMedia && status is RecordTaskStatus.Pending or RecordTaskStatus.Starting or RecordTaskStatus.Running or RecordTaskStatus.Stopping;
|
||||
|
||||
public IReadOnlyDictionary<Guid, RecordTaskRuntimeState> GetTaskRuntimeStates(IReadOnlyCollection<Guid> recordTaskIds)
|
||||
{
|
||||
@@ -83,6 +94,120 @@ public sealed partial class FfmpegService : IFfmpegService
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
public async Task<int> ResumeRecoveringSessionsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var lookupScope = _serviceScopeFactory.CreateScope();
|
||||
var lookupDb = lookupScope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
|
||||
var candidates = await lookupDb.RecordSessions
|
||||
.AsNoTracking()
|
||||
.Where(item => item.Status == RecordSessionStatus.Starting &&
|
||||
item.ErrorMessage != null &&
|
||||
item.ErrorMessage.StartsWith("[runtime-recovery]"))
|
||||
.OrderBy(item => item.UpdatedAt)
|
||||
.Select(item => item.Id)
|
||||
.Take(20)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var resumed = 0;
|
||||
foreach (var sessionId in candidates)
|
||||
{
|
||||
if (IsRunning(sessionId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var transition = new SessionTransitionRuntime(sessionId);
|
||||
if (!_sessionTransitions.TryAdd(sessionId, transition))
|
||||
{
|
||||
transition.Dispose();
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
|
||||
var session = await dbContext.RecordSessions
|
||||
.Include(item => item.LiveRoom)
|
||||
.Include(item => item.RecordTasks)
|
||||
.ThenInclude(item => item.Result)
|
||||
.FirstOrDefaultAsync(item => item.Id == sessionId, cancellationToken);
|
||||
if (session?.LiveRoom is null || session.Status != RecordSessionStatus.Starting)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(session.OutputPathPattern))
|
||||
{
|
||||
_logger.LogWarning("Recovering session {RecordSessionId} has no output path pattern and cannot be resumed.", session.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
var adapterFactory = scope.ServiceProvider.GetRequiredService<ILivePlatformAdapterFactory>();
|
||||
var adapter = adapterFactory.GetByPlatform(session.LiveRoom.Platform);
|
||||
var liveStatus = await adapter.GetLiveStatusAsync(session.LiveRoom.RoomId, cancellationToken);
|
||||
if (!liveStatus.IsLive)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var stream = await adapter.GetStreamUrlAsync(session.LiveRoom.RoomId, session.PreferredQuality, cancellationToken);
|
||||
var latest = session.RecordTasks
|
||||
.OrderByDescending(item => item.SegmentIndex)
|
||||
.ThenByDescending(item => item.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
var hasMedia = latest?.Result is { DurationSeconds: > 0 } result && File.Exists(result.FilePath);
|
||||
RecordTask task;
|
||||
if (latest is not null && ShouldReuseRecoveryTask(hasMedia, latest.Status))
|
||||
{
|
||||
task = latest;
|
||||
}
|
||||
else
|
||||
{
|
||||
var nextIndex = Math.Max(1, (latest?.SegmentIndex ?? 0) + 1);
|
||||
task = new RecordTask(session.LiveRoomId, session.Id, nextIndex, session.PreferredQuality, session.OutputFormat, DateTimeOffset.UtcNow);
|
||||
var output = NormalizeAbsolutePath(ResolveSegmentOutputPath(session.OutputPathPattern, session.SaveMode, nextIndex));
|
||||
task.MarkStarting(stream.SelectedUrl, output, DateTimeOffset.UtcNow);
|
||||
await dbContext.RecordTasks.AddAsync(task, cancellationToken);
|
||||
}
|
||||
|
||||
var settingsResolver = scope.ServiceProvider.GetRequiredService<LiveRoomRecordingSettingsResolver>();
|
||||
var recordingSettings = await settingsResolver.ResolveAsync(session.LiveRoom, cancellationToken);
|
||||
var taskOutputPath = string.IsNullOrWhiteSpace(task.OutputFilePath)
|
||||
? NormalizeAbsolutePath(ResolveSegmentOutputPath(session.OutputPathPattern, session.SaveMode, task.SegmentIndex))
|
||||
: task.OutputFilePath;
|
||||
session.MarkStarting(stream.SelectedUrl, session.OutputPathPattern, DateTimeOffset.UtcNow);
|
||||
session.ActivateSegment(task.SegmentIndex, DateTimeOffset.UtcNow);
|
||||
task.MarkStarting(stream.SelectedUrl, taskOutputPath, DateTimeOffset.UtcNow);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await StartInternalAsync(
|
||||
session,
|
||||
task,
|
||||
stream,
|
||||
recordingSettings,
|
||||
InitialRecoveryContext with { AttemptCount = 1, HasRetriedWithRefreshedStream = true },
|
||||
cancellationToken);
|
||||
session.MarkRunning(DateTimeOffset.UtcNow);
|
||||
task.MarkRunning(DateTimeOffset.UtcNow);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
resumed++;
|
||||
}
|
||||
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to resume persisted recorder recovery for session {RecordSessionId}", sessionId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sessionTransitions.TryRemove(sessionId, out _);
|
||||
transition.Completion.TrySetResult(true);
|
||||
transition.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
return resumed;
|
||||
}
|
||||
|
||||
public Task CompleteAsync(Guid recordSessionId, CancellationToken cancellationToken = default) =>
|
||||
RequestStopAsync(recordSessionId, markAsCompletedOnExit: true, cancellationToken);
|
||||
|
||||
@@ -229,6 +354,7 @@ public sealed partial class FfmpegService : IFfmpegService
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(args.Data))
|
||||
{
|
||||
runtime.RememberCurlErrorLine(args.Data);
|
||||
_logger.LogDebug("curl[{SessionId}] {Line}", recordSession.Id, args.Data);
|
||||
}
|
||||
};
|
||||
@@ -283,6 +409,12 @@ public sealed partial class FfmpegService : IFfmpegService
|
||||
{
|
||||
if (!_processes.TryGetValue(recordSessionId, out var runtime))
|
||||
{
|
||||
if (_sessionTransitions.TryGetValue(recordSessionId, out var transition))
|
||||
{
|
||||
transition.RequestStop(markAsCompletedOnExit);
|
||||
return await WaitForTransitionAsync(transition, timeout, cancellationToken);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -297,6 +429,12 @@ public sealed partial class FfmpegService : IFfmpegService
|
||||
{
|
||||
if (!_processes.TryGetValue(recordSessionId, out var runtime))
|
||||
{
|
||||
if (_sessionTransitions.TryGetValue(recordSessionId, out var transition))
|
||||
{
|
||||
transition.RequestStop(markAsCompletedOnExit: false);
|
||||
return await WaitForTransitionAsync(transition, timeout, cancellationToken);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -340,6 +478,7 @@ public sealed partial class FfmpegService : IFfmpegService
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var runtimes = _processes.Values.ToArray();
|
||||
var transitions = _sessionTransitions.Values.ToArray();
|
||||
foreach (var runtime in runtimes)
|
||||
{
|
||||
// Mark the captured runtime before looking it up again. The process may exit
|
||||
@@ -355,7 +494,13 @@ public sealed partial class FfmpegService : IFfmpegService
|
||||
cancellationToken,
|
||||
shutdownRequested: true)));
|
||||
|
||||
foreach (var transition in transitions)
|
||||
{
|
||||
transition.RequestStop(markAsCompletedOnExit: true);
|
||||
}
|
||||
|
||||
await WaitForRuntimeCompletionsAsync(runtimes, gracefulTimeout, cancellationToken);
|
||||
await WaitForTransitionCompletionsAsync(transitions, gracefulTimeout, cancellationToken);
|
||||
|
||||
foreach (var runtime in runtimes.Where(static runtime => !runtime.ExitCompletion.Task.IsCompleted))
|
||||
{
|
||||
@@ -391,7 +536,23 @@ public sealed partial class FfmpegService : IFfmpegService
|
||||
}
|
||||
|
||||
await WaitForAllProcessesAsync(forceKillTimeout, CancellationToken.None);
|
||||
return runtimes.Length;
|
||||
return runtimes.Length + transitions.Length;
|
||||
}
|
||||
|
||||
private static async Task WaitForTransitionCompletionsAsync(
|
||||
IReadOnlyCollection<SessionTransitionRuntime> transitions,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (transitions.Count == 0 || transitions.All(static item => item.Completion.Task.IsCompleted)) return;
|
||||
var completion = Task.WhenAll(transitions.Select(static item => item.Completion.Task));
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
var delay = Task.Delay(timeout, timeoutCts.Token);
|
||||
if (await Task.WhenAny(completion, delay) == completion)
|
||||
{
|
||||
timeoutCts.Cancel();
|
||||
await completion;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitForRuntimeCompletionsAsync(
|
||||
@@ -417,7 +578,7 @@ public sealed partial class FfmpegService : IFfmpegService
|
||||
private async Task WaitForAllProcessesAsync(TimeSpan timeout, CancellationToken cancellationToken)
|
||||
{
|
||||
var deadline = DateTimeOffset.UtcNow + timeout;
|
||||
while ((!_processes.IsEmpty || !_postProcessProcesses.IsEmpty || !_postProcessStates.IsEmpty) &&
|
||||
while ((!_processes.IsEmpty || !_sessionTransitions.IsEmpty || !_postProcessProcesses.IsEmpty || !_postProcessStates.IsEmpty) &&
|
||||
DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken);
|
||||
@@ -1020,6 +1181,215 @@ public sealed partial class FfmpegService : IFfmpegService
|
||||
return started;
|
||||
}
|
||||
|
||||
public async Task<bool> StartArtifactRepairAsync(
|
||||
Guid recordTaskId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_postProcessStates.ContainsKey(recordTaskId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
|
||||
var settingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
|
||||
var recordTask = await dbContext.RecordTasks
|
||||
.Include(static item => item.RecordSession)
|
||||
.Include(static item => item.Result)
|
||||
.FirstOrDefaultAsync(item => item.Id == recordTaskId, cancellationToken);
|
||||
if (recordTask?.Result is null || recordTask.RecordSession is null ||
|
||||
recordTask.Status is not (RecordTaskStatus.Failed or RecordTaskStatus.Processing) ||
|
||||
recordTask.Status == RecordTaskStatus.Processing &&
|
||||
recordTask.ErrorMessage?.StartsWith(ArtifactRepairMarker, StringComparison.Ordinal) != true)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var sourcePath = NormalizeAbsolutePath(recordTask.Result.FilePath);
|
||||
if (!File.Exists(sourcePath) || new FileInfo(sourcePath).Length <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var settings = await settingsService.GetAsync(cancellationToken);
|
||||
var storage = _storageGuardService.CheckCanStartOrResume(settings);
|
||||
if (!storage.IsAvailable || storage.AvailableBytes < Math.Max(storage.RequiredBytes, new FileInfo(sourcePath).Length))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var targetPath = BuildRecoveredArtifactPath(sourcePath, recordTaskId);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (!_postProcessStates.TryAdd(
|
||||
recordTask.Id,
|
||||
new PostProcessRuntimeEntry(
|
||||
recordTask.RecordSessionId,
|
||||
new RecordTaskRuntimeState(
|
||||
RecordTaskStatus.Processing,
|
||||
"Queued",
|
||||
0,
|
||||
$"Waiting to repair {Path.GetFileName(sourcePath)}"))))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
recordTask.MarkProcessing(
|
||||
$"{ArtifactRepairMarker} 正在生成非破坏性恢复文件:{Path.GetFileName(targetPath)}",
|
||||
now);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
ClearPostProcessState(recordTask.Id);
|
||||
throw;
|
||||
}
|
||||
|
||||
_ = Task.Run(
|
||||
async () => await RunArtifactRepairAsync(recordTaskId),
|
||||
CancellationToken.None);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<int> ResumeArtifactRepairsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
|
||||
var taskIds = await dbContext.RecordTasks
|
||||
.AsNoTracking()
|
||||
.Where(item => item.Status == RecordTaskStatus.Processing &&
|
||||
item.ErrorMessage != null &&
|
||||
item.ErrorMessage.StartsWith(ArtifactRepairMarker))
|
||||
.OrderBy(static item => item.UpdatedAt)
|
||||
.Select(static item => item.Id)
|
||||
.Take(20)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
var started = 0;
|
||||
foreach (var taskId in taskIds)
|
||||
{
|
||||
if (await StartArtifactRepairAsync(taskId, cancellationToken))
|
||||
{
|
||||
started++;
|
||||
}
|
||||
}
|
||||
|
||||
return started;
|
||||
}
|
||||
|
||||
private async Task RunArtifactRepairAsync(Guid recordTaskId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
|
||||
var settingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
|
||||
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
|
||||
var recordTask = await dbContext.RecordTasks
|
||||
.Include(static item => item.RecordSession)
|
||||
.ThenInclude(static item => item!.RecordTasks)
|
||||
.Include(static item => item.Result)
|
||||
.FirstOrDefaultAsync(item => item.Id == recordTaskId);
|
||||
if (recordTask?.Result is null || recordTask.RecordSession is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sourcePath = NormalizeAbsolutePath(recordTask.Result.FilePath);
|
||||
var targetPath = BuildRecoveredArtifactPath(sourcePath, recordTaskId);
|
||||
var originalError = recordTask.ErrorMessage;
|
||||
var settings = await settingsService.GetAsync();
|
||||
SetPostProcessState(
|
||||
recordTask.RecordSessionId,
|
||||
recordTask.Id,
|
||||
"Repairing",
|
||||
null,
|
||||
$"Repairing {Path.GetFileName(sourcePath)} without replacing the source");
|
||||
|
||||
var repair = await TryRepairArtifactFileAsync(
|
||||
settings.FfmpegPath,
|
||||
settings.MaxConcurrentFfmpegTranscodeTasks,
|
||||
settings.Mp4FinalizeTimeoutMinutes,
|
||||
recordTask.Id,
|
||||
sourcePath,
|
||||
targetPath,
|
||||
_shutdownCts.Token);
|
||||
if (_shutdownCts.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var metadata = string.IsNullOrWhiteSpace(repair.ErrorMessage)
|
||||
? await scope.ServiceProvider.GetRequiredService<IVideoMetadataService>()
|
||||
.ExtractMetadataAsync(targetPath)
|
||||
: null;
|
||||
if (metadata?.DurationSeconds is > 0 && !string.IsNullOrWhiteSpace(metadata.VideoCodec))
|
||||
{
|
||||
recordTask.MarkCompleted(now, metadata.DurationSeconds);
|
||||
recordTask.Result.Update(
|
||||
targetPath,
|
||||
new FileInfo(targetPath).Length,
|
||||
metadata.DurationSeconds,
|
||||
recordTask.Result.DanmakuFilePath,
|
||||
recordTask.Result.DanmakuMessageCount,
|
||||
RecordTaskStatus.Completed,
|
||||
null);
|
||||
recordTask.Result.ResetUploadForRecoveredArtifact();
|
||||
if (recordTask.RecordSession.RecordTasks.All(static task =>
|
||||
task.Status is RecordTaskStatus.Completed or RecordTaskStatus.Stopped))
|
||||
{
|
||||
recordTask.RecordSession.MarkCompleted(now);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
await logService.WriteAsync(
|
||||
SystemLogLevel.Info,
|
||||
"Recovery",
|
||||
"录制失败产物已修复,原文件已保留。",
|
||||
$"source={sourcePath}; recovered={targetPath}; originalError={originalError}",
|
||||
recordTask.LiveRoomId,
|
||||
recordTask.RecordSessionId,
|
||||
recordTask.Id);
|
||||
SetPostProcessState(recordTask.RecordSessionId, recordTask.Id, "Completed", 100, "Recovered file is ready for manual upload");
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = repair.ErrorMessage ?? "恢复输出仍无法识别有效视频流。";
|
||||
recordTask.MarkFailed($"录制产物修复失败:{error}", now, recordTask.DurationSeconds);
|
||||
await dbContext.SaveChangesAsync();
|
||||
await logService.WriteAsync(
|
||||
SystemLogLevel.Warning,
|
||||
"Recovery",
|
||||
"录制失败产物修复未成功,原文件保持不变。",
|
||||
$"source={sourcePath}; error={error}",
|
||||
recordTask.LiveRoomId,
|
||||
recordTask.RecordSessionId,
|
||||
recordTask.Id);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (_shutdownCts.IsCancellationRequested)
|
||||
{
|
||||
// The persisted Processing marker is intentionally kept for restart recovery.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Artifact repair failed for task {RecordTaskId}", recordTaskId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ClearPostProcessState(recordTaskId);
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildRecoveredArtifactPath(string sourcePath, Guid recordTaskId)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(sourcePath) ?? AppContext.BaseDirectory;
|
||||
var stem = Path.GetFileNameWithoutExtension(sourcePath);
|
||||
return Path.Combine(directory, $"{stem}.{recordTaskId.ToString("N")[..8]}.recovered.mp4");
|
||||
}
|
||||
|
||||
private static bool NeedsInterruptedMp4Finalization(RecordTask recordTask)
|
||||
{
|
||||
if (recordTask.Status != RecordTaskStatus.Completed ||
|
||||
@@ -1081,7 +1451,7 @@ public sealed partial class FfmpegService : IFfmpegService
|
||||
SystemLogLevel.Info,
|
||||
"FFmpeg",
|
||||
$"ffmpeg input profile={runtime.InputOptionProfile}.",
|
||||
detail: $"stream={runtime.SelectedProtocol}:{runtime.SelectedQuality}; videoCodec={runtime.SelectedVideoCodec ?? "unknown"}; recoveryEncoder={runtime.RecoveryVideoEncoder.Kind}; recoveryDevice={runtime.RecoveryVideoEncoder.DevicePath ?? "default"}; attempt={runtime.RetryAttemptCount}/{MaxInSessionRetryAttempts}; compatibilityRetry={runtime.HasRetriedWithCompatibilityProfile}; refreshRetry={runtime.HasRetriedWithRefreshedStream}; alternateProtocolRetry={runtime.HasRetriedWithAlternateProtocol}; softwareEncoderRetry={runtime.RecoveryContext.HasRetriedWithSoftwareEncoder}",
|
||||
detail: $"stream={runtime.SelectedProtocol}:{runtime.SelectedQuality}; videoCodec={runtime.SelectedVideoCodec ?? "unknown"}; recoveryEncoder={runtime.RecoveryVideoEncoder.Kind}; recoveryDevice={runtime.RecoveryVideoEncoder.DevicePath ?? "default"}; attempt={runtime.RetryAttemptCount}; compatibilityRetry={runtime.HasRetriedWithCompatibilityProfile}; refreshRetry={runtime.HasRetriedWithRefreshedStream}; alternateProtocolRetry={runtime.HasRetriedWithAlternateProtocol}; softwareEncoderRetry={runtime.RecoveryContext.HasRetriedWithSoftwareEncoder}",
|
||||
liveRoomId: runtime.LiveRoomId,
|
||||
recordSessionId: runtime.RecordSessionId,
|
||||
recordTaskId: runtime.CurrentTaskId,
|
||||
@@ -1106,6 +1476,24 @@ public sealed partial class FfmpegService : IFfmpegService
|
||||
return false;
|
||||
}
|
||||
|
||||
private static async Task<bool> WaitForTransitionAsync(
|
||||
SessionTransitionRuntime transition,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
var delayTask = Task.Delay(timeout, timeoutCts.Token);
|
||||
var completed = await Task.WhenAny(transition.Completion.Task, delayTask);
|
||||
if (completed != transition.Completion.Task)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
timeoutCts.Cancel();
|
||||
await transition.Completion.Task;
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task RequestStopAsync(
|
||||
Guid recordSessionId,
|
||||
bool markAsCompletedOnExit,
|
||||
@@ -1114,6 +1502,11 @@ public sealed partial class FfmpegService : IFfmpegService
|
||||
{
|
||||
if (!_processes.TryGetValue(recordSessionId, out var runtime))
|
||||
{
|
||||
if (_sessionTransitions.TryGetValue(recordSessionId, out var transition))
|
||||
{
|
||||
transition.RequestStop(markAsCompletedOnExit || shutdownRequested);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user