Fix Surah seed tracking conflict on API startup retries.
build-and-deploy / deploy (push) Has been cancelled
build-and-deploy / deploy (push) Has been cancelled
Use idempotent seed, fresh DbContext per retry, and explicit Surah/Tafsir keys.
This commit is contained in:
@@ -40,14 +40,15 @@ builder.Services.AddOpenApi();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
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<AppDbContext>();
|
||||
await DbSeeder.SeedAsync(db, audioRoot);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
||||
|
||||
modelBuilder.Entity<Tafsir>(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<AppDbContext> options) : DbContext(op
|
||||
|
||||
modelBuilder.Entity<Surah>(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);
|
||||
|
||||
@@ -43,12 +43,13 @@ 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++)
|
||||
{
|
||||
var n = i + 1;
|
||||
if (existingSurahIds.Contains(n)) continue;
|
||||
db.Surahs.Add(new Surah
|
||||
{
|
||||
Id = n,
|
||||
@@ -57,10 +58,12 @@ public static class DbSeeder
|
||||
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
|
||||
var existingSet = (await db.Episodes.AsNoTracking()
|
||||
.Where(e => e.TafsirId == GhatrehChekaniId)
|
||||
.Select(e => e.AudioUrl)
|
||||
.ToListAsync(ct);
|
||||
var existingSet = existing.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
.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;
|
||||
|
||||
Generated
+396
@@ -0,0 +1,396 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TafsirQuran.Domain.Entities.Ayah", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int>("Number")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("SurahId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("TextAr")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("TextFa")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SurahId", "Number")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Ayahs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TafsirQuran.Domain.Entities.Bookmark", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<Guid>("EpisodeId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<Guid?>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<Guid>("EpisodeId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid?>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<Guid>("EpisodeId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<double>("PositionSeconds")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<Guid?>("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<int>("Id")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("NameAr")
|
||||
.IsRequired()
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("nvarchar(80)");
|
||||
|
||||
b.Property<string>("NameFa")
|
||||
.IsRequired()
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("nvarchar(80)");
|
||||
|
||||
b.Property<int>("Number")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Number")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Surahs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TafsirQuran.Domain.Entities.Tafsir", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("Author")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("nvarchar(120)");
|
||||
|
||||
b.Property<string>("CoverUrl")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsPublished")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Slug")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("nvarchar(120)");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("AudioUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("AyahNumber")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("DurationSeconds")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("SurahId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<Guid>("TafsirId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TafsirQuran.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SurahExplicitIds : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<int>(
|
||||
name: "Id",
|
||||
table: "Surahs",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
oldClrType: typeof(int),
|
||||
oldType: "int")
|
||||
.OldAnnotation("SqlServer:Identity", "1, 1");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<int>(
|
||||
name: "Id",
|
||||
table: "Surahs",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
oldClrType: typeof(int),
|
||||
oldType: "int")
|
||||
.Annotation("SqlServer:Identity", "1, 1");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,11 +187,8 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations
|
||||
modelBuilder.Entity("TafsirQuran.Domain.Entities.Surah", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("NameAr")
|
||||
.IsRequired()
|
||||
.HasMaxLength(80)
|
||||
@@ -216,7 +213,6 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations
|
||||
modelBuilder.Entity("TafsirQuran.Domain.Entities.Tafsir", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("Author")
|
||||
|
||||
Reference in New Issue
Block a user