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.Configuration.Seeker; using Cleanuparr.Persistence.Models.State; using Cleanuparr.Persistence.Providers; using Cleanuparr.Shared.Helpers; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 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 QBitSeedingRules { get; set; } public DbSet DelugeSeedingRules { get; set; } public DbSet TransmissionSeedingRules { get; set; } public DbSet UTorrentSeedingRules { get; set; } public DbSet RTorrentSeedingRules { get; set; } public DbSet UnlinkedConfigs { get; set; } public DbSet DeadTorrentConfigs { 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 DbSet OrphanedFilesConfigs { get; set; } public DbSet SeekerConfigs { get; set; } public DbSet SeekerInstanceConfigs { get; set; } private readonly IDatabaseProvider _provider; public DataContext() : this(DatabaseProviderFactory.Current) { } public DataContext(IDatabaseProvider provider) { _provider = provider; } public DataContext(DbContextOptions options) : this(options, DatabaseProviderFactory.Current) { } public DataContext(DbContextOptions options, IDatabaseProvider provider) : base(options) { _provider = provider; } public static DataContext CreateStaticInstance() { IDatabaseProvider provider = DatabaseProviderFactory.Current; DbContextOptionsBuilder optionsBuilder = new(); provider.ConfigureContext(optionsBuilder, DbContextKind.Data); return new DataContext(optionsBuilder.Options, provider); } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { SetDbContextOptions(optionsBuilder); } protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { _provider.ConfigureConventions(configurationBuilder); } protected override void OnModelCreating(ModelBuilder modelBuilder) { string? schema = _provider.GetSchema(DbContextKind.Data); if (schema is not null) { modelBuilder.HasDefaultSchema(schema); } modelBuilder.Entity(entity => { entity.ComplexProperty(e => e.Auth, cp => { cp.Property(a => a.TrustedNetworks) .HasConversion( v => string.Join(',', v), v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList()); }); }); // 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.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()); }); modelBuilder.Entity(entity => { entity.HasOne(s => s.ArrInstance) .WithMany() .HasForeignKey(s => s.ArrInstanceId) .OnDelete(DeleteBehavior.Cascade); entity.HasIndex(s => s.ArrInstanceId).IsUnique(); }); // Configure per-client seeding rule relationships and JSON list converters var jsonListConverter = new JsonStringListConverter(); modelBuilder.Entity(entity => { entity.HasOne(s => s.DownloadClientConfig) .WithMany() .HasForeignKey(s => s.DownloadClientConfigId) .OnDelete(DeleteBehavior.Cascade); entity.Property(s => s.Categories).HasConversion(jsonListConverter); entity.Property(s => s.TrackerPatterns).HasConversion(jsonListConverter); entity.Property(s => s.TagsAny).HasConversion(jsonListConverter); entity.Property(s => s.TagsAll).HasConversion(jsonListConverter); }); modelBuilder.Entity(entity => { entity.HasOne(s => s.DownloadClientConfig) .WithMany() .HasForeignKey(s => s.DownloadClientConfigId) .OnDelete(DeleteBehavior.Cascade); entity.Property(s => s.Categories).HasConversion(jsonListConverter); entity.Property(s => s.TrackerPatterns).HasConversion(jsonListConverter); }); modelBuilder.Entity(entity => { entity.HasOne(s => s.DownloadClientConfig) .WithMany() .HasForeignKey(s => s.DownloadClientConfigId) .OnDelete(DeleteBehavior.Cascade); entity.Property(s => s.Categories).HasConversion(jsonListConverter); entity.Property(s => s.TrackerPatterns).HasConversion(jsonListConverter); entity.Property(s => s.TagsAny).HasConversion(jsonListConverter); entity.Property(s => s.TagsAll).HasConversion(jsonListConverter); }); modelBuilder.Entity(entity => { entity.HasOne(s => s.DownloadClientConfig) .WithMany() .HasForeignKey(s => s.DownloadClientConfigId) .OnDelete(DeleteBehavior.Cascade); entity.Property(s => s.Categories).HasConversion(jsonListConverter); entity.Property(s => s.TrackerPatterns).HasConversion(jsonListConverter); }); modelBuilder.Entity(entity => { entity.HasOne(s => s.DownloadClientConfig) .WithMany() .HasForeignKey(s => s.DownloadClientConfigId) .OnDelete(DeleteBehavior.Cascade); entity.Property(s => s.Categories).HasConversion(jsonListConverter); entity.Property(s => s.TrackerPatterns).HasConversion(jsonListConverter); }); // Configure per-client unlinked config relationship modelBuilder.Entity(entity => { entity.HasOne(u => u.DownloadClientConfig) .WithMany() .HasForeignKey(u => u.DownloadClientConfigId) .OnDelete(DeleteBehavior.Cascade); entity.HasIndex(u => u.DownloadClientConfigId).IsUnique(); }); // Configure per-client dead torrent config relationship modelBuilder.Entity(entity => { entity.HasOne(d => d.DownloadClientConfig) .WithMany() .HasForeignKey(d => d.DownloadClientConfigId) .OnDelete(DeleteBehavior.Cascade); entity.HasIndex(d => d.DownloadClientConfigId).IsUnique(); entity.Property(d => d.Categories).HasConversion(jsonListConverter); }); // Configure per-client orphaned files config relationship modelBuilder.Entity(entity => { entity.HasOne(c => c.DownloadClientConfig) .WithMany() .HasForeignKey(c => c.DownloadClientConfigId) .OnDelete(DeleteBehavior.Cascade); entity.HasIndex(c => c.DownloadClientConfigId).IsUnique(); entity.Property(c => c.ScanDirectories).HasConversion(jsonListConverter); entity.Property(c => c.ExcludePatterns).HasConversion(jsonListConverter); }); // 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))); } } modelBuilder.ApplyLowercaseEnumConversions(); } private void SetDbContextOptions(DbContextOptionsBuilder optionsBuilder) { if (optionsBuilder.IsConfigured) { return; } _provider.ConfigureContext(optionsBuilder, DbContextKind.Data); } }