feat: harden recording lifecycle and refresh fnOS UI
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using LiveRecorder.Application.Abstractions.Platforms;
|
||||
using LiveRecorder.Infrastructure.Services;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace LiveRecorder.Tests;
|
||||
|
||||
public sealed class FfmpegFailureClassificationTests
|
||||
{
|
||||
[Fact]
|
||||
public void FfprobeProcess_DoesNotInheritBundledCurlLibraries()
|
||||
{
|
||||
var startInfo = new ProcessStartInfo();
|
||||
startInfo.Environment["LD_LIBRARY_PATH"] = "/app/runtime/lib";
|
||||
|
||||
FfmpegVideoMetadataService.SanitizeFfprobeProcessEnvironment(startInfo);
|
||||
|
||||
Assert.False(startInfo.Environment.ContainsKey("LD_LIBRARY_PATH"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FfmpegHeaderFallback_ParsesValidMediaMetadata()
|
||||
{
|
||||
const string output = """
|
||||
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from '/records/sample.mp4':
|
||||
Duration: 00:02:36.64, start: 0.090000, bitrate: 2788 kb/s
|
||||
Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(tv, progressive), 1088x1920, 2668 kb/s, 22 fps, 22 tbr, 90k tbn (default)
|
||||
Stream #0:1[0x2](und): Audio: aac (LC) (mp4a / 0x6134706D), 48000 Hz, stereo, fltp, 115 kb/s (default)
|
||||
Stream mapping:
|
||||
""";
|
||||
|
||||
var metadata = FfmpegVideoMetadataService.ParseFfmpegHeaderOutput(output);
|
||||
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal(156.64, metadata.DurationSeconds);
|
||||
Assert.Equal(1088, metadata.Width);
|
||||
Assert.Equal(1920, metadata.Height);
|
||||
Assert.Equal("h264", metadata.VideoCodec);
|
||||
Assert.Equal("aac", metadata.AudioCodec);
|
||||
Assert.Equal(22, metadata.FrameRate);
|
||||
Assert.Equal(2_788_000, metadata.BitRate);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("Duration: N/A")]
|
||||
[InlineData("Duration: 00:00:00.00, bitrate: N/A")]
|
||||
public void FfmpegHeaderFallback_RejectsUnreadableMedia(string output)
|
||||
{
|
||||
Assert.Null(FfmpegVideoMetadataService.ParseFfmpegHeaderOutput(output));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("pipe:0: Invalid data found when processing input")]
|
||||
[InlineData("Error opening input file pipe:0.")]
|
||||
[InlineData("Error writing trailer: Invalid argument")]
|
||||
[InlineData("Error muxing a packet")]
|
||||
[InlineData("Conversion failed!")]
|
||||
public void RecoverableFfmpegLines_ArePersistedAsWarnings(string line)
|
||||
{
|
||||
Assert.True(FfmpegService.IsRecoverableFfmpegWarningLine(line));
|
||||
Assert.True(FfmpegService.TryClassifyPersistedFfmpegLine(line, isError: true, out var level));
|
||||
Assert.Equal(SystemLogLevel.Warning, level);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Error writing trailer: Invalid argument")]
|
||||
[InlineData("Error muxing a packet")]
|
||||
[InlineData("Conversion failed!")]
|
||||
public void MuxingFailures_TriggerRepairTranscodeFallback(string detail)
|
||||
{
|
||||
Assert.True(FfmpegService.IsRepairableMp4FinalizeError(detail));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Application provided invalid, non monotonically increasing dts to muxer in stream 1")]
|
||||
[InlineData("Non-monotonous DTS in output stream 0:1")]
|
||||
public void TimestampDiscontinuityFailures_EnableTimestampRepair(string line)
|
||||
{
|
||||
Assert.True(FfmpegService.IsTimestampDiscontinuityFailureLine(line));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Error submitting a packet to the muxer: Invalid argument")]
|
||||
[InlineData("Error muxing a packet")]
|
||||
[InlineData("Task finished with error code: -22")]
|
||||
public void TimestampRepairMuxerFailures_EnableTranscodeFallback(string line)
|
||||
{
|
||||
Assert.True(FfmpegService.IsTimestampMuxerFailureLine(line));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.2, 85_000, false)]
|
||||
[InlineData(5, 85_000, true)]
|
||||
[InlineData(0.2, 1_048_576, false)]
|
||||
public void UnexpectedExitArtifacts_RequireMeaningfulMediaDuration(
|
||||
double durationSeconds,
|
||||
long fileSizeBytes,
|
||||
bool expected)
|
||||
{
|
||||
Assert.Equal(
|
||||
expected,
|
||||
FfmpegService.IsMeaningfulUnexpectedExitArtifact(durationSeconds, fileSizeBytes));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://example.test/live.flv", "flv", true)]
|
||||
[InlineData("https://example.test/live.m3u8", "hls", false)]
|
||||
[InlineData("https://example.test/playlist", "hls", false)]
|
||||
public void HttpInput_UsesCurlExceptForHls(string url, string protocol, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, FfmpegService.ShouldUseCurlPipe(url, protocol));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NativeHlsInput_PreservesRequestHeaders()
|
||||
{
|
||||
var headers = new StreamInputHeaders(
|
||||
"RecorderTest/1.0",
|
||||
"https://live.example.test/room",
|
||||
"session=abc",
|
||||
new Dictionary<string, string> { ["X-Test"] = "yes" });
|
||||
|
||||
var arguments = FfmpegService.BuildArgumentList(
|
||||
"https://cdn.example.test/live/index.m3u8",
|
||||
"/tmp/output.ts",
|
||||
RecordOutputFormat.Ts,
|
||||
RecordSaveMode.SingleFile,
|
||||
1,
|
||||
RecordingTemplateType.StreamCopy,
|
||||
true,
|
||||
10,
|
||||
30_000_000,
|
||||
30,
|
||||
headers,
|
||||
"hls",
|
||||
"h264",
|
||||
FfmpegService.FfmpegInputOptionProfile.Baseline);
|
||||
|
||||
Assert.Contains("https://cdn.example.test/live/index.m3u8", arguments);
|
||||
Assert.DoesNotContain("pipe:0", arguments);
|
||||
Assert.Contains("-user_agent", arguments);
|
||||
Assert.Contains("RecorderTest/1.0", arguments);
|
||||
Assert.Contains("-referer", arguments);
|
||||
Assert.Contains(arguments, item => item.Contains("Cookie: session=abc", StringComparison.Ordinal));
|
||||
Assert.Contains(arguments, item => item.Contains("X-Test: yes", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TimestampTranscodeProfile_RebuildsVideoAndAudioTimestamps()
|
||||
{
|
||||
var arguments = FfmpegService.BuildArgumentList(
|
||||
"https://cdn.example.test/live.flv",
|
||||
"/tmp/output.ts",
|
||||
RecordOutputFormat.Ts,
|
||||
RecordSaveMode.Segmented,
|
||||
1,
|
||||
RecordingTemplateType.StreamCopy,
|
||||
true,
|
||||
10,
|
||||
30_000_000,
|
||||
30,
|
||||
null,
|
||||
"flv",
|
||||
"h264",
|
||||
FfmpegService.FfmpegInputOptionProfile.TimestampTranscode);
|
||||
|
||||
Assert.Contains("settb=AVTB,setpts=PTS-STARTPTS", arguments);
|
||||
Assert.Contains("libx264", arguments);
|
||||
Assert.Contains("aresample=async=1:first_pts=0,asetpts=PTS-STARTPTS", arguments);
|
||||
Assert.Contains("make_zero", arguments);
|
||||
Assert.Contains("-reset_timestamps", arguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecoveryContext_PreservesBudgetAcrossHlsFallbackAndReachesTimestampTranscode()
|
||||
{
|
||||
var repairProfile = FfmpegService.ResolveRetryInputOptionProfile(
|
||||
FfmpegService.FfmpegInputOptionProfile.Baseline,
|
||||
hasTimestampDiscontinuityFailure: true,
|
||||
hasTimestampMuxerFailure: false);
|
||||
var repairHls = FfmpegService.AdvanceRecoveryContext(
|
||||
FfmpegService.InitialRecoveryContext,
|
||||
repairProfile,
|
||||
"flv",
|
||||
"hls");
|
||||
|
||||
Assert.Equal(FfmpegService.FfmpegInputOptionProfile.TimestampRepair, repairHls.InputOptionProfile);
|
||||
Assert.Equal(1, repairHls.AttemptCount);
|
||||
Assert.True(repairHls.HasRetriedWithAlternateProtocol);
|
||||
Assert.True(FfmpegService.ShouldImmediatelyFallbackFromHls("hls", hasHlsOverlongHeadersFailure: true));
|
||||
|
||||
var repairFlv = FfmpegService.AdvanceRecoveryContext(
|
||||
repairHls,
|
||||
repairHls.InputOptionProfile,
|
||||
"hls",
|
||||
"flv");
|
||||
var transcodeProfile = FfmpegService.ResolveRetryInputOptionProfile(
|
||||
repairFlv.InputOptionProfile,
|
||||
hasTimestampDiscontinuityFailure: false,
|
||||
hasTimestampMuxerFailure: true);
|
||||
var transcodeFlv = FfmpegService.AdvanceRecoveryContext(
|
||||
repairFlv,
|
||||
transcodeProfile,
|
||||
"flv",
|
||||
"flv");
|
||||
|
||||
Assert.Equal(2, repairFlv.AttemptCount);
|
||||
Assert.Equal(3, transcodeFlv.AttemptCount);
|
||||
Assert.Equal(FfmpegService.FfmpegInputOptionProfile.TimestampTranscode, transcodeFlv.InputOptionProfile);
|
||||
Assert.True(transcodeFlv.HasRetriedWithAlternateProtocol);
|
||||
Assert.False(transcodeFlv.HasRetriedWithRefreshedStream);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(RecordSessionStatus.Failed, 0, true)]
|
||||
[InlineData(RecordSessionStatus.Failed, 59, true)]
|
||||
[InlineData(RecordSessionStatus.Failed, 60, false)]
|
||||
[InlineData(RecordSessionStatus.Completed, 1, false)]
|
||||
public void RuntimeFailureBackoff_UsesProcessRuntimeInsteadOfMediaDuration(
|
||||
RecordSessionStatus status,
|
||||
int processRuntimeSeconds,
|
||||
bool expected)
|
||||
{
|
||||
Assert.Equal(
|
||||
expected,
|
||||
FfmpegService.ShouldApplyRuntimeFailureBackoff(status, TimeSpan.FromSeconds(processRuntimeSeconds)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, false, false, 2)]
|
||||
[InlineData(false, true, true, 0)]
|
||||
[InlineData(false, true, false, 3)]
|
||||
public void DeploymentShutdown_ClassifiesFinalizedAndRecoverableArtifactsWithoutFalseFailures(
|
||||
bool mediaValid,
|
||||
bool hasFinalizationError,
|
||||
bool hasRecoverableIntermediateOutput,
|
||||
int expected)
|
||||
{
|
||||
var disposition = FfmpegService.ClassifyExitedRecording(
|
||||
shutdownRequested: true,
|
||||
stopRequested: false,
|
||||
finalizationPaused: false,
|
||||
hasFinalizationError,
|
||||
mediaValid,
|
||||
exitCode: 0,
|
||||
completionRequested: true,
|
||||
hasUsableOutput: true,
|
||||
hasRecoverableIntermediateOutput);
|
||||
|
||||
Assert.Equal((ExitedRecordingDisposition)expected, disposition);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeploymentShutdown_InterruptedFinalizationRemainsRecoverable()
|
||||
{
|
||||
var disposition = FfmpegService.ClassifyExitedRecording(
|
||||
shutdownRequested: true,
|
||||
stopRequested: false,
|
||||
finalizationPaused: true,
|
||||
hasFinalizationError: true,
|
||||
mediaValid: false,
|
||||
exitCode: 0,
|
||||
completionRequested: true,
|
||||
hasUsableOutput: true,
|
||||
hasRecoverableIntermediateOutput: true);
|
||||
|
||||
Assert.Equal(ExitedRecordingDisposition.Processing, disposition);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Unknown encoder h264_nvenc")]
|
||||
[InlineData("Cannot load libcuda.so.1")]
|
||||
[InlineData("No VA display found for device /dev/dri/renderD128")]
|
||||
[InlineData("Error initializing an internal MFX session")]
|
||||
[InlineData("Impossible to convert between the formats supported by the filter")]
|
||||
public void HardwareEncoderFailures_TriggerSoftwareFallback(string line)
|
||||
{
|
||||
Assert.True(FfmpegService.IsHardwareEncoderFailureLine(line));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HardwareRecoveryEncoders_UseExpectedCodecAndDeviceArguments()
|
||||
{
|
||||
var nvenc = new RecoveryVideoEncoderSelection(RecoveryVideoEncoderKind.Nvenc, null);
|
||||
var qsv = new RecoveryVideoEncoderSelection(RecoveryVideoEncoderKind.Qsv, "/dev/dri/renderD128");
|
||||
var vaapi = new RecoveryVideoEncoderSelection(RecoveryVideoEncoderKind.Vaapi, "/dev/dri/renderD129");
|
||||
|
||||
var nvencArguments = FfmpegService.BuildRecoveryEncoderProbeArgumentList(nvenc);
|
||||
var qsvArguments = FfmpegService.BuildRecoveryEncoderProbeArgumentList(qsv);
|
||||
var vaapiArguments = FfmpegService.BuildRecoveryEncoderProbeArgumentList(vaapi);
|
||||
|
||||
Assert.Contains("h264_nvenc", nvencArguments);
|
||||
Assert.Contains("h264_qsv", qsvArguments);
|
||||
Assert.Contains("-qsv_device", qsvArguments);
|
||||
Assert.Contains("/dev/dri/renderD128", qsvArguments);
|
||||
Assert.Contains("h264_vaapi", vaapiArguments);
|
||||
Assert.Contains("-vaapi_device", vaapiArguments);
|
||||
Assert.Contains("/dev/dri/renderD129", vaapiArguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TimestampTranscode_WithQsv_PlacesDeviceBeforeInputAndKeepsTimestampFilters()
|
||||
{
|
||||
var arguments = FfmpegService.BuildArgumentList(
|
||||
"https://cdn.example.test/live.flv",
|
||||
"/tmp/output.ts",
|
||||
RecordOutputFormat.Ts,
|
||||
RecordSaveMode.Segmented,
|
||||
1,
|
||||
RecordingTemplateType.StreamCopy,
|
||||
true,
|
||||
10,
|
||||
30_000_000,
|
||||
30,
|
||||
null,
|
||||
"flv",
|
||||
"h264",
|
||||
FfmpegService.FfmpegInputOptionProfile.TimestampTranscode,
|
||||
new RecoveryVideoEncoderSelection(RecoveryVideoEncoderKind.Qsv, "/dev/dri/renderD128"));
|
||||
|
||||
var argumentList = arguments.ToList();
|
||||
Assert.True(argumentList.IndexOf("-qsv_device") < argumentList.IndexOf("-i"));
|
||||
Assert.Contains("h264_qsv", arguments);
|
||||
Assert.Contains(arguments, item => item.Contains("setpts=PTS-STARTPTS", StringComparison.Ordinal));
|
||||
Assert.Contains(arguments, item => item.Contains("hwupload", StringComparison.Ordinal));
|
||||
Assert.Contains("aresample=async=1:first_pts=0,asetpts=PTS-STARTPTS", arguments);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using LiveRecorder.Domain.Entities;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using LiveRecorder.Infrastructure.Services;
|
||||
|
||||
namespace LiveRecorder.Tests;
|
||||
|
||||
public sealed class LiveRoomPollingDecisionTests
|
||||
{
|
||||
private const string ActiveCode = "skipped_active_session";
|
||||
private const string ActiveSummary = "Auto-start skipped because an active recording session already exists.";
|
||||
|
||||
[Fact]
|
||||
public void HasAutoStartDecisionChanged_ReturnsFalseForSameActiveSession()
|
||||
{
|
||||
var room = CreateRoom();
|
||||
var sessionId = Guid.NewGuid();
|
||||
room.SetLastAutoStartDecision(
|
||||
ActiveCode,
|
||||
ActiveSummary,
|
||||
$"activeSessionId={sessionId}",
|
||||
DateTimeOffset.UtcNow.AddMinutes(-1));
|
||||
|
||||
var changed = LiveRoomPollingBackgroundService.HasAutoStartDecisionChanged(
|
||||
room,
|
||||
ActiveCode,
|
||||
ActiveSummary,
|
||||
$"activeSessionId={sessionId}");
|
||||
|
||||
Assert.False(changed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasAutoStartDecisionChanged_ReturnsTrueForNewActiveSession()
|
||||
{
|
||||
var room = CreateRoom();
|
||||
room.SetLastAutoStartDecision(
|
||||
ActiveCode,
|
||||
ActiveSummary,
|
||||
$"activeSessionId={Guid.NewGuid()}",
|
||||
DateTimeOffset.UtcNow.AddMinutes(-1));
|
||||
|
||||
var changed = LiveRoomPollingBackgroundService.HasAutoStartDecisionChanged(
|
||||
room,
|
||||
ActiveCode,
|
||||
ActiveSummary,
|
||||
$"activeSessionId={Guid.NewGuid()}");
|
||||
|
||||
Assert.True(changed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasAutoStartDecisionChanged_ReturnsTrueForFirstSkipDecision()
|
||||
{
|
||||
var room = CreateRoom();
|
||||
|
||||
var changed = LiveRoomPollingBackgroundService.HasAutoStartDecisionChanged(
|
||||
room,
|
||||
ActiveCode,
|
||||
ActiveSummary,
|
||||
$"activeSessionId={Guid.NewGuid()}");
|
||||
|
||||
Assert.True(changed);
|
||||
}
|
||||
|
||||
private static LiveRoom CreateRoom() => new(
|
||||
LivePlatformType.Douyin,
|
||||
"https://live.douyin.com/123456",
|
||||
"123456",
|
||||
"https://live.douyin.com/123456",
|
||||
DateTimeOffset.UtcNow);
|
||||
}
|
||||
@@ -137,6 +137,7 @@ public sealed class LiveRoomStatusServiceTests
|
||||
string segmentFilePath,
|
||||
DateTimeOffset occurredAt,
|
||||
bool forceRun = false,
|
||||
Guid? eventId = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<EventScriptExecutionResultDto?>(null);
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LiveRecorder.Application.Abstractions.Logging;
|
||||
using LiveRecorder.Application.Abstractions.Recording;
|
||||
using LiveRecorder.Application.Abstractions.Settings;
|
||||
using LiveRecorder.Application.Models.Logs;
|
||||
using LiveRecorder.Application.Models.Settings;
|
||||
using LiveRecorder.Domain.Entities;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using LiveRecorder.Infrastructure.Persistence;
|
||||
using LiveRecorder.Infrastructure.Persistence.Repositories;
|
||||
using LiveRecorder.Infrastructure.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
@@ -16,6 +19,21 @@ namespace LiveRecorder.Tests;
|
||||
|
||||
public sealed class OpenListUploadTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Enqueue_RejectsShortMediaAndKeepsItNotUploaded()
|
||||
{
|
||||
await using var fixture = await QueueFixture.CreateAsync(
|
||||
new VideoMetadata(0.18, 1920, 1080, "h264", "aac", 30, 4_000_000));
|
||||
|
||||
var enqueue = await fixture.Queue.EnqueueAsync(fixture.RecordTaskId);
|
||||
|
||||
Assert.False(enqueue.Success);
|
||||
Assert.Contains("不足 5 秒", enqueue.Message);
|
||||
Assert.Null(await fixture.Context.RecordUploadJobs.SingleOrDefaultAsync());
|
||||
var result = await fixture.Context.RecordResults.SingleAsync();
|
||||
Assert.Equal(RecordArtifactUploadStatus.NotUploaded, result.UploadStatus);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://openlist.example.com/", "https://openlist.example.com")]
|
||||
[InlineData("https://openlist.example.com/base/dav/archive/file", "https://openlist.example.com/base")]
|
||||
@@ -225,6 +243,9 @@ public sealed class OpenListUploadTests
|
||||
Assert.Equal(RecordArtifactUploadStatus.Uploading, job.Status);
|
||||
Assert.Equal(1, job.AttemptCount);
|
||||
Assert.Equal(41.67, job.ProgressPercent, precision: 2);
|
||||
Assert.Equal(now.AddSeconds(2), job.LastProgressAt);
|
||||
job.SetProgress(job.ProgressPercent, now.AddMinutes(1));
|
||||
Assert.Equal(now.AddSeconds(2), job.LastProgressAt);
|
||||
|
||||
job.CompleteCurrentArtifact(now.AddSeconds(3));
|
||||
Assert.Equal(RecordUploadArtifactStage.Danmaku, job.CurrentArtifact);
|
||||
@@ -290,7 +311,14 @@ public sealed class OpenListUploadTests
|
||||
Assert.Equal("/source/Douyin/2026/08/01/主播/segment.mp4", job.SourceVideoPath);
|
||||
Assert.Equal("/destination/Douyin/2026/08/01/主播/segment.mp4", job.TargetVideoPath);
|
||||
|
||||
fixture.OpenList.Objects[job.TargetVideoPath] = FileObject("segment.mp4", job.VideoSizeBytes);
|
||||
fixture.OpenList.Objects[job.TargetVideoPath] = new OpenListObjectInfo(
|
||||
"segment.mp4",
|
||||
job.VideoSizeBytes,
|
||||
false,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["sha256"] = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("video-content"))).ToLowerInvariant()
|
||||
});
|
||||
Assert.True(await fixture.Queue.ProcessNextAsync());
|
||||
|
||||
fixture.Context.ChangeTracker.Clear();
|
||||
@@ -308,7 +336,7 @@ public sealed class OpenListUploadTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Queue_RejectsSameNameConflictWithoutOverwriting()
|
||||
public async Task Queue_RenamesSameNameConflictWithoutOverwriting()
|
||||
{
|
||||
await using var fixture = await QueueFixture.CreateAsync();
|
||||
await fixture.Queue.EnqueueAsync(fixture.RecordTaskId);
|
||||
@@ -318,11 +346,12 @@ public sealed class OpenListUploadTests
|
||||
Assert.True(await fixture.Queue.ProcessNextAsync());
|
||||
|
||||
fixture.Context.ChangeTracker.Clear();
|
||||
var failedJob = await fixture.Context.RecordUploadJobs.SingleAsync();
|
||||
var renamedJob = await fixture.Context.RecordUploadJobs.SingleAsync();
|
||||
var result = await fixture.Context.RecordResults.SingleAsync();
|
||||
Assert.Equal(RecordArtifactUploadStatus.Failed, failedJob.Status);
|
||||
Assert.Equal(RecordArtifactUploadStatus.Failed, result.UploadStatus);
|
||||
Assert.Contains("内容不一致", failedJob.ErrorMessage);
|
||||
Assert.Equal(RecordArtifactUploadStatus.Uploading, renamedJob.Status);
|
||||
Assert.Equal(RecordArtifactUploadStatus.Uploading, result.UploadStatus);
|
||||
Assert.EndsWith($"_{fixture.RecordTaskId.ToString("N")[..8]}.mp4", renamedJob.TargetVideoPath, StringComparison.Ordinal);
|
||||
Assert.NotNull(renamedJob.TransferTargetPath);
|
||||
Assert.Empty(fixture.OpenList.CopyRequests);
|
||||
}
|
||||
|
||||
@@ -380,6 +409,57 @@ public sealed class OpenListUploadTests
|
||||
Assert.Single(fixture.OpenList.CopyRequests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutomaticRecovery_QueuesCompletedTaskThatWasMissedAfterRestart_OnlyOnce()
|
||||
{
|
||||
await using var fixture = await QueueFixture.CreateAsync();
|
||||
var task = await fixture.Context.RecordTasks.SingleAsync();
|
||||
task.MarkCompleted(DateTimeOffset.UtcNow.AddMinutes(-1), 60);
|
||||
await fixture.Context.SaveChangesAsync();
|
||||
Assert.Equal(1, await fixture.Context.RecordCompletionDispatches.CountAsync());
|
||||
|
||||
var recovered = await fixture.Queue.RecoverPendingAutomaticUploadsAsync();
|
||||
|
||||
Assert.Equal(1, recovered);
|
||||
var job = await fixture.Context.RecordUploadJobs.AsNoTracking().SingleAsync();
|
||||
var result = await fixture.Context.RecordResults.AsNoTracking().SingleAsync();
|
||||
Assert.Equal(task.Id, job.RecordTaskId);
|
||||
Assert.Equal(RecordArtifactUploadStatus.Queued, job.Status);
|
||||
Assert.Equal(RecordArtifactUploadStatus.Queued, result.UploadStatus);
|
||||
|
||||
fixture.Context.ChangeTracker.Clear();
|
||||
Assert.Equal(0, await fixture.Queue.RecoverPendingAutomaticUploadsAsync());
|
||||
Assert.Equal(1, await fixture.Context.RecordUploadJobs.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompletionOutbox_IsCreatedWhenRecordResultWasWrittenOutsideEfTracking()
|
||||
{
|
||||
await using var fixture = await QueueFixture.CreateAsync();
|
||||
fixture.Context.ChangeTracker.Clear();
|
||||
var task = await fixture.Context.RecordTasks.SingleAsync();
|
||||
|
||||
task.MarkCompleted(DateTimeOffset.UtcNow, 60);
|
||||
await fixture.Context.SaveChangesAsync();
|
||||
|
||||
var dispatch = await fixture.Context.RecordCompletionDispatches.AsNoTracking().SingleAsync();
|
||||
Assert.Equal(task.Id, dispatch.RecordTaskId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PendingUploadMetrics_SeparateFailedFragmentsFromEligibleArtifacts()
|
||||
{
|
||||
await using var fixture = await QueueFixture.CreateAsync();
|
||||
var task = await fixture.Context.RecordTasks.SingleAsync();
|
||||
task.MarkFailed("broken timestamps", DateTimeOffset.UtcNow);
|
||||
await fixture.Context.SaveChangesAsync();
|
||||
var repository = new RecordResultRepository(fixture.Context);
|
||||
|
||||
Assert.Equal(0, await repository.CountPendingUploadAsync());
|
||||
Assert.Equal(0, await repository.SumPendingUploadBytesAsync());
|
||||
Assert.Equal(1, await repository.CountFailedArtifactAsync());
|
||||
}
|
||||
|
||||
private static OpenListClient CreateClient(HttpMessageHandler handler) =>
|
||||
new(new StubHttpClientFactory(handler));
|
||||
|
||||
@@ -442,12 +522,14 @@ public sealed class OpenListUploadTests
|
||||
{
|
||||
private readonly DbContextOptions<LiveRecorderDbContext> _options;
|
||||
private readonly FixedSettingsService _settingsService;
|
||||
private readonly IVideoMetadataService _videoMetadataService;
|
||||
private readonly string _temporaryRoot;
|
||||
|
||||
private QueueFixture(
|
||||
DbContextOptions<LiveRecorderDbContext> options,
|
||||
LiveRecorderDbContext context,
|
||||
FixedSettingsService settingsService,
|
||||
IVideoMetadataService videoMetadataService,
|
||||
FakeOpenListClient openList,
|
||||
string temporaryRoot,
|
||||
Guid recordTaskId)
|
||||
@@ -455,6 +537,7 @@ public sealed class OpenListUploadTests
|
||||
_options = options;
|
||||
Context = context;
|
||||
_settingsService = settingsService;
|
||||
_videoMetadataService = videoMetadataService;
|
||||
OpenList = openList;
|
||||
_temporaryRoot = temporaryRoot;
|
||||
RecordTaskId = recordTaskId;
|
||||
@@ -469,7 +552,7 @@ public sealed class OpenListUploadTests
|
||||
|
||||
public Guid RecordTaskId { get; }
|
||||
|
||||
public static async Task<QueueFixture> CreateAsync()
|
||||
public static async Task<QueueFixture> CreateAsync(VideoMetadata? metadata = null)
|
||||
{
|
||||
var temporaryRoot = Path.Combine(Path.GetTempPath(), $"live-recorder-openlist-{Guid.NewGuid():N}");
|
||||
var recordDirectory = Path.Combine(temporaryRoot, "Douyin", "2026", "08", "01", "主播");
|
||||
@@ -504,6 +587,8 @@ public sealed class OpenListUploadTests
|
||||
"origin",
|
||||
RecordOutputFormat.Mp4,
|
||||
now);
|
||||
task.MarkStarting("https://cdn.example/stream.flv", videoPath, now);
|
||||
task.MarkCompleted(now.AddMinutes(1), 60);
|
||||
var result = new RecordResult(
|
||||
task.Id,
|
||||
videoPath,
|
||||
@@ -533,11 +618,14 @@ public sealed class OpenListUploadTests
|
||||
}
|
||||
};
|
||||
var settingsService = new FixedSettingsService(settings);
|
||||
var videoMetadataService = new FixedVideoMetadataService(
|
||||
metadata ?? new VideoMetadata(60, 1920, 1080, "h264", "aac", 30, 4_000_000));
|
||||
var openList = new FakeOpenListClient();
|
||||
return new QueueFixture(
|
||||
options,
|
||||
context,
|
||||
settingsService,
|
||||
videoMetadataService,
|
||||
openList,
|
||||
temporaryRoot,
|
||||
task.Id);
|
||||
@@ -560,7 +648,25 @@ public sealed class OpenListUploadTests
|
||||
}
|
||||
|
||||
private OpenListUploadQueueService CreateQueue(LiveRecorderDbContext context) =>
|
||||
new(context, _settingsService, OpenList, new NullSystemLogService());
|
||||
new(context, _settingsService, OpenList, new NullSystemLogService(), _videoMetadataService);
|
||||
}
|
||||
|
||||
private sealed class FixedVideoMetadataService : IVideoMetadataService
|
||||
{
|
||||
private readonly VideoMetadata? _metadata;
|
||||
|
||||
public FixedVideoMetadataService(VideoMetadata? metadata)
|
||||
{
|
||||
_metadata = metadata;
|
||||
}
|
||||
|
||||
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 FixedSettingsService : ISystemSettingsService
|
||||
@@ -660,5 +766,35 @@ public sealed class OpenListUploadTests
|
||||
string taskId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Tasks.GetValueOrDefault(taskId));
|
||||
|
||||
public Task<bool> TryCancelCopyTaskAsync(
|
||||
OpenListConnectionRequest connection,
|
||||
string taskId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Operations.Add($"cancel:{taskId}");
|
||||
Tasks.Remove(taskId);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task RenameAsync(
|
||||
OpenListConnectionRequest connection,
|
||||
string path,
|
||||
string newName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Operations.Add($"rename:{path}->{newName}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task MoveAsync(
|
||||
OpenListConnectionRequest connection,
|
||||
string sourcePath,
|
||||
string targetDirectory,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Operations.Add($"move:{sourcePath}->{targetDirectory}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using LiveRecorder.Infrastructure.Services;
|
||||
|
||||
namespace LiveRecorder.Tests;
|
||||
|
||||
public sealed class OrphanedSegmentRecoveryTests : IDisposable
|
||||
{
|
||||
private readonly string _temporaryDirectory = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"liverecorder-orphan-recovery-{Guid.NewGuid():N}");
|
||||
|
||||
[Fact]
|
||||
public void DiscoverRecoverableSegments_FindsExactNonEmptySegmentFilesInOrder()
|
||||
{
|
||||
Directory.CreateDirectory(_temporaryDirectory);
|
||||
var pattern = Path.Combine(_temporaryDirectory, "224332_186东北男大_%05d.mp4");
|
||||
File.WriteAllBytes(Path.Combine(_temporaryDirectory, "224332_186东北男大_00003.ts"), [3]);
|
||||
File.WriteAllBytes(Path.Combine(_temporaryDirectory, "224332_186东北男大_00001.ts"), [1]);
|
||||
File.WriteAllBytes(Path.Combine(_temporaryDirectory, "224332_186东北男大_00002.ts"), [2]);
|
||||
File.WriteAllBytes(Path.Combine(_temporaryDirectory, "other_00004.ts"), [4]);
|
||||
File.WriteAllBytes(Path.Combine(_temporaryDirectory, "224332_186东北男大_00004.ts"), []);
|
||||
File.WriteAllText(Path.Combine(_temporaryDirectory, "224332_186东北男大_00001.xml"), "<i />");
|
||||
|
||||
var segments = FfmpegService.DiscoverRecoverableSegments(
|
||||
pattern,
|
||||
RecordOutputFormat.Mp4,
|
||||
RecordSaveMode.Segmented);
|
||||
|
||||
Assert.Equal([1, 2, 3], segments.Select(segment => segment.SegmentIndex));
|
||||
Assert.Equal(
|
||||
Path.Combine(_temporaryDirectory, "224332_186东北男大_00002.ts"),
|
||||
segments[1].RecorderPath);
|
||||
Assert.Equal(
|
||||
Path.Combine(_temporaryDirectory, "224332_186东北男大_00002.mp4"),
|
||||
segments[1].OutputPath);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(RecordOutputFormat.Ts, RecordSaveMode.Segmented)]
|
||||
[InlineData(RecordOutputFormat.Mp4, RecordSaveMode.SingleFile)]
|
||||
public void DiscoverRecoverableSegments_RejectsUnsupportedRecordingModes(
|
||||
RecordOutputFormat outputFormat,
|
||||
RecordSaveMode saveMode)
|
||||
{
|
||||
Directory.CreateDirectory(_temporaryDirectory);
|
||||
var pattern = Path.Combine(_temporaryDirectory, "record_%05d.mp4");
|
||||
File.WriteAllBytes(Path.Combine(_temporaryDirectory, "record_00001.ts"), [1]);
|
||||
|
||||
var segments = FfmpegService.DiscoverRecoverableSegments(pattern, outputFormat, saveMode);
|
||||
|
||||
Assert.Empty(segments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoverRecoverableSegments_IsIdempotentAndDoesNotModifySourceFiles()
|
||||
{
|
||||
Directory.CreateDirectory(_temporaryDirectory);
|
||||
var sourcePath = Path.Combine(_temporaryDirectory, "record_00001.ts");
|
||||
var pattern = Path.Combine(_temporaryDirectory, "record_%05d.mp4");
|
||||
File.WriteAllBytes(sourcePath, [1, 2, 3]);
|
||||
|
||||
var first = FfmpegService.DiscoverRecoverableSegments(pattern, RecordOutputFormat.Mp4, RecordSaveMode.Segmented);
|
||||
var second = FfmpegService.DiscoverRecoverableSegments(pattern, RecordOutputFormat.Mp4, RecordSaveMode.Segmented);
|
||||
|
||||
Assert.Equal(first, second);
|
||||
Assert.True(File.Exists(sourcePath));
|
||||
Assert.Equal(3, new FileInfo(sourcePath).Length);
|
||||
Assert.False(File.Exists(Path.ChangeExtension(sourcePath, ".mp4")));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_temporaryDirectory))
|
||||
{
|
||||
Directory.Delete(_temporaryDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
using LiveRecorder.Application.Abstractions.Persistence;
|
||||
using LiveRecorder.Domain.Entities;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using LiveRecorder.Infrastructure.Persistence;
|
||||
using LiveRecorder.Infrastructure.Persistence.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace LiveRecorder.Tests;
|
||||
|
||||
public sealed class RecordSessionRepositoryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Overview_IsBoundedButAlwaysIncludesOlderActiveSessions()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<LiveRecorderDbContext>()
|
||||
.UseInMemoryDatabase($"record-session-overview-{Guid.NewGuid():N}")
|
||||
.Options;
|
||||
await using var context = new LiveRecorderDbContext(options);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var room = new LiveRoom(
|
||||
LivePlatformType.Douyin,
|
||||
"https://live.example/room",
|
||||
"room-1",
|
||||
"https://live.example/room",
|
||||
now.AddDays(-2));
|
||||
var oldActive = new RecordSession(
|
||||
room.Id,
|
||||
"origin",
|
||||
RecordOutputFormat.Mp4,
|
||||
RecordSaveMode.Segmented,
|
||||
now.AddDays(-1));
|
||||
oldActive.MarkStarting("https://stream.example/live", "/records/active.mp4", now.AddDays(-1));
|
||||
oldActive.MarkRunning(now.AddDays(-1));
|
||||
|
||||
var completed = Enumerable.Range(0, 205)
|
||||
.Select(index =>
|
||||
{
|
||||
var createdAt = now.AddMinutes(index);
|
||||
var session = new RecordSession(
|
||||
room.Id,
|
||||
"origin",
|
||||
RecordOutputFormat.Mp4,
|
||||
RecordSaveMode.Segmented,
|
||||
createdAt);
|
||||
session.MarkCompleted(createdAt.AddMinutes(1));
|
||||
return session;
|
||||
})
|
||||
.ToArray();
|
||||
context.Add(room);
|
||||
context.Add(oldActive);
|
||||
context.AddRange(completed);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var repository = new RecordSessionRepository(context);
|
||||
var overview = await repository.ListOverviewAsync(null, 200);
|
||||
var activeIds = await repository.ListActiveIdsAsync();
|
||||
|
||||
Assert.Equal(201, overview.Count);
|
||||
Assert.Contains(overview, item => item.Id == oldActive.Id);
|
||||
Assert.Equal([oldActive.Id], activeIds);
|
||||
Assert.True(overview.SequenceEqual(overview.OrderByDescending(static item => item.CreatedAt)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Page_IsBoundedFilterableAndReturnsGlobalTotals()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<LiveRecorderDbContext>()
|
||||
.UseInMemoryDatabase($"record-session-page-{Guid.NewGuid():N}")
|
||||
.Options;
|
||||
await using var context = new LiveRecorderDbContext(options);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var room = new LiveRoom(
|
||||
LivePlatformType.Douyin,
|
||||
"https://live.example/page-room",
|
||||
"page-room",
|
||||
"https://live.example/page-room",
|
||||
now.AddDays(-3));
|
||||
room.UpdateMetadata("分页测试直播间", "分页主播", null, null, null, now.AddDays(-3));
|
||||
|
||||
var oldActive = new RecordSession(
|
||||
room.Id,
|
||||
"origin",
|
||||
RecordOutputFormat.Mp4,
|
||||
RecordSaveMode.Segmented,
|
||||
now.AddDays(-2));
|
||||
oldActive.MarkStarting("https://stream.example/live", "/records/active.mp4", now.AddDays(-2));
|
||||
oldActive.MarkRunning(now.AddDays(-2));
|
||||
|
||||
var completed = Enumerable.Range(0, 29)
|
||||
.Select(index =>
|
||||
{
|
||||
var createdAt = now.AddMinutes(index);
|
||||
var session = new RecordSession(
|
||||
room.Id,
|
||||
"origin",
|
||||
RecordOutputFormat.Mp4,
|
||||
RecordSaveMode.Segmented,
|
||||
createdAt);
|
||||
session.MarkCompleted(createdAt.AddMinutes(1));
|
||||
return session;
|
||||
})
|
||||
.ToArray();
|
||||
var searchableTask = new RecordTask(
|
||||
room.Id,
|
||||
completed[0].Id,
|
||||
1,
|
||||
"origin",
|
||||
RecordOutputFormat.Mp4,
|
||||
now);
|
||||
searchableTask.MarkStarting("https://stream.example/archive", "/records/needle-video.mp4", now);
|
||||
searchableTask.MarkCompleted(now.AddMinutes(1), 60);
|
||||
var result = new RecordResult(
|
||||
searchableTask.Id,
|
||||
"/records/needle-video.mp4",
|
||||
1024,
|
||||
60,
|
||||
"/records/needle-video.xml",
|
||||
37,
|
||||
RecordTaskStatus.Completed,
|
||||
null,
|
||||
now.AddMinutes(1));
|
||||
|
||||
context.Add(room);
|
||||
context.Add(oldActive);
|
||||
context.AddRange(completed);
|
||||
context.Add(searchableTask);
|
||||
context.Add(result);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var repository = new RecordSessionRepository(context);
|
||||
var firstPage = await repository.ListPageAsync(null, null, null, 0, 10);
|
||||
var completedPage = await repository.ListPageAsync(
|
||||
null,
|
||||
[RecordSessionStatus.Completed],
|
||||
null,
|
||||
0,
|
||||
10);
|
||||
var searchPage = await repository.ListPageAsync(null, null, "needle-video", 0, 10);
|
||||
var totals = await repository.GetOverviewTotalsAsync();
|
||||
|
||||
Assert.Equal(30, firstPage.TotalCount);
|
||||
Assert.Equal(10, firstPage.Items.Count);
|
||||
Assert.Equal(oldActive.Id, firstPage.Items[0].Id);
|
||||
Assert.Equal(29, completedPage.TotalCount);
|
||||
Assert.DoesNotContain(completedPage.Items, item => item.Id == oldActive.Id);
|
||||
Assert.Single(searchPage.Items);
|
||||
Assert.Equal(completed[0].Id, searchPage.Items[0].Id);
|
||||
Assert.Equal(new RecordSessionOverviewTotals(30, 1, 1, 37), totals);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrackedBatchLookup_ReusesLiveRoomAfterTasksAreDeleted()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<LiveRecorderDbContext>()
|
||||
.UseInMemoryDatabase($"record-session-delete-{Guid.NewGuid():N}")
|
||||
.Options;
|
||||
await using var context = new LiveRecorderDbContext(options);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var room = new LiveRoom(
|
||||
LivePlatformType.Douyin,
|
||||
"https://live.example/delete-room",
|
||||
"delete-room",
|
||||
"https://live.example/delete-room",
|
||||
now.AddHours(-2));
|
||||
var sessions = Enumerable.Range(0, 2)
|
||||
.Select(index =>
|
||||
{
|
||||
var createdAt = now.AddMinutes(index - 10);
|
||||
var session = new RecordSession(
|
||||
room.Id,
|
||||
"origin",
|
||||
RecordOutputFormat.Mp4,
|
||||
RecordSaveMode.Segmented,
|
||||
createdAt);
|
||||
session.MarkCompleted(createdAt.AddMinutes(1));
|
||||
return session;
|
||||
})
|
||||
.ToArray();
|
||||
var tasks = sessions
|
||||
.Select((session, index) =>
|
||||
{
|
||||
var task = new RecordTask(
|
||||
room.Id,
|
||||
session.Id,
|
||||
1,
|
||||
"origin",
|
||||
RecordOutputFormat.Mp4,
|
||||
now.AddMinutes(index - 10));
|
||||
task.MarkStarting(
|
||||
"https://stream.example/archive",
|
||||
$"/records/delete-{index}.mp4",
|
||||
now.AddMinutes(index - 10));
|
||||
task.MarkFailed("invalid media", now.AddMinutes(index - 9), durationSeconds: null);
|
||||
return task;
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
context.Add(room);
|
||||
context.AddRange(sessions);
|
||||
context.AddRange(tasks);
|
||||
await context.SaveChangesAsync();
|
||||
context.ChangeTracker.Clear();
|
||||
|
||||
var taskRepository = new RecordTaskRepository(context);
|
||||
var sessionRepository = new RecordSessionRepository(context);
|
||||
var trackedTasks = await taskRepository.GetByIdsAsync(tasks.Select(static item => item.Id).ToArray());
|
||||
var trackedRoom = Assert.Single(trackedTasks.Select(static item => item.LiveRoom).Distinct());
|
||||
taskRepository.RemoveRange(trackedTasks);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var trackedSessions = await sessionRepository.GetByIdsAsync(
|
||||
sessions.Select(static item => item.Id).ToArray());
|
||||
|
||||
Assert.Equal(2, trackedSessions.Count);
|
||||
Assert.All(trackedSessions, session =>
|
||||
{
|
||||
Assert.Same(trackedRoom, session.LiveRoom);
|
||||
Assert.Empty(session.RecordTasks);
|
||||
sessionRepository.Remove(session);
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
Assert.Empty(await context.RecordSessions.ToListAsync());
|
||||
Assert.Empty(await context.RecordTasks.ToListAsync());
|
||||
Assert.Single(await context.LiveRooms.ToListAsync());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using LiveRecorder.Domain.Entities;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using LiveRecorder.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace LiveRecorder.Tests;
|
||||
|
||||
public sealed class ShutdownRecoveryDispatchTests : IDisposable
|
||||
{
|
||||
private readonly string _temporaryDirectory = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"liverecorder-shutdown-dispatch-{Guid.NewGuid():N}");
|
||||
|
||||
[Fact]
|
||||
public async Task RecoveredProcessingTask_CreatesDurableCompletionDispatch()
|
||||
{
|
||||
Directory.CreateDirectory(_temporaryDirectory);
|
||||
var outputPath = Path.Combine(_temporaryDirectory, "recovered.mp4");
|
||||
await File.WriteAllBytesAsync(outputPath, [1, 2, 3, 4]);
|
||||
|
||||
var options = new DbContextOptionsBuilder<LiveRecorderDbContext>()
|
||||
.UseInMemoryDatabase($"shutdown-recovery-dispatch-{Guid.NewGuid():N}")
|
||||
.Options;
|
||||
await using var context = new LiveRecorderDbContext(options);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var room = new LiveRoom(
|
||||
LivePlatformType.Douyin,
|
||||
"https://live.example/recovery",
|
||||
"recovery-room",
|
||||
"https://live.example/recovery",
|
||||
now.AddHours(-1));
|
||||
var session = new RecordSession(
|
||||
room.Id,
|
||||
"origin",
|
||||
RecordOutputFormat.Mp4,
|
||||
RecordSaveMode.Segmented,
|
||||
now.AddMinutes(-30));
|
||||
session.MarkStopped(now, "Application shutdown deferred MP4 finalization.");
|
||||
var task = new RecordTask(
|
||||
room.Id,
|
||||
session.Id,
|
||||
1,
|
||||
"origin",
|
||||
RecordOutputFormat.Mp4,
|
||||
now.AddMinutes(-30));
|
||||
task.MarkStarting("https://stream.example/recovery", outputPath, now.AddMinutes(-30));
|
||||
task.MarkProcessing("Waiting for restart recovery.", now);
|
||||
var result = new RecordResult(
|
||||
task.Id,
|
||||
outputPath,
|
||||
new FileInfo(outputPath).Length,
|
||||
1_800,
|
||||
null,
|
||||
0,
|
||||
RecordTaskStatus.Processing,
|
||||
task.ErrorMessage,
|
||||
now);
|
||||
|
||||
context.AddRange(room, session, task, result);
|
||||
await context.SaveChangesAsync();
|
||||
Assert.Empty(context.RecordCompletionDispatches);
|
||||
|
||||
task.MarkCompleted(now.AddMinutes(1), 1_800);
|
||||
session.MarkCompleted(now.AddMinutes(1));
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var dispatch = await context.RecordCompletionDispatches.SingleAsync();
|
||||
Assert.Equal(task.Id, dispatch.RecordTaskId);
|
||||
Assert.False(dispatch.UploadDispatched);
|
||||
Assert.Null(dispatch.CompletedAt);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_temporaryDirectory))
|
||||
{
|
||||
Directory.Delete(_temporaryDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using LiveRecorder.Application.Abstractions.Storage;
|
||||
using LiveRecorder.Application.Models.Settings;
|
||||
using LiveRecorder.Infrastructure.Services;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace LiveRecorder.Tests;
|
||||
|
||||
public sealed class StorageGuardServiceTests
|
||||
{
|
||||
private readonly StorageGuardService _service = new(NullLogger<StorageGuardService>.Instance);
|
||||
|
||||
[Fact]
|
||||
public void DisabledProtection_StillReportsActualCapacityWithoutBlockingRecording()
|
||||
{
|
||||
var settings = CreateSettings(enableStorageGuard: false);
|
||||
|
||||
var result = _service.CheckCanStartOrResume(settings);
|
||||
|
||||
Assert.False(result.IsEnabled);
|
||||
Assert.True(result.IsAvailable);
|
||||
Assert.True(result.HasEnoughSpace);
|
||||
Assert.True(result.CanStartNewRecording);
|
||||
Assert.False(result.ShouldPauseActive);
|
||||
Assert.True(result.TotalBytes > 0);
|
||||
Assert.Equal(result.TotalBytes, result.UsedBytes + result.AvailableBytes);
|
||||
Assert.InRange(result.UsagePercent, 0, 100);
|
||||
Assert.InRange(result.FreePercent, 0, 100);
|
||||
Assert.InRange(result.UsagePercent + result.FreePercent, 99.8, 100.2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnabledProtection_ReportsConfiguredThresholdsAndConsistentPercentages()
|
||||
{
|
||||
var settings = CreateSettings(enableStorageGuard: true);
|
||||
settings.StorageGreenThresholdPercent = 35;
|
||||
settings.StorageRedThresholdPercent = 12;
|
||||
|
||||
var result = _service.CheckCanStartOrResume(settings);
|
||||
|
||||
Assert.True(result.IsEnabled);
|
||||
Assert.True(result.IsAvailable);
|
||||
Assert.Equal(35, result.GreenThresholdPercent);
|
||||
Assert.Equal(12, result.RedThresholdPercent);
|
||||
Assert.Equal(result.TotalBytes, result.UsedBytes + result.AvailableBytes);
|
||||
Assert.InRange(result.UsagePercent + result.FreePercent, 99.8, 100.2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidPath_ReturnsUnavailableStateInsteadOfThrowing()
|
||||
{
|
||||
var settings = CreateSettings(enableStorageGuard: true);
|
||||
settings.OutputRoot = "invalid\0path";
|
||||
|
||||
var result = _service.CheckCanStartOrResume(settings);
|
||||
|
||||
Assert.True(result.IsEnabled);
|
||||
Assert.False(result.IsAvailable);
|
||||
Assert.False(result.HasEnoughSpace);
|
||||
Assert.False(result.CanStartNewRecording);
|
||||
Assert.Equal(StorageTier.Red, result.Tier);
|
||||
Assert.Equal(0, result.TotalBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RedPercentageTier_DoesNotBlockFinalizeWhenAbsoluteTemporarySpaceIsAvailable()
|
||||
{
|
||||
var settings = CreateSettings(enableStorageGuard: true);
|
||||
settings.PauseRecordingWhenFreeSpaceBelowMegabytes = 0;
|
||||
settings.ResumeRecordingWhenFreeSpaceAboveMegabytes = 0;
|
||||
settings.StorageGreenThresholdPercent = 90;
|
||||
settings.StorageRedThresholdPercent = 85;
|
||||
|
||||
var result = _service.CheckCanFinalize(settings, estimatedTemporaryBytes: 1);
|
||||
|
||||
Assert.Equal(StorageTier.Red, result.Tier);
|
||||
Assert.True(result.HasEnoughSpace);
|
||||
Assert.True(result.ShouldPauseActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Finalize_ReservesSourceEstimateInAdditionToPauseThreshold()
|
||||
{
|
||||
var settings = CreateSettings(enableStorageGuard: true);
|
||||
settings.PauseRecordingWhenFreeSpaceBelowMegabytes = int.MaxValue;
|
||||
|
||||
var result = _service.CheckCanFinalize(settings, estimatedTemporaryBytes: 1024);
|
||||
|
||||
Assert.False(result.HasEnoughSpace);
|
||||
Assert.True(result.RequiredBytes > 1024);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultiSegmentFinalize_UsesConcatDemuxerWithoutMaterializingCombinedTs()
|
||||
{
|
||||
var arguments = FfmpegService.BuildMp4FinalizeArgumentList(
|
||||
"/records/input.ffconcat",
|
||||
"/records/output.remux.mp4",
|
||||
strategy: default,
|
||||
useConcatDemuxer: true);
|
||||
|
||||
Assert.Contains("concat", arguments);
|
||||
Assert.Contains("-safe", arguments);
|
||||
Assert.Contains("/records/input.ffconcat", arguments);
|
||||
Assert.DoesNotContain(".concat.ts", arguments);
|
||||
}
|
||||
|
||||
private static SystemSettingsDto CreateSettings(bool enableStorageGuard) => new()
|
||||
{
|
||||
OutputRoot = Path.GetTempPath(),
|
||||
EnableStorageGuard = enableStorageGuard,
|
||||
PauseRecordingWhenFreeSpaceBelowMegabytes = 1024,
|
||||
ResumeRecordingWhenFreeSpaceAboveMegabytes = 4096,
|
||||
StorageGreenThresholdPercent = 30,
|
||||
StorageRedThresholdPercent = 10
|
||||
};
|
||||
}
|
||||
@@ -63,6 +63,34 @@ public sealed class SystemSettingsServiceTests
|
||||
Assert.Contains(allSettings, static item => item.Key == "platform_request.twitch.cookie" && item.Value == "auth-token=123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StorageThresholds_NormalizeUnsafeLegacyValuesAndKeepSafetyGap()
|
||||
{
|
||||
var repository = new InMemoryAppSettingRepository(
|
||||
[
|
||||
new AppSetting("storage.guard.green_threshold_percent", "5", DateTimeOffset.UtcNow),
|
||||
new AppSetting("storage.guard.red_threshold_percent", "1", DateTimeOffset.UtcNow)
|
||||
]);
|
||||
var service = new SystemSettingsService(repository, new NoOpUnitOfWork());
|
||||
|
||||
var settings = await service.GetAsync();
|
||||
|
||||
Assert.Equal(10, settings.StorageGreenThresholdPercent);
|
||||
Assert.Equal(5, settings.StorageRedThresholdPercent);
|
||||
|
||||
var request = new Application.Models.Settings.UpdateSystemSettingsRequest
|
||||
{
|
||||
StorageGreenThresholdPercent = 20,
|
||||
StorageRedThresholdPercent = 19,
|
||||
PlatformRequestSettings = Application.Models.Settings.SystemSettingsDto.CreatePlatformRequestSettingsMap()
|
||||
};
|
||||
await service.UpdateAsync(request);
|
||||
|
||||
var updated = await service.GetAsync();
|
||||
Assert.Equal(20, updated.StorageGreenThresholdPercent);
|
||||
Assert.Equal(15, updated.StorageRedThresholdPercent);
|
||||
}
|
||||
|
||||
private sealed class InMemoryAppSettingRepository : IAppSettingRepository
|
||||
{
|
||||
private readonly Dictionary<string, AppSetting> _items;
|
||||
|
||||
Reference in New Issue
Block a user