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);
@@ -0,0 +1,165 @@
using System.Text.Json;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Domain.Entities;
public class CleanupOperation
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private CleanupOperation()
{
}
public CleanupOperation(
CleanupOperationKind kind,
bool deleteFiles,
string? filtersJson,
DateTimeOffset createdAt)
{
Id = Guid.NewGuid();
Kind = kind;
Status = CleanupOperationStatus.Queued;
DeleteFiles = deleteFiles;
FiltersJson = string.IsNullOrWhiteSpace(filtersJson) ? "{}" : filtersJson;
WarningsJson = "[]";
CreatedAt = createdAt;
}
public Guid Id { get; private set; }
public CleanupOperationKind Kind { get; private set; }
public CleanupOperationStatus Status { get; private set; }
public bool DeleteFiles { get; private set; }
public string FiltersJson { get; private set; } = "{}";
public DateTimeOffset CreatedAt { get; private set; }
public DateTimeOffset? StartedAt { get; private set; }
public DateTimeOffset? CompletedAt { get; private set; }
public int TotalSessionCount { get; private set; }
public int ProcessedSessionCount { get; private set; }
public int DeletedSessionCount { get; private set; }
public int DeletedTaskCount { get; private set; }
public int DeletedResultCount { get; private set; }
public int DeletedLogCount { get; private set; }
public int DeletedFileCount { get; private set; }
public int DeletedDanmakuFileCount { get; private set; }
public string WarningsJson { get; private set; } = "[]";
public string? ErrorMessage { get; private set; }
public void MarkRunning(DateTimeOffset startedAt)
{
Status = CleanupOperationStatus.Running;
StartedAt = startedAt;
CompletedAt = null;
ErrorMessage = null;
}
public void SetTotalSessionCount(int totalSessionCount)
{
TotalSessionCount = Math.Max(0, totalSessionCount);
if (ProcessedSessionCount > TotalSessionCount)
{
ProcessedSessionCount = TotalSessionCount;
}
}
public void MarkCompleted(DateTimeOffset completedAt)
{
Status = CleanupOperationStatus.Completed;
CompletedAt = completedAt;
ErrorMessage = null;
}
public void MarkFailed(string errorMessage, DateTimeOffset completedAt)
{
Status = CleanupOperationStatus.Failed;
CompletedAt = completedAt;
ErrorMessage = string.IsNullOrWhiteSpace(errorMessage)
? "Cleanup operation failed."
: errorMessage.Trim();
}
public void Requeue(string warning)
{
Status = CleanupOperationStatus.Queued;
StartedAt = null;
CompletedAt = null;
ErrorMessage = null;
AppendWarnings([warning]);
}
public void ApplyBatchProgress(
int processedSessionCount,
int deletedSessionCount,
int deletedTaskCount,
int deletedResultCount,
int deletedLogCount,
int deletedFileCount,
int deletedDanmakuFileCount,
IReadOnlyCollection<string>? warnings = null)
{
ProcessedSessionCount += Math.Max(0, processedSessionCount);
DeletedSessionCount += Math.Max(0, deletedSessionCount);
DeletedTaskCount += Math.Max(0, deletedTaskCount);
DeletedResultCount += Math.Max(0, deletedResultCount);
DeletedLogCount += Math.Max(0, deletedLogCount);
DeletedFileCount += Math.Max(0, deletedFileCount);
DeletedDanmakuFileCount += Math.Max(0, deletedDanmakuFileCount);
if (ProcessedSessionCount > TotalSessionCount)
{
ProcessedSessionCount = TotalSessionCount;
}
if (warnings is { Count: > 0 })
{
AppendWarnings(warnings);
}
}
public IReadOnlyList<string> GetWarnings()
{
if (string.IsNullOrWhiteSpace(WarningsJson))
{
return [];
}
try
{
return JsonSerializer.Deserialize<List<string>>(WarningsJson, JsonOptions) ?? [];
}
catch
{
return [];
}
}
private void AppendWarnings(IReadOnlyCollection<string> warnings)
{
if (warnings.Count == 0)
{
return;
}
var merged = GetWarnings().Concat(warnings.Where(static item => !string.IsNullOrWhiteSpace(item)))
.Select(static item => item.Trim())
.ToList();
WarningsJson = JsonSerializer.Serialize(merged, JsonOptions);
}
}
@@ -0,0 +1,9 @@
namespace LiveRecorder.Domain.Enums;
public enum CleanupOperationKind
{
Selected = 0,
Conditional = 1,
Empty = 2,
Retention = 3
}
@@ -0,0 +1,9 @@
namespace LiveRecorder.Domain.Enums;
public enum CleanupOperationStatus
{
Queued = 0,
Running = 1,
Completed = 2,
Failed = 3
}
@@ -0,0 +1,8 @@
namespace LiveRecorder.Domain.Enums;
public enum CleanupVideoFileCondition
{
Any = 0,
AllMissing = 1,
AllPresent = 2
}
@@ -21,6 +21,8 @@ public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork
public DbSet<SystemLogEntry> SystemLogEntries => Set<SystemLogEntry>();
public DbSet<CleanupOperation> CleanupOperations => Set<CleanupOperation>();
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
public DbSet<UserAccount> UserAccounts => Set<UserAccount>();
@@ -128,6 +130,18 @@ public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork
builder.HasIndex(static x => x.RecordSessionId);
});
modelBuilder.Entity<CleanupOperation>(builder =>
{
builder.ToTable("CleanupOperations");
builder.HasKey(static x => x.Id);
builder.Property(static x => x.Kind).HasConversion<int>();
builder.Property(static x => x.Status).HasConversion<int>();
builder.Property(static x => x.FiltersJson).HasColumnType("text");
builder.Property(static x => x.WarningsJson).HasColumnType("text");
builder.Property(static x => x.ErrorMessage).HasColumnType("text");
builder.HasIndex(static x => new { x.Status, x.CreatedAt });
});
modelBuilder.Entity<AppSetting>(builder =>
{
builder.ToTable("AppSettings");
@@ -0,0 +1,647 @@
// <auto-generated />
using System;
using LiveRecorder.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace LiveRecorder.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(LiveRecorderDbContext))]
[Migration("20260508090000_AddCleanupOperations")]
partial class AddCleanupOperations
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("LiveRecorder.Domain.Entities.AppSetting", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("AppSettings", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.CleanupOperation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("DeleteFiles")
.HasColumnType("boolean");
b.Property<int>("DeletedDanmakuFileCount")
.HasColumnType("integer");
b.Property<int>("DeletedFileCount")
.HasColumnType("integer");
b.Property<int>("DeletedLogCount")
.HasColumnType("integer");
b.Property<int>("DeletedResultCount")
.HasColumnType("integer");
b.Property<int>("DeletedSessionCount")
.HasColumnType("integer");
b.Property<int>("DeletedTaskCount")
.HasColumnType("integer");
b.Property<string>("ErrorMessage")
.HasColumnType("text");
b.Property<string>("FiltersJson")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<int>("ProcessedSessionCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("StartedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<int>("TotalSessionCount")
.HasColumnType("integer");
b.Property<string>("WarningsJson")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Status", "CreatedAt");
b.ToTable("CleanupOperations", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.LiveRoom", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Alias")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("AnchorId")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("AnchorName")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int>("AvailabilityStatus")
.HasColumnType("integer");
b.Property<string>("AvatarUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("CoverUrl")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool?>("DanmakuIncludeNonChatEventsOverride")
.HasColumnType("boolean");
b.Property<int?>("DanmakuMinPollIntervalMillisecondsOverride")
.HasColumnType("integer");
b.Property<int?>("DanmakuRetryDelayMaxSecondsOverride")
.HasColumnType("integer");
b.Property<bool?>("EnableAutoReconnectOverride")
.HasColumnType("boolean");
b.Property<bool?>("EnableDanmakuRecordingOverride")
.HasColumnType("boolean");
b.Property<bool>("HasSentLiveNotificationForCurrentSession")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("IsEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("IsPinned")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("IsPriority")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<DateTimeOffset?>("LastAutoStartDecisionAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastAutoStartDecisionCode")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("LastAutoStartDecisionDetail")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("LastAutoStartDecisionSummary")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTimeOffset?>("LastCheckedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("LastStartRecordingTriggeredAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<int?>("OutputFormatOverride")
.HasColumnType("integer");
b.Property<int>("Platform")
.HasColumnType("integer");
b.Property<int?>("PollingIntervalSecondsOverride")
.HasColumnType("integer");
b.Property<string>("PreferredQualityOverride")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int?>("ReadWriteTimeoutMillisecondsOverride")
.HasColumnType("integer");
b.Property<int?>("ReconnectDelayMaxSecondsOverride")
.HasColumnType("integer");
b.Property<int?>("RecordingTemplateOverride")
.HasColumnType("integer");
b.Property<string>("Remark")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("RoomId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int?>("SaveModeOverride")
.HasColumnType("integer");
b.Property<int?>("SegmentDurationMinutesOverride")
.HasColumnType("integer");
b.Property<string>("SourceUrl")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Platform", "RoomId")
.IsUnique();
b.ToTable("LiveRooms", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordResult", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DanmakuFilePath")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("DanmakuMessageCount")
.HasColumnType("integer");
b.Property<bool>("DeletedLocalFilesAfterUpload")
.HasColumnType("boolean");
b.Property<double?>("DurationSeconds")
.HasColumnType("double precision");
b.Property<string>("ErrorMessage")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("FilePath")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<long?>("FileSizeBytes")
.HasColumnType("bigint");
b.Property<int>("FinalStatus")
.HasColumnType("integer");
b.Property<string>("LastUploadProvider")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset?>("LastUploadedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("RecordTaskId")
.HasColumnType("uuid");
b.Property<string>("RemoteDanmakuPath")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("RemoteVideoPath")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("UploadErrorMessage")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("UploadStatus")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("RecordTaskId")
.IsUnique();
b.ToTable("RecordResults", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordSession", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("ActiveSegmentIndex")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("EndedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ErrorMessage")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<Guid>("LiveRoomId")
.HasColumnType("uuid");
b.Property<int>("OutputFormat")
.HasColumnType("integer");
b.Property<string>("OutputPathPattern")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("PreferredQuality")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int?>("RecorderProcessId")
.HasColumnType("integer");
b.Property<int>("SaveMode")
.HasColumnType("integer");
b.Property<int>("SegmentCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("StartedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("StreamUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("LiveRoomId");
b.ToTable("RecordSessions", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<double?>("DurationSeconds")
.HasColumnType("double precision");
b.Property<DateTimeOffset?>("EndedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ErrorMessage")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<Guid>("LiveRoomId")
.HasColumnType("uuid");
b.Property<string>("OutputFilePath")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("OutputFormat")
.HasColumnType("integer");
b.Property<string>("PreferredQuality")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("RecordSessionId")
.HasColumnType("uuid");
b.Property<int?>("RecorderProcessId")
.HasColumnType("integer");
b.Property<int>("SegmentIndex")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("StartedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("StreamUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("LiveRoomId");
b.HasIndex("RecordSessionId", "SegmentIndex");
b.ToTable("RecordTasks", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.SystemLogEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Detail")
.HasColumnType("text");
b.Property<int>("Level")
.HasColumnType("integer");
b.Property<Guid?>("LiveRoomId")
.HasColumnType("uuid");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<Guid?>("RecordSessionId")
.HasColumnType("uuid");
b.Property<Guid?>("RecordTaskId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("RecordSessionId");
b.ToTable("SystemLogEntries", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.UserAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("UserAccounts", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.UserSession", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("UserAccountId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.HasIndex("UserAccountId");
b.ToTable("UserSessions", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordResult", b =>
{
b.HasOne("LiveRecorder.Domain.Entities.RecordTask", "RecordTask")
.WithOne("Result")
.HasForeignKey("LiveRecorder.Domain.Entities.RecordResult", "RecordTaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("RecordTask");
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordSession", b =>
{
b.HasOne("LiveRecorder.Domain.Entities.LiveRoom", "LiveRoom")
.WithMany()
.HasForeignKey("LiveRoomId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LiveRoom");
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordTask", b =>
{
b.HasOne("LiveRecorder.Domain.Entities.LiveRoom", "LiveRoom")
.WithMany("RecordTasks")
.HasForeignKey("LiveRoomId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LiveRecorder.Domain.Entities.RecordSession", "RecordSession")
.WithMany("RecordTasks")
.HasForeignKey("RecordSessionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LiveRoom");
b.Navigation("RecordSession");
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.UserSession", b =>
{
b.HasOne("LiveRecorder.Domain.Entities.UserAccount", "UserAccount")
.WithMany()
.HasForeignKey("UserAccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("UserAccount");
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.LiveRoom", b =>
{
b.Navigation("RecordTasks");
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordSession", b =>
{
b.Navigation("RecordTasks");
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.RecordTask", b =>
{
b.Navigation("Result");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,55 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LiveRecorder.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddCleanupOperations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CleanupOperations",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Kind = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
DeleteFiles = table.Column<bool>(type: "boolean", nullable: false),
FiltersJson = table.Column<string>(type: "text", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
StartedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
CompletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
TotalSessionCount = table.Column<int>(type: "integer", nullable: false),
ProcessedSessionCount = table.Column<int>(type: "integer", nullable: false),
DeletedSessionCount = table.Column<int>(type: "integer", nullable: false),
DeletedTaskCount = table.Column<int>(type: "integer", nullable: false),
DeletedResultCount = table.Column<int>(type: "integer", nullable: false),
DeletedLogCount = table.Column<int>(type: "integer", nullable: false),
DeletedFileCount = table.Column<int>(type: "integer", nullable: false),
DeletedDanmakuFileCount = table.Column<int>(type: "integer", nullable: false),
WarningsJson = table.Column<string>(type: "text", nullable: false),
ErrorMessage = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_CleanupOperations", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_CleanupOperations_Status_CreatedAt",
table: "CleanupOperations",
columns: new[] { "Status", "CreatedAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CleanupOperations");
}
}
}
@@ -48,6 +48,72 @@ namespace LiveRecorder.Infrastructure.Persistence.Migrations
b.ToTable("AppSettings", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.CleanupOperation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("DeleteFiles")
.HasColumnType("boolean");
b.Property<int>("DeletedDanmakuFileCount")
.HasColumnType("integer");
b.Property<int>("DeletedFileCount")
.HasColumnType("integer");
b.Property<int>("DeletedLogCount")
.HasColumnType("integer");
b.Property<int>("DeletedResultCount")
.HasColumnType("integer");
b.Property<int>("DeletedSessionCount")
.HasColumnType("integer");
b.Property<int>("DeletedTaskCount")
.HasColumnType("integer");
b.Property<string>("ErrorMessage")
.HasColumnType("text");
b.Property<string>("FiltersJson")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<int>("ProcessedSessionCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("StartedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<int>("TotalSessionCount")
.HasColumnType("integer");
b.Property<string>("WarningsJson")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Status", "CreatedAt");
b.ToTable("CleanupOperations", (string)null);
});
modelBuilder.Entity("LiveRecorder.Domain.Entities.LiveRoom", b =>
{
b.Property<Guid>("Id")
@@ -0,0 +1,65 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace LiveRecorder.Infrastructure.Services;
public sealed class CleanupOperationBackgroundService : BackgroundService
{
private static readonly TimeSpan IdleDelay = TimeSpan.FromSeconds(2);
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly ILogger<CleanupOperationBackgroundService> _logger;
public CleanupOperationBackgroundService(
IServiceScopeFactory serviceScopeFactory,
ILogger<CleanupOperationBackgroundService> logger)
{
_serviceScopeFactory = serviceScopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
using var startupScope = _serviceScopeFactory.CreateScope();
var coordinator = startupScope.ServiceProvider.GetRequiredService<CleanupOperationCoordinator>();
await coordinator.RequeueRunningOperationsAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to requeue interrupted cleanup operations at startup");
}
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _serviceScopeFactory.CreateScope();
var coordinator = scope.ServiceProvider.GetRequiredService<CleanupOperationCoordinator>();
var processed = await coordinator.ProcessNextQueuedOperationAsync(stoppingToken);
if (processed)
{
continue;
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Cleanup operation background worker failed");
}
try
{
await Task.Delay(IdleDelay, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
}
}
}
@@ -0,0 +1,228 @@
using System.Text.Json;
using LiveRecorder.Application.Models.Cleanup;
using LiveRecorder.Application.Models.RecordTasks;
using LiveRecorder.Application.Models.Settings;
using LiveRecorder.Application.Services;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
using LiveRecorder.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace LiveRecorder.Infrastructure.Services;
public sealed class CleanupOperationCoordinator
{
private const int SessionDeleteBatchSize = 32;
private readonly LiveRecorderDbContext _dbContext;
private readonly RecordSessionCleanupResolver _cleanupResolver;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly ILogger<CleanupOperationCoordinator> _logger;
public CleanupOperationCoordinator(
LiveRecorderDbContext dbContext,
RecordSessionCleanupResolver cleanupResolver,
IServiceScopeFactory serviceScopeFactory,
ILogger<CleanupOperationCoordinator> logger)
{
_dbContext = dbContext;
_cleanupResolver = cleanupResolver;
_serviceScopeFactory = serviceScopeFactory;
_logger = logger;
}
public async Task<CleanupOperationDto> EnqueueSelectedAsync(
DeleteRecordSessionsRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var operation = new CleanupOperation(
CleanupOperationKind.Selected,
request.DeleteFiles,
JsonSerializer.Serialize(
new SelectedCleanupOperationFilters
{
SessionIds = request.SessionIds
},
CleanupOperationSupport.JsonOptions),
DateTimeOffset.UtcNow);
await _dbContext.CleanupOperations.AddAsync(operation, cancellationToken);
await _dbContext.SaveChangesAsync(cancellationToken);
return CleanupOperationSupport.Map(operation);
}
public async Task<CleanupOperationDto> EnqueueConditionalAsync(
DeleteConditionalSessionsRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var operation = new CleanupOperation(
CleanupOperationKind.Conditional,
request.DeleteFiles,
JsonSerializer.Serialize(
new ConditionalCleanupOperationFilters
{
VideoFileCondition = request.VideoFileCondition,
TaskStatuses = request.TaskStatuses
},
CleanupOperationSupport.JsonOptions),
DateTimeOffset.UtcNow);
await _dbContext.CleanupOperations.AddAsync(operation, cancellationToken);
await _dbContext.SaveChangesAsync(cancellationToken);
return CleanupOperationSupport.Map(operation);
}
public async Task<CleanupOperationDto> EnqueueEmptyAsync(
DeleteEmptyRecordSessionsRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var operation = new CleanupOperation(
CleanupOperationKind.Empty,
request.DeleteFiles,
JsonSerializer.Serialize(new EmptyCleanupOperationFilters(), CleanupOperationSupport.JsonOptions),
DateTimeOffset.UtcNow);
await _dbContext.CleanupOperations.AddAsync(operation, cancellationToken);
await _dbContext.SaveChangesAsync(cancellationToken);
return CleanupOperationSupport.Map(operation);
}
public async Task<CleanupOperationDto> EnqueueRetentionAsync(
SystemSettingsDto settings,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(settings);
var existing = await _dbContext.CleanupOperations
.AsNoTracking()
.Where(item => item.Kind == CleanupOperationKind.Retention &&
(item.Status == CleanupOperationStatus.Queued || item.Status == CleanupOperationStatus.Running))
.OrderByDescending(static item => item.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
if (existing is not null)
{
return CleanupOperationSupport.Map(existing);
}
var operation = new CleanupOperation(
CleanupOperationKind.Retention,
settings.RetentionDeleteFiles,
JsonSerializer.Serialize(
new RetentionCleanupOperationFilters
{
RetentionDays = settings.RetentionDays,
VideoFileCondition = settings.RetentionVideoFileCondition,
TaskStatuses = settings.RetentionTaskStatuses
},
CleanupOperationSupport.JsonOptions),
DateTimeOffset.UtcNow);
await _dbContext.CleanupOperations.AddAsync(operation, cancellationToken);
await _dbContext.SaveChangesAsync(cancellationToken);
return CleanupOperationSupport.Map(operation);
}
public async Task<CleanupOperationDto?> GetAsync(Guid id, CancellationToken cancellationToken = default)
{
var operation = await _dbContext.CleanupOperations
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == id, cancellationToken);
return operation is null ? null : CleanupOperationSupport.Map(operation);
}
public async Task RequeueRunningOperationsAsync(CancellationToken cancellationToken = default)
{
var runningOperations = await _dbContext.CleanupOperations
.Where(item => item.Status == CleanupOperationStatus.Running)
.ToListAsync(cancellationToken);
if (runningOperations.Count == 0)
{
return;
}
foreach (var operation in runningOperations)
{
operation.Requeue("Cleanup operation was interrupted by an application restart and has been queued again.");
}
await _dbContext.SaveChangesAsync(cancellationToken);
}
public async Task<bool> ProcessNextQueuedOperationAsync(CancellationToken cancellationToken = default)
{
var operation = await _dbContext.CleanupOperations
.OrderBy(static item => item.CreatedAt)
.FirstOrDefaultAsync(item => item.Status == CleanupOperationStatus.Queued, cancellationToken);
if (operation is null)
{
return false;
}
operation.MarkRunning(DateTimeOffset.UtcNow);
await _dbContext.SaveChangesAsync(cancellationToken);
try
{
var sessionIds = await _cleanupResolver.ResolveSessionIdsAsync(operation, cancellationToken);
operation.SetTotalSessionCount(sessionIds.Count);
await _dbContext.SaveChangesAsync(cancellationToken);
foreach (var batch in sessionIds.Chunk(SessionDeleteBatchSize))
{
using var batchScope = _serviceScopeFactory.CreateScope();
var batchDbContext = batchScope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
var recordSessionService = batchScope.ServiceProvider.GetRequiredService<RecordSessionService>();
var trackedOperation = await batchDbContext.CleanupOperations
.FirstAsync(item => item.Id == operation.Id, cancellationToken);
var batchResult = await recordSessionService.DeleteSessionsByIdsAsync(batch, trackedOperation.DeleteFiles, cancellationToken);
trackedOperation.ApplyBatchProgress(
processedSessionCount: batch.Length,
deletedSessionCount: batchResult.DeletedSessionIds.Count,
deletedTaskCount: batchResult.DeletedTaskIds.Count,
deletedResultCount: batchResult.DeletedResultCount,
deletedLogCount: batchResult.DeletedLogCount,
deletedFileCount: batchResult.DeletedFilePaths.Count,
deletedDanmakuFileCount: batchResult.DeletedDanmakuPaths.Count,
warnings: batchResult.Warnings);
await batchDbContext.SaveChangesAsync(cancellationToken);
}
using var completionScope = _serviceScopeFactory.CreateScope();
var completionDbContext = completionScope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
var completedOperation = await completionDbContext.CleanupOperations
.FirstAsync(item => item.Id == operation.Id, cancellationToken);
completedOperation.MarkCompleted(DateTimeOffset.UtcNow);
await completionDbContext.SaveChangesAsync(cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Cleanup operation {CleanupOperationId} failed", operation.Id);
using var failureScope = _serviceScopeFactory.CreateScope();
var failureDbContext = failureScope.ServiceProvider.GetRequiredService<LiveRecorderDbContext>();
var failedOperation = await failureDbContext.CleanupOperations
.FirstAsync(item => item.Id == operation.Id, CancellationToken.None);
failedOperation.MarkFailed(ex.ToString(), DateTimeOffset.UtcNow);
await failureDbContext.SaveChangesAsync(CancellationToken.None);
}
return true;
}
}
@@ -0,0 +1,103 @@
using System.Text.Json;
using LiveRecorder.Application.Models.Cleanup;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
namespace LiveRecorder.Infrastructure.Services;
internal static class CleanupOperationSupport
{
internal static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
internal static CleanupOperationDto Map(CleanupOperation operation) => new()
{
Id = operation.Id,
Kind = MapKind(operation.Kind),
Status = MapStatus(operation.Status),
DeleteFiles = operation.DeleteFiles,
CreatedAt = operation.CreatedAt,
StartedAt = operation.StartedAt,
CompletedAt = operation.CompletedAt,
TotalSessionCount = operation.TotalSessionCount,
ProcessedSessionCount = operation.ProcessedSessionCount,
DeletedSessionCount = operation.DeletedSessionCount,
DeletedTaskCount = operation.DeletedTaskCount,
DeletedResultCount = operation.DeletedResultCount,
DeletedLogCount = operation.DeletedLogCount,
DeletedFileCount = operation.DeletedFileCount,
DeletedDanmakuFileCount = operation.DeletedDanmakuFileCount,
Warnings = operation.GetWarnings(),
ErrorMessage = operation.ErrorMessage
};
internal static string MapKind(CleanupOperationKind kind) =>
kind switch
{
CleanupOperationKind.Conditional => CleanupOperationKinds.Conditional,
CleanupOperationKind.Empty => CleanupOperationKinds.Empty,
CleanupOperationKind.Retention => CleanupOperationKinds.Retention,
_ => CleanupOperationKinds.Selected
};
internal static string MapStatus(CleanupOperationStatus status) =>
status switch
{
CleanupOperationStatus.Running => CleanupOperationStatuses.Running,
CleanupOperationStatus.Completed => CleanupOperationStatuses.Completed,
CleanupOperationStatus.Failed => CleanupOperationStatuses.Failed,
_ => CleanupOperationStatuses.Queued
};
internal static CleanupVideoFileCondition ParseVideoFileCondition(string? value) =>
value?.Trim() switch
{
var item when string.Equals(item, CleanupVideoFileConditions.AllMissing, StringComparison.OrdinalIgnoreCase) => CleanupVideoFileCondition.AllMissing,
var item when string.Equals(item, CleanupVideoFileConditions.AllPresent, StringComparison.OrdinalIgnoreCase) => CleanupVideoFileCondition.AllPresent,
_ => CleanupVideoFileCondition.Any
};
internal static IReadOnlySet<RecordTaskStatus> ParseTaskStatuses(IReadOnlyCollection<int>? values)
{
if (values is null || values.Count == 0)
{
return new HashSet<RecordTaskStatus>();
}
return values
.Where(static value => Enum.IsDefined(typeof(RecordTaskStatus), value))
.Select(static value => (RecordTaskStatus)value)
.ToHashSet();
}
}
internal sealed class SelectedCleanupOperationFilters
{
public IReadOnlyList<Guid> SessionIds { get; init; } = [];
}
internal sealed class ConditionalCleanupOperationFilters
{
public string VideoFileCondition { get; init; } = CleanupVideoFileConditions.Any;
public IReadOnlyList<int> TaskStatuses { get; init; } = [];
}
internal sealed class RetentionCleanupOperationFilters : ConditionalCleanupOperationFilters
{
public int RetentionDays { get; init; } = 30;
}
internal sealed class EmptyCleanupOperationFilters
{
}
internal sealed class CleanupTaskCandidate
{
public Guid SessionId { get; init; }
public RecordTaskStatus Status { get; init; }
public string? ResultFilePath { get; init; }
public string? OutputFilePath { get; init; }
}
@@ -0,0 +1,220 @@
using System.Text.Json;
using LiveRecorder.Application.Abstractions.Recording;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
using LiveRecorder.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace LiveRecorder.Infrastructure.Services;
public sealed class RecordSessionCleanupResolver
{
private const int CandidateBatchSize = 128;
private readonly LiveRecorderDbContext _dbContext;
private readonly IFfmpegService _ffmpegService;
public RecordSessionCleanupResolver(
LiveRecorderDbContext dbContext,
IFfmpegService ffmpegService)
{
_dbContext = dbContext;
_ffmpegService = ffmpegService;
}
public async Task<IReadOnlyList<Guid>> ResolveSessionIdsAsync(
CleanupOperation operation,
CancellationToken cancellationToken = default)
{
return operation.Kind switch
{
CleanupOperationKind.Selected => ResolveSelectedSessionIds(operation),
CleanupOperationKind.Conditional => await ResolveConditionalSessionIdsAsync(
Deserialize<ConditionalCleanupOperationFilters>(operation.FiltersJson),
createdBeforeUtc: null,
cancellationToken),
CleanupOperationKind.Empty => await ResolveEmptySessionIdsAsync(
createdBeforeUtc: null,
cancellationToken),
CleanupOperationKind.Retention => await ResolveRetentionSessionIdsAsync(
Deserialize<RetentionCleanupOperationFilters>(operation.FiltersJson),
cancellationToken),
_ => []
};
}
private async Task<IReadOnlyList<Guid>> ResolveRetentionSessionIdsAsync(
RetentionCleanupOperationFilters filters,
CancellationToken cancellationToken)
{
var cutoff = DateTimeOffset.UtcNow.AddDays(-Math.Max(1, filters.RetentionDays));
var nonEmptySessionIds = await ResolveConditionalSessionIdsAsync(filters, cutoff, cancellationToken);
var emptySessionIds = await ResolveEmptySessionIdsAsync(cutoff, cancellationToken);
return nonEmptySessionIds
.Concat(emptySessionIds)
.Distinct()
.ToArray();
}
private IReadOnlyList<Guid> ResolveSelectedSessionIds(CleanupOperation operation)
{
var filters = Deserialize<SelectedCleanupOperationFilters>(operation.FiltersJson);
return filters.SessionIds
.Where(static item => item != Guid.Empty)
.Distinct()
.ToArray();
}
private async Task<IReadOnlyList<Guid>> ResolveConditionalSessionIdsAsync(
ConditionalCleanupOperationFilters filters,
DateTimeOffset? createdBeforeUtc,
CancellationToken cancellationToken)
{
await ReconcileActiveSessionsAsync(cancellationToken);
IQueryable<RecordSession> query = _dbContext.RecordSessions
.AsNoTracking()
.Where(static item => item.Status != RecordSessionStatus.Starting &&
item.Status != RecordSessionStatus.Running &&
item.Status != RecordSessionStatus.Stopping)
.Where(item => _dbContext.RecordTasks.Any(task => task.RecordSessionId == item.Id));
if (createdBeforeUtc.HasValue)
{
query = query.Where(item => item.CreatedAt < createdBeforeUtc.Value);
}
var candidateSessionIds = await query
.OrderBy(static item => item.CreatedAt)
.Select(static item => item.Id)
.ToListAsync(cancellationToken);
if (candidateSessionIds.Count == 0)
{
return [];
}
var matchedSessionIds = new List<Guid>();
var allowedStatuses = CleanupOperationSupport.ParseTaskStatuses(filters.TaskStatuses);
var videoFileCondition = CleanupOperationSupport.ParseVideoFileCondition(filters.VideoFileCondition);
foreach (var batch in candidateSessionIds.Chunk(CandidateBatchSize))
{
var taskCandidates = await _dbContext.RecordTasks
.AsNoTracking()
.Where(item => batch.Contains(item.RecordSessionId))
.Select(item => new CleanupTaskCandidate
{
SessionId = item.RecordSessionId,
Status = item.Status,
ResultFilePath = item.Result != null ? item.Result.FilePath : null,
OutputFilePath = item.OutputFilePath
})
.ToListAsync(cancellationToken);
foreach (var group in taskCandidates.GroupBy(static item => item.SessionId))
{
if (MatchesAllTaskConditions(group, allowedStatuses, videoFileCondition))
{
matchedSessionIds.Add(group.Key);
}
}
}
return matchedSessionIds;
}
private async Task<IReadOnlyList<Guid>> ResolveEmptySessionIdsAsync(
DateTimeOffset? createdBeforeUtc,
CancellationToken cancellationToken)
{
await ReconcileActiveSessionsAsync(cancellationToken);
IQueryable<RecordSession> query = _dbContext.RecordSessions
.AsNoTracking()
.Where(static item => item.Status != RecordSessionStatus.Starting &&
item.Status != RecordSessionStatus.Running &&
item.Status != RecordSessionStatus.Stopping)
.Where(item => !_dbContext.RecordTasks.Any(task => task.RecordSessionId == item.Id));
if (createdBeforeUtc.HasValue)
{
query = query.Where(item => item.CreatedAt < createdBeforeUtc.Value);
}
return await query
.OrderBy(static item => item.CreatedAt)
.Select(static item => item.Id)
.ToListAsync(cancellationToken);
}
private async Task ReconcileActiveSessionsAsync(CancellationToken cancellationToken)
{
var activeSessionIds = await _dbContext.RecordSessions
.AsNoTracking()
.Where(static item => item.Status == RecordSessionStatus.Starting ||
item.Status == RecordSessionStatus.Running ||
item.Status == RecordSessionStatus.Stopping)
.Select(static item => item.Id)
.Distinct()
.ToListAsync(cancellationToken);
foreach (var activeSessionId in activeSessionIds)
{
await _ffmpegService.TryReconcileInactiveSessionAsync(activeSessionId, cancellationToken);
}
}
private static bool MatchesAllTaskConditions(
IEnumerable<CleanupTaskCandidate> tasks,
IReadOnlySet<RecordTaskStatus> allowedStatuses,
CleanupVideoFileCondition videoFileCondition)
{
var taskList = tasks.ToList();
if (taskList.Count == 0)
{
return false;
}
if (allowedStatuses.Count > 0 && !taskList.All(task => allowedStatuses.Contains(task.Status)))
{
return false;
}
return videoFileCondition switch
{
CleanupVideoFileCondition.AllMissing => taskList.All(static task => !HasExistingVideoFile(task)),
CleanupVideoFileCondition.AllPresent => taskList.All(HasExistingVideoFile),
_ => true
};
}
private static bool HasExistingVideoFile(CleanupTaskCandidate task)
{
var candidatePath = !string.IsNullOrWhiteSpace(task.ResultFilePath)
? task.ResultFilePath
: task.OutputFilePath;
if (string.IsNullOrWhiteSpace(candidatePath))
{
return false;
}
var resolvedPath = Path.IsPathRooted(candidatePath)
? candidatePath
: Path.GetFullPath(candidatePath, AppContext.BaseDirectory);
return File.Exists(resolvedPath);
}
private static T Deserialize<T>(string? json)
where T : new()
{
if (string.IsNullOrWhiteSpace(json))
{
return new T();
}
return JsonSerializer.Deserialize<T>(json, CleanupOperationSupport.JsonOptions) ?? new T();
}
}
@@ -1,6 +1,3 @@
using LiveRecorder.Application.Abstractions.Logging;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Domain.Enums;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -29,14 +26,8 @@ public sealed class RetentionCleanupBackgroundService : BackgroundService
try
{
using var scope = _serviceScopeFactory.CreateScope();
var settingsService = scope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
var cleanupService = scope.ServiceProvider.GetRequiredService<RetentionCleanupService>();
var settings = await settingsService.GetAsync(stoppingToken);
if (settings.EnableRetentionCleanup)
{
await cleanupService.RunAsync(ignoreEnabledSetting: false, stoppingToken);
}
await cleanupService.TryEnqueueAsync(ignoreEnabledSetting: false, cancellationToken: stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
@@ -45,22 +36,6 @@ public sealed class RetentionCleanupBackgroundService : BackgroundService
catch (Exception ex)
{
_logger.LogError(ex, "Retention cleanup background task failed");
try
{
using var scope = _serviceScopeFactory.CreateScope();
var logService = scope.ServiceProvider.GetRequiredService<ISystemLogService>();
await logService.WriteAsync(
SystemLogLevel.Error,
"Retention",
"Retention cleanup background task failed.",
ex.ToString(),
cancellationToken: CancellationToken.None);
}
catch (Exception logEx)
{
_logger.LogWarning(logEx, "Failed to persist retention cleanup background error log");
}
}
try
@@ -1,283 +1,31 @@
using LiveRecorder.Application.Abstractions.Logging;
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Models.Settings;
using LiveRecorder.Domain.Entities;
using LiveRecorder.Domain.Enums;
using LiveRecorder.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using LiveRecorder.Application.Models.Cleanup;
namespace LiveRecorder.Infrastructure.Services;
public sealed class RetentionCleanupService
{
private readonly LiveRecorderDbContext _dbContext;
private readonly ISystemSettingsService _systemSettingsService;
private readonly ISystemLogService _systemLogService;
private readonly CleanupOperationCoordinator _cleanupOperationCoordinator;
public RetentionCleanupService(
LiveRecorderDbContext dbContext,
ISystemSettingsService systemSettingsService,
ISystemLogService systemLogService)
CleanupOperationCoordinator cleanupOperationCoordinator)
{
_dbContext = dbContext;
_systemSettingsService = systemSettingsService;
_systemLogService = systemLogService;
_cleanupOperationCoordinator = cleanupOperationCoordinator;
}
public async Task<RetentionCleanupResultDto> RunAsync(
public async Task<CleanupOperationDto?> TryEnqueueAsync(
bool ignoreEnabledSetting = false,
CancellationToken cancellationToken = default)
{
var settings = await _systemSettingsService.GetAsync(cancellationToken);
if (!settings.EnableRetentionCleanup && !ignoreEnabledSetting)
{
return CreateEmptyResult();
return null;
}
var warnings = new List<string>();
var deletedFilePaths = new List<string>();
var deletedDanmakuPaths = new List<string>();
var deletedTaskIds = new HashSet<Guid>();
var deletedSessionIds = new HashSet<Guid>();
var deletedResultIds = new HashSet<Guid>();
var deletedLogIds = new HashSet<Guid>();
var cutoff = DateTimeOffset.UtcNow.AddDays(-Math.Max(1, settings.RetentionDays));
var staleTasks = await _dbContext.RecordTasks
.Include(item => item.LiveRoom)
.Include(item => item.RecordSession)
.Include(item => item.Result)
.Where(item => item.CreatedAt < cutoff &&
item.Status != RecordTaskStatus.Starting &&
item.Status != RecordTaskStatus.Running &&
item.Status != RecordTaskStatus.Stopping &&
item.Status != RecordTaskStatus.Processing)
.ToListAsync(cancellationToken);
foreach (var task in staleTasks)
{
if (settings.RetentionDeleteFiles)
{
TryDeleteRecordOutput(task, warnings, deletedFilePaths, deletedDanmakuPaths);
}
var taskLogs = await _dbContext.SystemLogEntries
.Where(item => item.RecordTaskId == task.Id)
.ToListAsync(cancellationToken);
foreach (var log in taskLogs)
{
deletedLogIds.Add(log.Id);
}
if (task.Result is not null)
{
deletedResultIds.Add(task.Result.Id);
_dbContext.RecordResults.Remove(task.Result);
}
if (taskLogs.Count > 0)
{
_dbContext.SystemLogEntries.RemoveRange(taskLogs);
}
deletedTaskIds.Add(task.Id);
_dbContext.RecordTasks.Remove(task);
}
if (deletedTaskIds.Count > 0 || deletedResultIds.Count > 0 || deletedLogIds.Count > 0)
{
await _dbContext.SaveChangesAsync(cancellationToken);
}
var staleSessions = await _dbContext.RecordSessions
.Include(item => item.RecordTasks)
.Where(item => item.CreatedAt < cutoff &&
item.Status != RecordSessionStatus.Starting &&
item.Status != RecordSessionStatus.Running &&
item.Status != RecordSessionStatus.Stopping)
.ToListAsync(cancellationToken);
foreach (var session in staleSessions.Where(static item => item.RecordTasks.Count == 0))
{
var sessionLogs = await _dbContext.SystemLogEntries
.Where(item => item.RecordSessionId == session.Id)
.ToListAsync(cancellationToken);
foreach (var log in sessionLogs)
{
deletedLogIds.Add(log.Id);
}
if (sessionLogs.Count > 0)
{
_dbContext.SystemLogEntries.RemoveRange(sessionLogs);
}
deletedSessionIds.Add(session.Id);
_dbContext.RecordSessions.Remove(session);
}
var staleGlobalLogs = await _dbContext.SystemLogEntries
.Where(item => item.CreatedAt < cutoff)
.ToListAsync(cancellationToken);
foreach (var log in staleGlobalLogs)
{
deletedLogIds.Add(log.Id);
}
if (staleGlobalLogs.Count > 0)
{
_dbContext.SystemLogEntries.RemoveRange(staleGlobalLogs);
}
if (deletedSessionIds.Count > 0 || staleGlobalLogs.Count > 0)
{
await _dbContext.SaveChangesAsync(cancellationToken);
}
var result = new RetentionCleanupResultDto
{
DeletedSessionCount = deletedSessionIds.Count,
DeletedTaskCount = deletedTaskIds.Count,
DeletedResultCount = deletedResultIds.Count,
DeletedLogCount = deletedLogIds.Count,
DeletedFileCount = deletedFilePaths.Count,
DeletedDanmakuFileCount = deletedDanmakuPaths.Count,
Warnings = warnings
};
if (deletedSessionIds.Count > 0 ||
deletedTaskIds.Count > 0 ||
deletedResultIds.Count > 0 ||
deletedLogIds.Count > 0 ||
deletedFilePaths.Count > 0 ||
deletedDanmakuPaths.Count > 0 ||
warnings.Count > 0)
{
await _systemLogService.WriteAsync(
SystemLogLevel.Info,
"Retention",
"Retention cleanup completed.",
$"sessions={result.DeletedSessionCount}; tasks={result.DeletedTaskCount}; results={result.DeletedResultCount}; logs={result.DeletedLogCount}; video-files={result.DeletedFileCount}; danmaku-files={result.DeletedDanmakuFileCount}; warnings={warnings.Count}",
cancellationToken: cancellationToken);
}
return result;
}
private static RetentionCleanupResultDto CreateEmptyResult() => new()
{
DeletedSessionCount = 0,
DeletedTaskCount = 0,
DeletedResultCount = 0,
DeletedLogCount = 0,
DeletedFileCount = 0,
DeletedDanmakuFileCount = 0,
Warnings = []
};
private static void TryDeleteRecordOutput(
RecordTask recordTask,
List<string> warnings,
List<string> deletedFilePaths,
List<string> deletedDanmakuPaths)
{
var outputPath = recordTask.Result?.FilePath ?? recordTask.OutputFilePath;
if (!string.IsNullOrWhiteSpace(outputPath))
{
TryDeletePath(outputPath, warnings, deletedFilePaths, $"output for task {recordTask.Id}");
TryDeleteIntermediateRecordingArtifacts(outputPath, recordTask.OutputFormat, warnings, deletedFilePaths, recordTask.Id);
}
var danmakuPath = recordTask.Result?.DanmakuFilePath;
if (!string.IsNullOrWhiteSpace(danmakuPath))
{
TryDeletePath(danmakuPath, warnings, deletedDanmakuPaths, $"danmaku for task {recordTask.Id}");
}
}
private static void TryDeleteIntermediateRecordingArtifacts(
string finalOutputPath,
RecordOutputFormat outputFormat,
List<string> warnings,
List<string> deletedFilePaths,
Guid recordTaskId)
{
if (outputFormat != RecordOutputFormat.Mp4)
{
return;
}
var absoluteFinalPath = Path.IsPathRooted(finalOutputPath)
? finalOutputPath
: Path.GetFullPath(finalOutputPath, AppContext.BaseDirectory);
var intermediateCandidates = new[]
{
Path.ChangeExtension(absoluteFinalPath, ".ts"),
Path.Combine(
Path.GetDirectoryName(absoluteFinalPath) ?? string.Empty,
$"{Path.GetFileNameWithoutExtension(absoluteFinalPath)}.recording.ts")
};
foreach (var candidate in intermediateCandidates
.Where(static path => !string.IsNullOrWhiteSpace(path))
.Distinct(StringComparer.OrdinalIgnoreCase))
{
if (string.Equals(candidate, absoluteFinalPath, StringComparison.OrdinalIgnoreCase) || !File.Exists(candidate))
{
continue;
}
TryDeletePath(candidate, warnings, deletedFilePaths, $"intermediate output for task {recordTaskId}");
}
}
private static void TryDeletePath(
string path,
List<string> warnings,
List<string> deletedPaths,
string label)
{
var absolutePath = Path.IsPathRooted(path)
? path
: Path.GetFullPath(path, AppContext.BaseDirectory);
try
{
if (IsUnsafeDeletionTarget(absolutePath))
{
warnings.Add($"Skipped deleting suspicious path: {absolutePath}");
return;
}
if (File.Exists(absolutePath))
{
File.Delete(absolutePath);
deletedPaths.Add(absolutePath);
return;
}
if (Directory.Exists(absolutePath))
{
Directory.Delete(absolutePath, true);
deletedPaths.Add(absolutePath);
return;
}
warnings.Add($"Path not found for {label}: {absolutePath}");
}
catch (Exception ex)
{
warnings.Add($"Failed to delete {label}: {ex.Message}");
}
}
private static bool IsUnsafeDeletionTarget(string absolutePath)
{
var normalized = absolutePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var root = Path.GetPathRoot(normalized)?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
return string.IsNullOrWhiteSpace(normalized) ||
normalized.Length < 4 ||
string.Equals(normalized, root, StringComparison.OrdinalIgnoreCase);
return await _cleanupOperationCoordinator.EnqueueRetentionAsync(settings, cancellationToken);
}
}
@@ -0,0 +1,24 @@
using LiveRecorder.Application.Models.Cleanup;
using LiveRecorder.Infrastructure.Services;
using Microsoft.AspNetCore.Mvc;
namespace LiveRecorder.WebApi.Controllers;
[ApiController]
[Route("api/cleanup-operations")]
public sealed class CleanupOperationsController : ControllerBase
{
private readonly CleanupOperationCoordinator _cleanupOperationCoordinator;
public CleanupOperationsController(CleanupOperationCoordinator cleanupOperationCoordinator)
{
_cleanupOperationCoordinator = cleanupOperationCoordinator;
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<CleanupOperationDto>> Get(Guid id, CancellationToken cancellationToken)
{
var operation = await _cleanupOperationCoordinator.GetAsync(id, cancellationToken);
return operation is null ? NotFound() : Ok(operation);
}
}
@@ -1,3 +1,4 @@
using LiveRecorder.Application.Models.Cleanup;
using LiveRecorder.Application.Models.RecordTasks;
using LiveRecorder.Application.Services;
using LiveRecorder.Infrastructure.Services;
@@ -14,13 +15,16 @@ public sealed class RecordSessionsController : ControllerBase
private static readonly JsonSerializerOptions StreamJsonOptions = new(JsonSerializerDefaults.Web);
private readonly RecordSessionService _recordSessionService;
private readonly RecordUploadService _recordUploadService;
private readonly CleanupOperationCoordinator _cleanupOperationCoordinator;
public RecordSessionsController(
RecordSessionService recordSessionService,
RecordUploadService recordUploadService)
RecordUploadService recordUploadService,
CleanupOperationCoordinator cleanupOperationCoordinator)
{
_recordSessionService = recordSessionService;
_recordUploadService = recordUploadService;
_cleanupOperationCoordinator = cleanupOperationCoordinator;
}
[HttpGet]
@@ -76,22 +80,22 @@ public sealed class RecordSessionsController : ControllerBase
Ok(await _recordSessionService.StopAsync(id, cancellationToken));
[HttpPost("delete")]
public async Task<ActionResult<DeleteCompletedRecordTasksResultDto>> Delete(
public async Task<ActionResult<CleanupOperationDto>> Delete(
[FromBody] DeleteRecordSessionsRequest request,
CancellationToken cancellationToken) =>
Ok(await _recordSessionService.DeleteAsync(request, cancellationToken));
Ok(await _cleanupOperationCoordinator.EnqueueSelectedAsync(request, cancellationToken));
[HttpPost("delete-missing-files")]
public async Task<ActionResult<DeleteCompletedRecordTasksResultDto>> DeleteMissingFiles(
[FromBody] DeleteMissingFileRecordSessionsRequest request,
[HttpPost("delete-conditional")]
public async Task<ActionResult<CleanupOperationDto>> DeleteConditional(
[FromBody] DeleteConditionalSessionsRequest request,
CancellationToken cancellationToken) =>
Ok(await _recordSessionService.DeleteMissingFilesAsync(request, cancellationToken));
Ok(await _cleanupOperationCoordinator.EnqueueConditionalAsync(request, cancellationToken));
[HttpPost("delete-empty")]
public async Task<ActionResult<DeleteCompletedRecordTasksResultDto>> DeleteEmpty(
[FromQuery] bool deleteFiles = false,
public async Task<ActionResult<CleanupOperationDto>> DeleteEmpty(
[FromBody] DeleteEmptyRecordSessionsRequest request,
CancellationToken cancellationToken = default) =>
Ok(await _recordSessionService.DeleteEmptyAsync(deleteFiles, cancellationToken));
Ok(await _cleanupOperationCoordinator.EnqueueEmptyAsync(request, cancellationToken));
[HttpPost("{id:guid}/upload")]
public async Task<ActionResult<RecordArtifactUploadBatchResultDto>> Upload(Guid id, CancellationToken cancellationToken) =>
@@ -1,5 +1,6 @@
using LiveRecorder.Application.Abstractions.Settings;
using LiveRecorder.Application.Abstractions.Notifications;
using LiveRecorder.Application.Models.Cleanup;
using LiveRecorder.Application.Abstractions.Scripting;
using LiveRecorder.Application.Models.Settings;
using LiveRecorder.Infrastructure.Services;
@@ -91,6 +92,10 @@ public sealed class SettingsController : ControllerBase
Ok(await _webhookNotificationService.SendTestAsync(request, cancellationToken));
[HttpPost("retention/run-now")]
public async Task<ActionResult<RetentionCleanupResultDto>> RunRetentionCleanupNow(CancellationToken cancellationToken) =>
Ok(await _retentionCleanupService.RunAsync(ignoreEnabledSetting: true, cancellationToken));
public async Task<ActionResult<CleanupOperationDto>> RunRetentionCleanupNow(CancellationToken cancellationToken)
{
var operation = await _retentionCleanupService.TryEnqueueAsync(ignoreEnabledSetting: true, cancellationToken: cancellationToken)
?? throw new InvalidOperationException("Retention cleanup is disabled.");
return Ok(operation);
}
}
+3
View File
@@ -167,6 +167,8 @@ builder.Services.AddScoped<TranscodeTaskService>();
builder.Services.AddScoped<MediaBrowserService>();
builder.Services.AddScoped<SessionAnalyticsService>();
builder.Services.AddScoped<RecoveryService>();
builder.Services.AddScoped<RecordSessionCleanupResolver>();
builder.Services.AddScoped<CleanupOperationCoordinator>();
builder.Services.AddScoped<RetentionCleanupService>();
builder.Services.AddScoped<StoppedOrphanRecordSessionCleanupService>();
builder.Services.AddScoped<PlatformHttpClientFactory>();
@@ -193,6 +195,7 @@ builder.Services.AddScoped<IRecordMediaService, RecordMediaService>();
builder.Services.AddSingleton<LiveRoomPollingBackgroundService>();
builder.Services.AddSingleton<ILiveRoomPollingSignal>(provider => provider.GetRequiredService<LiveRoomPollingBackgroundService>());
builder.Services.AddHostedService(provider => provider.GetRequiredService<LiveRoomPollingBackgroundService>());
builder.Services.AddHostedService<CleanupOperationBackgroundService>();
builder.Services.AddHostedService<RetentionCleanupBackgroundService>();
var app = builder.Build();