📄 src/Domain/Cleaner.cs
using System;
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

namespace Slopper.Domain;

public sealed class Cleaner(
    IOptionsMonitor<CleanerOptions> options,
    IClipRepository clipRepository,
    TimeProvider timeProvider
)
{
    public async Task Cleanup(CancellationToken cancellationToken)
    {
        var cutoff = timeProvider.GetUtcNow() - options.CurrentValue.Retention;
        await foreach (var clip in clipRepository.GetCreatedBefore(cutoff, cancellationToken))
        {
            File.Delete(clip.Path);
            clip.RemovedAt = timeProvider.GetUtcNow();
            await clipRepository.Save(clip, cancellationToken);
        }
    }
}

public sealed class CleanerOptions
{
    [Required]
    public required TimeSpan Retention { get; init; }
}

[OptionsValidator]
internal sealed partial class CleanerOptionsValidator : IValidateOptions<CleanerOptions>;

public static class CleanerServiceCollectionExtensions
{
    extension(IServiceCollection services)
    {
        public IServiceCollection AddCleaner()
        {
            services.AddOptions<CleanerOptions>().BindConfiguration("Cleaner").ValidateOnStart();
            services.AddTransient<IValidateOptions<CleanerOptions>, CleanerOptionsValidator>();
            services.AddTransient<Cleaner>();
            return services;
        }
    }
}