feat: harden recording lifecycle and refresh fnOS UI

This commit is contained in:
2026-08-03 23:45:26 +08:00
parent e5b50ea85c
commit ecc737f0bd
90 changed files with 6995 additions and 1826 deletions
@@ -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);