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
+44
View File
@@ -0,0 +1,44 @@
# One-shot crawl of Tafsir Noor into tafsirquran-audio PVC.
# Scale API to 0 first (RWO PVC), then create ConfigMap + Job:
# kubectl -n tafsirquran create configmap tafsirquran-gharaati-scripts \
# --from-file=crawl-gharaati.py=scripts/crawl-gharaati.py --dry-run=client -o yaml | kubectl apply -f -
# kubectl -n tafsirquran apply -f k8s/gharaati-crawl-job.yaml
# kubectl -n tafsirquran logs -f job/tafsirquran-gharaati-crawl
apiVersion: batch/v1
kind: Job
metadata:
name: tafsirquran-gharaati-crawl
namespace: tafsirquran
spec:
backoffLimit: 2
ttlSecondsAfterFinished: 86400
template:
spec:
restartPolicy: Never
containers:
- name: crawl
image: python:3.12-slim
command: ["/bin/bash", "-lc"]
args:
- |
set -euo pipefail
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq ca-certificates >/dev/null
python3 /scripts/crawl-gharaati.py /data/audio --skip-existing-json --delay 0.25
volumeMounts:
- name: audio
mountPath: /data/audio
- name: scripts
mountPath: /scripts
resources:
requests:
memory: 256Mi
cpu: 250m
volumes:
- name: audio
persistentVolumeClaim:
claimName: tafsirquran-audio
- name: scripts
configMap:
name: tafsirquran-gharaati-scripts
defaultMode: 0755
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""Crawl Tafsir Noor from gharaati.ir into AUDIO_DIR/gharaati + content JSON."""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request
from html import unescape
from pathlib import Path
BASE = "https://gharaati.ir"
UA = "TafsirQuranCrawler/1.0 (+local; educational mirror)"
def fetch(url: str, retries: int = 5) -> bytes:
last: Exception | None = None
for i in range(retries):
try:
req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "*/*"})
with urllib.request.urlopen(req, timeout=60) as resp:
return resp.read()
except Exception as ex: # noqa: BLE001
last = ex
time.sleep(1.5 * (i + 1))
raise RuntimeError(f"fetch failed {url}: {last}")
def strip_tags(html: str) -> str:
text = re.sub(r"<br\s*/?>", "\n", html, flags=re.I)
text = re.sub(r"</p\s*>", "\n", text, flags=re.I)
text = re.sub(r"<[^>]+>", "", text)
return unescape(re.sub(r"\s+\n", "\n", text)).strip()
def section_after_title(html: str, title: str) -> str | None:
# Match heading text then first content cell with class tafsir or tafsir_ayeh
pattern = (
rf'<span class="post-title h-text">\s*{re.escape(title)}\s*</span>.*?'
rf'<td[^>]*class=[\'"]?(?:tafsir_ayeh|tafsir)[\'"]?[^>]*>(.*?)</td>'
)
m = re.search(pattern, html, flags=re.I | re.S)
if not m:
# alternate spellings
return None
return m.group(1).strip()
def parse_show3(html: str) -> dict:
# Normalize common title variants
ayah_html = section_after_title(html, "آيه") or section_after_title(html, "آیه")
tarjome = section_after_title(html, "ترجمه")
nokteh = section_after_title(html, "نکته ها") or section_after_title(html, "نكته ها")
payam = (
section_after_title(html, "پيام ها")
or section_after_title(html, "پیام ها")
or section_after_title(html, "پيامها")
)
video = None
vm = re.search(r'src=["\'](https?://[^"\']*aparat\.com[^"\']*)["\']', html, flags=re.I)
if vm:
video = vm.group(1)
return {
"textAr": strip_tags(ayah_html) if ayah_html else "",
"textFa": strip_tags(tarjome) if tarjome else "",
"notesHtml": nokteh or "",
"messagesHtml": payam or "",
"videoEmbedUrl": video,
}
def ayah_list(surah: int) -> list[int]:
raw = fetch(f"{BASE}/mydata.php?op=getayehlist&id={surah}").decode("utf-8", "replace")
nums = [int(x) for x in re.findall(r"value=['\"]?(\d+)", raw)]
return sorted(set(nums)) if nums else []
def download_mp3(surah: int, ayah: int, dest: Path) -> bool:
if dest.exists() and dest.stat().st_size > 1000:
return False
dest.parent.mkdir(parents=True, exist_ok=True)
url = f"{BASE}/archive/tafsir/{surah}/{ayah}.mp3"
try:
data = fetch(url, retries=3)
if len(data) < 500:
return False
tmp = dest.with_suffix(".part")
tmp.write_bytes(data)
tmp.replace(dest)
return True
except Exception as ex: # noqa: BLE001
print(f" mp3 miss {surah}/{ayah}: {ex}", file=sys.stderr)
return False
def crawl_one(surah: int, ayah: int, audio_dir: Path, delay: float) -> dict:
html = fetch(f"{BASE}/show3.php?page=tafsir3&numsooreh={surah}&numayeh={ayah}").decode(
"utf-8", "replace"
)
parsed = parse_show3(html)
rel = f"gharaati/{surah}/{ayah}.mp3"
mp3_path = audio_dir / rel
download_mp3(surah, ayah, mp3_path)
if mp3_path.exists():
parsed["audioPath"] = rel
else:
parsed["audioPath"] = None
parsed["surah"] = surah
parsed["ayah"] = ayah
content_path = audio_dir / "gharaati" / "content" / str(surah) / f"{ayah}.json"
content_path.parent.mkdir(parents=True, exist_ok=True)
content_path.write_text(json.dumps(parsed, ensure_ascii=False, indent=2), encoding="utf-8")
time.sleep(delay)
return parsed
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("audio_dir", nargs="?", default=os.environ.get("AUDIO_DIR", "/data/audio"))
ap.add_argument("--surah-from", type=int, default=1)
ap.add_argument("--surah-to", type=int, default=114)
ap.add_argument("--delay", type=float, default=0.35)
ap.add_argument("--skip-existing-json", action="store_true")
args = ap.parse_args()
audio_dir = Path(args.audio_dir)
audio_dir.mkdir(parents=True, exist_ok=True)
total = 0
for s in range(args.surah_from, args.surah_to + 1):
ayahs = ayah_list(s)
if not ayahs:
print(f"surah {s}: no ayah list", file=sys.stderr)
continue
print(f"surah {s}: {len(ayahs)} ayahs")
for a in ayahs:
jpath = audio_dir / "gharaati" / "content" / str(s) / f"{a}.json"
if args.skip_existing_json and jpath.exists():
# still try mp3 if missing
mp3 = audio_dir / "gharaati" / str(s) / f"{a}.mp3"
if not mp3.exists():
download_mp3(s, a, mp3)
continue
try:
crawl_one(s, a, audio_dir, args.delay)
total += 1
if total % 25 == 0:
print(f" crawled {total} ayahs (at {s}:{a})")
except Exception as ex: # noqa: BLE001
print(f"FAIL {s}:{a} {ex}", file=sys.stderr)
time.sleep(2)
print(f"done. crawled={total}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,7 +1,6 @@
using System.Security.Claims; using System.Security.Claims;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TafsirQuran.Api.Auth; using TafsirQuran.Api.Auth;
using TafsirQuran.Api.Mapping;
using TafsirQuran.Domain.Entities; using TafsirQuran.Domain.Entities;
using TafsirQuran.Infrastructure.Persistence; using TafsirQuran.Infrastructure.Persistence;
using TafsirQuran.Shared.Dtos; using TafsirQuran.Shared.Dtos;
@@ -12,52 +11,106 @@ public static class BookmarkEndpoints
{ {
public static RouteGroupBuilder MapBookmarkEndpoints(this WebApplication app) 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 userId = JwtTokenService.GetUserId(user);
var items = await db.Bookmarks.AsNoTracking() var items = await db.SurahBookmarks.AsNoTracking()
.Include(b => b.Episode).ThenInclude(e => e.Surah) .Include(b => b.Surah)
.Where(b => b.DeviceId == deviceId || (userId.HasValue && b.UserId == userId)) .Where(b => b.DeviceId == deviceId || (userId.HasValue && b.UserId == userId))
.OrderByDescending(b => b.CreatedAt) .OrderByDescending(b => b.CreatedAt)
.ToListAsync(ct); .ToListAsync(ct);
return Results.Ok(items.Select(b => new BookmarkDto( return Results.Ok(items.Select(b => new SurahBookmarkDto(
b.Id, b.EpisodeId, b.Note, b.CreatedAt, EpisodeMapper.ToListItem(b.Episode))).ToList()); 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 userId = JwtTokenService.GetUserId(user);
var exists = await db.Bookmarks.AnyAsync(b => var exists = await db.SurahBookmarks.AnyAsync(b =>
b.EpisodeId == request.EpisodeId && b.TafsirId == request.TafsirId &&
b.SurahId == request.SurahNumber &&
(b.DeviceId == request.DeviceId || (userId.HasValue && b.UserId == userId)), ct); (b.DeviceId == request.DeviceId || (userId.HasValue && b.UserId == userId)), ct);
if (exists) return Results.NoContent(); if (exists) return Results.NoContent();
db.Bookmarks.Add(new Bookmark db.SurahBookmarks.Add(new SurahBookmark
{ {
DeviceId = request.DeviceId, DeviceId = request.DeviceId,
UserId = userId, UserId = userId,
EpisodeId = request.EpisodeId, TafsirId = request.TafsirId,
SurahId = request.SurahNumber,
Note = request.Note Note = request.Note
}); });
await db.SaveChangesAsync(ct); 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 userId = JwtTokenService.GetUserId(user);
var items = await db.Bookmarks var items = await db.SurahBookmarks
.Where(b => b.EpisodeId == episodeId && .Where(b => b.TafsirId == tafsirId && b.SurahId == surahNumber &&
(b.DeviceId == deviceId || (userId.HasValue && b.UserId == userId))) (b.DeviceId == deviceId || (userId.HasValue && b.UserId == userId)))
.ToListAsync(ct); .ToListAsync(ct);
db.Bookmarks.RemoveRange(items); db.SurahBookmarks.RemoveRange(items);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return Results.NoContent(); 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 System.Security.Claims;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TafsirQuran.Api.Auth; using TafsirQuran.Api.Auth;
using TafsirQuran.Api.Mapping;
using TafsirQuran.Domain.Entities; using TafsirQuran.Domain.Entities;
using TafsirQuran.Infrastructure.Persistence; using TafsirQuran.Infrastructure.Persistence;
using TafsirQuran.Shared.Dtos; using TafsirQuran.Shared.Dtos;
@@ -12,47 +11,53 @@ public static class FavoriteEndpoints
{ {
public static RouteGroupBuilder MapFavoriteEndpoints(this WebApplication app) 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) => group.MapGet("/", async (string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
{ {
var userId = JwtTokenService.GetUserId(user); var userId = JwtTokenService.GetUserId(user);
var items = await db.Favorites.AsNoTracking() var items = await db.AyahFavorites.AsNoTracking()
.Include(f => f.Episode).ThenInclude(e => e.Surah) .Include(f => f.Surah)
.Include(f => f.Tafsir)
.Where(f => f.DeviceId == deviceId || (userId.HasValue && f.UserId == userId)) .Where(f => f.DeviceId == deviceId || (userId.HasValue && f.UserId == userId))
.OrderByDescending(f => f.CreatedAt) .OrderByDescending(f => f.CreatedAt)
.ToListAsync(ct); .ToListAsync(ct);
return Results.Ok(items.Select(f => new FavoriteDto( return Results.Ok(items.Select(f => new AyahFavoriteDto(
f.Id, f.EpisodeId, f.CreatedAt, EpisodeMapper.ToListItem(f.Episode))).ToList()); 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 userId = JwtTokenService.GetUserId(user);
var exists = await db.Favorites.AnyAsync(f => var exists = await db.AyahFavorites.AnyAsync(f =>
f.EpisodeId == request.EpisodeId && f.TafsirId == request.TafsirId &&
f.SurahId == request.SurahNumber &&
f.AyahNumber == request.AyahNumber &&
(f.DeviceId == request.DeviceId || (userId.HasValue && f.UserId == userId)), ct); (f.DeviceId == request.DeviceId || (userId.HasValue && f.UserId == userId)), ct);
if (exists) return Results.NoContent(); if (exists) return Results.NoContent();
db.Favorites.Add(new Favorite db.AyahFavorites.Add(new AyahFavorite
{ {
DeviceId = request.DeviceId, DeviceId = request.DeviceId,
UserId = userId, UserId = userId,
EpisodeId = request.EpisodeId TafsirId = request.TafsirId,
SurahId = request.SurahNumber,
AyahNumber = request.AyahNumber
}); });
await db.SaveChangesAsync(ct); 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 userId = JwtTokenService.GetUserId(user);
var items = await db.Favorites var items = await db.AyahFavorites
.Where(f => f.EpisodeId == episodeId && .Where(f => f.TafsirId == tafsirId && f.SurahId == surahNumber && f.AyahNumber == ayahNumber &&
(f.DeviceId == deviceId || (userId.HasValue && f.UserId == userId))) (f.DeviceId == deviceId || (userId.HasValue && f.UserId == userId)))
.ToListAsync(ct); .ToListAsync(ct);
db.Favorites.RemoveRange(items); db.AyahFavorites.RemoveRange(items);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return Results.NoContent(); return Results.NoContent();
}); });
@@ -1,7 +1,6 @@
using System.Security.Claims; using System.Security.Claims;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TafsirQuran.Api.Auth; using TafsirQuran.Api.Auth;
using TafsirQuran.Api.Mapping;
using TafsirQuran.Domain.Entities; using TafsirQuran.Domain.Entities;
using TafsirQuran.Infrastructure.Persistence; using TafsirQuran.Infrastructure.Persistence;
using TafsirQuran.Shared.Dtos; using TafsirQuran.Shared.Dtos;
@@ -17,22 +16,22 @@ public static class ProgressEndpoints
group.MapGet("/", async (string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => group.MapGet("/", async (string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
{ {
var userId = JwtTokenService.GetUserId(user); 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; ListeningProgress? progress = null;
if (userId.HasValue) if (userId.HasValue)
{
progress = await query.Where(p => p.UserId == userId).OrderByDescending(p => p.UpdatedAt).FirstOrDefaultAsync(ct); 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); progress ??= await query.Where(p => p.DeviceId == deviceId).OrderByDescending(p => p.UpdatedAt).FirstOrDefaultAsync(ct);
if (progress is null) return Results.NoContent(); if (progress is null) return Results.NoContent();
return Results.Ok(new ProgressDto( return Results.Ok(new ProgressDto(
progress.EpisodeId, progress.TafsirId,
progress.SurahId,
progress.AyahNumber,
progress.PositionSeconds, progress.PositionSeconds,
progress.UpdatedAt, progress.UpdatedAt,
EpisodeMapper.ToListItem(progress.Episode))); progress.Surah.NameFa));
}); });
group.MapPut("/", async (UpsertProgressRequest request, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => group.MapPut("/", async (UpsertProgressRequest request, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
@@ -52,14 +51,18 @@ public static class ProgressEndpoints
{ {
DeviceId = request.DeviceId, DeviceId = request.DeviceId,
UserId = userId, UserId = userId,
EpisodeId = request.EpisodeId, TafsirId = request.TafsirId,
SurahId = request.SurahNumber,
AyahNumber = request.AyahNumber,
PositionSeconds = request.PositionSeconds PositionSeconds = request.PositionSeconds
}; };
db.ListeningProgresses.Add(existing); db.ListeningProgresses.Add(existing);
} }
else else
{ {
existing.EpisodeId = request.EpisodeId; existing.TafsirId = request.TafsirId;
existing.SurahId = request.SurahNumber;
existing.AyahNumber = request.AyahNumber;
existing.PositionSeconds = request.PositionSeconds; existing.PositionSeconds = request.PositionSeconds;
existing.UpdatedAt = DateTimeOffset.UtcNow; existing.UpdatedAt = DateTimeOffset.UtcNow;
existing.UserId ??= userId; existing.UserId ??= userId;
+163 -53
View File
@@ -1,5 +1,4 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TafsirQuran.Api.Mapping;
using TafsirQuran.Infrastructure.Persistence; using TafsirQuran.Infrastructure.Persistence;
using TafsirQuran.Shared.Dtos; using TafsirQuran.Shared.Dtos;
@@ -22,67 +21,178 @@ public static class TafsirEndpoints
return Results.Ok(items); 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); var tafsir = await db.Tafsirs.AsNoTracking().FirstOrDefaultAsync(t => t.Slug == slug && t.IsPublished, ct);
if (tafsir is null) return Results.NotFound(); if (tafsir is null) return Results.NotFound();
var episodes = await db.Episodes var surah = await db.Surahs.AsNoTracking().FirstOrDefaultAsync(s => s.Number == surahNumber, ct);
.AsNoTracking() if (surah is null) return Results.NotFound();
.Include(e => e.Surah)
.Where(e => e.TafsirId == tafsir.Id)
.OrderBy(e => e.SortOrder)
.ToListAsync(ct);
return Results.Ok(episodes.Select(e => EpisodeMapper.ToListItem(e)).ToList()); var bookmarks = new HashSet<int>();
}); var favorites = new HashSet<int>();
if (!string.IsNullOrWhiteSpace(deviceId))
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
{ {
AudioUrl = $"/api/audio/{episode.Id}" bookmarks = (await db.AyahBookmarks.AsNoTracking()
}; .Where(b => b.TafsirId == tafsir.Id && b.SurahId == surah.Id && b.DeviceId == deviceId)
return Results.Ok(detail); .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)
group.MapGet("/audio/{episodeId:guid}", async (Guid episodeId, AppDbContext db, IConfiguration config, IWebHostEnvironment env, CancellationToken ct) => .Select(f => f.AyahNumber).ToListAsync(ct)).ToHashSet();
{
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);
} }
var silence = Path.Combine(env.ContentRootPath, "wwwroot", "audio", "silence.mp3"); var contents = await db.TafsirAyahContents.AsNoTracking()
if (File.Exists(silence)) .Where(c => c.TafsirId == tafsir.Id && c.SurahId == surah.Id)
return Results.File(silence, "audio/mpeg", enableRangeProcessing: true); .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; return group;
+3 -2
View File
@@ -9,6 +9,7 @@ public class AppUser
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public ICollection<ListeningProgress> ListeningProgresses { get; set; } = new List<ListeningProgress>(); public ICollection<ListeningProgress> ListeningProgresses { get; set; } = new List<ListeningProgress>();
public ICollection<Favorite> Favorites { get; set; } = new List<Favorite>(); public ICollection<SurahBookmark> SurahBookmarks { get; set; } = new List<SurahBookmark>();
public ICollection<Bookmark> Bookmarks { get; set; } = new List<Bookmark>(); public ICollection<AyahBookmark> AyahBookmarks { get; set; } = new List<AyahBookmark>();
public ICollection<AyahFavorite> AyahFavorites { get; set; } = new List<AyahFavorite>();
} }
@@ -0,0 +1,17 @@
namespace TafsirQuran.Domain.Entities;
public class AyahBookmark
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid? UserId { get; set; }
public string DeviceId { get; set; } = string.Empty;
public Guid TafsirId { get; set; }
public int SurahId { get; set; }
public int AyahNumber { get; set; }
public string? Note { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public AppUser? User { get; set; }
public Tafsir Tafsir { get; set; } = null!;
public Surah Surah { get; set; } = null!;
}
@@ -1,13 +1,16 @@
namespace TafsirQuran.Domain.Entities; namespace TafsirQuran.Domain.Entities;
public class Favorite public class AyahFavorite
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public Guid? UserId { get; set; } public Guid? UserId { get; set; }
public string DeviceId { get; set; } = string.Empty; public string DeviceId { get; set; } = string.Empty;
public Guid EpisodeId { get; set; } public Guid TafsirId { get; set; }
public int SurahId { get; set; }
public int AyahNumber { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public AppUser? User { get; set; } public AppUser? User { get; set; }
public TafsirEpisode Episode { get; set; } = null!; public Tafsir Tafsir { get; set; } = null!;
public Surah Surah { get; set; } = null!;
} }
@@ -5,10 +5,13 @@ public class ListeningProgress
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public Guid? UserId { get; set; } public Guid? UserId { get; set; }
public string DeviceId { get; set; } = string.Empty; public string DeviceId { get; set; } = string.Empty;
public Guid EpisodeId { get; set; } public Guid TafsirId { get; set; }
public int SurahId { get; set; }
public int AyahNumber { get; set; }
public double PositionSeconds { get; set; } public double PositionSeconds { get; set; }
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
public AppUser? User { get; set; } public AppUser? User { get; set; }
public TafsirEpisode Episode { get; set; } = null!; public Tafsir Tafsir { get; set; } = null!;
public Surah Surah { get; set; } = null!;
} }
@@ -1,14 +1,16 @@
namespace TafsirQuran.Domain.Entities; namespace TafsirQuran.Domain.Entities;
public class Bookmark public class SurahBookmark
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public Guid? UserId { get; set; } public Guid? UserId { get; set; }
public string DeviceId { get; set; } = string.Empty; public string DeviceId { get; set; } = string.Empty;
public Guid EpisodeId { get; set; } public Guid TafsirId { get; set; }
public int SurahId { get; set; }
public string? Note { get; set; } public string? Note { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public AppUser? User { get; set; } public AppUser? User { get; set; }
public TafsirEpisode Episode { get; set; } = null!; public Tafsir Tafsir { get; set; } = null!;
public Surah Surah { get; set; } = null!;
} }
@@ -10,4 +10,5 @@ public class Tafsir
public bool IsPublished { get; set; } = true; public bool IsPublished { get; set; } = true;
public ICollection<TafsirEpisode> Episodes { get; set; } = new List<TafsirEpisode>(); public ICollection<TafsirEpisode> Episodes { get; set; } = new List<TafsirEpisode>();
public ICollection<TafsirAyahContent> AyahContents { get; set; } = new List<TafsirAyahContent>();
} }
@@ -0,0 +1,18 @@
namespace TafsirQuran.Domain.Entities;
public class TafsirAyahContent
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid TafsirId { get; set; }
public int SurahId { get; set; }
public int AyahNumber { get; set; }
public string? NotesHtml { get; set; }
public string? MessagesHtml { get; set; }
public string? VideoEmbedUrl { get; set; }
/// <summary>Relative path under AudioRoot, e.g. gharaati/1/2.mp3</summary>
public string? AudioPath { get; set; }
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
public Tafsir Tafsir { get; set; } = null!;
public Surah Surah { get; set; } = null!;
}
@@ -10,9 +10,11 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
public DbSet<Surah> Surahs => Set<Surah>(); public DbSet<Surah> Surahs => Set<Surah>();
public DbSet<Ayah> Ayahs => Set<Ayah>(); public DbSet<Ayah> Ayahs => Set<Ayah>();
public DbSet<TafsirEpisode> Episodes => Set<TafsirEpisode>(); public DbSet<TafsirEpisode> Episodes => Set<TafsirEpisode>();
public DbSet<TafsirAyahContent> TafsirAyahContents => Set<TafsirAyahContent>();
public DbSet<ListeningProgress> ListeningProgresses => Set<ListeningProgress>(); public DbSet<ListeningProgress> ListeningProgresses => Set<ListeningProgress>();
public DbSet<Favorite> Favorites => Set<Favorite>(); public DbSet<SurahBookmark> SurahBookmarks => Set<SurahBookmark>();
public DbSet<Bookmark> Bookmarks => Set<Bookmark>(); public DbSet<AyahBookmark> AyahBookmarks => Set<AyahBookmark>();
public DbSet<AyahFavorite> AyahFavorites => Set<AyahFavorite>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -56,29 +58,51 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
e.HasOne(x => x.Surah).WithMany(x => x.Episodes).HasForeignKey(x => x.SurahId); e.HasOne(x => x.Surah).WithMany(x => x.Episodes).HasForeignKey(x => x.SurahId);
}); });
modelBuilder.Entity<TafsirAyahContent>(e =>
{
e.HasIndex(x => new { x.TafsirId, x.SurahId, x.AyahNumber }).IsUnique();
e.Property(x => x.AudioPath).HasMaxLength(400);
e.Property(x => x.VideoEmbedUrl).HasMaxLength(500);
e.HasOne(x => x.Tafsir).WithMany(x => x.AyahContents).HasForeignKey(x => x.TafsirId);
e.HasOne(x => x.Surah).WithMany().HasForeignKey(x => x.SurahId);
});
modelBuilder.Entity<ListeningProgress>(e => modelBuilder.Entity<ListeningProgress>(e =>
{ {
e.HasIndex(x => new { x.DeviceId, x.UserId }); e.HasIndex(x => new { x.DeviceId, x.UserId });
e.Property(x => x.DeviceId).HasMaxLength(64); e.Property(x => x.DeviceId).HasMaxLength(64);
e.HasOne(x => x.User).WithMany(x => x.ListeningProgresses).HasForeignKey(x => x.UserId); e.HasOne(x => x.User).WithMany(x => x.ListeningProgresses).HasForeignKey(x => x.UserId);
e.HasOne(x => x.Episode).WithMany().HasForeignKey(x => x.EpisodeId); e.HasOne(x => x.Tafsir).WithMany().HasForeignKey(x => x.TafsirId);
e.HasOne(x => x.Surah).WithMany().HasForeignKey(x => x.SurahId);
}); });
modelBuilder.Entity<Favorite>(e => modelBuilder.Entity<SurahBookmark>(e =>
{ {
e.HasIndex(x => new { x.DeviceId, x.UserId, x.EpisodeId }).IsUnique(); e.HasIndex(x => new { x.DeviceId, x.UserId, x.TafsirId, x.SurahId }).IsUnique();
e.Property(x => x.DeviceId).HasMaxLength(64);
e.HasOne(x => x.User).WithMany(x => x.Favorites).HasForeignKey(x => x.UserId);
e.HasOne(x => x.Episode).WithMany().HasForeignKey(x => x.EpisodeId);
});
modelBuilder.Entity<Bookmark>(e =>
{
e.HasIndex(x => new { x.DeviceId, x.UserId, x.EpisodeId }).IsUnique();
e.Property(x => x.DeviceId).HasMaxLength(64); e.Property(x => x.DeviceId).HasMaxLength(64);
e.Property(x => x.Note).HasMaxLength(500); e.Property(x => x.Note).HasMaxLength(500);
e.HasOne(x => x.User).WithMany(x => x.Bookmarks).HasForeignKey(x => x.UserId); e.HasOne(x => x.User).WithMany(x => x.SurahBookmarks).HasForeignKey(x => x.UserId);
e.HasOne(x => x.Episode).WithMany().HasForeignKey(x => x.EpisodeId); e.HasOne(x => x.Tafsir).WithMany().HasForeignKey(x => x.TafsirId);
e.HasOne(x => x.Surah).WithMany().HasForeignKey(x => x.SurahId);
});
modelBuilder.Entity<AyahBookmark>(e =>
{
e.HasIndex(x => new { x.DeviceId, x.UserId, x.TafsirId, x.SurahId, x.AyahNumber }).IsUnique();
e.Property(x => x.DeviceId).HasMaxLength(64);
e.Property(x => x.Note).HasMaxLength(500);
e.HasOne(x => x.User).WithMany(x => x.AyahBookmarks).HasForeignKey(x => x.UserId);
e.HasOne(x => x.Tafsir).WithMany().HasForeignKey(x => x.TafsirId);
e.HasOne(x => x.Surah).WithMany().HasForeignKey(x => x.SurahId);
});
modelBuilder.Entity<AyahFavorite>(e =>
{
e.HasIndex(x => new { x.DeviceId, x.UserId, x.TafsirId, x.SurahId, x.AyahNumber }).IsUnique();
e.Property(x => x.DeviceId).HasMaxLength(64);
e.HasOne(x => x.User).WithMany(x => x.AyahFavorites).HasForeignKey(x => x.UserId);
e.HasOne(x => x.Tafsir).WithMany().HasForeignKey(x => x.TafsirId);
e.HasOne(x => x.Surah).WithMany().HasForeignKey(x => x.SurahId);
}); });
} }
} }
@@ -1,3 +1,4 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TafsirQuran.Domain.Entities; using TafsirQuran.Domain.Entities;
@@ -40,6 +41,18 @@ public static class DbSeeder
("الفلق", "فلق"), ("الناس", "ناس") ("الفلق", "فلق"), ("الناس", "ناس")
]; ];
private sealed class GharaatiJson
{
public int Surah { get; set; }
public int Ayah { get; set; }
public string? TextAr { get; set; }
public string? TextFa { get; set; }
public string? NotesHtml { get; set; }
public string? MessagesHtml { get; set; }
public string? VideoEmbedUrl { get; set; }
public string? AudioPath { get; set; }
}
public static async Task SeedAsync(AppDbContext db, string? audioRoot = null, CancellationToken ct = default) public static async Task SeedAsync(AppDbContext db, string? audioRoot = null, CancellationToken ct = default)
{ {
await db.Database.MigrateAsync(ct); await db.Database.MigrateAsync(ct);
@@ -77,94 +90,111 @@ public static class DbSeeder
} }
db.ChangeTracker.Clear(); db.ChangeTracker.Clear();
await SyncEpisodesFromAudioAsync(db, audioRoot, ct); await ImportGharaatiContentAsync(db, audioRoot, ct);
} }
public static async Task SyncEpisodesFromAudioAsync(AppDbContext db, string? audioRoot, CancellationToken ct = default) public static async Task ImportGharaatiContentAsync(AppDbContext db, string? audioRoot, CancellationToken ct = default)
{ {
if (string.IsNullOrWhiteSpace(audioRoot) || !Directory.Exists(audioRoot)) if (string.IsNullOrWhiteSpace(audioRoot)) return;
return; var contentRoot = Path.Combine(audioRoot, "gharaati", "content");
if (!Directory.Exists(contentRoot)) return;
if (!await db.Tafsirs.AsNoTracking().AnyAsync(t => t.Id == GhatrehChekaniId, ct)) var files = Directory.EnumerateFiles(contentRoot, "*.json", SearchOption.AllDirectories).OrderBy(f => f).ToList();
return; if (files.Count == 0) return;
var existingSet = (await db.Episodes.AsNoTracking() var existingAyahs = (await db.Ayahs.AsNoTracking()
.Where(e => e.TafsirId == GhatrehChekaniId) .Select(a => new { a.SurahId, a.Number })
.Select(e => e.AudioUrl)
.ToListAsync(ct)) .ToListAsync(ct))
.ToHashSet(StringComparer.OrdinalIgnoreCase); .Select(a => (a.SurahId, a.Number))
.ToHashSet();
var audioFiles = Directory.EnumerateFiles(audioRoot, "*.*", SearchOption.AllDirectories) // For updates we need tracked entities — reload after each batch clear
.Where(f => f.EndsWith(".mp3", StringComparison.OrdinalIgnoreCase) var trackedContent = await db.TafsirAyahContents
|| f.EndsWith(".m4a", StringComparison.OrdinalIgnoreCase) .Where(c => c.TafsirId == GhatrehChekaniId)
|| f.EndsWith(".ogg", StringComparison.OrdinalIgnoreCase) .ToDictionaryAsync(c => (c.SurahId, c.AyahNumber), ct);
|| f.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
.ToList();
if (audioFiles.Count == 0) return; var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var n = 0;
var sort = existingSet.Count; foreach (var file in files)
var added = 0;
foreach (var file in audioFiles)
{ {
var relative = Path.GetRelativePath(audioRoot, file).Replace('\\', '/'); GharaatiJson? item;
if (existingSet.Contains(relative)) continue; try
var surahNumber = GuessSurahNumber(relative, file);
var ayahNumber = GuessAyahNumber(Path.GetFileNameWithoutExtension(file));
sort++;
db.Episodes.Add(new TafsirEpisode
{ {
Id = Guid.NewGuid(), item = JsonSerializer.Deserialize<GharaatiJson>(await File.ReadAllTextAsync(file, ct), opts);
TafsirId = GhatrehChekaniId, }
SurahId = surahNumber, catch
AyahNumber = ayahNumber, {
Title = $"{SurahNames[surahNumber - 1].Fa} — {Path.GetFileNameWithoutExtension(file)}", continue;
AudioUrl = relative, }
DurationSeconds = 0,
SortOrder = sort,
Summary = null
});
existingSet.Add(relative);
added++;
// batch inserts to avoid huge change tracker if (item is null || item.Surah is < 1 or > 114 || item.Ayah < 1)
if (added % 200 == 0) continue;
if (!existingAyahs.Contains((item.Surah, item.Ayah)))
{
db.Ayahs.Add(new Ayah
{
SurahId = item.Surah,
Number = item.Ayah,
TextAr = item.TextAr ?? "",
TextFa = item.TextFa
});
existingAyahs.Add((item.Surah, item.Ayah));
}
else
{
var ayah = await db.Ayahs.FirstOrDefaultAsync(a => a.SurahId == item.Surah && a.Number == item.Ayah, ct);
if (ayah is not null)
{
if (!string.IsNullOrWhiteSpace(item.TextAr)) ayah.TextAr = item.TextAr!;
if (!string.IsNullOrWhiteSpace(item.TextFa)) ayah.TextFa = item.TextFa;
}
}
var audioPath = item.AudioPath;
if (string.IsNullOrWhiteSpace(audioPath))
{
var candidate = Path.Combine(audioRoot, "gharaati", item.Surah.ToString(), $"{item.Ayah}.mp3");
if (File.Exists(candidate))
audioPath = $"gharaati/{item.Surah}/{item.Ayah}.mp3";
}
if (trackedContent.TryGetValue((item.Surah, item.Ayah), out var content))
{
content.NotesHtml = item.NotesHtml;
content.MessagesHtml = item.MessagesHtml;
content.VideoEmbedUrl = item.VideoEmbedUrl;
content.AudioPath = audioPath;
content.UpdatedAt = DateTimeOffset.UtcNow;
}
else
{
content = new TafsirAyahContent
{
TafsirId = GhatrehChekaniId,
SurahId = item.Surah,
AyahNumber = item.Ayah,
NotesHtml = item.NotesHtml,
MessagesHtml = item.MessagesHtml,
VideoEmbedUrl = item.VideoEmbedUrl,
AudioPath = audioPath
};
db.TafsirAyahContents.Add(content);
trackedContent[(item.Surah, item.Ayah)] = content;
}
n++;
if (n % 100 == 0)
{ {
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
db.ChangeTracker.Clear(); db.ChangeTracker.Clear();
trackedContent = await db.TafsirAyahContents
.Where(c => c.TafsirId == GhatrehChekaniId)
.ToDictionaryAsync(c => (c.SurahId, c.AyahNumber), ct);
} }
} }
if (db.ChangeTracker.HasChanges()) if (db.ChangeTracker.HasChanges())
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }
private static int GuessSurahNumber(string relative, string fullPath)
{
var parts = relative.Split('/', StringSplitOptions.RemoveEmptyEntries);
foreach (var part in parts)
{
var digits = new string(part.TakeWhile(char.IsDigit).ToArray());
if (digits.Length >= 1 && int.TryParse(digits, out var n) && n is >= 1 and <= 114)
return n;
}
var name = Path.GetFileName(fullPath);
var nameDigits = new string(name.TakeWhile(char.IsDigit).ToArray());
if (nameDigits.Length >= 1 && int.TryParse(nameDigits, out var fromName) && fromName is >= 1 and <= 114)
return fromName;
return 1;
}
private static int GuessAyahNumber(string fileName)
{
var matches = System.Text.RegularExpressions.Regex.Matches(fileName, @"(\d+)");
if (matches.Count == 0) return 1;
if (int.TryParse(matches[^1].Value, out var n) && n > 0)
return n;
return 1;
}
} }
@@ -0,0 +1,576 @@
// <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("20261009205128_AyahBrowseAndGharaati")]
partial class AyahBrowseAndGharaati
{
/// <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.AyahBookmark", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("AyahNumber")
.HasColumnType("int");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Note")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("SurahId")
.HasColumnType("int");
b.Property<Guid>("TafsirId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("SurahId");
b.HasIndex("TafsirId");
b.HasIndex("UserId");
b.HasIndex("DeviceId", "UserId", "TafsirId", "SurahId", "AyahNumber")
.IsUnique()
.HasFilter("[UserId] IS NOT NULL");
b.ToTable("AyahBookmarks");
});
modelBuilder.Entity("TafsirQuran.Domain.Entities.AyahFavorite", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("AyahNumber")
.HasColumnType("int");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<int>("SurahId")
.HasColumnType("int");
b.Property<Guid>("TafsirId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("SurahId");
b.HasIndex("TafsirId");
b.HasIndex("UserId");
b.HasIndex("DeviceId", "UserId", "TafsirId", "SurahId", "AyahNumber")
.IsUnique()
.HasFilter("[UserId] IS NOT NULL");
b.ToTable("AyahFavorites");
});
modelBuilder.Entity("TafsirQuran.Domain.Entities.ListeningProgress", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("AyahNumber")
.HasColumnType("int");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<double>("PositionSeconds")
.HasColumnType("float");
b.Property<int>("SurahId")
.HasColumnType("int");
b.Property<Guid>("TafsirId")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("SurahId");
b.HasIndex("TafsirId");
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.SurahBookmark", 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<string>("Note")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("SurahId")
.HasColumnType("int");
b.Property<Guid>("TafsirId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("SurahId");
b.HasIndex("TafsirId");
b.HasIndex("UserId");
b.HasIndex("DeviceId", "UserId", "TafsirId", "SurahId")
.IsUnique()
.HasFilter("[UserId] IS NOT NULL");
b.ToTable("SurahBookmarks");
});
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.TafsirAyahContent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("AudioPath")
.HasMaxLength(400)
.HasColumnType("nvarchar(400)");
b.Property<int>("AyahNumber")
.HasColumnType("int");
b.Property<string>("MessagesHtml")
.HasColumnType("nvarchar(max)");
b.Property<string>("NotesHtml")
.HasColumnType("nvarchar(max)");
b.Property<int>("SurahId")
.HasColumnType("int");
b.Property<Guid>("TafsirId")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("VideoEmbedUrl")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.HasKey("Id");
b.HasIndex("SurahId");
b.HasIndex("TafsirId", "SurahId", "AyahNumber")
.IsUnique();
b.ToTable("TafsirAyahContents");
});
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.AyahBookmark", b =>
{
b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah")
.WithMany()
.HasForeignKey("SurahId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TafsirQuran.Domain.Entities.Tafsir", "Tafsir")
.WithMany()
.HasForeignKey("TafsirId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TafsirQuran.Domain.Entities.AppUser", "User")
.WithMany("AyahBookmarks")
.HasForeignKey("UserId");
b.Navigation("Surah");
b.Navigation("Tafsir");
b.Navigation("User");
});
modelBuilder.Entity("TafsirQuran.Domain.Entities.AyahFavorite", b =>
{
b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah")
.WithMany()
.HasForeignKey("SurahId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TafsirQuran.Domain.Entities.Tafsir", "Tafsir")
.WithMany()
.HasForeignKey("TafsirId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TafsirQuran.Domain.Entities.AppUser", "User")
.WithMany("AyahFavorites")
.HasForeignKey("UserId");
b.Navigation("Surah");
b.Navigation("Tafsir");
b.Navigation("User");
});
modelBuilder.Entity("TafsirQuran.Domain.Entities.ListeningProgress", b =>
{
b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah")
.WithMany()
.HasForeignKey("SurahId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TafsirQuran.Domain.Entities.Tafsir", "Tafsir")
.WithMany()
.HasForeignKey("TafsirId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TafsirQuran.Domain.Entities.AppUser", "User")
.WithMany("ListeningProgresses")
.HasForeignKey("UserId");
b.Navigation("Surah");
b.Navigation("Tafsir");
b.Navigation("User");
});
modelBuilder.Entity("TafsirQuran.Domain.Entities.SurahBookmark", b =>
{
b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah")
.WithMany()
.HasForeignKey("SurahId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TafsirQuran.Domain.Entities.Tafsir", "Tafsir")
.WithMany()
.HasForeignKey("TafsirId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TafsirQuran.Domain.Entities.AppUser", "User")
.WithMany("SurahBookmarks")
.HasForeignKey("UserId");
b.Navigation("Surah");
b.Navigation("Tafsir");
b.Navigation("User");
});
modelBuilder.Entity("TafsirQuran.Domain.Entities.TafsirAyahContent", b =>
{
b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah")
.WithMany()
.HasForeignKey("SurahId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TafsirQuran.Domain.Entities.Tafsir", "Tafsir")
.WithMany("AyahContents")
.HasForeignKey("TafsirId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Surah");
b.Navigation("Tafsir");
});
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("AyahBookmarks");
b.Navigation("AyahFavorites");
b.Navigation("ListeningProgresses");
b.Navigation("SurahBookmarks");
});
modelBuilder.Entity("TafsirQuran.Domain.Entities.Surah", b =>
{
b.Navigation("Ayahs");
b.Navigation("Episodes");
});
modelBuilder.Entity("TafsirQuran.Domain.Entities.Tafsir", b =>
{
b.Navigation("AyahContents");
b.Navigation("Episodes");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,422 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TafsirQuran.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AyahBrowseAndGharaati : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_ListeningProgresses_Episodes_EpisodeId",
table: "ListeningProgresses");
migrationBuilder.DropTable(
name: "Bookmarks");
migrationBuilder.DropTable(
name: "Favorites");
migrationBuilder.RenameColumn(
name: "EpisodeId",
table: "ListeningProgresses",
newName: "TafsirId");
migrationBuilder.RenameIndex(
name: "IX_ListeningProgresses_EpisodeId",
table: "ListeningProgresses",
newName: "IX_ListeningProgresses_TafsirId");
migrationBuilder.AddColumn<int>(
name: "AyahNumber",
table: "ListeningProgresses",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "SurahId",
table: "ListeningProgresses",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateTable(
name: "AyahBookmarks",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DeviceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
TafsirId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SurahId = table.Column<int>(type: "int", nullable: false),
AyahNumber = table.Column<int>(type: "int", nullable: false),
Note = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AyahBookmarks", x => x.Id);
table.ForeignKey(
name: "FK_AyahBookmarks_Surahs_SurahId",
column: x => x.SurahId,
principalTable: "Surahs",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AyahBookmarks_Tafsirs_TafsirId",
column: x => x.TafsirId,
principalTable: "Tafsirs",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AyahBookmarks_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "AyahFavorites",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DeviceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
TafsirId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SurahId = table.Column<int>(type: "int", nullable: false),
AyahNumber = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AyahFavorites", x => x.Id);
table.ForeignKey(
name: "FK_AyahFavorites_Surahs_SurahId",
column: x => x.SurahId,
principalTable: "Surahs",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AyahFavorites_Tafsirs_TafsirId",
column: x => x.TafsirId,
principalTable: "Tafsirs",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AyahFavorites_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "SurahBookmarks",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DeviceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
TafsirId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SurahId = table.Column<int>(type: "int", nullable: false),
Note = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SurahBookmarks", x => x.Id);
table.ForeignKey(
name: "FK_SurahBookmarks_Surahs_SurahId",
column: x => x.SurahId,
principalTable: "Surahs",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_SurahBookmarks_Tafsirs_TafsirId",
column: x => x.TafsirId,
principalTable: "Tafsirs",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_SurahBookmarks_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "TafsirAyahContents",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
TafsirId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SurahId = table.Column<int>(type: "int", nullable: false),
AyahNumber = table.Column<int>(type: "int", nullable: false),
NotesHtml = table.Column<string>(type: "nvarchar(max)", nullable: true),
MessagesHtml = table.Column<string>(type: "nvarchar(max)", nullable: true),
VideoEmbedUrl = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
AudioPath = table.Column<string>(type: "nvarchar(400)", maxLength: 400, nullable: true),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TafsirAyahContents", x => x.Id);
table.ForeignKey(
name: "FK_TafsirAyahContents_Surahs_SurahId",
column: x => x.SurahId,
principalTable: "Surahs",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_TafsirAyahContents_Tafsirs_TafsirId",
column: x => x.TafsirId,
principalTable: "Tafsirs",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ListeningProgresses_SurahId",
table: "ListeningProgresses",
column: "SurahId");
migrationBuilder.CreateIndex(
name: "IX_AyahBookmarks_DeviceId_UserId_TafsirId_SurahId_AyahNumber",
table: "AyahBookmarks",
columns: new[] { "DeviceId", "UserId", "TafsirId", "SurahId", "AyahNumber" },
unique: true,
filter: "[UserId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_AyahBookmarks_SurahId",
table: "AyahBookmarks",
column: "SurahId");
migrationBuilder.CreateIndex(
name: "IX_AyahBookmarks_TafsirId",
table: "AyahBookmarks",
column: "TafsirId");
migrationBuilder.CreateIndex(
name: "IX_AyahBookmarks_UserId",
table: "AyahBookmarks",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AyahFavorites_DeviceId_UserId_TafsirId_SurahId_AyahNumber",
table: "AyahFavorites",
columns: new[] { "DeviceId", "UserId", "TafsirId", "SurahId", "AyahNumber" },
unique: true,
filter: "[UserId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_AyahFavorites_SurahId",
table: "AyahFavorites",
column: "SurahId");
migrationBuilder.CreateIndex(
name: "IX_AyahFavorites_TafsirId",
table: "AyahFavorites",
column: "TafsirId");
migrationBuilder.CreateIndex(
name: "IX_AyahFavorites_UserId",
table: "AyahFavorites",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_SurahBookmarks_DeviceId_UserId_TafsirId_SurahId",
table: "SurahBookmarks",
columns: new[] { "DeviceId", "UserId", "TafsirId", "SurahId" },
unique: true,
filter: "[UserId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_SurahBookmarks_SurahId",
table: "SurahBookmarks",
column: "SurahId");
migrationBuilder.CreateIndex(
name: "IX_SurahBookmarks_TafsirId",
table: "SurahBookmarks",
column: "TafsirId");
migrationBuilder.CreateIndex(
name: "IX_SurahBookmarks_UserId",
table: "SurahBookmarks",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_TafsirAyahContents_SurahId",
table: "TafsirAyahContents",
column: "SurahId");
migrationBuilder.CreateIndex(
name: "IX_TafsirAyahContents_TafsirId_SurahId_AyahNumber",
table: "TafsirAyahContents",
columns: new[] { "TafsirId", "SurahId", "AyahNumber" },
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_ListeningProgresses_Surahs_SurahId",
table: "ListeningProgresses",
column: "SurahId",
principalTable: "Surahs",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_ListeningProgresses_Tafsirs_TafsirId",
table: "ListeningProgresses",
column: "TafsirId",
principalTable: "Tafsirs",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_ListeningProgresses_Surahs_SurahId",
table: "ListeningProgresses");
migrationBuilder.DropForeignKey(
name: "FK_ListeningProgresses_Tafsirs_TafsirId",
table: "ListeningProgresses");
migrationBuilder.DropTable(
name: "AyahBookmarks");
migrationBuilder.DropTable(
name: "AyahFavorites");
migrationBuilder.DropTable(
name: "SurahBookmarks");
migrationBuilder.DropTable(
name: "TafsirAyahContents");
migrationBuilder.DropIndex(
name: "IX_ListeningProgresses_SurahId",
table: "ListeningProgresses");
migrationBuilder.DropColumn(
name: "AyahNumber",
table: "ListeningProgresses");
migrationBuilder.DropColumn(
name: "SurahId",
table: "ListeningProgresses");
migrationBuilder.RenameColumn(
name: "TafsirId",
table: "ListeningProgresses",
newName: "EpisodeId");
migrationBuilder.RenameIndex(
name: "IX_ListeningProgresses_TafsirId",
table: "ListeningProgresses",
newName: "IX_ListeningProgresses_EpisodeId");
migrationBuilder.CreateTable(
name: "Bookmarks",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
EpisodeId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
DeviceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Note = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Bookmarks", x => x.Id);
table.ForeignKey(
name: "FK_Bookmarks_Episodes_EpisodeId",
column: x => x.EpisodeId,
principalTable: "Episodes",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Bookmarks_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "Favorites",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
EpisodeId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
DeviceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Favorites", x => x.Id);
table.ForeignKey(
name: "FK_Favorites_Episodes_EpisodeId",
column: x => x.EpisodeId,
principalTable: "Episodes",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Favorites_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id");
});
migrationBuilder.CreateIndex(
name: "IX_Bookmarks_DeviceId_UserId_EpisodeId",
table: "Bookmarks",
columns: new[] { "DeviceId", "UserId", "EpisodeId" },
unique: true,
filter: "[UserId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_Bookmarks_EpisodeId",
table: "Bookmarks",
column: "EpisodeId");
migrationBuilder.CreateIndex(
name: "IX_Bookmarks_UserId",
table: "Bookmarks",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_Favorites_DeviceId_UserId_EpisodeId",
table: "Favorites",
columns: new[] { "DeviceId", "UserId", "EpisodeId" },
unique: true,
filter: "[UserId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_Favorites_EpisodeId",
table: "Favorites",
column: "EpisodeId");
migrationBuilder.CreateIndex(
name: "IX_Favorites_UserId",
table: "Favorites",
column: "UserId");
migrationBuilder.AddForeignKey(
name: "FK_ListeningProgresses_Episodes_EpisodeId",
table: "ListeningProgresses",
column: "EpisodeId",
principalTable: "Episodes",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}
@@ -80,12 +80,15 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations
b.ToTable("Ayahs"); b.ToTable("Ayahs");
}); });
modelBuilder.Entity("TafsirQuran.Domain.Entities.Bookmark", b => modelBuilder.Entity("TafsirQuran.Domain.Entities.AyahBookmark", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier"); .HasColumnType("uniqueidentifier");
b.Property<int>("AyahNumber")
.HasColumnType("int");
b.Property<DateTimeOffset>("CreatedAt") b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset"); .HasColumnType("datetimeoffset");
@@ -94,35 +97,43 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("nvarchar(64)"); .HasColumnType("nvarchar(64)");
b.Property<Guid>("EpisodeId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Note") b.Property<string>("Note")
.HasMaxLength(500) .HasMaxLength(500)
.HasColumnType("nvarchar(500)"); .HasColumnType("nvarchar(500)");
b.Property<int>("SurahId")
.HasColumnType("int");
b.Property<Guid>("TafsirId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("UserId") b.Property<Guid?>("UserId")
.HasColumnType("uniqueidentifier"); .HasColumnType("uniqueidentifier");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("EpisodeId"); b.HasIndex("SurahId");
b.HasIndex("TafsirId");
b.HasIndex("UserId"); b.HasIndex("UserId");
b.HasIndex("DeviceId", "UserId", "EpisodeId") b.HasIndex("DeviceId", "UserId", "TafsirId", "SurahId", "AyahNumber")
.IsUnique() .IsUnique()
.HasFilter("[UserId] IS NOT NULL"); .HasFilter("[UserId] IS NOT NULL");
b.ToTable("Bookmarks"); b.ToTable("AyahBookmarks");
}); });
modelBuilder.Entity("TafsirQuran.Domain.Entities.Favorite", b => modelBuilder.Entity("TafsirQuran.Domain.Entities.AyahFavorite", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier"); .HasColumnType("uniqueidentifier");
b.Property<int>("AyahNumber")
.HasColumnType("int");
b.Property<DateTimeOffset>("CreatedAt") b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset"); .HasColumnType("datetimeoffset");
@@ -131,7 +142,10 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("nvarchar(64)"); .HasColumnType("nvarchar(64)");
b.Property<Guid>("EpisodeId") b.Property<int>("SurahId")
.HasColumnType("int");
b.Property<Guid>("TafsirId")
.HasColumnType("uniqueidentifier"); .HasColumnType("uniqueidentifier");
b.Property<Guid?>("UserId") b.Property<Guid?>("UserId")
@@ -139,15 +153,17 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("EpisodeId"); b.HasIndex("SurahId");
b.HasIndex("TafsirId");
b.HasIndex("UserId"); b.HasIndex("UserId");
b.HasIndex("DeviceId", "UserId", "EpisodeId") b.HasIndex("DeviceId", "UserId", "TafsirId", "SurahId", "AyahNumber")
.IsUnique() .IsUnique()
.HasFilter("[UserId] IS NOT NULL"); .HasFilter("[UserId] IS NOT NULL");
b.ToTable("Favorites"); b.ToTable("AyahFavorites");
}); });
modelBuilder.Entity("TafsirQuran.Domain.Entities.ListeningProgress", b => modelBuilder.Entity("TafsirQuran.Domain.Entities.ListeningProgress", b =>
@@ -156,17 +172,23 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier"); .HasColumnType("uniqueidentifier");
b.Property<int>("AyahNumber")
.HasColumnType("int");
b.Property<string>("DeviceId") b.Property<string>("DeviceId")
.IsRequired() .IsRequired()
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("nvarchar(64)"); .HasColumnType("nvarchar(64)");
b.Property<Guid>("EpisodeId")
.HasColumnType("uniqueidentifier");
b.Property<double>("PositionSeconds") b.Property<double>("PositionSeconds")
.HasColumnType("float"); .HasColumnType("float");
b.Property<int>("SurahId")
.HasColumnType("int");
b.Property<Guid>("TafsirId")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("UpdatedAt") b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset"); .HasColumnType("datetimeoffset");
@@ -175,7 +197,9 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("EpisodeId"); b.HasIndex("SurahId");
b.HasIndex("TafsirId");
b.HasIndex("UserId"); b.HasIndex("UserId");
@@ -210,6 +234,48 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations
b.ToTable("Surahs"); b.ToTable("Surahs");
}); });
modelBuilder.Entity("TafsirQuran.Domain.Entities.SurahBookmark", 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<string>("Note")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("SurahId")
.HasColumnType("int");
b.Property<Guid>("TafsirId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("SurahId");
b.HasIndex("TafsirId");
b.HasIndex("UserId");
b.HasIndex("DeviceId", "UserId", "TafsirId", "SurahId")
.IsUnique()
.HasFilter("[UserId] IS NOT NULL");
b.ToTable("SurahBookmarks");
});
modelBuilder.Entity("TafsirQuran.Domain.Entities.Tafsir", b => modelBuilder.Entity("TafsirQuran.Domain.Entities.Tafsir", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -244,6 +310,48 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations
b.ToTable("Tafsirs"); b.ToTable("Tafsirs");
}); });
modelBuilder.Entity("TafsirQuran.Domain.Entities.TafsirAyahContent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("AudioPath")
.HasMaxLength(400)
.HasColumnType("nvarchar(400)");
b.Property<int>("AyahNumber")
.HasColumnType("int");
b.Property<string>("MessagesHtml")
.HasColumnType("nvarchar(max)");
b.Property<string>("NotesHtml")
.HasColumnType("nvarchar(max)");
b.Property<int>("SurahId")
.HasColumnType("int");
b.Property<Guid>("TafsirId")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("VideoEmbedUrl")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.HasKey("Id");
b.HasIndex("SurahId");
b.HasIndex("TafsirId", "SurahId", "AyahNumber")
.IsUnique();
b.ToTable("TafsirAyahContents");
});
modelBuilder.Entity("TafsirQuran.Domain.Entities.TafsirEpisode", b => modelBuilder.Entity("TafsirQuran.Domain.Entities.TafsirEpisode", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -297,45 +405,67 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations
b.Navigation("Surah"); b.Navigation("Surah");
}); });
modelBuilder.Entity("TafsirQuran.Domain.Entities.Bookmark", b => modelBuilder.Entity("TafsirQuran.Domain.Entities.AyahBookmark", b =>
{ {
b.HasOne("TafsirQuran.Domain.Entities.TafsirEpisode", "Episode") b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah")
.WithMany() .WithMany()
.HasForeignKey("EpisodeId") .HasForeignKey("SurahId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TafsirQuran.Domain.Entities.Tafsir", "Tafsir")
.WithMany()
.HasForeignKey("TafsirId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("TafsirQuran.Domain.Entities.AppUser", "User") b.HasOne("TafsirQuran.Domain.Entities.AppUser", "User")
.WithMany("Bookmarks") .WithMany("AyahBookmarks")
.HasForeignKey("UserId"); .HasForeignKey("UserId");
b.Navigation("Episode"); b.Navigation("Surah");
b.Navigation("Tafsir");
b.Navigation("User"); b.Navigation("User");
}); });
modelBuilder.Entity("TafsirQuran.Domain.Entities.Favorite", b => modelBuilder.Entity("TafsirQuran.Domain.Entities.AyahFavorite", b =>
{ {
b.HasOne("TafsirQuran.Domain.Entities.TafsirEpisode", "Episode") b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah")
.WithMany() .WithMany()
.HasForeignKey("EpisodeId") .HasForeignKey("SurahId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TafsirQuran.Domain.Entities.Tafsir", "Tafsir")
.WithMany()
.HasForeignKey("TafsirId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("TafsirQuran.Domain.Entities.AppUser", "User") b.HasOne("TafsirQuran.Domain.Entities.AppUser", "User")
.WithMany("Favorites") .WithMany("AyahFavorites")
.HasForeignKey("UserId"); .HasForeignKey("UserId");
b.Navigation("Episode"); b.Navigation("Surah");
b.Navigation("Tafsir");
b.Navigation("User"); b.Navigation("User");
}); });
modelBuilder.Entity("TafsirQuran.Domain.Entities.ListeningProgress", b => modelBuilder.Entity("TafsirQuran.Domain.Entities.ListeningProgress", b =>
{ {
b.HasOne("TafsirQuran.Domain.Entities.TafsirEpisode", "Episode") b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah")
.WithMany() .WithMany()
.HasForeignKey("EpisodeId") .HasForeignKey("SurahId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TafsirQuran.Domain.Entities.Tafsir", "Tafsir")
.WithMany()
.HasForeignKey("TafsirId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
@@ -343,11 +473,57 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations
.WithMany("ListeningProgresses") .WithMany("ListeningProgresses")
.HasForeignKey("UserId"); .HasForeignKey("UserId");
b.Navigation("Episode"); b.Navigation("Surah");
b.Navigation("Tafsir");
b.Navigation("User"); b.Navigation("User");
}); });
modelBuilder.Entity("TafsirQuran.Domain.Entities.SurahBookmark", b =>
{
b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah")
.WithMany()
.HasForeignKey("SurahId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TafsirQuran.Domain.Entities.Tafsir", "Tafsir")
.WithMany()
.HasForeignKey("TafsirId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TafsirQuran.Domain.Entities.AppUser", "User")
.WithMany("SurahBookmarks")
.HasForeignKey("UserId");
b.Navigation("Surah");
b.Navigation("Tafsir");
b.Navigation("User");
});
modelBuilder.Entity("TafsirQuran.Domain.Entities.TafsirAyahContent", b =>
{
b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah")
.WithMany()
.HasForeignKey("SurahId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TafsirQuran.Domain.Entities.Tafsir", "Tafsir")
.WithMany("AyahContents")
.HasForeignKey("TafsirId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Surah");
b.Navigation("Tafsir");
});
modelBuilder.Entity("TafsirQuran.Domain.Entities.TafsirEpisode", b => modelBuilder.Entity("TafsirQuran.Domain.Entities.TafsirEpisode", b =>
{ {
b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah") b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah")
@@ -369,11 +545,13 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations
modelBuilder.Entity("TafsirQuran.Domain.Entities.AppUser", b => modelBuilder.Entity("TafsirQuran.Domain.Entities.AppUser", b =>
{ {
b.Navigation("Bookmarks"); b.Navigation("AyahBookmarks");
b.Navigation("Favorites"); b.Navigation("AyahFavorites");
b.Navigation("ListeningProgresses"); b.Navigation("ListeningProgresses");
b.Navigation("SurahBookmarks");
}); });
modelBuilder.Entity("TafsirQuran.Domain.Entities.Surah", b => modelBuilder.Entity("TafsirQuran.Domain.Entities.Surah", b =>
@@ -385,6 +563,8 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations
modelBuilder.Entity("TafsirQuran.Domain.Entities.Tafsir", b => modelBuilder.Entity("TafsirQuran.Domain.Entities.Tafsir", b =>
{ {
b.Navigation("AyahContents");
b.Navigation("Episodes"); b.Navigation("Episodes");
}); });
#pragma warning restore 612, 618 #pragma warning restore 612, 618
+87 -16
View File
@@ -2,6 +2,93 @@ namespace TafsirQuran.Shared.Dtos;
public record TafsirDto(Guid Id, string Title, string Author, string Slug, string? CoverUrl); public record TafsirDto(Guid Id, string Title, string Author, string Slug, string? CoverUrl);
public record SurahListItemDto(
int Number,
string NameAr,
string NameFa,
int AyahCount,
bool IsBookmarked);
public record AyahListItemDto(
int SurahNumber,
string SurahNameFa,
int AyahNumber,
string? TextFaPreview,
bool HasAudio,
bool IsBookmarked,
bool IsFavorite);
public record AyahDetailDto(
Guid TafsirId,
string TafsirSlug,
int SurahNumber,
string SurahNameFa,
string SurahNameAr,
int AyahNumber,
string TextAr,
string? TextFa,
string? NotesHtml,
string? MessagesHtml,
string? VideoEmbedUrl,
string? AudioUrl,
int? PrevAyah,
int? NextAyah,
int? PrevSurah,
int? NextSurah);
public record ProgressDto(
Guid TafsirId,
int SurahNumber,
int AyahNumber,
double PositionSeconds,
DateTimeOffset UpdatedAt,
string? SurahNameFa);
public record UpsertProgressRequest(
Guid TafsirId,
int SurahNumber,
int AyahNumber,
double PositionSeconds,
string DeviceId);
public record SurahBookmarkDto(
Guid Id,
Guid TafsirId,
int SurahNumber,
string SurahNameFa,
string? Note,
DateTimeOffset CreatedAt);
public record AyahBookmarkDto(
Guid Id,
Guid TafsirId,
string TafsirSlug,
int SurahNumber,
string SurahNameFa,
int AyahNumber,
string? Note,
DateTimeOffset CreatedAt);
public record AyahFavoriteDto(
Guid Id,
Guid TafsirId,
string TafsirSlug,
int SurahNumber,
string SurahNameFa,
int AyahNumber,
DateTimeOffset CreatedAt);
public record ToggleSurahBookmarkRequest(Guid TafsirId, int SurahNumber, string DeviceId, string? Note = null);
public record ToggleAyahRequest(Guid TafsirId, int SurahNumber, int AyahNumber, string DeviceId, string? Note = null);
public record AuthRequest(string Email, string Password, string? DisplayName = null);
public record AuthResponse(Guid UserId, string Email, string DisplayName, string Token);
public record UserStatusDto(bool IsAuthenticated, Guid? UserId, string? Email, string? DisplayName);
// Legacy episode DTOs kept for optional archive endpoints
public record EpisodeListItemDto( public record EpisodeListItemDto(
Guid Id, Guid Id,
string Title, string Title,
@@ -24,19 +111,3 @@ public record EpisodeDetailDto(
string AudioUrl, string AudioUrl,
int DurationSeconds, int DurationSeconds,
string? Summary); string? Summary);
public record ProgressDto(Guid EpisodeId, double PositionSeconds, DateTimeOffset UpdatedAt, EpisodeListItemDto? Episode);
public record UpsertProgressRequest(Guid EpisodeId, double PositionSeconds, string DeviceId);
public record FavoriteDto(Guid Id, Guid EpisodeId, DateTimeOffset CreatedAt, EpisodeListItemDto Episode);
public record BookmarkDto(Guid Id, Guid EpisodeId, string? Note, DateTimeOffset CreatedAt, EpisodeListItemDto Episode);
public record ToggleEpisodeRequest(Guid EpisodeId, string DeviceId, string? Note = null);
public record AuthRequest(string Email, string Password, string? DisplayName = null);
public record AuthResponse(Guid UserId, string Email, string DisplayName, string Token);
public record UserStatusDto(bool IsAuthenticated, Guid? UserId, string? Email, string? DisplayName);
@@ -20,16 +20,25 @@ public class TafsirApiClient(HttpClient http, IAuthTokenStore tokenStore)
return await http.GetFromJsonAsync<List<TafsirDto>>("api/tafsirs", ct) ?? []; return await http.GetFromJsonAsync<List<TafsirDto>>("api/tafsirs", ct) ?? [];
} }
public async Task<IReadOnlyList<EpisodeListItemDto>> GetEpisodesAsync(string slug, CancellationToken ct = default) public async Task<IReadOnlyList<SurahListItemDto>> GetSurahsAsync(string deviceId, Guid? tafsirId = null, CancellationToken ct = default)
{ {
await EnsureAuthHeaderAsync(); await EnsureAuthHeaderAsync();
return await http.GetFromJsonAsync<List<EpisodeListItemDto>>($"api/tafsirs/{slug}/episodes", ct) ?? []; var q = $"api/surahs?deviceId={Uri.EscapeDataString(deviceId)}";
if (tafsirId is Guid id) q += $"&tafsirId={id}";
return await http.GetFromJsonAsync<List<SurahListItemDto>>(q, ct) ?? [];
} }
public async Task<EpisodeDetailDto?> GetEpisodeAsync(Guid id, CancellationToken ct = default) public async Task<IReadOnlyList<AyahListItemDto>> GetAyahsAsync(string slug, int surahNumber, string deviceId, CancellationToken ct = default)
{ {
await EnsureAuthHeaderAsync(); await EnsureAuthHeaderAsync();
return await http.GetFromJsonAsync<EpisodeDetailDto>($"api/episodes/{id}", ct); var url = $"api/tafsirs/{slug}/surahs/{surahNumber}/ayahs?deviceId={Uri.EscapeDataString(deviceId)}";
return await http.GetFromJsonAsync<List<AyahListItemDto>>(url, ct) ?? [];
}
public async Task<AyahDetailDto?> GetAyahAsync(string slug, int surahNumber, int ayahNumber, CancellationToken ct = default)
{
await EnsureAuthHeaderAsync();
return await http.GetFromJsonAsync<AyahDetailDto>($"api/tafsirs/{slug}/surahs/{surahNumber}/ayahs/{ayahNumber}", ct);
} }
public async Task<ProgressDto?> GetProgressAsync(string deviceId, CancellationToken ct = default) public async Task<ProgressDto?> GetProgressAsync(string deviceId, CancellationToken ct = default)
@@ -49,43 +58,66 @@ public class TafsirApiClient(HttpClient http, IAuthTokenStore tokenStore)
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
} }
public async Task<IReadOnlyList<FavoriteDto>> GetFavoritesAsync(string deviceId, CancellationToken ct = default) public async Task<IReadOnlyList<AyahFavoriteDto>> GetFavoritesAsync(string deviceId, CancellationToken ct = default)
{ {
await EnsureAuthHeaderAsync(); await EnsureAuthHeaderAsync();
return await http.GetFromJsonAsync<List<FavoriteDto>>($"api/favorites?deviceId={Uri.EscapeDataString(deviceId)}", ct) ?? []; return await http.GetFromJsonAsync<List<AyahFavoriteDto>>($"api/favorites/ayahs?deviceId={Uri.EscapeDataString(deviceId)}", ct) ?? [];
} }
public async Task AddFavoriteAsync(ToggleEpisodeRequest request, CancellationToken ct = default) public async Task AddFavoriteAsync(ToggleAyahRequest request, CancellationToken ct = default)
{ {
await EnsureAuthHeaderAsync(); await EnsureAuthHeaderAsync();
var response = await http.PostAsJsonAsync("api/favorites", request, ct); var response = await http.PostAsJsonAsync("api/favorites/ayahs", request, ct);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
} }
public async Task RemoveFavoriteAsync(Guid episodeId, string deviceId, CancellationToken ct = default) public async Task RemoveFavoriteAsync(Guid tafsirId, int surahNumber, int ayahNumber, string deviceId, CancellationToken ct = default)
{ {
await EnsureAuthHeaderAsync(); await EnsureAuthHeaderAsync();
var response = await http.DeleteAsync($"api/favorites/{episodeId}?deviceId={Uri.EscapeDataString(deviceId)}", ct); var url = $"api/favorites/ayahs/{surahNumber}/{ayahNumber}?tafsirId={tafsirId}&deviceId={Uri.EscapeDataString(deviceId)}";
var response = await http.DeleteAsync(url, ct);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
} }
public async Task<IReadOnlyList<BookmarkDto>> GetBookmarksAsync(string deviceId, CancellationToken ct = default) public async Task<IReadOnlyList<SurahBookmarkDto>> GetSurahBookmarksAsync(string deviceId, CancellationToken ct = default)
{ {
await EnsureAuthHeaderAsync(); await EnsureAuthHeaderAsync();
return await http.GetFromJsonAsync<List<BookmarkDto>>($"api/bookmarks?deviceId={Uri.EscapeDataString(deviceId)}", ct) ?? []; return await http.GetFromJsonAsync<List<SurahBookmarkDto>>($"api/bookmarks/surahs?deviceId={Uri.EscapeDataString(deviceId)}", ct) ?? [];
} }
public async Task AddBookmarkAsync(ToggleEpisodeRequest request, CancellationToken ct = default) public async Task AddSurahBookmarkAsync(ToggleSurahBookmarkRequest request, CancellationToken ct = default)
{ {
await EnsureAuthHeaderAsync(); await EnsureAuthHeaderAsync();
var response = await http.PostAsJsonAsync("api/bookmarks", request, ct); var response = await http.PostAsJsonAsync("api/bookmarks/surahs", request, ct);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
} }
public async Task RemoveBookmarkAsync(Guid episodeId, string deviceId, CancellationToken ct = default) public async Task RemoveSurahBookmarkAsync(Guid tafsirId, int surahNumber, string deviceId, CancellationToken ct = default)
{ {
await EnsureAuthHeaderAsync(); await EnsureAuthHeaderAsync();
var response = await http.DeleteAsync($"api/bookmarks/{episodeId}?deviceId={Uri.EscapeDataString(deviceId)}", ct); var url = $"api/bookmarks/surahs/{surahNumber}?tafsirId={tafsirId}&deviceId={Uri.EscapeDataString(deviceId)}";
var response = await http.DeleteAsync(url, ct);
response.EnsureSuccessStatusCode();
}
public async Task<IReadOnlyList<AyahBookmarkDto>> GetAyahBookmarksAsync(string deviceId, CancellationToken ct = default)
{
await EnsureAuthHeaderAsync();
return await http.GetFromJsonAsync<List<AyahBookmarkDto>>($"api/bookmarks/ayahs?deviceId={Uri.EscapeDataString(deviceId)}", ct) ?? [];
}
public async Task AddAyahBookmarkAsync(ToggleAyahRequest request, CancellationToken ct = default)
{
await EnsureAuthHeaderAsync();
var response = await http.PostAsJsonAsync("api/bookmarks/ayahs", request, ct);
response.EnsureSuccessStatusCode();
}
public async Task RemoveAyahBookmarkAsync(Guid tafsirId, int surahNumber, int ayahNumber, string deviceId, CancellationToken ct = default)
{
await EnsureAuthHeaderAsync();
var url = $"api/bookmarks/ayahs/{surahNumber}/{ayahNumber}?tafsirId={tafsirId}&deviceId={Uri.EscapeDataString(deviceId)}";
var response = await http.DeleteAsync(url, ct);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
} }
+38 -17
View File
@@ -3,29 +3,46 @@
@implements IAsyncDisposable @implements IAsyncDisposable
<div class="audio-player" @ref="root"> <div class="audio-player" @ref="root">
<div class="player-title">@Episode.Title</div> <div class="player-title">@Title</div>
<div class="player-actions"> <div class="player-actions">
<button type="button" class="chip @(IsFavorite ? "on" : null)" @onclick="OnFavoriteToggle">علاقه</button> @if (ShowFavorite)
<button type="button" class="chip @(IsBookmarked ? "on" : null)" @onclick="OnBookmarkToggle">نشان</button> {
<button type="button" class="chip @(IsFavorite ? "on" : null)" @onclick="OnFavoriteToggle">علاقه</button>
}
@if (ShowBookmark)
{
<button type="button" class="chip @(IsBookmarked ? "on" : null)" @onclick="OnBookmarkToggle">نشان</button>
}
</div> </div>
<audio @ref="audioEl" preload="metadata" src="@AudioSrc"></audio> @if (!string.IsNullOrWhiteSpace(AudioUrl))
{
<audio @ref="audioEl" preload="metadata" src="@AudioSrc"></audio>
<div class="player-controls"> <div class="player-controls">
<button type="button" class="play-btn" @onclick="TogglePlay"> <button type="button" class="play-btn" @onclick="TogglePlay">
@(playing ? "❚❚" : "▶") @(playing ? "❚❚" : "▶")
</button> </button>
<input class="seek" type="range" min="0" max="@duration.ToString("0.###")" step="0.1" <input class="seek" type="range" min="0" max="@duration.ToString("0.###")" step="0.1"
value="@current.ToString("0.###")" @oninput="OnSeek" /> value="@current.ToString("0.###")" @oninput="OnSeek" />
<div class="time">@Format(current) / @Format(duration)</div> <div class="time">@Format(current) / @Format(duration)</div>
</div> </div>
}
else
{
<p class="muted">صوت این آیه در دسترس نیست</p>
}
</div> </div>
@code { @code {
[Parameter, EditorRequired] public EpisodeDetailDto Episode { get; set; } = default!; [Parameter, EditorRequired] public string Title { get; set; } = "";
[Parameter] public string? AudioUrl { get; set; }
[Parameter] public string AudioKey { get; set; } = "";
[Parameter] public double InitialPosition { get; set; } [Parameter] public double InitialPosition { get; set; }
[Parameter] public bool IsFavorite { get; set; } [Parameter] public bool IsFavorite { get; set; }
[Parameter] public bool IsBookmarked { get; set; } [Parameter] public bool IsBookmarked { get; set; }
[Parameter] public bool ShowFavorite { get; set; } = true;
[Parameter] public bool ShowBookmark { get; set; } = true;
[Parameter] public EventCallback OnFavoriteToggle { get; set; } [Parameter] public EventCallback OnFavoriteToggle { get; set; }
[Parameter] public EventCallback OnBookmarkToggle { get; set; } [Parameter] public EventCallback OnBookmarkToggle { get; set; }
[Parameter] public EventCallback<double> OnProgress { get; set; } [Parameter] public EventCallback<double> OnProgress { get; set; }
@@ -36,21 +53,24 @@
private bool playing; private bool playing;
private double current; private double current;
private double duration; private double duration;
private Guid boundEpisodeId; private string boundKey = "";
private string AudioSrc => ResolveAudioUrl(Episode.AudioUrl); private string AudioSrc => ResolveAudioUrl(AudioUrl ?? "");
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (string.IsNullOrWhiteSpace(AudioUrl)) return;
if (firstRender) if (firstRender)
{ {
self = DotNetObjectReference.Create(this); self = DotNetObjectReference.Create(this);
await Js.InvokeVoidAsync("tqAudio.bind", audioEl, self); await Js.InvokeVoidAsync("tqAudio.bind", audioEl, self);
} }
if (boundEpisodeId != Episode.Id) var key = string.IsNullOrWhiteSpace(AudioKey) ? AudioSrc : AudioKey;
if (boundKey != key)
{ {
boundEpisodeId = Episode.Id; boundKey = key;
playing = false; playing = false;
current = InitialPosition; current = InitialPosition;
await Js.InvokeVoidAsync("tqAudio.load", audioEl, AudioSrc, InitialPosition); await Js.InvokeVoidAsync("tqAudio.load", audioEl, AudioSrc, InitialPosition);
@@ -59,6 +79,7 @@
private string ResolveAudioUrl(string url) private string ResolveAudioUrl(string url)
{ {
if (string.IsNullOrWhiteSpace(url)) return "";
if (url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) if (url.StartsWith("http", StringComparison.OrdinalIgnoreCase))
return url; return url;
return new Uri(new Uri(ApiOptions.BaseAddress), url.TrimStart('/')).ToString(); return new Uri(new Uri(ApiOptions.BaseAddress), url.TrimStart('/')).ToString();
+46 -17
View File
@@ -3,39 +3,68 @@
@inject IDeviceIdStore DeviceStore @inject IDeviceIdStore DeviceStore
<section class="list-page"> <section class="list-page">
<h1>نشان‌شده‌ها</h1> <header class="page-header">
<h1>نشان‌ها</h1>
</header>
@if (loading) @if (loading)
{ {
<p class="muted">در حال بارگذاری...</p> <p class="muted">در حال بارگذاری...</p>
} }
else if (items.Count == 0)
{
<p class="muted">هنوز نشانی ذخیره نشده است.</p>
}
else else
{ {
<ul class="simple-list"> <h2 class="section-title">سوره‌ها</h2>
@foreach (var item in items) @if (surahs.Count == 0)
{ {
<li> <p class="muted">سوره نشان‌شده‌ای نیست</p>
<a href="/tafsir/ghatreh-chekani">@item.Episode.Title</a> }
<span class="ep-meta">@item.Episode.SurahNameFa — آیه @item.Episode.AyahNumber</span> else
</li> {
} <div class="simple-list">
</ul> @foreach (var b in surahs)
{
<a class="list-row" href="@($"/tafsir/ghatreh-chekani/surah/{b.SurahNumber}")">
<strong>@b.SurahNameFa</strong>
<span class="muted">سوره @b.SurahNumber</span>
</a>
}
</div>
}
<h2 class="section-title">آیه‌ها</h2>
@if (ayahs.Count == 0)
{
<p class="muted">آیه نشان‌شده‌ای نیست</p>
}
else
{
<div class="simple-list">
@foreach (var b in ayahs)
{
<a class="list-row" href="@($"/tafsir/{b.TafsirSlug}/surah/{b.SurahNumber}/ayah/{b.AyahNumber}")">
<strong>@b.SurahNameFa — آیه @b.AyahNumber</strong>
</a>
}
</div>
}
} }
</section> </section>
@code { @code {
private List<BookmarkDto> items = [];
private bool loading = true; private bool loading = true;
private List<SurahBookmarkDto> surahs = [];
private List<AyahBookmarkDto> ayahs = [];
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (!firstRender) return; if (!firstRender) return;
var deviceId = await DeviceStore.GetOrCreateAsync(); var deviceId = await DeviceStore.GetOrCreateAsync();
try { items = (await Api.GetBookmarksAsync(deviceId)).ToList(); } try
catch { items = []; } {
surahs = (await Api.GetSurahBookmarksAsync(deviceId)).ToList();
ayahs = (await Api.GetAyahBookmarksAsync(deviceId)).ToList();
}
catch { /* ignore */ }
finally finally
{ {
loading = false; loading = false;
+13 -11
View File
@@ -3,39 +3,41 @@
@inject IDeviceIdStore DeviceStore @inject IDeviceIdStore DeviceStore
<section class="list-page"> <section class="list-page">
<h1>علاقه‌مندی‌ها</h1> <header class="page-header">
<h1>علاقه‌مندی‌ها</h1>
</header>
@if (loading) @if (loading)
{ {
<p class="muted">در حال بارگذاری...</p> <p class="muted">در حال بارگذاری...</p>
} }
else if (items.Count == 0) else if (items.Count == 0)
{ {
<p class="muted">هنوز علاقه‌مندی‌ای ندارید.</p> <p class="muted">آیه علاقه‌مندی ثبت نشده</p>
} }
else else
{ {
<ul class="simple-list"> <div class="simple-list">
@foreach (var item in items) @foreach (var f in items)
{ {
<li> <a class="list-row" href="@($"/tafsir/{f.TafsirSlug}/surah/{f.SurahNumber}/ayah/{f.AyahNumber}")">
<a href="/tafsir/ghatreh-chekani">@item.Episode.Title</a> <strong>@f.SurahNameFa — آیه @f.AyahNumber</strong>
<span class="ep-meta">@item.Episode.SurahNameFa — آیه @item.Episode.AyahNumber</span> </a>
</li>
} }
</ul> </div>
} }
</section> </section>
@code { @code {
private List<FavoriteDto> items = [];
private bool loading = true; private bool loading = true;
private List<AyahFavoriteDto> items = [];
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (!firstRender) return; if (!firstRender) return;
var deviceId = await DeviceStore.GetOrCreateAsync(); var deviceId = await DeviceStore.GetOrCreateAsync();
try { items = (await Api.GetFavoritesAsync(deviceId)).ToList(); } try { items = (await Api.GetFavoritesAsync(deviceId)).ToList(); }
catch { items = []; } catch { /* ignore */ }
finally finally
{ {
loading = false; loading = false;
+188 -65
View File
@@ -1,71 +1,136 @@
@page "/tafsir/{Slug}" @page "/tafsir/{Slug}"
@page "/tafsir/{Slug}/surah/{SurahNumber:int}"
@page "/tafsir/{Slug}/surah/{SurahNumber:int}/ayah/{AyahNumber:int}"
@inject TafsirApiClient Api @inject TafsirApiClient Api
@inject IDeviceIdStore DeviceStore @inject IDeviceIdStore DeviceStore
@inject NavigationManager Nav
<section class="tafsir-page"> <section class="tafsir-page">
<header class="page-header"> <header class="page-header">
<h1>@title</h1> <h1>@title</h1>
<p class="muted">@author</p> <p class="muted">@author</p>
@if (view == View.Ayahs || view == View.Detail)
{
<button type="button" class="linkish" @onclick="BackToSurahs">← فهرست سوره‌ها</button>
}
</header> </header>
@if (loading) @if (loading)
{ {
<p class="muted">در حال بارگذاری...</p> <p class="muted">در حال بارگذاری...</p>
} }
else else if (view == View.Surahs)
{ {
<div class="episode-layout"> <div class="surah-grid">
<div class="episode-list"> @foreach (var s in surahs)
@foreach (var ep in episodes) {
{ <div class="surah-card">
<button type="button" <button type="button" class="surah-main" @onclick="() => OpenSurah(s.Number)">
class="episode-row @(selected?.Id == ep.Id ? "active" : null)" <span class="surah-num">@s.Number</span>
@onclick="() => SelectAsync(ep.Id)"> <span class="surah-names">
<span class="ep-title">@ep.Title</span> <strong>@s.NameFa</strong>
<span class="ep-meta">@ep.SurahNameFa — آیه @ep.AyahNumber</span> <span class="muted">@s.NameAr</span>
</span>
<span class="muted">@s.AyahCount آیه</span>
</button> </button>
<button type="button" class="chip @(s.IsBookmarked ? "on" : null)"
@onclick="() => ToggleSurahBookmark(s)">نشان</button>
</div>
}
</div>
}
else if (view == View.Ayahs)
{
<h2 class="section-title">سوره @selectedSurah?.NameFa</h2>
<div class="ayah-list">
@foreach (var a in ayahs)
{
<button type="button" class="ayah-row" @onclick="() => OpenAyah(a.AyahNumber)">
<span class="ayah-num">@a.AyahNumber</span>
<span class="ayah-preview">@(a.TextFaPreview ?? "آیه " + a.AyahNumber)</span>
<span class="ayah-flags">
@if (a.IsFavorite) { <span class="dot fav" title="علاقه"></span> }
@if (a.IsBookmarked) { <span class="dot mark" title="نشان"></span> }
@if (a.HasAudio) { <span class="dot audio" title="صوت"></span> }
</span>
</button>
}
</div>
}
else if (detail is not null)
{
<div class="ayah-detail">
<div class="ayah-nav">
<button type="button" class="chip" disabled="@(detail.PrevAyah is null)"
@onclick="() => OpenAyah(detail.PrevAyah!.Value)">آیه قبل</button>
<button type="button" class="chip" @onclick="() => OpenSurah(detail.SurahNumber)">فهرست آیات</button>
<button type="button" class="chip" disabled="@(detail.NextAyah is null)"
@onclick="() => OpenAyah(detail.NextAyah!.Value)">آیه بعد</button>
</div>
<div class="ayah-block">
<p class="ayah-ar">@detail.TextAr</p>
@if (!string.IsNullOrWhiteSpace(detail.TextFa))
{
<p class="ayah-fa">@detail.TextFa</p>
} }
</div> </div>
<div class="player-panel"> <AudioPlayer @key="@($"{detail.SurahNumber}:{detail.AyahNumber}")"
@if (detail is null) Title="@($"{detail.SurahNameFa} — آیه {detail.AyahNumber}")"
{ AudioUrl="@detail.AudioUrl"
<p class="muted">یک آیه را انتخاب کنید</p> AudioKey="@($"{detail.SurahNumber}:{detail.AyahNumber}")"
} InitialPosition="initialPosition"
else IsFavorite="isFavorite"
{ IsBookmarked="isBookmarked"
<div class="ayah-block"> OnFavoriteToggle="ToggleFavorite"
<p class="ayah-ar">@detail.AyahTextAr</p> OnBookmarkToggle="ToggleBookmark"
@if (!string.IsNullOrWhiteSpace(detail.AyahTextFa)) OnProgress="SaveProgress" />
{
<p class="ayah-fa">@detail.AyahTextFa</p> @if (!string.IsNullOrWhiteSpace(detail.NotesHtml))
} {
<details class="content-block" open>
<summary>نکته‌ها</summary>
<div class="html-body">@((MarkupString)detail.NotesHtml)</div>
</details>
}
@if (!string.IsNullOrWhiteSpace(detail.MessagesHtml))
{
<details class="content-block" open>
<summary>پیام‌ها</summary>
<div class="html-body">@((MarkupString)detail.MessagesHtml)</div>
</details>
}
@if (!string.IsNullOrWhiteSpace(detail.VideoEmbedUrl))
{
<details class="content-block">
<summary>فیلم</summary>
<div class="video-wrap">
<iframe src="@detail.VideoEmbedUrl" allowfullscreen loading="lazy"></iframe>
</div> </div>
</details>
<AudioPlayer @key="detail.Id" }
Episode="detail"
InitialPosition="initialPosition"
IsFavorite="isFavorite"
IsBookmarked="isBookmarked"
OnFavoriteToggle="ToggleFavorite"
OnBookmarkToggle="ToggleBookmark"
OnProgress="SaveProgress" />
}
</div>
</div> </div>
} }
</section> </section>
@code { @code {
private enum View { Surahs, Ayahs, Detail }
[Parameter] public string Slug { get; set; } = ""; [Parameter] public string Slug { get; set; } = "";
[Parameter] public int? SurahNumber { get; set; }
[Parameter] public int? AyahNumber { get; set; }
private string title = "تفسیر"; private string title = "تفسیر";
private string author = ""; private string author = "";
private Guid tafsirId;
private bool loading = true; private bool loading = true;
private string? loadedForSlug; private string? routeKey;
private List<EpisodeListItemDto> episodes = []; private View view = View.Surahs;
private EpisodeListItemDto? selected; private List<SurahListItemDto> surahs = [];
private EpisodeDetailDto? detail; private List<AyahListItemDto> ayahs = [];
private SurahListItemDto? selectedSurah;
private AyahDetailDto? detail;
private double initialPosition; private double initialPosition;
private bool isFavorite; private bool isFavorite;
private bool isBookmarked; private bool isBookmarked;
@@ -74,31 +139,52 @@
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (firstRender) ready = true; if (!firstRender) return;
if (!ready || string.IsNullOrWhiteSpace(Slug) || loadedForSlug == Slug) return; ready = true;
deviceId = await DeviceStore.GetOrCreateAsync();
await LoadRouteAsync();
}
protected override async Task OnParametersSetAsync()
{
if (!ready) return;
await LoadRouteAsync();
}
private async Task LoadRouteAsync()
{
var key = $"{Slug}|{SurahNumber}|{AyahNumber}";
if (routeKey == key) return;
routeKey = key;
loading = true; loading = true;
loadedForSlug = Slug; await InvokeAsync(StateHasChanged);
deviceId = await DeviceStore.GetOrCreateAsync();
try try
{ {
if (string.IsNullOrWhiteSpace(deviceId))
deviceId = await DeviceStore.GetOrCreateAsync();
var tafsirs = await Api.GetTafsirsAsync(); var tafsirs = await Api.GetTafsirsAsync();
var tafsir = tafsirs.FirstOrDefault(t => t.Slug == Slug); var tafsir = tafsirs.FirstOrDefault(t => t.Slug == slug);
title = tafsir?.Title ?? "تفسیر"; title = tafsir?.Title ?? "تفسیر";
author = tafsir?.Author ?? ""; author = tafsir?.Author ?? "";
tafsirId = tafsir?.Id ?? Guid.Empty;
episodes = (await Api.GetEpisodesAsync(Slug)).ToList(); if (AyahNumber is int a && SurahNumber is int s)
var progress = await Api.GetProgressAsync(deviceId);
var startId = progress?.Episode?.Id;
if (startId is null || episodes.All(e => e.Id != startId))
startId = episodes.FirstOrDefault()?.Id;
if (startId is Guid id)
{ {
initialPosition = progress?.EpisodeId == id ? progress.PositionSeconds : 0; view = View.Detail;
await SelectAsync(id, keepPosition: true); await LoadDetail(s, a);
}
else if (SurahNumber is int sn)
{
view = View.Ayahs;
await LoadAyahs(sn);
}
else
{
view = View.Surahs;
surahs = (await Api.GetSurahsAsync(deviceId, tafsirId == Guid.Empty ? null : tafsirId)).ToList();
} }
} }
finally finally
@@ -108,25 +194,61 @@
} }
} }
private async Task SelectAsync(Guid id, bool keepPosition = false) private string slug => Slug;
private void BackToSurahs() => Nav.NavigateTo($"/tafsir/{Slug}");
private void OpenSurah(int n) => Nav.NavigateTo($"/tafsir/{Slug}/surah/{n}");
private void OpenAyah(int n)
{ {
selected = episodes.FirstOrDefault(e => e.Id == id); var s = SurahNumber ?? detail?.SurahNumber ?? selectedSurah?.Number ?? 1;
detail = await Api.GetEpisodeAsync(id); Nav.NavigateTo($"/tafsir/{Slug}/surah/{s}/ayah/{n}");
if (!keepPosition) initialPosition = 0; }
private async Task LoadAyahs(int surahNumber)
{
surahs = surahs.Count > 0 ? surahs : (await Api.GetSurahsAsync(deviceId, tafsirId == Guid.Empty ? null : tafsirId)).ToList();
selectedSurah = surahs.FirstOrDefault(x => x.Number == surahNumber);
ayahs = (await Api.GetAyahsAsync(Slug, surahNumber, deviceId)).ToList();
}
private async Task LoadDetail(int surahNumber, int ayahNumber)
{
detail = await Api.GetAyahAsync(Slug, surahNumber, ayahNumber);
if (detail is null) return;
var progress = await Api.GetProgressAsync(deviceId);
initialPosition = progress is not null
&& progress.SurahNumber == surahNumber
&& progress.AyahNumber == ayahNumber
? progress.PositionSeconds
: 0;
var favorites = await Api.GetFavoritesAsync(deviceId); var favorites = await Api.GetFavoritesAsync(deviceId);
var bookmarks = await Api.GetBookmarksAsync(deviceId); var bookmarks = await Api.GetAyahBookmarksAsync(deviceId);
isFavorite = favorites.Any(f => f.EpisodeId == id); isFavorite = favorites.Any(f => f.TafsirId == detail.TafsirId && f.SurahNumber == surahNumber && f.AyahNumber == ayahNumber);
isBookmarked = bookmarks.Any(b => b.EpisodeId == id); isBookmarked = bookmarks.Any(b => b.TafsirId == detail.TafsirId && b.SurahNumber == surahNumber && b.AyahNumber == ayahNumber);
tafsirId = detail.TafsirId;
}
private async Task ToggleSurahBookmark(SurahListItemDto s)
{
if (tafsirId == Guid.Empty) return;
if (s.IsBookmarked)
await Api.RemoveSurahBookmarkAsync(tafsirId, s.Number, deviceId);
else
await Api.AddSurahBookmarkAsync(new ToggleSurahBookmarkRequest(tafsirId, s.Number, deviceId));
surahs = (await Api.GetSurahsAsync(deviceId, tafsirId)).ToList();
} }
private async Task ToggleFavorite() private async Task ToggleFavorite()
{ {
if (detail is null) return; if (detail is null) return;
if (isFavorite) if (isFavorite)
await Api.RemoveFavoriteAsync(detail.Id, deviceId); await Api.RemoveFavoriteAsync(detail.TafsirId, detail.SurahNumber, detail.AyahNumber, deviceId);
else else
await Api.AddFavoriteAsync(new ToggleEpisodeRequest(detail.Id, deviceId)); await Api.AddFavoriteAsync(new ToggleAyahRequest(detail.TafsirId, detail.SurahNumber, detail.AyahNumber, deviceId));
isFavorite = !isFavorite; isFavorite = !isFavorite;
} }
@@ -134,15 +256,16 @@
{ {
if (detail is null) return; if (detail is null) return;
if (isBookmarked) if (isBookmarked)
await Api.RemoveBookmarkAsync(detail.Id, deviceId); await Api.RemoveAyahBookmarkAsync(detail.TafsirId, detail.SurahNumber, detail.AyahNumber, deviceId);
else else
await Api.AddBookmarkAsync(new ToggleEpisodeRequest(detail.Id, deviceId)); await Api.AddAyahBookmarkAsync(new ToggleAyahRequest(detail.TafsirId, detail.SurahNumber, detail.AyahNumber, deviceId));
isBookmarked = !isBookmarked; isBookmarked = !isBookmarked;
} }
private async Task SaveProgress(double position) private async Task SaveProgress(double position)
{ {
if (detail is null) return; if (detail is null) return;
await Api.SaveProgressAsync(new UpsertProgressRequest(detail.Id, position, deviceId)); await Api.SaveProgressAsync(new UpsertProgressRequest(
detail.TafsirId, detail.SurahNumber, detail.AyahNumber, position, deviceId));
} }
} }
+46
View File
@@ -226,3 +226,49 @@ button, input { font: inherit; }
.link-btn { .link-btn {
margin-top: 0.85rem; border: 0; background: transparent; color: var(--brand-deep); cursor: pointer; margin-top: 0.85rem; border: 0; background: transparent; color: var(--brand-deep); cursor: pointer;
} }
.surah-grid { display: grid; gap: 0.55rem; }
.surah-card {
display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; align-items: center;
border: 1px solid var(--line); background: var(--surface); border-radius: 16px; padding: 0.35rem;
}
.surah-main {
border: 0; background: transparent; cursor: pointer; text-align: right;
display: grid; grid-template-columns: auto 1fr auto; gap: 0.75rem; align-items: center;
padding: 0.65rem 0.75rem; width: 100%;
}
.surah-num {
width: 2.2rem; height: 2.2rem; border-radius: 10px; display: grid; place-items: center;
background: rgba(31,107,74,0.12); color: var(--brand-deep); font-weight: 700; font-size: 0.85rem;
}
.surah-names { display: flex; flex-direction: column; gap: 0.1rem; }
.ayah-list { display: flex; flex-direction: column; gap: 0.4rem; max-height: 70vh; overflow: auto; }
.ayah-row {
border: 1px solid var(--line); background: var(--surface); border-radius: 14px;
padding: 0.75rem 0.9rem; text-align: right; cursor: pointer;
display: grid; grid-template-columns: auto 1fr auto; gap: 0.7rem; align-items: center;
}
.ayah-row:hover { border-color: rgba(31,107,74,0.45); background: #fff; }
.ayah-num { font-weight: 700; color: var(--brand); }
.ayah-preview { color: var(--muted); font-size: 0.92rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ayah-flags { display: flex; gap: 0.25rem; }
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.dot.fav { background: #c45c26; }
.dot.mark { background: var(--brand); }
.dot.audio { background: #3b6ea5; }
.section-title { margin: 1.25rem 0 0.6rem; font-size: 1.05rem; }
.linkish { border: 0; background: transparent; color: var(--brand-deep); cursor: pointer; padding: 0; }
.ayah-nav { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem; }
.content-block {
margin-top: 1rem; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); padding: 0.85rem 1rem;
}
.content-block summary { cursor: pointer; font-weight: 600; margin-bottom: 0.5rem; }
.html-body { line-height: 1.95; color: var(--ink); }
.html-body p { margin: 0 0 0.65rem; }
.video-wrap { position: relative; aspect-ratio: 16 / 9; max-width: 520px; }
.video-wrap iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; border-radius: 12px; }
.list-row {
display: flex; flex-direction: column; gap: 0.2rem;
padding: 0.9rem 1rem; border-radius: 14px; background: var(--surface); border: 1px solid var(--line);
}
.chip:disabled { opacity: 0.45; cursor: default; }