diff --git a/src/TafsirQuran.Api/Program.cs b/src/TafsirQuran.Api/Program.cs index afd2705..37132e4 100644 --- a/src/TafsirQuran.Api/Program.cs +++ b/src/TafsirQuran.Api/Program.cs @@ -40,14 +40,15 @@ builder.Services.AddOpenApi(); var app = builder.Build(); -using (var scope = app.Services.CreateScope()) { - var db = scope.ServiceProvider.GetRequiredService(); + var audioRoot = app.Configuration["AudioRoot"] ?? "/data/audio"; for (var attempt = 1; ; attempt++) { try { - var audioRoot = app.Configuration["AudioRoot"] ?? "/data/audio"; + // Fresh scope each attempt so a failed seed cannot leave tracked entities. + using var scope = app.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); await DbSeeder.SeedAsync(db, audioRoot); break; } diff --git a/src/TafsirQuran.Infrastructure/Persistence/AppDbContext.cs b/src/TafsirQuran.Infrastructure/Persistence/AppDbContext.cs index 3313721..a3ac1a8 100644 --- a/src/TafsirQuran.Infrastructure/Persistence/AppDbContext.cs +++ b/src/TafsirQuran.Infrastructure/Persistence/AppDbContext.cs @@ -27,6 +27,7 @@ public class AppDbContext(DbContextOptions options) : DbContext(op modelBuilder.Entity(e => { + e.Property(x => x.Id).ValueGeneratedNever(); e.HasIndex(x => x.Slug).IsUnique(); e.Property(x => x.Title).HasMaxLength(200); e.Property(x => x.Author).HasMaxLength(120); @@ -35,6 +36,7 @@ public class AppDbContext(DbContextOptions options) : DbContext(op modelBuilder.Entity(e => { + e.Property(x => x.Id).ValueGeneratedNever(); e.HasIndex(x => x.Number).IsUnique(); e.Property(x => x.NameAr).HasMaxLength(80); e.Property(x => x.NameFa).HasMaxLength(80); diff --git a/src/TafsirQuran.Infrastructure/Persistence/DbSeeder.cs b/src/TafsirQuran.Infrastructure/Persistence/DbSeeder.cs index 1ace426..bd389e1 100644 --- a/src/TafsirQuran.Infrastructure/Persistence/DbSeeder.cs +++ b/src/TafsirQuran.Infrastructure/Persistence/DbSeeder.cs @@ -43,24 +43,27 @@ public static class DbSeeder public static async Task SeedAsync(AppDbContext db, string? audioRoot = null, CancellationToken ct = default) { await db.Database.MigrateAsync(ct); + db.ChangeTracker.Clear(); - if (!await db.Surahs.AnyAsync(ct)) + var existingSurahIds = (await db.Surahs.AsNoTracking().Select(s => s.Id).ToListAsync(ct)).ToHashSet(); + for (var i = 0; i < SurahNames.Length; i++) { - for (var i = 0; i < SurahNames.Length; i++) + var n = i + 1; + if (existingSurahIds.Contains(n)) continue; + db.Surahs.Add(new Surah { - var n = i + 1; - db.Surahs.Add(new Surah - { - Id = n, - Number = n, - NameAr = SurahNames[i].Ar, - NameFa = SurahNames[i].Fa - }); - } - await db.SaveChangesAsync(ct); + Id = n, + Number = n, + NameAr = SurahNames[i].Ar, + NameFa = SurahNames[i].Fa + }); } + if (db.ChangeTracker.HasChanges()) + await db.SaveChangesAsync(ct); - if (!await db.Tafsirs.AnyAsync(ct)) + db.ChangeTracker.Clear(); + + if (!await db.Tafsirs.AsNoTracking().AnyAsync(t => t.Id == GhatrehChekaniId, ct)) { db.Tafsirs.Add(new Tafsir { @@ -73,6 +76,7 @@ public static class DbSeeder await db.SaveChangesAsync(ct); } + db.ChangeTracker.Clear(); await SyncEpisodesFromAudioAsync(db, audioRoot, ct); } @@ -81,14 +85,14 @@ public static class DbSeeder if (string.IsNullOrWhiteSpace(audioRoot) || !Directory.Exists(audioRoot)) return; - var tafsir = await db.Tafsirs.FirstOrDefaultAsync(t => t.Id == GhatrehChekaniId, ct); - if (tafsir is null) return; + if (!await db.Tafsirs.AsNoTracking().AnyAsync(t => t.Id == GhatrehChekaniId, ct)) + return; - var existing = await db.Episodes - .Where(e => e.TafsirId == GhatrehChekaniId) - .Select(e => e.AudioUrl) - .ToListAsync(ct); - var existingSet = existing.ToHashSet(StringComparer.OrdinalIgnoreCase); + var existingSet = (await db.Episodes.AsNoTracking() + .Where(e => e.TafsirId == GhatrehChekaniId) + .Select(e => e.AudioUrl) + .ToListAsync(ct)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); var audioFiles = Directory.EnumerateFiles(audioRoot, "*.*", SearchOption.AllDirectories) .Where(f => f.EndsWith(".mp3", StringComparison.OrdinalIgnoreCase) @@ -100,7 +104,7 @@ public static class DbSeeder if (audioFiles.Count == 0) return; - var sort = existing.Count; + var sort = existingSet.Count; var added = 0; foreach (var file in audioFiles) { @@ -122,10 +126,18 @@ public static class DbSeeder SortOrder = sort, Summary = null }); + existingSet.Add(relative); added++; + + // batch inserts to avoid huge change tracker + if (added % 200 == 0) + { + await db.SaveChangesAsync(ct); + db.ChangeTracker.Clear(); + } } - if (added > 0) + if (db.ChangeTracker.HasChanges()) await db.SaveChangesAsync(ct); } @@ -149,10 +161,8 @@ public static class DbSeeder private static int GuessAyahNumber(string fileName) { - // patterns: ..._001, -1, .1, ayah01 var matches = System.Text.RegularExpressions.Regex.Matches(fileName, @"(\d+)"); if (matches.Count == 0) return 1; - // prefer last number group as ayah when multiple if (int.TryParse(matches[^1].Value, out var n) && n > 0) return n; return 1; diff --git a/src/TafsirQuran.Infrastructure/Persistence/Migrations/20261009192702_SurahExplicitIds.Designer.cs b/src/TafsirQuran.Infrastructure/Persistence/Migrations/20261009192702_SurahExplicitIds.Designer.cs new file mode 100644 index 0000000..5bd4233 --- /dev/null +++ b/src/TafsirQuran.Infrastructure/Persistence/Migrations/20261009192702_SurahExplicitIds.Designer.cs @@ -0,0 +1,396 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TafsirQuran.Infrastructure.Persistence; + +#nullable disable + +namespace TafsirQuran.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20261009192702_SurahExplicitIds")] + partial class SurahExplicitIds + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.Ayah", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Number") + .HasColumnType("int"); + + b.Property("SurahId") + .HasColumnType("int"); + + b.Property("TextAr") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TextFa") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("SurahId", "Number") + .IsUnique(); + + b.ToTable("Ayahs"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.Bookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EpisodeId") + .HasColumnType("uniqueidentifier"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("UserId"); + + b.HasIndex("DeviceId", "UserId", "EpisodeId") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Bookmarks"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.Favorite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EpisodeId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("UserId"); + + b.HasIndex("DeviceId", "UserId", "EpisodeId") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Favorites"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.ListeningProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EpisodeId") + .HasColumnType("uniqueidentifier"); + + b.Property("PositionSeconds") + .HasColumnType("float"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("UserId"); + + b.HasIndex("DeviceId", "UserId"); + + b.ToTable("ListeningProgresses"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.Surah", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("Number") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Number") + .IsUnique(); + + b.ToTable("Surahs"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.Tafsir", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("Author") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("CoverUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsPublished") + .HasColumnType("bit"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tafsirs"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.TafsirEpisode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AudioUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AyahNumber") + .HasColumnType("int"); + + b.Property("DurationSeconds") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Summary") + .HasColumnType("nvarchar(max)"); + + b.Property("SurahId") + .HasColumnType("int"); + + b.Property("TafsirId") + .HasColumnType("uniqueidentifier"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.HasKey("Id"); + + b.HasIndex("SurahId"); + + b.HasIndex("TafsirId", "SortOrder"); + + b.ToTable("Episodes"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.Ayah", b => + { + b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah") + .WithMany("Ayahs") + .HasForeignKey("SurahId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Surah"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.Bookmark", b => + { + b.HasOne("TafsirQuran.Domain.Entities.TafsirEpisode", "Episode") + .WithMany() + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TafsirQuran.Domain.Entities.AppUser", "User") + .WithMany("Bookmarks") + .HasForeignKey("UserId"); + + b.Navigation("Episode"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.Favorite", b => + { + b.HasOne("TafsirQuran.Domain.Entities.TafsirEpisode", "Episode") + .WithMany() + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TafsirQuran.Domain.Entities.AppUser", "User") + .WithMany("Favorites") + .HasForeignKey("UserId"); + + b.Navigation("Episode"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.ListeningProgress", b => + { + b.HasOne("TafsirQuran.Domain.Entities.TafsirEpisode", "Episode") + .WithMany() + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TafsirQuran.Domain.Entities.AppUser", "User") + .WithMany("ListeningProgresses") + .HasForeignKey("UserId"); + + b.Navigation("Episode"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.TafsirEpisode", b => + { + b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah") + .WithMany("Episodes") + .HasForeignKey("SurahId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TafsirQuran.Domain.Entities.Tafsir", "Tafsir") + .WithMany("Episodes") + .HasForeignKey("TafsirId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Surah"); + + b.Navigation("Tafsir"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Favorites"); + + b.Navigation("ListeningProgresses"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.Surah", b => + { + b.Navigation("Ayahs"); + + b.Navigation("Episodes"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.Tafsir", b => + { + b.Navigation("Episodes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/TafsirQuran.Infrastructure/Persistence/Migrations/20261009192702_SurahExplicitIds.cs b/src/TafsirQuran.Infrastructure/Persistence/Migrations/20261009192702_SurahExplicitIds.cs new file mode 100644 index 0000000..3d1b954 --- /dev/null +++ b/src/TafsirQuran.Infrastructure/Persistence/Migrations/20261009192702_SurahExplicitIds.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TafsirQuran.Infrastructure.Persistence.Migrations +{ + /// + public partial class SurahExplicitIds : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Id", + table: "Surahs", + type: "int", + nullable: false, + oldClrType: typeof(int), + oldType: "int") + .OldAnnotation("SqlServer:Identity", "1, 1"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Id", + table: "Surahs", + type: "int", + nullable: false, + oldClrType: typeof(int), + oldType: "int") + .Annotation("SqlServer:Identity", "1, 1"); + } + } +} diff --git a/src/TafsirQuran.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/TafsirQuran.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index ee659a0..4def008 100644 --- a/src/TafsirQuran.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/src/TafsirQuran.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -187,11 +187,8 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations modelBuilder.Entity("TafsirQuran.Domain.Entities.Surah", b => { b.Property("Id") - .ValueGeneratedOnAdd() .HasColumnType("int"); - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - b.Property("NameAr") .IsRequired() .HasMaxLength(80) @@ -216,7 +213,6 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations modelBuilder.Entity("TafsirQuran.Domain.Entities.Tafsir", b => { b.Property("Id") - .ValueGeneratedOnAdd() .HasColumnType("uniqueidentifier"); b.Property("Author")