feat: improve recording recovery and upload workflow

This commit is contained in:
2026-08-10 11:14:34 +08:00
parent e0d3969e46
commit 19d358b312
58 changed files with 5979 additions and 586 deletions
@@ -1,4 +1,5 @@
using LiveRecorder.Application.Abstractions.Recording;
using LiveRecorder.Application.Abstractions.Logging;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Abstractions.Storage;
using LiveRecorder.Application.Common;
@@ -19,19 +20,25 @@ public sealed class RecoveryService
private readonly IStorageGuardService _storageGuardService;
private readonly IFfmpegService _ffmpegService;
private readonly RecordService _recordService;
private readonly IVideoMetadataService _videoMetadataService;
private readonly ISystemLogService _systemLogService;
public RecoveryService(
LiveRecorderDbContext dbContext,
ISystemSettingsService systemSettingsService,
IStorageGuardService storageGuardService,
IFfmpegService ffmpegService,
RecordService recordService)
RecordService recordService,
IVideoMetadataService videoMetadataService,
ISystemLogService systemLogService)
{
_dbContext = dbContext;
_systemSettingsService = systemSettingsService;
_storageGuardService = storageGuardService;
_ffmpegService = ffmpegService;
_recordService = recordService;
_videoMetadataService = videoMetadataService;
_systemLogService = systemLogService;
}
public async Task<RecoveryOverviewDto> GetOverviewAsync(CancellationToken cancellationToken = default)
@@ -40,6 +47,7 @@ public sealed class RecoveryService
var storage = _storageGuardService.CheckCanStartOrResume(settings);
var liveRooms = await ListRecoverableLiveRoomsAsync(cancellationToken);
var finalizations = await ListRecoverableFinalizationsAsync(cancellationToken);
var mergedArtifacts = await ListMergedArtifactsAsync(cancellationToken);
return new RecoveryOverviewDto
{
@@ -61,10 +69,69 @@ public sealed class RecoveryService
RedThresholdPercent = storage.RedThresholdPercent
},
LiveRooms = liveRooms,
Finalizations = finalizations
Finalizations = finalizations,
MergedArtifacts = mergedArtifacts
};
}
private async Task<IReadOnlyList<MergedArtifactRecordDto>> ListMergedArtifactsAsync(CancellationToken cancellationToken)
{
var sources = await _dbContext.RecordTasks
.AsNoTracking()
.Include(item => item.Result)
.Where(item => item.IsHiddenArtifactSource)
.OrderByDescending(item => item.UpdatedAt)
.Take(100)
.ToListAsync(cancellationToken);
var targetIds = sources.Where(item => item.MergedIntoRecordTaskId.HasValue)
.Select(item => item.MergedIntoRecordTaskId!.Value)
.Distinct()
.ToArray();
var targets = await _dbContext.RecordTasks
.AsNoTracking()
.Include(item => item.Result)
.Where(item => targetIds.Contains(item.Id))
.ToDictionaryAsync(item => item.Id, cancellationToken);
return sources.Select(source =>
{
var sourcePath = source.Result?.FilePath ?? source.OutputFilePath;
var parent = string.IsNullOrWhiteSpace(sourcePath) ? null : Path.GetDirectoryName(Path.GetFullPath(sourcePath));
var recoveryBase = parent is null ? null : Path.Combine(parent, ".liverecorder-recovery", source.RecordSessionId.ToString("N"));
var manifest = FindRecoveryManifest(recoveryBase, source.Id);
return new MergedArtifactRecordDto
{
SourceRecordTaskId = source.Id,
RecordSessionId = source.RecordSessionId,
MergedIntoRecordTaskId = source.MergedIntoRecordTaskId,
SourceVideoPath = sourcePath,
MergedVideoPath = source.MergedIntoRecordTaskId.HasValue && targets.TryGetValue(source.MergedIntoRecordTaskId.Value, out var target)
? target.Result?.FilePath
: null,
RecoveryDirectory = manifest is null ? recoveryBase : Path.GetDirectoryName(manifest),
ManifestPath = manifest,
SourceDurationSeconds = source.Result?.DurationSeconds ?? source.DurationSeconds,
CreatedAt = source.UpdatedAt
};
}).ToList();
}
private static string? FindRecoveryManifest(string? recoveryBase, Guid recordTaskId)
{
if (string.IsNullOrWhiteSpace(recoveryBase) || !Directory.Exists(recoveryBase)) return null;
try
{
foreach (var path in Directory.EnumerateFiles(recoveryBase, "manifest.json", SearchOption.AllDirectories).Take(100))
{
if (File.ReadAllText(path).Contains(recordTaskId.ToString(), StringComparison.OrdinalIgnoreCase)) return path;
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
}
return null;
}
public async Task<RecoveryActionResultDto> RetryLiveRoomAsync(Guid liveRoomId, CancellationToken cancellationToken = default)
{
var room = await _dbContext.LiveRooms.FirstOrDefaultAsync(item => item.Id == liveRoomId, cancellationToken);
@@ -225,6 +292,211 @@ public sealed class RecoveryService
};
}
public async Task<RecordingFailureListResponse> ListRecordingFailuresAsync(
string? failureKind,
int skip,
int take,
CancellationToken cancellationToken = default)
{
var tasks = await _dbContext.RecordTasks
.AsNoTracking()
.Include(static item => item.LiveRoom)
.Include(static item => item.Result)
.Where(item => item.Status == RecordTaskStatus.Failed ||
item.Status == RecordTaskStatus.Processing &&
item.ErrorMessage != null &&
item.ErrorMessage.StartsWith("[artifact-repair]"))
.OrderByDescending(static item => item.UpdatedAt)
.ToListAsync(cancellationToken);
var items = tasks.Select(MapRecordingFailure).AsEnumerable();
if (!string.IsNullOrWhiteSpace(failureKind))
{
items = items.Where(item => item.FailureKind.Equals(failureKind.Trim(), StringComparison.OrdinalIgnoreCase));
}
var materialized = items.ToList();
return new RecordingFailureListResponse
{
TotalCount = materialized.Count,
Items = materialized
.Skip(Math.Max(0, skip))
.Take(Math.Clamp(take, 1, 100))
.ToList()
};
}
public async Task<RecoveryActionResultDto> AcceptRecordingArtifactAsync(
Guid recordTaskId,
bool confirmShortArtifact,
CancellationToken cancellationToken = default)
{
var task = await _dbContext.RecordTasks
.Include(static item => item.RecordSession)
.ThenInclude(static item => item!.RecordTasks)
.Include(static item => item.Result)
.FirstOrDefaultAsync(item => item.Id == recordTaskId, cancellationToken);
if (task?.Result is null || task.Status != RecordTaskStatus.Failed)
{
return FailureResult("该任务不是可认领的录制失败产物。");
}
var path = NormalizeAbsolutePath(task.Result.FilePath);
if (!File.Exists(path) || new FileInfo(path).Length <= 0)
{
return FailureResult("本地媒体文件不存在或为空,无法认领。");
}
var metadata = await _videoMetadataService.ExtractMetadataAsync(path, cancellationToken);
if (metadata is null || string.IsNullOrWhiteSpace(metadata.VideoCodec) || metadata.DurationSeconds is not > 0)
{
return FailureResult("媒体仍无法读取,请先使用非破坏修复。");
}
if (metadata.DurationSeconds < 5 && !confirmShortArtifact)
{
return FailureResult($"媒体只有 {metadata.DurationSeconds:0.###} 秒,需要确认短分片后才能认领。");
}
var originalError = task.ErrorMessage;
var now = DateTimeOffset.UtcNow;
task.MarkCompleted(task.EndedAt ?? now, metadata.DurationSeconds);
task.Result.Update(
path,
new FileInfo(path).Length,
metadata.DurationSeconds,
task.Result.DanmakuFilePath,
task.Result.DanmakuMessageCount,
RecordTaskStatus.Completed,
null);
task.Result.ResetUploadForRecoveredArtifact();
if (task.RecordSession is not null && task.RecordSession.RecordTasks.All(static item =>
item.Status is RecordTaskStatus.Completed or RecordTaskStatus.Stopped))
{
task.RecordSession.MarkCompleted(now);
}
await _dbContext.SaveChangesAsync(cancellationToken);
await _systemLogService.WriteAsync(
SystemLogLevel.Info,
"Recovery",
"录制失败产物已由管理员确认有效,等待手动上传。",
$"path={path}; duration={metadata.DurationSeconds:0.###}; originalError={originalError}",
task.LiveRoomId,
task.RecordSessionId,
task.Id,
cancellationToken);
return new RecoveryActionResultDto
{
RequestedCount = 1,
SuccessCount = 1,
FailedCount = 0,
Messages = ["分片已认领为有效产物,可前往上传任务页面手动上传。"]
};
}
public async Task<RecoveryActionResultDto> RepairRecordingArtifactAsync(
Guid recordTaskId,
CancellationToken cancellationToken = default)
{
var started = await _ffmpegService.StartArtifactRepairAsync(recordTaskId, cancellationToken);
return new RecoveryActionResultDto
{
RequestedCount = 1,
SuccessCount = started ? 1 : 0,
FailedCount = started ? 0 : 1,
Messages =
[
started
? "非破坏修复已排队;原文件不会被覆盖或删除。"
: "无法启动修复:任务可能正在处理、文件缺失或可用空间不足。"
]
};
}
private static RecordingFailureItemDto MapRecordingFailure(RecordTask task)
{
var result = task.Result;
var path = result?.FilePath;
var normalizedPath = string.IsNullOrWhiteSpace(path) ? null : NormalizeAbsolutePath(path);
var fileExists = normalizedPath is not null && File.Exists(normalizedPath) && new FileInfo(normalizedPath).Length > 0;
var classification = ClassifyRecordingFailure(task, fileExists);
var isRepairing = task.Status == RecordTaskStatus.Processing &&
task.ErrorMessage?.StartsWith("[artifact-repair]", StringComparison.Ordinal) == true;
return new RecordingFailureItemDto
{
RecordTaskId = task.Id,
RecordSessionId = task.RecordSessionId,
LiveRoomId = task.LiveRoomId,
LiveRoomTitle = task.LiveRoom?.Title ?? task.LiveRoom?.AnchorName ?? task.LiveRoom?.RoomId ?? "未知直播间",
RoomId = task.LiveRoom?.RoomId ?? "-",
PlatformName = task.LiveRoom?.Platform.ToString() ?? "Unknown",
SegmentIndex = task.SegmentIndex,
FailureKind = classification.Kind,
FailureLabel = classification.Label,
RecommendedAction = classification.Action,
ErrorMessage = task.ErrorMessage ?? result?.ErrorMessage,
FilePath = normalizedPath,
FileSizeBytes = result?.FileSizeBytes,
DurationSeconds = result?.DurationSeconds ?? task.DurationSeconds,
FileExists = fileExists,
CanAccept = !isRepairing && fileExists && classification.Kind is "ReadableFragment" or "TooShort" or "Unknown",
CanRepair = !isRepairing && fileExists && classification.Kind is "UnreadableMedia" or "FinalizationFailed" or "Unknown",
CanRetryRoom = !isRepairing && task.LiveRoom?.AvailabilityStatus == LiveRoomAvailabilityStatus.Live,
IsRepairing = isRepairing,
CreatedAt = task.CreatedAt
};
}
private static (string Kind, string Label, string Action) ClassifyRecordingFailure(RecordTask task, bool fileExists)
{
var error = task.ErrorMessage ?? task.Result?.ErrorMessage ?? string.Empty;
if (task.Status == RecordTaskStatus.Processing && error.StartsWith("[artifact-repair]", StringComparison.Ordinal))
{
return ("Repairing", "正在修复", "等待修复完成,应用重启后会自动续排。");
}
if (!fileExists)
{
return ("MissingMedia", "文件缺失", "历史媒体文件不存在,无法恢复;若直播仍在线可重新开录。");
}
if (error.Contains("offline", StringComparison.OrdinalIgnoreCase) || error.Contains("已离线", StringComparison.OrdinalIgnoreCase))
{
return ("SourceOffline", "直播已离线", "历史时段无法补录;直播重新在线后可重新开录。");
}
if (error.Contains("播放地址", StringComparison.OrdinalIgnoreCase) || error.Contains("stream url", StringComparison.OrdinalIgnoreCase))
{
return ("StreamUrlUnavailable", "未取得播放地址", "等待下一次巡检刷新播放地址,在线时可手动重试开录。");
}
if (error.Contains("short fragment", StringComparison.OrdinalIgnoreCase) ||
task.Result?.DurationSeconds is > 0 and < 5)
{
return ("TooShort", "短分片", "确认内容有价值后可人工认领;不足 5 秒需要二次确认。");
}
if (error.Contains("ffprobe", StringComparison.OrdinalIgnoreCase) ||
error.Contains("does not contain a video", StringComparison.OrdinalIgnoreCase))
{
return ("UnreadableMedia", "媒体不可读", "尝试生成新的恢复文件;原文件保持不变。");
}
if (error.Contains("finaliz", StringComparison.OrdinalIgnoreCase) ||
error.Contains("mux", StringComparison.OrdinalIgnoreCase))
{
return ("FinalizationFailed", "封装或收尾失败", "先重新封装,失败后再转码修复。");
}
if (task.Result?.DurationSeconds is > 0)
{
return ("ReadableFragment", "异常退出分片", "重新校验媒体;确认有效后转为待上传。");
}
return ("Unknown", "待检测", "可先尝试校验认领;无法读取时再执行非破坏修复。");
}
private async Task<IReadOnlyList<RecoverableLiveRoomDto>> ListRecoverableLiveRoomsAsync(CancellationToken cancellationToken)
{
var activeLiveRoomIds = await _dbContext.RecordSessions