feat: async session cleanup and fix live room scroll

This commit is contained in:
2026-05-08 18:18:36 +08:00
parent 8f5e63cffd
commit cc37d90d92
27 changed files with 2788 additions and 692 deletions
@@ -0,0 +1,69 @@
namespace LiveRecorder.Application.Models.Cleanup;
public static class CleanupOperationKinds
{
public const string Selected = "selected";
public const string Conditional = "conditional";
public const string Empty = "empty";
public const string Retention = "retention";
}
public static class CleanupOperationStatuses
{
public const string Queued = "queued";
public const string Running = "running";
public const string Completed = "completed";
public const string Failed = "failed";
}
public static class CleanupVideoFileConditions
{
public const string Any = "any";
public const string AllMissing = "allMissing";
public const string AllPresent = "allPresent";
}
public sealed class CleanupOperationDto
{
public Guid Id { get; init; }
public required string Kind { get; init; }
public required string Status { get; init; }
public bool DeleteFiles { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? StartedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
public int TotalSessionCount { get; init; }
public int ProcessedSessionCount { get; init; }
public int DeletedSessionCount { get; init; }
public int DeletedTaskCount { get; init; }
public int DeletedResultCount { get; init; }
public int DeletedLogCount { get; init; }
public int DeletedFileCount { get; init; }
public int DeletedDanmakuFileCount { get; init; }
public required IReadOnlyList<string> Warnings { get; init; }
public string? ErrorMessage { get; init; }
}
@@ -1,4 +1,5 @@
using LiveRecorder.Application.Models.Logs;
using LiveRecorder.Application.Models.Cleanup;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Application.Models.RecordTasks;
@@ -134,3 +135,34 @@ public sealed class DeleteMissingFileRecordSessionsRequest
{
public bool DeleteFiles { get; set; }
}
public sealed class DeleteConditionalSessionsRequest
{
public string VideoFileCondition { get; set; } = CleanupVideoFileConditions.Any;
public IReadOnlyList<int> TaskStatuses { get; set; } = [];
public bool DeleteFiles { get; set; }
}
public sealed class DeleteEmptyRecordSessionsRequest
{
public bool DeleteFiles { get; set; }
}
public sealed class RecordSessionDeletionBatchResult
{
public required IReadOnlyList<Guid> DeletedSessionIds { get; init; }
public required IReadOnlyList<Guid> DeletedTaskIds { get; init; }
public int DeletedResultCount { get; init; }
public int DeletedLogCount { get; init; }
public required IReadOnlyList<string> DeletedFilePaths { get; init; }
public required IReadOnlyList<string> DeletedDanmakuPaths { get; init; }
public required IReadOnlyList<string> Warnings { get; init; }
}
@@ -1,4 +1,5 @@
using LiveRecorder.Domain.Enums;
using LiveRecorder.Application.Models.Cleanup;
namespace LiveRecorder.Application.Models.Settings;
@@ -148,6 +149,10 @@ public sealed class SystemSettingsDto
public bool RetentionDeleteFiles { get; set; } = false;
public string RetentionVideoFileCondition { get; set; } = CleanupVideoFileConditions.Any;
public IReadOnlyList<int> RetentionTaskStatuses { get; set; } = [];
public bool EnableEmailNotification { get; set; } = false;
public string EmailSmtpHost { get; set; } = string.Empty;
@@ -331,6 +336,10 @@ public sealed class UpdateSystemSettingsRequest
public bool RetentionDeleteFiles { get; set; } = false;
public string RetentionVideoFileCondition { get; set; } = CleanupVideoFileConditions.Any;
public IReadOnlyList<int> RetentionTaskStatuses { get; set; } = [];
public bool EnableEmailNotification { get; set; } = false;
public string EmailSmtpHost { get; set; } = string.Empty;
@@ -508,23 +517,6 @@ public sealed class WebhookTestResultDto
public string? Detail { get; init; }
}
public sealed class RetentionCleanupResultDto
{
public int DeletedSessionCount { get; init; }
public int DeletedTaskCount { get; init; }
public int DeletedResultCount { get; init; }
public int DeletedLogCount { get; init; }
public int DeletedFileCount { get; init; }
public int DeletedDanmakuFileCount { get; init; }
public required IReadOnlyList<string> Warnings { get; init; }
}
public sealed class ImportSystemSettingsRequest
{
public SystemSettingsDto? Settings { get; set; }
@@ -124,14 +124,109 @@ public sealed class RecordSessionService
{
ArgumentNullException.ThrowIfNull(request);
var sessionIds = request.SessionIds
var result = await DeleteSessionsByIdsAsync(request.SessionIds, request.DeleteFiles, cancellationToken);
if (result.DeletedSessionIds.Count > 0)
{
await _systemLogService.WriteAsync(
SystemLogLevel.Warning,
"RecordSession",
$"Deleted {result.DeletedSessionIds.Count} recording session(s).",
detail: request.DeleteFiles
? $"video-files={result.DeletedFilePaths.Count}; danmaku-files={result.DeletedDanmakuPaths.Count}; tasks={result.DeletedTaskIds.Count}"
: $"video-files=0; danmaku-files=0; tasks={result.DeletedTaskIds.Count}",
cancellationToken: cancellationToken);
}
return new DeleteCompletedRecordTasksResultDto
{
DeletedTaskIds = result.DeletedTaskIds,
DeletedFilePaths = result.DeletedFilePaths,
DeletedDanmakuPaths = result.DeletedDanmakuPaths,
DeletedSessionIds = result.DeletedSessionIds,
Warnings = result.Warnings
};
}
public async Task<DeleteCompletedRecordTasksResultDto> DeleteMissingFilesAsync(
DeleteMissingFileRecordSessionsRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
await _stoppedOrphanRecordSessionCleanupService.CleanupAsync(cancellationToken: cancellationToken);
await ReconcileActiveSessionsAsync(null, cancellationToken);
var sessions = await _recordSessionRepository.ListAsync(null, cancellationToken);
var missingFileSessionIds = sessions
.Where(CanDeleteMissingFileSession)
.Select(static item => item.Id)
.Distinct()
.ToArray();
if (missingFileSessionIds.Length == 0)
{
return RecordService.CreateEmptyDeleteResult();
}
var result = await DeleteSessionsByIdsAsync(missingFileSessionIds, request.DeleteFiles, cancellationToken);
return new DeleteCompletedRecordTasksResultDto
{
DeletedTaskIds = result.DeletedTaskIds,
DeletedFilePaths = result.DeletedFilePaths,
DeletedDanmakuPaths = result.DeletedDanmakuPaths,
DeletedSessionIds = result.DeletedSessionIds,
Warnings = result.Warnings
};
}
public async Task<DeleteCompletedRecordTasksResultDto> DeleteEmptyAsync(
bool deleteFiles,
CancellationToken cancellationToken = default)
{
await _stoppedOrphanRecordSessionCleanupService.CleanupAsync(cancellationToken: cancellationToken);
await ReconcileActiveSessionsAsync(null, cancellationToken);
var emptySessionIds = await _systemLogRepository.ListSessionIdsWithoutTasksAsync(cancellationToken);
if (emptySessionIds.Count == 0)
{
return RecordService.CreateEmptyDeleteResult();
}
var result = await DeleteSessionsByIdsAsync(emptySessionIds, deleteFiles, cancellationToken);
return new DeleteCompletedRecordTasksResultDto
{
DeletedTaskIds = result.DeletedTaskIds,
DeletedFilePaths = result.DeletedFilePaths,
DeletedDanmakuPaths = result.DeletedDanmakuPaths,
DeletedSessionIds = result.DeletedSessionIds,
Warnings = result.Warnings
};
}
public async Task<RecordSessionDeletionBatchResult> DeleteSessionsByIdsAsync(
IReadOnlyCollection<Guid> requestedSessionIds,
bool deleteFiles,
CancellationToken cancellationToken = default)
{
var sessionIds = requestedSessionIds
.Where(static item => item != Guid.Empty)
.Distinct()
.ToArray();
if (sessionIds.Length == 0)
{
return RecordService.CreateEmptyDeleteResult();
return new RecordSessionDeletionBatchResult
{
DeletedSessionIds = [],
DeletedTaskIds = [],
DeletedResultCount = 0,
DeletedLogCount = 0,
DeletedFilePaths = [],
DeletedDanmakuPaths = [],
Warnings = []
};
}
var warnings = new List<string>();
@@ -139,6 +234,8 @@ public sealed class RecordSessionService
var deletedTaskIds = new List<Guid>();
var deletedFilePaths = new List<string>();
var deletedDanmakuPaths = new List<string>();
var deletedResultCount = 0;
var deletedLogCount = 0;
foreach (var sessionId in sessionIds)
{
@@ -197,7 +294,7 @@ public sealed class RecordSessionService
.Distinct()
.ToArray();
if (request.DeleteFiles)
if (deleteFiles)
{
foreach (var recordTask in session.RecordTasks)
{
@@ -208,6 +305,7 @@ public sealed class RecordSessionService
var relatedLogs = await ListRelatedLogsAsync(session.Id, taskIds, cancellationToken);
if (relatedLogs.Count > 0)
{
deletedLogCount += relatedLogs.Count;
_systemLogRepository.RemoveRange(relatedLogs);
}
@@ -217,6 +315,7 @@ public sealed class RecordSessionService
.ToArray();
if (results.Length > 0)
{
deletedResultCount += results.Length;
_recordResultRepository.RemoveRange(results);
}
@@ -231,81 +330,18 @@ public sealed class RecordSessionService
deletedSessionIds.Add(session.Id);
}
if (deletedSessionIds.Count > 0)
{
await _systemLogService.WriteAsync(
SystemLogLevel.Warning,
"RecordSession",
$"Deleted {deletedSessionIds.Count} recording session(s).",
detail: request.DeleteFiles
? $"video-files={deletedFilePaths.Count}; danmaku-files={deletedDanmakuPaths.Count}; tasks={deletedTaskIds.Count}"
: $"video-files=0; danmaku-files=0; tasks={deletedTaskIds.Count}",
cancellationToken: cancellationToken);
}
return new DeleteCompletedRecordTasksResultDto
return new RecordSessionDeletionBatchResult
{
DeletedSessionIds = deletedSessionIds,
DeletedTaskIds = deletedTaskIds,
DeletedResultCount = deletedResultCount,
DeletedLogCount = deletedLogCount,
DeletedFilePaths = deletedFilePaths,
DeletedDanmakuPaths = deletedDanmakuPaths,
DeletedSessionIds = deletedSessionIds,
Warnings = warnings
};
}
public async Task<DeleteCompletedRecordTasksResultDto> DeleteMissingFilesAsync(
DeleteMissingFileRecordSessionsRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
await _stoppedOrphanRecordSessionCleanupService.CleanupAsync(cancellationToken: cancellationToken);
await ReconcileActiveSessionsAsync(null, cancellationToken);
var sessions = await _recordSessionRepository.ListAsync(null, cancellationToken);
var missingFileSessionIds = sessions
.Where(CanDeleteMissingFileSession)
.Select(static item => item.Id)
.Distinct()
.ToArray();
if (missingFileSessionIds.Length == 0)
{
return RecordService.CreateEmptyDeleteResult();
}
return await DeleteAsync(
new DeleteRecordSessionsRequest
{
SessionIds = missingFileSessionIds,
DeleteFiles = request.DeleteFiles
},
cancellationToken);
}
public async Task<DeleteCompletedRecordTasksResultDto> DeleteEmptyAsync(
bool deleteFiles,
CancellationToken cancellationToken = default)
{
await _stoppedOrphanRecordSessionCleanupService.CleanupAsync(cancellationToken: cancellationToken);
await ReconcileActiveSessionsAsync(null, cancellationToken);
var emptySessionIds = await _systemLogRepository.ListSessionIdsWithoutTasksAsync(cancellationToken);
if (emptySessionIds.Count == 0)
{
return RecordService.CreateEmptyDeleteResult();
}
return await DeleteAsync(
new DeleteRecordSessionsRequest
{
SessionIds = emptySessionIds.ToArray(),
DeleteFiles = deleteFiles
},
cancellationToken);
}
private async Task ReconcileActiveSessionsAsync(Guid? liveRoomId, CancellationToken cancellationToken)
{
var sessions = await _recordSessionRepository.ListAsync(liveRoomId, cancellationToken);
@@ -1,8 +1,10 @@
using LiveRecorder.Application.Abstractions.Persistence;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Models.Cleanup;
using LiveRecorder.Application.Models.Settings;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
using System.Text.Json;
namespace LiveRecorder.Application.Services;
@@ -71,6 +73,8 @@ 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 RetentionVideoFileConditionKey = "retention.cleanup.video_file_condition";
private const string RetentionTaskStatusesKey = "retention.cleanup.task_statuses";
private const string EnableEmailNotificationKey = "notification.email.enabled";
private const string EmailSmtpHostKey = "notification.email.smtp_host";
private const string EmailSmtpPortKey = "notification.email.smtp_port";
@@ -214,6 +218,8 @@ 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,
RetentionVideoFileCondition = NormalizeCleanupVideoFileCondition(GetValue(lookup, RetentionVideoFileConditionKey, CleanupVideoFileConditions.Any)),
RetentionTaskStatuses = GetIntListValue(lookup, RetentionTaskStatusesKey),
EnableEmailNotification = bool.TryParse(GetValue(lookup, EnableEmailNotificationKey, "false"), out var enableEmailNotification) && enableEmailNotification,
EmailSmtpHost = GetValue(lookup, EmailSmtpHostKey, string.Empty),
EmailSmtpPort = GetIntValue(lookup, EmailSmtpPortKey, 587, 1, 65535),
@@ -368,6 +374,8 @@ 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(RetentionVideoFileConditionKey, NormalizeCleanupVideoFileCondition(request.RetentionVideoFileCondition), now, cancellationToken);
await UpsertAsync(RetentionTaskStatusesKey, SerializeIntList(request.RetentionTaskStatuses), now, cancellationToken);
await UpsertAsync(EnableEmailNotificationKey, request.EnableEmailNotification.ToString(), now, cancellationToken);
await UpsertAsync(EmailSmtpHostKey, request.EmailSmtpHost.Trim(), now, cancellationToken);
await UpsertAsync(EmailSmtpPortKey, request.EmailSmtpPort.ToString(), now, cancellationToken);
@@ -435,6 +443,36 @@ public sealed class SystemSettingsService : ISystemSettingsService
return Math.Clamp(parsedValue, minimum, maximum);
}
private static IReadOnlyList<int> GetIntListValue(IReadOnlyDictionary<string, string> lookup, string key)
{
if (!lookup.TryGetValue(key, out var raw) || string.IsNullOrWhiteSpace(raw))
{
return [];
}
try
{
var parsed = JsonSerializer.Deserialize<List<int>>(raw);
return parsed is null
? []
: parsed
.Distinct()
.OrderBy(static item => item)
.ToArray();
}
catch
{
return raw
.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(static item => int.TryParse(item, out var value) ? value : (int?)null)
.Where(static item => item.HasValue)
.Select(static item => item!.Value)
.Distinct()
.OrderBy(static item => item)
.ToArray();
}
}
private static string NormalizeEventScriptMode(string? value) =>
string.Equals(value?.Trim(), EventScriptSourceModes.Inline, StringComparison.OrdinalIgnoreCase)
? EventScriptSourceModes.Inline
@@ -445,6 +483,21 @@ public sealed class SystemSettingsService : ISystemSettingsService
.Replace("Detected At (UTC)", "Detected At (Beijing Time)", StringComparison.Ordinal)
.Replace("Occurred At (UTC)", "Occurred At (Beijing Time)", StringComparison.Ordinal);
private static string NormalizeCleanupVideoFileCondition(string? value) =>
value?.Trim() switch
{
var item when string.Equals(item, CleanupVideoFileConditions.AllMissing, StringComparison.OrdinalIgnoreCase) => CleanupVideoFileConditions.AllMissing,
var item when string.Equals(item, CleanupVideoFileConditions.AllPresent, StringComparison.OrdinalIgnoreCase) => CleanupVideoFileConditions.AllPresent,
_ => CleanupVideoFileConditions.Any
};
private static string SerializeIntList(IReadOnlyList<int>? values) =>
JsonSerializer.Serialize(
(values ?? [])
.Distinct()
.OrderBy(static item => item)
.ToArray());
private async Task UpsertAsync(string key, string value, DateTimeOffset updatedAt, CancellationToken cancellationToken)
{
var existing = await _appSettingRepository.GetByKeyAsync(key, cancellationToken);