feat: improve recording recovery and upload workflow
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
using LiveRecorder.Application.Abstractions.Logging;
|
||||
using LiveRecorder.Application.Abstractions.Recording;
|
||||
using LiveRecorder.Application.Models.Logs;
|
||||
using LiveRecorder.Domain.Entities;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using LiveRecorder.Infrastructure.Persistence;
|
||||
using LiveRecorder.Infrastructure.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace LiveRecorder.Tests;
|
||||
|
||||
public sealed class RecoveryServiceTests : IDisposable
|
||||
{
|
||||
private readonly string _temporaryDirectory = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"liverecorder-artifact-recovery-{Guid.NewGuid():N}");
|
||||
|
||||
[Fact]
|
||||
public async Task AcceptShortArtifact_RequiresConfirmationThenLeavesItForManualUpload()
|
||||
{
|
||||
Directory.CreateDirectory(_temporaryDirectory);
|
||||
var mediaPath = Path.Combine(_temporaryDirectory, "short-fragment.mp4");
|
||||
await File.WriteAllBytesAsync(mediaPath, [1, 2, 3, 4]);
|
||||
await using var context = CreateContext();
|
||||
var taskId = await SeedFailedTaskAsync(context, mediaPath, durationSeconds: 3);
|
||||
var service = CreateService(context, new VideoMetadata(3, 1920, 1080, "h264", "aac", 30, 2_000_000));
|
||||
|
||||
var rejected = await service.AcceptRecordingArtifactAsync(taskId, confirmShortArtifact: false);
|
||||
|
||||
Assert.Equal(0, rejected.SuccessCount);
|
||||
Assert.Contains("需要确认短分片", rejected.Messages.Single());
|
||||
Assert.Equal(RecordTaskStatus.Failed, (await context.RecordTasks.FindAsync(taskId))!.Status);
|
||||
|
||||
var accepted = await service.AcceptRecordingArtifactAsync(taskId, confirmShortArtifact: true);
|
||||
|
||||
Assert.Equal(1, accepted.SuccessCount);
|
||||
var task = await context.RecordTasks.Include(item => item.Result).SingleAsync(item => item.Id == taskId);
|
||||
Assert.Equal(RecordTaskStatus.Completed, task.Status);
|
||||
Assert.Equal(RecordArtifactUploadStatus.NotUploaded, task.Result!.UploadStatus);
|
||||
Assert.Null(task.Result.LastUploadProvider);
|
||||
Assert.Null(task.Result.LastUploadedAt);
|
||||
Assert.Null(task.Result.UploadErrorMessage);
|
||||
Assert.True(File.Exists(mediaPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListFailures_IdentifiesMissingMediaAsUnrecoverable()
|
||||
{
|
||||
Directory.CreateDirectory(_temporaryDirectory);
|
||||
var missingPath = Path.Combine(_temporaryDirectory, "missing.mp4");
|
||||
await using var context = CreateContext();
|
||||
await SeedFailedTaskAsync(context, missingPath, durationSeconds: 120);
|
||||
var service = CreateService(context, metadata: null);
|
||||
|
||||
var response = await service.ListRecordingFailuresAsync("MissingMedia", 0, 20);
|
||||
|
||||
var item = Assert.Single(response.Items);
|
||||
Assert.Equal("MissingMedia", item.FailureKind);
|
||||
Assert.False(item.FileExists);
|
||||
Assert.False(item.CanAccept);
|
||||
Assert.False(item.CanRepair);
|
||||
}
|
||||
|
||||
private static LiveRecorderDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<LiveRecorderDbContext>()
|
||||
.UseInMemoryDatabase($"artifact-recovery-{Guid.NewGuid():N}")
|
||||
.Options;
|
||||
return new LiveRecorderDbContext(options);
|
||||
}
|
||||
|
||||
private static async Task<Guid> SeedFailedTaskAsync(
|
||||
LiveRecorderDbContext context,
|
||||
string mediaPath,
|
||||
double durationSeconds)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var room = new LiveRoom(
|
||||
LivePlatformType.Douyin,
|
||||
"https://live.example/recovery",
|
||||
"recovery-room",
|
||||
"https://live.example/recovery",
|
||||
now.AddHours(-1));
|
||||
room.UpdateMetadata("恢复测试直播间", "恢复主播", null, null, null, now.AddHours(-1));
|
||||
var session = new RecordSession(
|
||||
room.Id,
|
||||
"origin",
|
||||
RecordOutputFormat.Mp4,
|
||||
RecordSaveMode.Segmented,
|
||||
now.AddMinutes(-10));
|
||||
session.MarkFailed("ffmpeg exited unexpectedly", now);
|
||||
var task = new RecordTask(
|
||||
room.Id,
|
||||
session.Id,
|
||||
1,
|
||||
"origin",
|
||||
RecordOutputFormat.Mp4,
|
||||
now.AddMinutes(-10));
|
||||
task.MarkStarting("https://stream.example/recovery", mediaPath, now.AddMinutes(-10));
|
||||
task.MarkFailed("ffmpeg exited unexpectedly", now, durationSeconds);
|
||||
var result = new RecordResult(
|
||||
task.Id,
|
||||
mediaPath,
|
||||
File.Exists(mediaPath) ? new FileInfo(mediaPath).Length : null,
|
||||
durationSeconds,
|
||||
null,
|
||||
0,
|
||||
RecordTaskStatus.Failed,
|
||||
task.ErrorMessage,
|
||||
now);
|
||||
result.MarkUploadFailed("openlist", "previous upload error", now);
|
||||
|
||||
context.AddRange(room, session, task, result);
|
||||
await context.SaveChangesAsync();
|
||||
return task.Id;
|
||||
}
|
||||
|
||||
private static RecoveryService CreateService(LiveRecorderDbContext context, VideoMetadata? metadata) =>
|
||||
new(
|
||||
context,
|
||||
systemSettingsService: null!,
|
||||
storageGuardService: null!,
|
||||
ffmpegService: null!,
|
||||
recordService: null!,
|
||||
videoMetadataService: new FixedVideoMetadataService(metadata),
|
||||
systemLogService: new NullSystemLogService());
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_temporaryDirectory))
|
||||
{
|
||||
Directory.Delete(_temporaryDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedVideoMetadataService(VideoMetadata? metadata) : IVideoMetadataService
|
||||
{
|
||||
public Task<VideoMetadata?> ExtractMetadataAsync(
|
||||
string filePath,
|
||||
CancellationToken cancellationToken = default) => Task.FromResult(metadata);
|
||||
|
||||
public Task<string?> GenerateThumbnailAsync(
|
||||
string filePath,
|
||||
string outputDir,
|
||||
CancellationToken cancellationToken = default) => Task.FromResult<string?>(null);
|
||||
}
|
||||
|
||||
private sealed class NullSystemLogService : ISystemLogService
|
||||
{
|
||||
public Task WriteAsync(
|
||||
SystemLogLevel level,
|
||||
string category,
|
||||
string message,
|
||||
string? detail = null,
|
||||
Guid? liveRoomId = null,
|
||||
Guid? recordSessionId = null,
|
||||
Guid? recordTaskId = null,
|
||||
CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
|
||||
public Task<IReadOnlyList<SystemLogDto>> ListAsync(
|
||||
Guid? liveRoomId = null,
|
||||
Guid? recordSessionId = null,
|
||||
Guid? recordTaskId = null,
|
||||
SystemLogLevel? level = null,
|
||||
string? content = null,
|
||||
int take = 200,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlyList<SystemLogDto>>([]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user