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