using Cleanuparr.Domain.Entities; using Cleanuparr.Domain.Enums; using Cleanuparr.Persistence.Converters; using Cleanuparr.Persistence.Models.Configuration; using Cleanuparr.Persistence.Models.Configuration.Arr; using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; using Cleanuparr.Persistence.Models.Configuration.General; using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker; using Cleanuparr.Persistence.Models.Configuration.Notification; using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; using Cleanuparr.Persistence.Models.Configuration.BlacklistSync; using Cleanuparr.Persistence.Models.State; using Cleanuparr.Shared.Helpers; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Serilog.Events; namespace Cleanuparr.Persistence; /// /// Database context for configuration data /// public class DataContext : DbContext { public static SemaphoreSlim Lock { get; } = new(1, 1); public DbSet GeneralConfigs { get; set; } public DbSet DownloadClients { get; set; } public DbSet QueueCleanerConfigs { get; set; } public DbSet StallRules { get; set; } public DbSet SlowRules { get; set; } public DbSet ContentBlockerConfigs { get; set; } public DbSet DownloadCleanerConfigs { get; set; } public DbSet SeedingRules { get; set; } public DbSet ArrConfigs { get; set; } public DbSet ArrInstances { get; set; } public DbSet NotificationConfigs { get; set; } public DbSet NotifiarrConfigs { get; set; } public DbSet AppriseConfigs { get; set; } public DbSet NtfyConfigs { get; set; } public DbSet PushoverConfigs { get; set; } public DbSet TelegramConfigs { get; set; } public DbSet DiscordConfigs { get; set; } public DbSet GotifyConfigs { get; set; } public DbSet BlacklistSyncHistory { get; set; } public DbSet BlacklistSyncConfigs { get; set; } public DataContext() { } public DataContext(DbContextOptions options) : base(options) { } public static DataContext CreateStaticInstance() { var optionsBuilder = new DbContextOptionsBuilder(); SetDbContextOptions(optionsBuilder); return new DataContext(optionsBuilder.Options); } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { SetDbContextOptions(optionsBuilder); } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity(entity => { entity.ComplexProperty(e => e.Log, cp => { cp.Property(l => l.Level).HasConversion>(); }); entity.ComplexProperty(e => e.Auth, cp => { cp.Property(a => a.TrustedNetworks) .HasConversion( v => string.Join(',', v), v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList()); }); }); modelBuilder.Entity(entity => { entity.ComplexProperty(e => e.FailedImport, cp => { cp.Property(x => x.PatternMode).HasConversion>(); }); }); modelBuilder.Entity(entity => { entity.ComplexProperty(e => e.Sonarr, cp => { cp.Property(s => s.BlocklistType).HasConversion>(); }); entity.ComplexProperty(e => e.Radarr, cp => { cp.Property(s => s.BlocklistType).HasConversion>(); }); entity.ComplexProperty(e => e.Lidarr, cp => { cp.Property(s => s.BlocklistType).HasConversion>(); }); entity.ComplexProperty(e => e.Readarr, cp => { cp.Property(s => s.BlocklistType).HasConversion>(); }); }); // Configure ArrConfig -> ArrInstance relationship modelBuilder.Entity(entity => { entity.HasMany(a => a.Instances) .WithOne(i => i.ArrConfig) .HasForeignKey(i => i.ArrConfigId) .OnDelete(DeleteBehavior.Cascade); }); // Configure new notification system relationships modelBuilder.Entity(entity => { entity.Property(e => e.Type).HasConversion(new LowercaseEnumConverter()); entity.HasOne(p => p.NotifiarrConfiguration) .WithOne(c => c.NotificationConfig) .HasForeignKey(c => c.NotificationConfigId) .OnDelete(DeleteBehavior.Cascade); entity.HasOne(p => p.AppriseConfiguration) .WithOne(c => c.NotificationConfig) .HasForeignKey(c => c.NotificationConfigId) .OnDelete(DeleteBehavior.Cascade); entity.HasOne(p => p.NtfyConfiguration) .WithOne(c => c.NotificationConfig) .HasForeignKey(c => c.NotificationConfigId) .OnDelete(DeleteBehavior.Cascade); entity.HasOne(p => p.PushoverConfiguration) .WithOne(c => c.NotificationConfig) .HasForeignKey(c => c.NotificationConfigId) .OnDelete(DeleteBehavior.Cascade); entity.HasOne(p => p.TelegramConfiguration) .WithOne(c => c.NotificationConfig) .HasForeignKey(c => c.NotificationConfigId) .OnDelete(DeleteBehavior.Cascade); entity.HasOne(p => p.DiscordConfiguration) .WithOne(c => c.NotificationConfig) .HasForeignKey(c => c.NotificationConfigId) .OnDelete(DeleteBehavior.Cascade); entity.HasOne(p => p.GotifyConfiguration) .WithOne(c => c.NotificationConfig) .HasForeignKey(c => c.NotificationConfigId) .OnDelete(DeleteBehavior.Cascade); entity.HasIndex(p => p.Name).IsUnique(); }); // Configure PushoverConfig List conversions modelBuilder.Entity(entity => { entity.Property(p => p.Devices) .HasConversion( v => string.Join(',', v), v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList()); entity.Property(p => p.Tags) .HasConversion( v => string.Join(',', v), v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList()); }); // Configure BlacklistSyncState relationships and indexes modelBuilder.Entity(entity => { // FK to DownloadClientConfig by DownloadClientId with cascade on delete entity.HasOne(s => s.DownloadClient) .WithMany() .HasForeignKey(s => s.DownloadClientId) .OnDelete(DeleteBehavior.Cascade); entity.HasIndex(s => new { s.Hash, DownloadClientId = s.DownloadClientId }).IsUnique(); entity.HasIndex(s => s.Hash); }); foreach (var entityType in modelBuilder.Model.GetEntityTypes()) { // Use OriginalString for Uri properties to preserve the exact input (including embedded credentials) foreach (var property in entityType.GetProperties().Where(p => p.ClrType == typeof(Uri))) { property.SetValueConverter( new ValueConverter( v => v.OriginalString, v => new Uri(v, UriKind.RelativeOrAbsolute))); property.SetValueComparer(new ValueComparer( (u1, u2) => u1 != null && u2 != null ? u1.OriginalString == u2.OriginalString : u1 == null && u2 == null, u => u == null ? 0 : u.OriginalString.GetHashCode(), u => u == null ? null! : new Uri(u.OriginalString, UriKind.RelativeOrAbsolute))); } var enumProperties = entityType.ClrType.GetProperties() .Where(p => p.PropertyType.IsEnum || (p.PropertyType.IsGenericType && p.PropertyType.GetGenericTypeDefinition() == typeof(Nullable<>) && p.PropertyType.GetGenericArguments()[0].IsEnum)); foreach (var property in enumProperties) { var enumType = property.PropertyType.IsEnum ? property.PropertyType : property.PropertyType.GetGenericArguments()[0]; var converterType = typeof(LowercaseEnumConverter<>).MakeGenericType(enumType); var converter = Activator.CreateInstance(converterType); modelBuilder.Entity(entityType.ClrType) .Property(property.Name) .HasConversion((ValueConverter)converter!); } } } private static void SetDbContextOptions(DbContextOptionsBuilder optionsBuilder) { if (optionsBuilder.IsConfigured) { return; } var dbPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "cleanuparr.db"); optionsBuilder .UseSqlite($"Data Source={dbPath}") .UseLowerCaseNamingConvention() .UseSnakeCaseNamingConvention(); } }