feat: harden recording lifecycle and refresh fnOS UI

This commit is contained in:
2026-08-03 23:45:26 +08:00
parent e5b50ea85c
commit ecc737f0bd
90 changed files with 6995 additions and 1826 deletions
@@ -46,13 +46,19 @@ public sealed class RecoveryService
Storage = new StorageGuardStatusDto
{
IsEnabled = storage.IsEnabled,
IsAvailable = storage.IsAvailable,
HasEnoughSpace = storage.HasEnoughSpace,
CheckedPath = storage.CheckedPath,
TotalBytes = storage.TotalBytes,
UsedBytes = storage.UsedBytes,
AvailableBytes = storage.AvailableBytes,
RequiredBytes = storage.RequiredBytes,
Message = storage.Message,
Tier = storage.Tier.ToString(),
UsagePercent = storage.UsagePercent
UsagePercent = storage.UsagePercent,
FreePercent = storage.FreePercent,
GreenThresholdPercent = storage.GreenThresholdPercent,
RedThresholdPercent = storage.RedThresholdPercent
},
LiveRooms = liveRooms,
Finalizations = finalizations
@@ -64,17 +70,17 @@ public sealed class RecoveryService
var room = await _dbContext.LiveRooms.FirstOrDefaultAsync(item => item.Id == liveRoomId, cancellationToken);
if (room is null)
{
return FailureResult("Live room was not found.");
return FailureResult("未找到该直播间。");
}
if (!room.IsEnabled)
{
return FailureResult("Live room is disabled and cannot be retried.");
return FailureResult("该直播间已禁用,无法重试开录。");
}
if (room.AvailabilityStatus != LiveRoomAvailabilityStatus.Live)
{
return FailureResult("Live room is not currently online.");
return FailureResult("该直播间当前未开播,无法重试开录。");
}
var hasActiveSession = await _dbContext.RecordSessions.AnyAsync(
@@ -88,11 +94,11 @@ public sealed class RecoveryService
{
room.SetLastAutoStartDecision(
AutoStartDecisionCodes.SkippedActiveSession,
"Auto-start skipped because an active recording session already exists.",
"已有活动录制会话,本次自动开录已跳过。",
null,
DateTimeOffset.UtcNow);
await _dbContext.SaveChangesAsync(cancellationToken);
return FailureResult("An active recording session already exists for this live room.");
return FailureResult("该直播间已经存在活动录制会话。");
}
try
@@ -114,14 +120,14 @@ public sealed class RecoveryService
Messages =
[
started
? $"Recording retry started for room {room.RoomId}."
: $"Recording retry did not start for room {room.RoomId}. Status={task.Status}; Error={task.ErrorMessage ?? "n/a"}"
? $"直播间 {room.RoomId} 已开始重试录制。"
: $"直播间 {room.RoomId} 未能开始录制。状态={task.Status};错误={task.ErrorMessage ?? ""}"
]
};
}
catch (Exception ex)
{
return FailureResult($"Recording retry failed for room {room.RoomId}: {ex.Message}");
return FailureResult($"直播间 {room.RoomId} 重试录制失败:{ex.Message}");
}
}
@@ -135,7 +141,7 @@ public sealed class RecoveryService
RequestedCount = 0,
SuccessCount = 0,
FailedCount = 0,
Messages = ["No live rooms currently require retry."]
Messages = ["当前没有需要重试开录的直播间。"]
};
}
@@ -159,7 +165,8 @@ public sealed class RecoveryService
public async Task<RecoveryActionResultDto> ResumeFinalizationAsync(Guid recordTaskId, CancellationToken cancellationToken = default)
{
var started = await _ffmpegService.StartManualFinalizeTaskAsync(recordTaskId, cancellationToken);
var recoveredOrphan = await _ffmpegService.TryRecoverOrphanedTerminalTaskAsync(recordTaskId, cancellationToken);
var started = recoveredOrphan || await _ffmpegService.StartManualFinalizeTaskAsync(recordTaskId, cancellationToken);
return new RecoveryActionResultDto
{
RequestedCount = 1,
@@ -168,8 +175,10 @@ public sealed class RecoveryService
Messages =
[
started
? $"MP4 finalization resumed for task {recordTaskId}."
: $"MP4 finalization could not be resumed for task {recordTaskId}."
? recoveredOrphan
? $"任务 {recordTaskId} 的遗留分片已恢复,并已进入后续上传流程。"
: $"任务 {recordTaskId} 已恢复 MP4 转码。"
: $"任务 {recordTaskId} 无法恢复 MP4 转码。"
]
};
}
@@ -184,7 +193,7 @@ public sealed class RecoveryService
RequestedCount = 0,
SuccessCount = 0,
FailedCount = 0,
Messages = ["No MP4 finalization tasks currently require recovery."]
Messages = ["当前没有需要恢复的 MP4 转码任务。"]
};
}
@@ -192,7 +201,8 @@ public sealed class RecoveryService
var messages = new List<string>();
foreach (var item in finalizations)
{
var started = await _ffmpegService.StartManualFinalizeTaskAsync(item.RecordTaskId, cancellationToken);
var recoveredOrphan = await _ffmpegService.TryRecoverOrphanedTerminalTaskAsync(item.RecordTaskId, cancellationToken);
var started = recoveredOrphan || await _ffmpegService.StartManualFinalizeTaskAsync(item.RecordTaskId, cancellationToken);
if (started)
{
successCount++;
@@ -200,8 +210,10 @@ public sealed class RecoveryService
messages.Add(
started
? $"MP4 finalization resumed for task {item.RecordTaskId}."
: $"MP4 finalization could not be resumed for task {item.RecordTaskId}.");
? recoveredOrphan
? $"任务 {item.RecordTaskId} 的遗留分片已恢复。"
: $"任务 {item.RecordTaskId} 已恢复 MP4 转码。"
: $"任务 {item.RecordTaskId} 无法恢复 MP4 转码。");
}
return new RecoveryActionResultDto
@@ -290,16 +302,6 @@ public sealed class RecoveryService
return false;
}
if (recordTask.Status == RecordTaskStatus.Processing)
{
return true;
}
if (recordTask.Status is RecordTaskStatus.Starting or RecordTaskStatus.Running or RecordTaskStatus.Stopping)
{
return false;
}
if (recordTask.RecordSession.Status is RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping)
{
return false;
@@ -310,8 +312,9 @@ public sealed class RecoveryService
return false;
}
var manifestSources = FfmpegService.ReadRecorderSegmentsManifest(recordTask.OutputFilePath);
var recorderOutputPath = ResolveManualFinalizeSourcePath(recordTask, recordTask.RecordSession);
if (!File.Exists(recorderOutputPath))
if (manifestSources.Count == 0 && !File.Exists(recorderOutputPath))
{
return false;
}
@@ -330,16 +333,21 @@ public sealed class RecoveryService
if (recordTask.Status == RecordTaskStatus.Processing)
{
return string.IsNullOrWhiteSpace(recordTask.ErrorMessage)
? "MP4 finalization is queued or paused and can be resumed."
? "MP4 转码正在排队或已暂停,可以继续恢复。"
: recordTask.ErrorMessage!;
}
if (recordTask.Status is RecordTaskStatus.Starting or RecordTaskStatus.Running or RecordTaskStatus.Stopping)
{
return "父会话已经结束,但该分片仍显示为录制中;可安全对账并恢复转码、上传。";
}
if (recordTask.Status == RecordTaskStatus.Completed)
{
return "The final MP4 output is missing, but the intermediate recording file is still available.";
return "最终 MP4 文件缺失,但中间录制文件仍然存在。";
}
return "Manual MP4 finalization can be retried from the intermediate recording file.";
return "可以使用保留的中间录制文件重新执行 MP4 转码。";
}
private static string ResolveManualFinalizeSourcePath(RecordTask recordTask, RecordSession recordSession)