feat: harden recording lifecycle and refresh fnOS UI
This commit is contained in:
@@ -61,8 +61,28 @@ public interface IRecordSessionRepository
|
||||
{
|
||||
Task<RecordSession?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<RecordSession>> GetByIdsAsync(
|
||||
IReadOnlyCollection<Guid> ids,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<RecordSession>> ListAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<RecordSession>> ListOverviewAsync(Guid? liveRoomId, int take, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<(IReadOnlyList<RecordSession> Items, int TotalCount)> ListPageAsync(
|
||||
Guid? liveRoomId,
|
||||
IReadOnlyCollection<RecordSessionStatus>? statuses,
|
||||
string? search,
|
||||
int skip,
|
||||
int take,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RecordSessionOverviewTotals> GetOverviewTotalsAsync(
|
||||
Guid? liveRoomId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyCollection<Guid>> ListActiveIdsAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyCollection<Guid>> ListActiveLiveRoomIdsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RecordSession?> GetActiveByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default);
|
||||
@@ -80,6 +100,12 @@ public interface IRecordSessionRepository
|
||||
void Remove(RecordSession recordSession);
|
||||
}
|
||||
|
||||
public sealed record RecordSessionOverviewTotals(
|
||||
int TotalSessionCount,
|
||||
int ActiveSessionCount,
|
||||
int TotalTaskCount,
|
||||
int TotalDanmakuCount);
|
||||
|
||||
public interface IRecordResultRepository
|
||||
{
|
||||
Task<RecordResult?> GetByTaskIdAsync(Guid recordTaskId, CancellationToken cancellationToken = default);
|
||||
@@ -90,6 +116,8 @@ public interface IRecordResultRepository
|
||||
|
||||
Task<long> SumPendingUploadBytesAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountFailedArtifactAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<List<(RecordResult Result, RecordTask Task)>> ListUploadStatusAsync(
|
||||
RecordArtifactUploadStatus? uploadStatusFilter,
|
||||
int skip,
|
||||
@@ -139,6 +167,17 @@ public interface ISystemLogRepository
|
||||
Task<IReadOnlyList<Guid>> ListSessionIdsWithoutTasksAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IOperationsMetricsRepository
|
||||
{
|
||||
Task<OperationsMetricsSnapshot> GetAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record OperationsMetricsSnapshot(
|
||||
DateTimeOffset? OldestTranscodeUpdatedAt,
|
||||
DateTimeOffset? OldestUploadProgressAt,
|
||||
int StalledUploadCount,
|
||||
int CleanupFailureCount);
|
||||
|
||||
public interface IUserAccountRepository
|
||||
{
|
||||
Task<UserAccount?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -28,8 +28,17 @@ public interface IFfmpegService
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> StopAllAndWaitAsync(
|
||||
TimeSpan gracefulTimeout,
|
||||
TimeSpan forceKillTimeout,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> TryReconcileInactiveSessionAsync(Guid recordSessionId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> RecoverOrphanedTerminalSessionTasksAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> TryRecoverOrphanedTerminalTaskAsync(Guid recordTaskId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> StartManualFinalizeTaskAsync(Guid recordTaskId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<LiveRecorder.Application.Models.Media.TranscodeMediaFileResultDto> StartManualFinalizeFileAsync(
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LiveRecorder.Application.Abstractions.Recording;
|
||||
|
||||
public interface IRecordingStartLock
|
||||
{
|
||||
Task<IAsyncDisposable> AcquireAsync(Guid liveRoomId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ public interface IEventScriptService
|
||||
string segmentFilePath,
|
||||
DateTimeOffset occurredAt,
|
||||
bool forceRun = false,
|
||||
Guid? eventId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<EventScriptTestResultDto> TestAsync(
|
||||
|
||||
@@ -19,6 +19,8 @@ public interface IStorageGuardService
|
||||
StorageGuardResult CheckCanStartOrResume(SystemSettingsDto settings, long additionalRequiredBytes = 0);
|
||||
|
||||
StorageGuardResult CheckShouldPause(SystemSettingsDto settings);
|
||||
|
||||
StorageGuardResult CheckCanFinalize(SystemSettingsDto settings, long estimatedTemporaryBytes);
|
||||
}
|
||||
|
||||
public sealed record StorageGuardResult(
|
||||
@@ -29,25 +31,37 @@ public sealed record StorageGuardResult(
|
||||
long RequiredBytes,
|
||||
string Message)
|
||||
{
|
||||
public bool IsAvailable { get; init; }
|
||||
|
||||
public long TotalBytes { get; init; }
|
||||
|
||||
public long UsedBytes { get; init; }
|
||||
|
||||
public double FreePercent { get; init; }
|
||||
|
||||
public double GreenThresholdPercent { get; init; }
|
||||
|
||||
public double RedThresholdPercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current storage tier (Green/Yellow/Red).
|
||||
/// </summary>
|
||||
public StorageTier Tier { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Disk usage percentage (0-100). Only populated when IsEnabled is true.
|
||||
/// Disk usage percentage (0-100).
|
||||
/// </summary>
|
||||
public double UsagePercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// True if new recordings can be started. Only true in Green tier.
|
||||
/// This replaces the old binary HasEnoughSpace check — the tier system is the single source of truth.
|
||||
/// True if new recordings can be started. Both the percentage and absolute
|
||||
/// resume thresholds must be satisfied.
|
||||
/// </summary>
|
||||
public bool CanStartNewRecording => Tier == StorageTier.Green;
|
||||
public bool CanStartNewRecording => !IsEnabled || Tier == StorageTier.Green;
|
||||
|
||||
/// <summary>
|
||||
/// True if active recordings should be paused. Only true in Red tier.
|
||||
/// This replaces the old MB-based CheckShouldPause — consolidated into the tier system.
|
||||
/// True if active recordings should be paused. Either the percentage or
|
||||
/// absolute pause threshold can put storage into the Red tier.
|
||||
/// </summary>
|
||||
public bool ShouldPauseActive => Tier == StorageTier.Red;
|
||||
public bool ShouldPauseActive => IsEnabled && Tier == StorageTier.Red;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,25 @@ public sealed class RecordSessionDto
|
||||
public required IReadOnlyList<RecordTaskDto> Tasks { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RecordSessionListResponse
|
||||
{
|
||||
public required IReadOnlyList<RecordSessionDto> Items { get; init; }
|
||||
|
||||
public int TotalCount { get; init; }
|
||||
|
||||
public int Skip { get; init; }
|
||||
|
||||
public int Take { get; init; }
|
||||
|
||||
public int TotalSessionCount { get; init; }
|
||||
|
||||
public int ActiveSessionCount { get; init; }
|
||||
|
||||
public int TotalTaskCount { get; init; }
|
||||
|
||||
public int TotalDanmakuCount { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RecordSessionDetailDto
|
||||
{
|
||||
public required RecordSessionDto Session { get; init; }
|
||||
|
||||
@@ -239,6 +239,8 @@ public sealed class UploadTaskListResponse
|
||||
|
||||
public int NotUploadedCount { get; init; }
|
||||
|
||||
public int FailedArtifactCount { get; init; }
|
||||
|
||||
public int SucceededCount { get; init; }
|
||||
|
||||
public int FailedCount { get; init; }
|
||||
|
||||
@@ -15,12 +15,18 @@ public sealed class StorageGuardStatusDto
|
||||
{
|
||||
public bool IsEnabled { get; init; }
|
||||
|
||||
public bool IsAvailable { get; init; }
|
||||
|
||||
public bool HasEnoughSpace { get; init; }
|
||||
|
||||
public required string CheckedPath { get; init; }
|
||||
|
||||
public long AvailableBytes { get; init; }
|
||||
|
||||
public long TotalBytes { get; init; }
|
||||
|
||||
public long UsedBytes { get; init; }
|
||||
|
||||
public long RequiredBytes { get; init; }
|
||||
|
||||
public required string Message { get; init; }
|
||||
@@ -30,6 +36,12 @@ public sealed class StorageGuardStatusDto
|
||||
|
||||
/// <summary>Disk usage percentage (0-100).</summary>
|
||||
public double UsagePercent { get; init; }
|
||||
|
||||
public double FreePercent { get; init; }
|
||||
|
||||
public double GreenThresholdPercent { get; init; }
|
||||
|
||||
public double RedThresholdPercent { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RecoverableLiveRoomDto
|
||||
|
||||
@@ -50,6 +50,11 @@ public sealed class DashboardDto
|
||||
/// </summary>
|
||||
public int RecentErrorCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of Error-level system logs in the last 30 minutes. This drives the current-health warning.
|
||||
/// </summary>
|
||||
public int CurrentErrorCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current storage guard status.
|
||||
/// </summary>
|
||||
@@ -79,15 +84,32 @@ public sealed class DashboardDto
|
||||
/// Total file size in bytes of files awaiting upload.
|
||||
/// </summary>
|
||||
public long QueuedDataBytes { get; init; }
|
||||
|
||||
public DateTimeOffset? OldestTranscodeUpdatedAt { get; init; }
|
||||
|
||||
public DateTimeOffset? OldestUploadProgressAt { get; init; }
|
||||
|
||||
public int StalledUploadCount { get; init; }
|
||||
|
||||
public int UploadCleanupFailureCount { get; init; }
|
||||
}
|
||||
|
||||
public sealed class StorageStatusDto
|
||||
{
|
||||
public bool IsEnabled { get; init; }
|
||||
public bool IsAvailable { get; init; }
|
||||
public bool HasEnoughSpace { get; init; }
|
||||
public string Message { get; init; } = string.Empty;
|
||||
public string CheckedPath { get; init; } = string.Empty;
|
||||
public long TotalBytes { get; init; }
|
||||
public long UsedBytes { get; init; }
|
||||
public long AvailableBytes { get; init; }
|
||||
public long RequiredBytes { get; init; }
|
||||
public string Tier { get; init; } = "Green";
|
||||
public double UsagePercent { get; init; }
|
||||
public double FreePercent { get; init; }
|
||||
public double GreenThresholdPercent { get; init; }
|
||||
public double RedThresholdPercent { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RecentSessionItemDto
|
||||
|
||||
@@ -249,6 +249,8 @@ public sealed class SystemSettingsDto
|
||||
|
||||
public bool RetentionDeleteFiles { get; set; } = false;
|
||||
|
||||
public bool RetentionRequireUploadSuccess { get; set; } = false;
|
||||
|
||||
public string RetentionVideoFileCondition { get; set; } = CleanupVideoFileConditions.Any;
|
||||
|
||||
public IReadOnlyList<int> RetentionTaskStatuses { get; set; } = [];
|
||||
@@ -530,6 +532,8 @@ public sealed class UpdateSystemSettingsRequest
|
||||
|
||||
public bool RetentionDeleteFiles { get; set; } = false;
|
||||
|
||||
public bool RetentionRequireUploadSuccess { get; set; } = false;
|
||||
|
||||
public string RetentionVideoFileCondition { get; set; } = CleanupVideoFileConditions.Any;
|
||||
|
||||
public IReadOnlyList<int> RetentionTaskStatuses { get; set; } = [];
|
||||
|
||||
@@ -16,6 +16,7 @@ public sealed class DashboardService
|
||||
private readonly ISystemLogRepository _systemLogRepository;
|
||||
private readonly ISystemSettingsService _systemSettingsService;
|
||||
private readonly IStorageGuardService _storageGuardService;
|
||||
private readonly IOperationsMetricsRepository _operationsMetricsRepository;
|
||||
|
||||
public DashboardService(
|
||||
ILiveRoomRepository liveRoomRepository,
|
||||
@@ -24,7 +25,8 @@ public sealed class DashboardService
|
||||
IRecordResultRepository recordResultRepository,
|
||||
ISystemLogRepository systemLogRepository,
|
||||
ISystemSettingsService systemSettingsService,
|
||||
IStorageGuardService storageGuardService)
|
||||
IStorageGuardService storageGuardService,
|
||||
IOperationsMetricsRepository operationsMetricsRepository)
|
||||
{
|
||||
_liveRoomRepository = liveRoomRepository;
|
||||
_recordSessionRepository = recordSessionRepository;
|
||||
@@ -33,6 +35,7 @@ public sealed class DashboardService
|
||||
_systemLogRepository = systemLogRepository;
|
||||
_systemSettingsService = systemSettingsService;
|
||||
_storageGuardService = storageGuardService;
|
||||
_operationsMetricsRepository = operationsMetricsRepository;
|
||||
}
|
||||
|
||||
public async Task<DashboardDto> GetDashboardAsync(CancellationToken cancellationToken = default)
|
||||
@@ -45,6 +48,7 @@ public sealed class DashboardService
|
||||
var todayUtcStart = new DateTimeOffset(todayBeijingDate.ToDateTime(TimeOnly.MinValue), ChinaTime.Zone.GetUtcOffset(beijingNow.DateTime)).ToUniversalTime();
|
||||
var todayUtcEnd = new DateTimeOffset(todayBeijingDate.ToDateTime(TimeOnly.MaxValue), ChinaTime.Zone.GetUtcOffset(beijingNow.DateTime)).ToUniversalTime();
|
||||
var recentErrorSince = now.AddHours(-24);
|
||||
var currentErrorSince = now.AddMinutes(-30);
|
||||
|
||||
// Run queries sequentially — DbContext is not thread-safe
|
||||
var activeRecordingCount = await _recordSessionRepository.CountByStatusAsync(RecordSessionStatus.Running, cancellationToken);
|
||||
@@ -53,6 +57,7 @@ public sealed class DashboardService
|
||||
var totalRoomCount = await _liveRoomRepository.CountAsync(cancellationToken);
|
||||
var activeSessionCount = await _recordSessionRepository.CountActiveAsync(cancellationToken);
|
||||
var recentErrorCount = await _systemLogRepository.CountRecentErrorsAsync(recentErrorSince, cancellationToken);
|
||||
var currentErrorCount = await _systemLogRepository.CountRecentErrorsAsync(currentErrorSince, cancellationToken);
|
||||
var todayRecordingSeconds = await _recordTaskRepository.SumDurationSecondsAsync(todayUtcStart, todayUtcEnd, cancellationToken);
|
||||
var (todayTotalBytes, todayTotalDanmaku) = await _recordResultRepository.GetTodayAggregateAsync(todayUtcStart, todayUtcEnd, cancellationToken);
|
||||
var recentSessions = await _recordSessionRepository.ListRecentAsync(5, cancellationToken);
|
||||
@@ -62,6 +67,7 @@ public sealed class DashboardService
|
||||
var pendingTranscodeCount = await _recordTaskRepository.CountByStatusAsync(RecordTaskStatus.Processing, cancellationToken);
|
||||
var pendingUploadCount = await _recordResultRepository.CountPendingUploadAsync(cancellationToken);
|
||||
var queuedDataBytes = await _recordResultRepository.SumPendingUploadBytesAsync(cancellationToken);
|
||||
var operationsMetrics = await _operationsMetricsRepository.GetAsync(cancellationToken);
|
||||
|
||||
return new DashboardDto
|
||||
{
|
||||
@@ -74,17 +80,31 @@ public sealed class DashboardService
|
||||
TodayDanmakuCount = todayTotalDanmaku,
|
||||
ActiveSessionCount = activeSessionCount,
|
||||
RecentErrorCount = recentErrorCount,
|
||||
CurrentErrorCount = currentErrorCount,
|
||||
StorageStatus = new StorageStatusDto
|
||||
{
|
||||
IsEnabled = storageCheck.IsEnabled,
|
||||
IsAvailable = storageCheck.IsAvailable,
|
||||
HasEnoughSpace = storageCheck.HasEnoughSpace,
|
||||
Message = storageCheck.Message ?? string.Empty,
|
||||
CheckedPath = storageCheck.CheckedPath,
|
||||
TotalBytes = storageCheck.TotalBytes,
|
||||
UsedBytes = storageCheck.UsedBytes,
|
||||
AvailableBytes = storageCheck.AvailableBytes,
|
||||
RequiredBytes = storageCheck.RequiredBytes,
|
||||
Tier = storageCheck.Tier.ToString(),
|
||||
UsagePercent = storageCheck.UsagePercent
|
||||
UsagePercent = storageCheck.UsagePercent,
|
||||
FreePercent = storageCheck.FreePercent,
|
||||
GreenThresholdPercent = storageCheck.GreenThresholdPercent,
|
||||
RedThresholdPercent = storageCheck.RedThresholdPercent
|
||||
},
|
||||
PendingTranscodeCount = pendingTranscodeCount,
|
||||
PendingUploadCount = pendingUploadCount,
|
||||
QueuedDataBytes = queuedDataBytes,
|
||||
OldestTranscodeUpdatedAt = operationsMetrics.OldestTranscodeUpdatedAt,
|
||||
OldestUploadProgressAt = operationsMetrics.OldestUploadProgressAt,
|
||||
StalledUploadCount = operationsMetrics.StalledUploadCount,
|
||||
UploadCleanupFailureCount = operationsMetrics.CleanupFailureCount,
|
||||
RecentSessions = recentSessions
|
||||
.Select(MapRecentSession)
|
||||
.ToList(),
|
||||
|
||||
@@ -34,6 +34,7 @@ public sealed class RecordService
|
||||
private readonly LiveRoomStatusService _liveRoomStatusService;
|
||||
private readonly LiveRoomRecordingSettingsResolver _liveRoomRecordingSettingsResolver;
|
||||
private readonly IStorageGuardService _storageGuardService;
|
||||
private readonly IRecordingStartLock _recordingStartLock;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
|
||||
public RecordService(
|
||||
@@ -53,6 +54,7 @@ public sealed class RecordService
|
||||
LiveRoomStatusService liveRoomStatusService,
|
||||
LiveRoomRecordingSettingsResolver liveRoomRecordingSettingsResolver,
|
||||
IStorageGuardService storageGuardService,
|
||||
IRecordingStartLock recordingStartLock,
|
||||
IUnitOfWork unitOfWork)
|
||||
{
|
||||
_liveRoomRepository = liveRoomRepository;
|
||||
@@ -71,6 +73,7 @@ public sealed class RecordService
|
||||
_liveRoomStatusService = liveRoomStatusService;
|
||||
_liveRoomRecordingSettingsResolver = liveRoomRecordingSettingsResolver;
|
||||
_storageGuardService = storageGuardService;
|
||||
_recordingStartLock = recordingStartLock;
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
@@ -249,13 +252,24 @@ public sealed class RecordService
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var storageAnchorName = GetStorageAnchorName(liveRoom, settings);
|
||||
|
||||
var recordSession = new RecordSession(liveRoom.Id, preferredQuality, outputFormat, saveMode, now);
|
||||
await _recordSessionRepository.AddAsync(recordSession, cancellationToken);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
RecordSession recordSession;
|
||||
RecordTask initialTask;
|
||||
await using (await _recordingStartLock.AcquireAsync(liveRoom.Id, cancellationToken))
|
||||
{
|
||||
activeSession = await _recordSessionRepository.GetActiveByLiveRoomIdAsync(liveRoom.Id, cancellationToken);
|
||||
if (activeSession is not null)
|
||||
{
|
||||
throw new InvalidOperationException("An active recording session already exists for the live room.");
|
||||
}
|
||||
|
||||
var initialTask = new RecordTask(liveRoom.Id, recordSession.Id, 1, preferredQuality, outputFormat, now);
|
||||
await _recordTaskRepository.AddAsync(initialTask, cancellationToken);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
recordSession = new RecordSession(liveRoom.Id, preferredQuality, outputFormat, saveMode, now);
|
||||
await _recordSessionRepository.AddAsync(recordSession, cancellationToken);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
initialTask = new RecordTask(liveRoom.Id, recordSession.Id, 1, preferredQuality, outputFormat, now);
|
||||
await _recordTaskRepository.AddAsync(initialTask, cancellationToken);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -300,7 +314,9 @@ public sealed class RecordService
|
||||
streamResult.SelectedQuality,
|
||||
outputFormat,
|
||||
saveMode,
|
||||
recordSession.Id,
|
||||
now);
|
||||
outputPattern = EnsureNonConflictingOutputPattern(outputPattern, outputFormat, saveMode, recordSession.Id);
|
||||
var initialOutputPath = ResolveSegmentOutputPath(outputPattern, outputFormat, saveMode, 1);
|
||||
|
||||
recordSession.MarkStarting(streamResult.SelectedUrl, outputPattern, now);
|
||||
@@ -422,6 +438,12 @@ public sealed class RecordService
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsUploadProtected(recordTask.UploadJob?.Status ?? recordTask.Result?.UploadStatus))
|
||||
{
|
||||
warnings.Add($"Task {recordTask.Id} is waiting for OpenList upload or cleanup and cannot be deleted.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (request.DeleteFiles)
|
||||
{
|
||||
TryDeleteRecordOutput(recordTask, warnings, deletedFilePaths, deletedDanmakuPaths);
|
||||
@@ -452,11 +474,10 @@ public sealed class RecordService
|
||||
|
||||
if (affectedSessionIds.Count > 0)
|
||||
{
|
||||
var sessions = await _recordSessionRepository.ListAsync(cancellationToken: cancellationToken);
|
||||
foreach (var session in sessions.Where(item => affectedSessionIds.Contains(item.Id)))
|
||||
var sessions = await _recordSessionRepository.GetByIdsAsync(affectedSessionIds, cancellationToken);
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
var remainingTasks = await _recordTaskRepository.ListBySessionIdAsync(session.Id, cancellationToken);
|
||||
if (remainingTasks.Count != 0 || IsActiveSessionStatus(session.Status))
|
||||
if (session.RecordTasks.Count != 0 || IsActiveSessionStatus(session.Status))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -605,7 +626,7 @@ public sealed class RecordService
|
||||
segmentFilePath,
|
||||
occurredAt,
|
||||
forceRun: true,
|
||||
cancellationToken);
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
await _systemLogService.WriteAsync(
|
||||
SystemLogLevel.Info,
|
||||
@@ -844,7 +865,8 @@ public sealed class RecordService
|
||||
};
|
||||
|
||||
private static bool IsActiveTaskStatus(RecordTaskStatus status) =>
|
||||
status is RecordTaskStatus.Starting
|
||||
status is RecordTaskStatus.Pending
|
||||
or RecordTaskStatus.Starting
|
||||
or RecordTaskStatus.Running
|
||||
or RecordTaskStatus.Stopping
|
||||
or RecordTaskStatus.Processing;
|
||||
@@ -868,7 +890,12 @@ public sealed class RecordService
|
||||
}
|
||||
|
||||
private static bool IsActiveSessionStatus(RecordSessionStatus status) =>
|
||||
status is RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping;
|
||||
status is RecordSessionStatus.Pending or RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping;
|
||||
|
||||
internal static bool IsUploadProtected(RecordArtifactUploadStatus? status) =>
|
||||
status is RecordArtifactUploadStatus.Queued
|
||||
or RecordArtifactUploadStatus.Uploading
|
||||
or RecordArtifactUploadStatus.WaitingRetry;
|
||||
|
||||
private static string BuildOutputPathPattern(
|
||||
string outputRoot,
|
||||
@@ -881,6 +908,7 @@ public sealed class RecordService
|
||||
string selectedQuality,
|
||||
RecordOutputFormat outputFormat,
|
||||
RecordSaveMode saveMode,
|
||||
Guid recordSessionId,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
var localNow = ChinaTime.ToBeijingTime(now);
|
||||
@@ -894,6 +922,7 @@ public sealed class RecordService
|
||||
title,
|
||||
selectedQuality,
|
||||
localNow,
|
||||
recordSessionId,
|
||||
segmentSuffix: string.Empty);
|
||||
var directoryPath = BuildDirectoryPath(
|
||||
outputDirectoryTemplate,
|
||||
@@ -903,6 +932,7 @@ public sealed class RecordService
|
||||
title,
|
||||
selectedQuality,
|
||||
localNow,
|
||||
recordSessionId,
|
||||
baseFileStem);
|
||||
var fileNameStem = BuildFileNameStem(
|
||||
effectiveFileNameTemplate,
|
||||
@@ -912,12 +942,71 @@ public sealed class RecordService
|
||||
title,
|
||||
selectedQuality,
|
||||
localNow,
|
||||
recordSessionId,
|
||||
saveMode == RecordSaveMode.Segmented ? "_%05d" : string.Empty);
|
||||
var folder = Path.Combine(outputRoot, directoryPath);
|
||||
var extension = outputFormat == RecordOutputFormat.Ts ? "ts" : "mp4";
|
||||
return Path.Combine(folder, $"{fileNameStem}.{extension}");
|
||||
}
|
||||
|
||||
private static string EnsureNonConflictingOutputPattern(
|
||||
string outputPattern,
|
||||
RecordOutputFormat outputFormat,
|
||||
RecordSaveMode saveMode,
|
||||
Guid recordSessionId)
|
||||
{
|
||||
var absolutePattern = Path.IsPathRooted(outputPattern)
|
||||
? Path.GetFullPath(outputPattern)
|
||||
: Path.GetFullPath(outputPattern, AppContext.BaseDirectory);
|
||||
if (!HasOutputPatternConflict(absolutePattern, outputFormat, saveMode))
|
||||
{
|
||||
return outputPattern;
|
||||
}
|
||||
|
||||
var extension = Path.GetExtension(outputPattern);
|
||||
var stem = outputPattern[..^extension.Length];
|
||||
var shortSessionId = recordSessionId.ToString("N")[..8];
|
||||
return $"{stem}_{shortSessionId}{extension}";
|
||||
}
|
||||
|
||||
private static bool HasOutputPatternConflict(
|
||||
string outputPattern,
|
||||
RecordOutputFormat outputFormat,
|
||||
RecordSaveMode saveMode)
|
||||
{
|
||||
if (saveMode == RecordSaveMode.SingleFile)
|
||||
{
|
||||
if (File.Exists(outputPattern))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return outputFormat == RecordOutputFormat.Mp4 &&
|
||||
File.Exists(Path.Combine(
|
||||
Path.GetDirectoryName(outputPattern)!,
|
||||
$"{Path.GetFileNameWithoutExtension(outputPattern)}.recording.ts"));
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(outputPattern);
|
||||
var filePattern = Path.GetFileName(outputPattern);
|
||||
var tokenIndex = filePattern.IndexOf("%05d", StringComparison.OrdinalIgnoreCase);
|
||||
if (string.IsNullOrWhiteSpace(directory) || tokenIndex < 0 || !Directory.Exists(directory))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var prefix = filePattern[..tokenIndex];
|
||||
var finalSuffix = filePattern[(tokenIndex + 4)..];
|
||||
var recorderSuffix = outputFormat == RecordOutputFormat.Mp4
|
||||
? Path.ChangeExtension(finalSuffix, ".ts")
|
||||
: finalSuffix;
|
||||
return Directory.EnumerateFiles(directory, $"{prefix}*", SearchOption.TopDirectoryOnly)
|
||||
.Select(Path.GetFileName)
|
||||
.Any(name => name is not null &&
|
||||
(name.EndsWith(finalSuffix, StringComparison.OrdinalIgnoreCase) ||
|
||||
name.EndsWith(recorderSuffix, StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
private static string? GetStorageAnchorName(LiveRoom liveRoom, Application.Models.Settings.SystemSettingsDto settings)
|
||||
{
|
||||
if (!settings.UseAliasForStorage)
|
||||
@@ -952,6 +1041,7 @@ public sealed class RecordService
|
||||
string? title,
|
||||
string quality,
|
||||
DateTimeOffset now,
|
||||
Guid recordSessionId,
|
||||
string fileStem)
|
||||
{
|
||||
var raw = ApplyOutputTemplate(
|
||||
@@ -962,6 +1052,7 @@ public sealed class RecordService
|
||||
title,
|
||||
quality,
|
||||
now,
|
||||
recordSessionId,
|
||||
forPathSegment: true,
|
||||
fileStem,
|
||||
segmentSuffix: string.Empty);
|
||||
@@ -982,6 +1073,7 @@ public sealed class RecordService
|
||||
string? title,
|
||||
string quality,
|
||||
DateTimeOffset now,
|
||||
Guid recordSessionId,
|
||||
string segmentSuffix)
|
||||
{
|
||||
var raw = ApplyOutputTemplate(
|
||||
@@ -992,6 +1084,7 @@ public sealed class RecordService
|
||||
title,
|
||||
quality,
|
||||
now,
|
||||
recordSessionId,
|
||||
forPathSegment: false,
|
||||
fileStem: string.Empty,
|
||||
segmentSuffix);
|
||||
@@ -1026,6 +1119,7 @@ public sealed class RecordService
|
||||
string? title,
|
||||
string quality,
|
||||
DateTimeOffset now,
|
||||
Guid recordSessionId,
|
||||
bool forPathSegment,
|
||||
string fileStem,
|
||||
string segmentSuffix)
|
||||
@@ -1042,6 +1136,7 @@ public sealed class RecordService
|
||||
["anchor"] = NormalizeTokenValue(anchorName, "unknown-anchor"),
|
||||
["title"] = NormalizeTokenValue(title, "untitled"),
|
||||
["quality"] = NormalizeTokenValue(quality, "origin"),
|
||||
["sessionId"] = recordSessionId.ToString("N")[..8],
|
||||
["fileStem"] = fileStem,
|
||||
["segmentSuffix"] = segmentSuffix,
|
||||
["yyyy"] = now.ToString("yyyy"),
|
||||
|
||||
@@ -10,6 +10,9 @@ namespace LiveRecorder.Application.Services;
|
||||
|
||||
public sealed class RecordSessionService
|
||||
{
|
||||
private const int OverviewSessionLimit = 200;
|
||||
private const int DefaultPageSize = 20;
|
||||
private const int MaximumPageSize = 100;
|
||||
private static readonly TimeSpan GracefulDeleteTimeout = TimeSpan.FromSeconds(15);
|
||||
private static readonly TimeSpan ForcedKillTimeout = TimeSpan.FromSeconds(8);
|
||||
|
||||
@@ -50,10 +53,7 @@ public sealed class RecordSessionService
|
||||
|
||||
public async Task<IReadOnlyList<RecordSessionDto>> ListAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _stoppedOrphanRecordSessionCleanupService.CleanupAsync(cancellationToken: cancellationToken);
|
||||
await ReconcileActiveSessionsAsync(liveRoomId, cancellationToken);
|
||||
|
||||
var sessions = await _recordSessionRepository.ListAsync(liveRoomId, cancellationToken);
|
||||
var sessions = await _recordSessionRepository.ListOverviewAsync(liveRoomId, OverviewSessionLimit, cancellationToken);
|
||||
var runtimeStates = _ffmpegService.GetTaskRuntimeStates(
|
||||
sessions.SelectMany(static item => item.RecordTasks).Select(static item => item.Id).ToArray());
|
||||
return sessions
|
||||
@@ -62,6 +62,40 @@ public sealed class RecordSessionService
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<RecordSessionListResponse> ListPageAsync(
|
||||
Guid? liveRoomId = null,
|
||||
string? state = null,
|
||||
string? search = null,
|
||||
int skip = 0,
|
||||
int take = DefaultPageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedSkip = Math.Max(0, skip);
|
||||
var normalizedTake = Math.Clamp(take, 1, MaximumPageSize);
|
||||
var (sessions, totalCount) = await _recordSessionRepository.ListPageAsync(
|
||||
liveRoomId,
|
||||
ResolveStatusFilter(state),
|
||||
search,
|
||||
normalizedSkip,
|
||||
normalizedTake,
|
||||
cancellationToken);
|
||||
var totals = await _recordSessionRepository.GetOverviewTotalsAsync(liveRoomId, cancellationToken);
|
||||
var runtimeStates = _ffmpegService.GetTaskRuntimeStates(
|
||||
sessions.SelectMany(static item => item.RecordTasks).Select(static item => item.Id).ToArray());
|
||||
|
||||
return new RecordSessionListResponse
|
||||
{
|
||||
Items = sessions.Select(item => RecordModelMapper.MapSession(item, runtimeStates)).ToList(),
|
||||
TotalCount = totalCount,
|
||||
Skip = normalizedSkip,
|
||||
Take = normalizedTake,
|
||||
TotalSessionCount = totals.TotalSessionCount,
|
||||
ActiveSessionCount = totals.ActiveSessionCount,
|
||||
TotalTaskCount = totals.TotalTaskCount,
|
||||
TotalDanmakuCount = totals.TotalDanmakuCount
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<RecordSessionDetailDto?> GetDetailAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var session = await _recordSessionRepository.GetByIdAsync(id, cancellationToken);
|
||||
@@ -276,6 +310,12 @@ public sealed class RecordSessionService
|
||||
session = sessionSnapshot;
|
||||
}
|
||||
|
||||
if (session.RecordTasks.Any(task => RecordService.IsUploadProtected(task.UploadJob?.Status ?? task.Result?.UploadStatus)))
|
||||
{
|
||||
warnings.Add($"Session {session.Id} contains OpenList uploads or cleanup retries and was not deleted.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_ffmpegService.IsRunning(session.Id) || IsActiveStatus(session.Status))
|
||||
{
|
||||
warnings.Add($"Session {session.Id} is still active and could not be deleted.");
|
||||
@@ -344,12 +384,7 @@ public sealed class RecordSessionService
|
||||
|
||||
private async Task ReconcileActiveSessionsAsync(Guid? liveRoomId, CancellationToken cancellationToken)
|
||||
{
|
||||
var sessions = await _recordSessionRepository.ListAsync(liveRoomId, cancellationToken);
|
||||
var activeIds = sessions
|
||||
.Where(item => IsActiveStatus(item.Status))
|
||||
.Select(item => item.Id)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
var activeIds = await _recordSessionRepository.ListActiveIdsAsync(liveRoomId, cancellationToken);
|
||||
|
||||
foreach (var activeId in activeIds)
|
||||
{
|
||||
@@ -360,6 +395,22 @@ public sealed class RecordSessionService
|
||||
private static bool IsActiveStatus(RecordSessionStatus status) =>
|
||||
status is RecordSessionStatus.Starting or RecordSessionStatus.Running or RecordSessionStatus.Stopping;
|
||||
|
||||
private static IReadOnlyCollection<RecordSessionStatus>? ResolveStatusFilter(string? state) =>
|
||||
state?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
null or "" or "all" => null,
|
||||
"active" =>
|
||||
[
|
||||
RecordSessionStatus.Starting,
|
||||
RecordSessionStatus.Running,
|
||||
RecordSessionStatus.Stopping
|
||||
],
|
||||
"completed" => [RecordSessionStatus.Completed],
|
||||
"failed" => [RecordSessionStatus.Failed],
|
||||
"stopped" => [RecordSessionStatus.Stopped],
|
||||
_ => throw new ArgumentException("Unsupported recording session state filter.", nameof(state))
|
||||
};
|
||||
|
||||
private static bool CanDeleteMissingFileSession(RecordSession session)
|
||||
{
|
||||
if (IsActiveStatus(session.Status))
|
||||
|
||||
@@ -12,6 +12,9 @@ namespace LiveRecorder.Application.Services;
|
||||
|
||||
public sealed class SystemSettingsService : ISystemSettingsService
|
||||
{
|
||||
private const double MinimumStorageGreenThresholdPercent = 10;
|
||||
private const double MinimumStorageRedThresholdPercent = 5;
|
||||
private const double MinimumStorageThresholdGapPercent = 5;
|
||||
private const string FfmpegPathKey = "ffmpeg.path";
|
||||
private const string OutputRootKey = "recording.output_root";
|
||||
private const string OutputDirectoryTemplateKey = "recording.output_directory_template";
|
||||
@@ -85,6 +88,7 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
private const string EnableRetentionCleanupKey = "retention.cleanup.enabled";
|
||||
private const string RetentionDaysKey = "retention.cleanup.days";
|
||||
private const string RetentionDeleteFilesKey = "retention.cleanup.delete_files";
|
||||
private const string RetentionRequireUploadSuccessKey = "retention.cleanup.require_upload_success";
|
||||
private const string RetentionVideoFileConditionKey = "retention.cleanup.video_file_condition";
|
||||
private const string RetentionTaskStatusesKey = "retention.cleanup.task_statuses";
|
||||
private const string EnableEmailNotificationKey = "notification.email.enabled";
|
||||
@@ -126,6 +130,15 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
{
|
||||
var settings = await _appSettingRepository.ListAsync(cancellationToken);
|
||||
var lookup = settings.ToDictionary(static item => item.Key, static item => item.Value, StringComparer.OrdinalIgnoreCase);
|
||||
var storageGreenThresholdPercent = GetDoubleValue(
|
||||
lookup,
|
||||
StorageGreenThresholdPercentKey,
|
||||
30,
|
||||
MinimumStorageGreenThresholdPercent,
|
||||
90);
|
||||
var storageRedThresholdPercent = Math.Min(
|
||||
GetDoubleValue(lookup, StorageRedThresholdPercentKey, 10, MinimumStorageRedThresholdPercent, 85),
|
||||
storageGreenThresholdPercent - MinimumStorageThresholdGapPercent);
|
||||
|
||||
return new SystemSettingsDto
|
||||
{
|
||||
@@ -149,8 +162,8 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
EnableStorageGuard = bool.TryParse(GetValue(lookup, EnableStorageGuardKey, "true"), out var enableStorageGuard) && enableStorageGuard,
|
||||
PauseRecordingWhenFreeSpaceBelowMegabytes = GetIntValue(lookup, PauseRecordingWhenFreeSpaceBelowMegabytesKey, 1024, 0, 1048576),
|
||||
ResumeRecordingWhenFreeSpaceAboveMegabytes = GetIntValue(lookup, ResumeRecordingWhenFreeSpaceAboveMegabytesKey, 4096, 0, 1048576),
|
||||
StorageGreenThresholdPercent = GetDoubleValue(lookup, StorageGreenThresholdPercentKey, 30, 5, 90),
|
||||
StorageRedThresholdPercent = GetDoubleValue(lookup, StorageRedThresholdPercentKey, 10, 1, 85),
|
||||
StorageGreenThresholdPercent = storageGreenThresholdPercent,
|
||||
StorageRedThresholdPercent = storageRedThresholdPercent,
|
||||
EnableAutoReconnect = bool.TryParse(GetValue(lookup, EnableReconnectKey, "true"), out var enableReconnect) && enableReconnect,
|
||||
ReconnectDelayMaxSeconds = GetIntValue(lookup, ReconnectDelayMaxSecondsKey, 5, 1, 300),
|
||||
ReadWriteTimeoutMilliseconds = GetIntValue(lookup, ReadWriteTimeoutMillisecondsKey, 15000000, 1000, 60000000),
|
||||
@@ -232,6 +245,7 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
EnableRetentionCleanup = bool.TryParse(GetValue(lookup, EnableRetentionCleanupKey, "false"), out var enableRetentionCleanup) && enableRetentionCleanup,
|
||||
RetentionDays = GetIntValue(lookup, RetentionDaysKey, 30, 1, 3650),
|
||||
RetentionDeleteFiles = bool.TryParse(GetValue(lookup, RetentionDeleteFilesKey, "false"), out var retentionDeleteFiles) && retentionDeleteFiles,
|
||||
RetentionRequireUploadSuccess = bool.TryParse(GetValue(lookup, RetentionRequireUploadSuccessKey, "false"), out var retentionRequireUploadSuccess) && retentionRequireUploadSuccess,
|
||||
RetentionVideoFileCondition = NormalizeCleanupVideoFileCondition(GetValue(lookup, RetentionVideoFileConditionKey, CleanupVideoFileConditions.Any)),
|
||||
RetentionTaskStatuses = GetIntListValue(lookup, RetentionTaskStatusesKey),
|
||||
EnableEmailNotification = bool.TryParse(GetValue(lookup, EnableEmailNotificationKey, "false"), out var enableEmailNotification) && enableEmailNotification,
|
||||
@@ -305,6 +319,14 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var webDavUpload = request.WebDavUpload ?? new WebDavUploadSettingsDto();
|
||||
var s3Upload = request.S3Upload ?? new S3UploadSettingsDto();
|
||||
var storageGreenThresholdPercent = Math.Clamp(
|
||||
request.StorageGreenThresholdPercent,
|
||||
MinimumStorageGreenThresholdPercent,
|
||||
90);
|
||||
var storageRedThresholdPercent = Math.Clamp(
|
||||
request.StorageRedThresholdPercent,
|
||||
MinimumStorageRedThresholdPercent,
|
||||
Math.Min(85, storageGreenThresholdPercent - MinimumStorageThresholdGapPercent));
|
||||
|
||||
await UpsertAsync(FfmpegPathKey, request.FfmpegPath.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(OutputRootKey, request.OutputRoot.Trim(), now, cancellationToken);
|
||||
@@ -338,12 +360,12 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
cancellationToken);
|
||||
await UpsertAsync(
|
||||
StorageGreenThresholdPercentKey,
|
||||
Math.Clamp(request.StorageGreenThresholdPercent, 5, 90).ToString("F1", CultureInfo.InvariantCulture),
|
||||
storageGreenThresholdPercent.ToString("F1", CultureInfo.InvariantCulture),
|
||||
now,
|
||||
cancellationToken);
|
||||
await UpsertAsync(
|
||||
StorageRedThresholdPercentKey,
|
||||
Math.Clamp(request.StorageRedThresholdPercent, 1, 85).ToString("F1", CultureInfo.InvariantCulture),
|
||||
storageRedThresholdPercent.ToString("F1", CultureInfo.InvariantCulture),
|
||||
now,
|
||||
cancellationToken);
|
||||
await UpsertAsync(EnableReconnectKey, request.EnableAutoReconnect.ToString(), now, cancellationToken);
|
||||
@@ -410,6 +432,7 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
await UpsertAsync(EnableRetentionCleanupKey, request.EnableRetentionCleanup.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(RetentionDaysKey, Math.Clamp(request.RetentionDays, 1, 3650).ToString(), now, cancellationToken);
|
||||
await UpsertAsync(RetentionDeleteFilesKey, request.RetentionDeleteFiles.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(RetentionRequireUploadSuccessKey, request.RetentionRequireUploadSuccess.ToString(), now, cancellationToken);
|
||||
await UpsertAsync(RetentionVideoFileConditionKey, NormalizeCleanupVideoFileCondition(request.RetentionVideoFileCondition), now, cancellationToken);
|
||||
await UpsertAsync(RetentionTaskStatusesKey, SerializeIntList(request.RetentionTaskStatuses), now, cancellationToken);
|
||||
await UpsertAsync(EnableEmailNotificationKey, request.EnableEmailNotification.ToString(), now, cancellationToken);
|
||||
|
||||
Reference in New Issue
Block a user