feat: async session cleanup and fix live room scroll
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user