Add Surah/Ayah browse with Gharaati Tafsir Noor crawl.
build-and-deploy / deploy (push) Failing after 11m0s

Replace episode bookmarks with surah/ayah bookmarks and ayah favorites;
import ayah text, notes, messages, and official per-ayah audio from gharaati.ir.
This commit is contained in:
2026-10-10 00:24:02 +03:30
parent d8189fab9e
commit 9309a1e6de
25 changed files with 2340 additions and 363 deletions
@@ -1,7 +1,6 @@
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using TafsirQuran.Api.Auth;
using TafsirQuran.Api.Mapping;
using TafsirQuran.Domain.Entities;
using TafsirQuran.Infrastructure.Persistence;
using TafsirQuran.Shared.Dtos;
@@ -12,52 +11,106 @@ public static class BookmarkEndpoints
{
public static RouteGroupBuilder MapBookmarkEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/bookmarks");
var surahGroup = app.MapGroup("/api/bookmarks/surahs");
var ayahGroup = app.MapGroup("/api/bookmarks/ayahs");
group.MapGet("/", async (string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
surahGroup.MapGet("/", async (string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
{
var userId = JwtTokenService.GetUserId(user);
var items = await db.Bookmarks.AsNoTracking()
.Include(b => b.Episode).ThenInclude(e => e.Surah)
var items = await db.SurahBookmarks.AsNoTracking()
.Include(b => b.Surah)
.Where(b => b.DeviceId == deviceId || (userId.HasValue && b.UserId == userId))
.OrderByDescending(b => b.CreatedAt)
.ToListAsync(ct);
return Results.Ok(items.Select(b => new BookmarkDto(
b.Id, b.EpisodeId, b.Note, b.CreatedAt, EpisodeMapper.ToListItem(b.Episode))).ToList());
return Results.Ok(items.Select(b => new SurahBookmarkDto(
b.Id, b.TafsirId, b.SurahId, b.Surah.NameFa, b.Note, b.CreatedAt)).ToList());
});
group.MapPost("/", async (ToggleEpisodeRequest request, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
surahGroup.MapPost("/", async (ToggleSurahBookmarkRequest request, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
{
var userId = JwtTokenService.GetUserId(user);
var exists = await db.Bookmarks.AnyAsync(b =>
b.EpisodeId == request.EpisodeId &&
var exists = await db.SurahBookmarks.AnyAsync(b =>
b.TafsirId == request.TafsirId &&
b.SurahId == request.SurahNumber &&
(b.DeviceId == request.DeviceId || (userId.HasValue && b.UserId == userId)), ct);
if (exists) return Results.NoContent();
db.Bookmarks.Add(new Bookmark
db.SurahBookmarks.Add(new SurahBookmark
{
DeviceId = request.DeviceId,
UserId = userId,
EpisodeId = request.EpisodeId,
TafsirId = request.TafsirId,
SurahId = request.SurahNumber,
Note = request.Note
});
await db.SaveChangesAsync(ct);
return Results.Created($"/api/bookmarks/{request.EpisodeId}", null);
return Results.Created($"/api/bookmarks/surahs/{request.SurahNumber}", null);
});
group.MapDelete("/{episodeId:guid}", async (Guid episodeId, string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
surahGroup.MapDelete("/{surahNumber:int}", async (
int surahNumber, Guid tafsirId, string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
{
var userId = JwtTokenService.GetUserId(user);
var items = await db.Bookmarks
.Where(b => b.EpisodeId == episodeId &&
var items = await db.SurahBookmarks
.Where(b => b.TafsirId == tafsirId && b.SurahId == surahNumber &&
(b.DeviceId == deviceId || (userId.HasValue && b.UserId == userId)))
.ToListAsync(ct);
db.Bookmarks.RemoveRange(items);
db.SurahBookmarks.RemoveRange(items);
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
return group;
ayahGroup.MapGet("/", async (string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
{
var userId = JwtTokenService.GetUserId(user);
var items = await db.AyahBookmarks.AsNoTracking()
.Include(b => b.Surah)
.Include(b => b.Tafsir)
.Where(b => b.DeviceId == deviceId || (userId.HasValue && b.UserId == userId))
.OrderByDescending(b => b.CreatedAt)
.ToListAsync(ct);
return Results.Ok(items.Select(b => new AyahBookmarkDto(
b.Id, b.TafsirId, b.Tafsir.Slug, b.SurahId, b.Surah.NameFa, b.AyahNumber, b.Note, b.CreatedAt)).ToList());
});
ayahGroup.MapPost("/", async (ToggleAyahRequest request, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
{
var userId = JwtTokenService.GetUserId(user);
var exists = await db.AyahBookmarks.AnyAsync(b =>
b.TafsirId == request.TafsirId &&
b.SurahId == request.SurahNumber &&
b.AyahNumber == request.AyahNumber &&
(b.DeviceId == request.DeviceId || (userId.HasValue && b.UserId == userId)), ct);
if (exists) return Results.NoContent();
db.AyahBookmarks.Add(new AyahBookmark
{
DeviceId = request.DeviceId,
UserId = userId,
TafsirId = request.TafsirId,
SurahId = request.SurahNumber,
AyahNumber = request.AyahNumber,
Note = request.Note
});
await db.SaveChangesAsync(ct);
return Results.Created($"/api/bookmarks/ayahs/{request.SurahNumber}/{request.AyahNumber}", null);
});
ayahGroup.MapDelete("/{surahNumber:int}/{ayahNumber:int}", async (
int surahNumber, int ayahNumber, Guid tafsirId, string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
{
var userId = JwtTokenService.GetUserId(user);
var items = await db.AyahBookmarks
.Where(b => b.TafsirId == tafsirId && b.SurahId == surahNumber && b.AyahNumber == ayahNumber &&
(b.DeviceId == deviceId || (userId.HasValue && b.UserId == userId)))
.ToListAsync(ct);
db.AyahBookmarks.RemoveRange(items);
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
return surahGroup;
}
}
@@ -1,7 +1,6 @@
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using TafsirQuran.Api.Auth;
using TafsirQuran.Api.Mapping;
using TafsirQuran.Domain.Entities;
using TafsirQuran.Infrastructure.Persistence;
using TafsirQuran.Shared.Dtos;
@@ -12,47 +11,53 @@ public static class FavoriteEndpoints
{
public static RouteGroupBuilder MapFavoriteEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/favorites");
var group = app.MapGroup("/api/favorites/ayahs");
group.MapGet("/", async (string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
{
var userId = JwtTokenService.GetUserId(user);
var items = await db.Favorites.AsNoTracking()
.Include(f => f.Episode).ThenInclude(e => e.Surah)
var items = await db.AyahFavorites.AsNoTracking()
.Include(f => f.Surah)
.Include(f => f.Tafsir)
.Where(f => f.DeviceId == deviceId || (userId.HasValue && f.UserId == userId))
.OrderByDescending(f => f.CreatedAt)
.ToListAsync(ct);
return Results.Ok(items.Select(f => new FavoriteDto(
f.Id, f.EpisodeId, f.CreatedAt, EpisodeMapper.ToListItem(f.Episode))).ToList());
return Results.Ok(items.Select(f => new AyahFavoriteDto(
f.Id, f.TafsirId, f.Tafsir.Slug, f.SurahId, f.Surah.NameFa, f.AyahNumber, f.CreatedAt)).ToList());
});
group.MapPost("/", async (ToggleEpisodeRequest request, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
group.MapPost("/", async (ToggleAyahRequest request, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
{
var userId = JwtTokenService.GetUserId(user);
var exists = await db.Favorites.AnyAsync(f =>
f.EpisodeId == request.EpisodeId &&
var exists = await db.AyahFavorites.AnyAsync(f =>
f.TafsirId == request.TafsirId &&
f.SurahId == request.SurahNumber &&
f.AyahNumber == request.AyahNumber &&
(f.DeviceId == request.DeviceId || (userId.HasValue && f.UserId == userId)), ct);
if (exists) return Results.NoContent();
db.Favorites.Add(new Favorite
db.AyahFavorites.Add(new AyahFavorite
{
DeviceId = request.DeviceId,
UserId = userId,
EpisodeId = request.EpisodeId
TafsirId = request.TafsirId,
SurahId = request.SurahNumber,
AyahNumber = request.AyahNumber
});
await db.SaveChangesAsync(ct);
return Results.Created($"/api/favorites/{request.EpisodeId}", null);
return Results.Created($"/api/favorites/ayahs/{request.SurahNumber}/{request.AyahNumber}", null);
});
group.MapDelete("/{episodeId:guid}", async (Guid episodeId, string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
group.MapDelete("/{surahNumber:int}/{ayahNumber:int}", async (
int surahNumber, int ayahNumber, Guid tafsirId, string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
{
var userId = JwtTokenService.GetUserId(user);
var items = await db.Favorites
.Where(f => f.EpisodeId == episodeId &&
var items = await db.AyahFavorites
.Where(f => f.TafsirId == tafsirId && f.SurahId == surahNumber && f.AyahNumber == ayahNumber &&
(f.DeviceId == deviceId || (userId.HasValue && f.UserId == userId)))
.ToListAsync(ct);
db.Favorites.RemoveRange(items);
db.AyahFavorites.RemoveRange(items);
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
@@ -1,7 +1,6 @@
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using TafsirQuran.Api.Auth;
using TafsirQuran.Api.Mapping;
using TafsirQuran.Domain.Entities;
using TafsirQuran.Infrastructure.Persistence;
using TafsirQuran.Shared.Dtos;
@@ -17,22 +16,22 @@ public static class ProgressEndpoints
group.MapGet("/", async (string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
{
var userId = JwtTokenService.GetUserId(user);
var query = db.ListeningProgresses.AsNoTracking().Include(p => p.Episode).ThenInclude(e => e.Surah).AsQueryable();
var query = db.ListeningProgresses.AsNoTracking().Include(p => p.Surah).AsQueryable();
ListeningProgress? progress = null;
if (userId.HasValue)
{
progress = await query.Where(p => p.UserId == userId).OrderByDescending(p => p.UpdatedAt).FirstOrDefaultAsync(ct);
}
progress ??= await query.Where(p => p.DeviceId == deviceId).OrderByDescending(p => p.UpdatedAt).FirstOrDefaultAsync(ct);
if (progress is null) return Results.NoContent();
return Results.Ok(new ProgressDto(
progress.EpisodeId,
progress.TafsirId,
progress.SurahId,
progress.AyahNumber,
progress.PositionSeconds,
progress.UpdatedAt,
EpisodeMapper.ToListItem(progress.Episode)));
progress.Surah.NameFa));
});
group.MapPut("/", async (UpsertProgressRequest request, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
@@ -52,14 +51,18 @@ public static class ProgressEndpoints
{
DeviceId = request.DeviceId,
UserId = userId,
EpisodeId = request.EpisodeId,
TafsirId = request.TafsirId,
SurahId = request.SurahNumber,
AyahNumber = request.AyahNumber,
PositionSeconds = request.PositionSeconds
};
db.ListeningProgresses.Add(existing);
}
else
{
existing.EpisodeId = request.EpisodeId;
existing.TafsirId = request.TafsirId;
existing.SurahId = request.SurahNumber;
existing.AyahNumber = request.AyahNumber;
existing.PositionSeconds = request.PositionSeconds;
existing.UpdatedAt = DateTimeOffset.UtcNow;
existing.UserId ??= userId;
+163 -53
View File
@@ -1,5 +1,4 @@
using Microsoft.EntityFrameworkCore;
using TafsirQuran.Api.Mapping;
using TafsirQuran.Infrastructure.Persistence;
using TafsirQuran.Shared.Dtos;
@@ -22,67 +21,178 @@ public static class TafsirEndpoints
return Results.Ok(items);
});
group.MapGet("/tafsirs/{slug}/episodes", async (string slug, AppDbContext db, CancellationToken ct) =>
group.MapGet("/surahs", async (AppDbContext db, string? deviceId, Guid? tafsirId, CancellationToken ct) =>
{
var tid = tafsirId ?? DbSeeder.GhatrehChekaniId;
var bookmarked = new HashSet<int>();
if (!string.IsNullOrWhiteSpace(deviceId))
{
bookmarked = (await db.SurahBookmarks.AsNoTracking()
.Where(b => b.TafsirId == tid && b.DeviceId == deviceId)
.Select(b => b.SurahId)
.ToListAsync(ct))
.ToHashSet();
}
var ayahCounts = await db.Ayahs.AsNoTracking()
.GroupBy(a => a.SurahId)
.Select(g => new { SurahId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.SurahId, x => x.Count, ct);
// Fallback: content rows if ayahs not yet imported for a surah
var contentCounts = await db.TafsirAyahContents.AsNoTracking()
.Where(c => c.TafsirId == tid)
.GroupBy(c => c.SurahId)
.Select(g => new { SurahId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.SurahId, x => x.Count, ct);
var surahs = await db.Surahs.AsNoTracking().OrderBy(s => s.Number).ToListAsync(ct);
var result = surahs.Select(s =>
{
var count = ayahCounts.GetValueOrDefault(s.Id);
if (count == 0) count = contentCounts.GetValueOrDefault(s.Id);
return new SurahListItemDto(s.Number, s.NameAr, s.NameFa, count, bookmarked.Contains(s.Id));
}).ToList();
return Results.Ok(result);
});
group.MapGet("/tafsirs/{slug}/surahs/{surahNumber:int}/ayahs", async (
string slug, int surahNumber, string? deviceId, AppDbContext db, CancellationToken ct) =>
{
var tafsir = await db.Tafsirs.AsNoTracking().FirstOrDefaultAsync(t => t.Slug == slug && t.IsPublished, ct);
if (tafsir is null) return Results.NotFound();
var episodes = await db.Episodes
.AsNoTracking()
.Include(e => e.Surah)
.Where(e => e.TafsirId == tafsir.Id)
.OrderBy(e => e.SortOrder)
.ToListAsync(ct);
var surah = await db.Surahs.AsNoTracking().FirstOrDefaultAsync(s => s.Number == surahNumber, ct);
if (surah is null) return Results.NotFound();
return Results.Ok(episodes.Select(e => EpisodeMapper.ToListItem(e)).ToList());
});
group.MapGet("/episodes/{id:guid}", async (Guid id, AppDbContext db, CancellationToken ct) =>
{
var episode = await db.Episodes
.AsNoTracking()
.Include(e => e.Surah)
.Include(e => e.Tafsir)
.FirstOrDefaultAsync(e => e.Id == id, ct);
if (episode is null) return Results.NotFound();
var ayah = await db.Ayahs.AsNoTracking()
.FirstOrDefaultAsync(a => a.SurahId == episode.SurahId && a.Number == episode.AyahNumber, ct);
var detail = EpisodeMapper.ToDetail(episode, ayah) with
var bookmarks = new HashSet<int>();
var favorites = new HashSet<int>();
if (!string.IsNullOrWhiteSpace(deviceId))
{
AudioUrl = $"/api/audio/{episode.Id}"
};
return Results.Ok(detail);
});
group.MapGet("/audio/{episodeId:guid}", async (Guid episodeId, AppDbContext db, IConfiguration config, IWebHostEnvironment env, CancellationToken ct) =>
{
var episode = await db.Episodes.AsNoTracking().FirstOrDefaultAsync(e => e.Id == episodeId, ct);
if (episode is null) return Results.NotFound();
var audioRoot = config["AudioRoot"] ?? "/data/audio";
var relative = episode.AudioUrl.TrimStart('/').Replace('\\', '/');
// Prefer PVC mount; fallback to wwwroot for local sample files
var candidates = new[]
{
Path.Combine(audioRoot, relative),
Path.Combine(env.ContentRootPath, "wwwroot", relative),
Path.Combine(env.ContentRootPath, "wwwroot", "audio", Path.GetFileName(relative))
};
foreach (var path in candidates)
{
if (File.Exists(path))
return Results.File(path, "audio/mpeg", enableRangeProcessing: true);
bookmarks = (await db.AyahBookmarks.AsNoTracking()
.Where(b => b.TafsirId == tafsir.Id && b.SurahId == surah.Id && b.DeviceId == deviceId)
.Select(b => b.AyahNumber).ToListAsync(ct)).ToHashSet();
favorites = (await db.AyahFavorites.AsNoTracking()
.Where(f => f.TafsirId == tafsir.Id && f.SurahId == surah.Id && f.DeviceId == deviceId)
.Select(f => f.AyahNumber).ToListAsync(ct)).ToHashSet();
}
var silence = Path.Combine(env.ContentRootPath, "wwwroot", "audio", "silence.mp3");
if (File.Exists(silence))
return Results.File(silence, "audio/mpeg", enableRangeProcessing: true);
var contents = await db.TafsirAyahContents.AsNoTracking()
.Where(c => c.TafsirId == tafsir.Id && c.SurahId == surah.Id)
.ToDictionaryAsync(c => c.AyahNumber, ct);
return Results.NotFound();
var ayahs = await db.Ayahs.AsNoTracking()
.Where(a => a.SurahId == surah.Id)
.OrderBy(a => a.Number)
.ToListAsync(ct);
if (ayahs.Count == 0 && contents.Count > 0)
{
ayahs = contents.Keys.OrderBy(k => k).Select(n => new Domain.Entities.Ayah
{
SurahId = surah.Id,
Number = n,
TextFa = null
}).ToList();
}
var list = ayahs.Select(a =>
{
contents.TryGetValue(a.Number, out var c);
var preview = a.TextFa;
if (!string.IsNullOrWhiteSpace(preview) && preview.Length > 80)
preview = preview[..80] + "…";
return new AyahListItemDto(
surah.Number,
surah.NameFa,
a.Number,
preview,
!string.IsNullOrWhiteSpace(c?.AudioPath),
bookmarks.Contains(a.Number),
favorites.Contains(a.Number));
}).ToList();
return Results.Ok(list);
});
group.MapGet("/tafsirs/{slug}/surahs/{surahNumber:int}/ayahs/{ayahNumber:int}", async (
string slug, int surahNumber, int ayahNumber, AppDbContext db, CancellationToken ct) =>
{
var tafsir = await db.Tafsirs.AsNoTracking().FirstOrDefaultAsync(t => t.Slug == slug && t.IsPublished, ct);
if (tafsir is null) return Results.NotFound();
var surah = await db.Surahs.AsNoTracking().FirstOrDefaultAsync(s => s.Number == surahNumber, ct);
if (surah is null) return Results.NotFound();
var ayah = await db.Ayahs.AsNoTracking()
.FirstOrDefaultAsync(a => a.SurahId == surah.Id && a.Number == ayahNumber, ct);
var content = await db.TafsirAyahContents.AsNoTracking()
.FirstOrDefaultAsync(c => c.TafsirId == tafsir.Id && c.SurahId == surah.Id && c.AyahNumber == ayahNumber, ct);
if (ayah is null && content is null) return Results.NotFound();
var numbers = await db.Ayahs.AsNoTracking()
.Where(a => a.SurahId == surah.Id)
.Select(a => a.Number)
.OrderBy(n => n)
.ToListAsync(ct);
if (numbers.Count == 0)
{
numbers = await db.TafsirAyahContents.AsNoTracking()
.Where(c => c.TafsirId == tafsir.Id && c.SurahId == surah.Id)
.Select(c => c.AyahNumber)
.OrderBy(n => n)
.ToListAsync(ct);
}
int? prev = null, next = null;
var idx = numbers.IndexOf(ayahNumber);
if (idx > 0) prev = numbers[idx - 1];
if (idx >= 0 && idx < numbers.Count - 1) next = numbers[idx + 1];
int? prevSurah = surahNumber > 1 ? surahNumber - 1 : null;
int? nextSurah = surahNumber < 114 ? surahNumber + 1 : null;
string? audioUrl = null;
if (!string.IsNullOrWhiteSpace(content?.AudioPath))
audioUrl = $"/api/audio/ayah/{surahNumber}/{ayahNumber}?tafsirId={tafsir.Id}";
return Results.Ok(new AyahDetailDto(
tafsir.Id,
tafsir.Slug,
surah.Number,
surah.NameFa,
surah.NameAr,
ayahNumber,
ayah?.TextAr ?? "",
ayah?.TextFa,
content?.NotesHtml,
content?.MessagesHtml,
content?.VideoEmbedUrl,
audioUrl,
prev,
next,
prevSurah,
nextSurah));
});
group.MapGet("/audio/ayah/{surahNumber:int}/{ayahNumber:int}", async (
int surahNumber, int ayahNumber, Guid? tafsirId, AppDbContext db, IConfiguration config, CancellationToken ct) =>
{
var tid = tafsirId ?? DbSeeder.GhatrehChekaniId;
var content = await db.TafsirAyahContents.AsNoTracking()
.FirstOrDefaultAsync(c => c.TafsirId == tid && c.SurahId == surahNumber && c.AyahNumber == ayahNumber, ct);
if (content is null || string.IsNullOrWhiteSpace(content.AudioPath))
return Results.NotFound();
var audioRoot = config["AudioRoot"] ?? "/data/audio";
var relative = content.AudioPath.TrimStart('/').Replace('\\', '/');
var path = Path.Combine(audioRoot, relative);
if (!File.Exists(path))
return Results.NotFound();
return Results.File(path, "audio/mpeg", enableRangeProcessing: true);
});
return group;