refactor: remove Series table and related fields from Novel model
Build and Push Reader API Image / docker (push) Successful in 12s
Build and Push Reader API Image / docker (push) Successful in 12s
- Dropped the Series table and removed the seriesId field from the Novel model. - Updated database migration scripts to reflect these changes. - Adjusted relevant queries and data handling to ensure consistency without the Series relationship.
This commit is contained in:
+79
-195
@@ -35,7 +35,7 @@ from app.config import settings
|
||||
from app.database import get_db_session
|
||||
from app.storage import storage
|
||||
|
||||
# Giới hạn an toàn khi tách chương EPUB (đồng bộ với batch import trên web).
|
||||
# Giới hạn chương EPUB chỉ khi client gửi `enforceMaxChapters=true` (import nhiều / batch).
|
||||
MOD_EPUB_MAX_CHAPTERS = 4000
|
||||
|
||||
|
||||
@@ -109,6 +109,18 @@ async def _ensure_migration_tables() -> None:
|
||||
continue
|
||||
raise
|
||||
|
||||
migration_ddls = [
|
||||
'ALTER TABLE "Novel" DROP CONSTRAINT IF EXISTS "Novel_seriesId_fkey"',
|
||||
'ALTER TABLE "Novel" DROP COLUMN IF EXISTS "seriesId"',
|
||||
'DROP TABLE IF EXISTS "Series" CASCADE',
|
||||
]
|
||||
async with engine.begin() as conn:
|
||||
for ddl in migration_ddls:
|
||||
try:
|
||||
await conn.execute(text(ddl))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(title=settings.app_name, lifespan=lifespan)
|
||||
|
||||
@@ -147,25 +159,6 @@ def _shuffle_rows[T](rows: list[T]) -> list[T]:
|
||||
return copied
|
||||
|
||||
|
||||
def _collapse_series_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
picked_series: set[str] = set()
|
||||
output: list[dict[str, Any]] = []
|
||||
|
||||
for row in rows:
|
||||
series_id = row.get("seriesId")
|
||||
if not series_id:
|
||||
output.append(row)
|
||||
continue
|
||||
|
||||
if series_id in picked_series:
|
||||
continue
|
||||
|
||||
picked_series.add(series_id)
|
||||
output.append(row)
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def _fill_unique_rows(rows: list[dict[str, Any]], fallback: list[dict[str, Any]], target: int) -> list[dict[str, Any]]:
|
||||
picked: set[str] = set()
|
||||
output: list[dict[str, Any]] = []
|
||||
@@ -196,7 +189,6 @@ def _home_novel_from_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
"status": row.get("status") or "Đang ra",
|
||||
"description": row.get("description") or "",
|
||||
"bookmarkCount": int(row.get("bookmarkCount") or 0),
|
||||
"seriesId": row.get("seriesId"),
|
||||
"updatedAt": _iso(row.get("updatedAt")),
|
||||
}
|
||||
|
||||
@@ -217,7 +209,7 @@ async def _fetch_home_ranking_rows(
|
||||
text(
|
||||
'SELECT n.id, n.slug, n.title, n."authorName", n."coverColor", n."coverUrl", '
|
||||
'n.rating, n.views, n."totalChapters", n.status, n.description, n."bookmarkCount", '
|
||||
'n."seriesId", n."updatedAt", COALESCE(SUM(v.views), 0)::int AS aggregated_views '
|
||||
'n."updatedAt", COALESCE(SUM(v.views), 0)::int AS aggregated_views '
|
||||
'FROM "NovelViewDaily" v '
|
||||
'JOIN "Novel" n ON n.id = v."novelId" '
|
||||
f'{where_sql} '
|
||||
@@ -232,7 +224,6 @@ async def _fetch_home_ranking_rows(
|
||||
return [
|
||||
{
|
||||
"id": row["id"],
|
||||
"seriesId": row["seriesId"],
|
||||
"aggregatedViews": int(row.get("aggregated_views") or 0),
|
||||
"novel": _home_novel_from_row(dict(row)),
|
||||
}
|
||||
@@ -245,7 +236,7 @@ async def _fetch_home_popular_fallback(db: AsyncSession, *, take: int = 400) ->
|
||||
await db.execute(
|
||||
text(
|
||||
'SELECT id, slug, title, "authorName", "coverColor", "coverUrl", rating, views, '
|
||||
'"totalChapters", status, description, "bookmarkCount", "seriesId", "updatedAt" '
|
||||
'"totalChapters", status, description, "bookmarkCount", "updatedAt" '
|
||||
'FROM "Novel" '
|
||||
'ORDER BY views DESC, "updatedAt" DESC '
|
||||
'LIMIT :take'
|
||||
@@ -257,7 +248,6 @@ async def _fetch_home_popular_fallback(db: AsyncSession, *, take: int = 400) ->
|
||||
return [
|
||||
{
|
||||
"id": row["id"],
|
||||
"seriesId": row["seriesId"],
|
||||
"aggregatedViews": int(row.get("views") or 0),
|
||||
"novel": _home_novel_from_row(dict(row)),
|
||||
}
|
||||
@@ -270,7 +260,7 @@ async def _fetch_home_random_pool(db: AsyncSession, *, take: int = 420) -> list[
|
||||
await db.execute(
|
||||
text(
|
||||
'SELECT id, slug, title, "authorName", "coverColor", "coverUrl", rating, views, '
|
||||
'"totalChapters", status, description, "bookmarkCount", "seriesId", "updatedAt" '
|
||||
'"totalChapters", status, description, "bookmarkCount", "updatedAt" '
|
||||
'FROM "Novel" '
|
||||
'ORDER BY "updatedAt" DESC '
|
||||
'LIMIT :take'
|
||||
@@ -453,7 +443,7 @@ async def _fetch_home_latest_novels(db: AsyncSession, *, take: int = 5) -> list[
|
||||
await db.execute(
|
||||
text(
|
||||
'SELECT id, slug, title, "authorName", "coverColor", "coverUrl", rating, views, '
|
||||
'"totalChapters", status, description, "bookmarkCount", "seriesId", "updatedAt" '
|
||||
'"totalChapters", status, description, "bookmarkCount", "updatedAt" '
|
||||
'FROM "Novel" '
|
||||
'WHERE id = ANY(:novel_ids)'
|
||||
),
|
||||
@@ -463,12 +453,12 @@ async def _fetch_home_latest_novels(db: AsyncSession, *, take: int = 5) -> list[
|
||||
|
||||
novel_map = {row["id"]: _home_novel_from_row(dict(row)) for row in novel_rows}
|
||||
ordered = [novel_map[novel_id] for novel_id in latest_novel_ids if novel_id in novel_map]
|
||||
collapsed = _collapse_series_rows(ordered)[:take]
|
||||
picked = ordered[:take]
|
||||
|
||||
for novel in collapsed:
|
||||
for novel in picked:
|
||||
novel["latestChapter"] = latest_chapter_map.get(novel["id"])
|
||||
|
||||
return collapsed
|
||||
return picked
|
||||
|
||||
|
||||
def _to_hot_slide(row: dict[str, Any], source: str) -> dict[str, Any]:
|
||||
@@ -843,18 +833,6 @@ async def _ensure_unique_slug(db: AsyncSession, *, table: str, slug: str, curren
|
||||
candidate = f"{base}-{idx}"
|
||||
|
||||
|
||||
async def _resolve_series_id(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
series_id: str | None,
|
||||
series_name: str | None,
|
||||
) -> str | None:
|
||||
_ = db
|
||||
_ = series_name
|
||||
sid = str(series_id or "").strip()
|
||||
return sid or None
|
||||
|
||||
|
||||
async def _set_novel_genres(db: AsyncSession, novel_id: str, genre_ids: list[str]) -> None:
|
||||
clean_ids = [str(g).strip() for g in (genre_ids or []) if str(g).strip()]
|
||||
await db.execute(text('DELETE FROM "NovelGenre" WHERE "novelId" = :novel_id'), {"novel_id": novel_id})
|
||||
@@ -939,8 +917,7 @@ async def mod_list_novels(
|
||||
rows = (
|
||||
await db.execute(
|
||||
text(
|
||||
'SELECT n.id, n.title, n.slug, n."authorName", n.status, n."totalChapters", n."coverUrl", '
|
||||
'NULL::text AS series_id, NULL::text AS series_name, NULL::text AS series_slug '
|
||||
'SELECT n.id, n.title, n.slug, n."authorName", n.status, n."totalChapters", n."coverUrl" '
|
||||
'FROM "Novel" n '
|
||||
'ORDER BY n."updatedAt" DESC, n."createdAt" DESC'
|
||||
)
|
||||
@@ -955,11 +932,6 @@ async def mod_list_novels(
|
||||
"status": r.get("status") or "Đang ra",
|
||||
"totalChapters": int(r.get("totalChapters") or 0),
|
||||
"coverUrl": r.get("coverUrl"),
|
||||
"series": (
|
||||
{"id": r["series_id"], "name": r["series_name"], "slug": r["series_slug"]}
|
||||
if r.get("series_id")
|
||||
else None
|
||||
),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -1007,8 +979,7 @@ async def mod_get_novel_detail(
|
||||
await db.execute(
|
||||
text(
|
||||
'SELECT n.id, n.title, n.slug, n."authorName", n."originalTitle", n."originalAuthorName", '
|
||||
'n.description, n."coverUrl", n.status, n."totalChapters", '
|
||||
'NULL::text AS series_id, NULL::text AS series_name, NULL::text AS series_slug '
|
||||
'n.description, n."coverUrl", n.status, n."totalChapters" '
|
||||
'FROM "Novel" n WHERE n.id = :id LIMIT 1'
|
||||
),
|
||||
{"id": novel_id},
|
||||
@@ -1033,11 +1004,6 @@ async def mod_get_novel_detail(
|
||||
"coverUrl": row.get("coverUrl"),
|
||||
"status": row.get("status") or "Đang ra",
|
||||
"totalChapters": int(row.get("totalChapters") or 0),
|
||||
"series": (
|
||||
{"id": row["series_id"], "name": row["series_name"], "slug": row["series_slug"]}
|
||||
if row.get("series_id")
|
||||
else None
|
||||
),
|
||||
"genres": [dict(g) for g in genre_rows],
|
||||
}
|
||||
|
||||
@@ -1057,14 +1023,13 @@ async def mod_create_novel(
|
||||
|
||||
slug_base = _norm_title(title).replace(" ", "-")[:120] or _new_id("n_")
|
||||
slug = await _ensure_unique_slug(db, table="Novel", slug=slug_base)
|
||||
resolved_series_id = await _resolve_series_id(db, series_id=payload.seriesId, series_name=payload.seriesName)
|
||||
|
||||
novel_id = _new_id("n_")
|
||||
row = (
|
||||
await db.execute(
|
||||
text(
|
||||
'INSERT INTO "Novel" (id, title, slug, "authorName", "originalTitle", "originalAuthorName", description, "coverUrl", status, "seriesId", "totalChapters", views, rating, "ratingCount", "bookmarkCount", "createdAt", "updatedAt") '
|
||||
'VALUES (:id,:title,:slug,:author,:original_title,:original_author,:description,:cover_url,:status,:series_id,0,0,0,0,0,NOW(),NOW()) '
|
||||
'INSERT INTO "Novel" (id, title, slug, "authorName", "originalTitle", "originalAuthorName", description, "coverUrl", status, "totalChapters", views, rating, "ratingCount", "bookmarkCount", "createdAt", "updatedAt") '
|
||||
'VALUES (:id,:title,:slug,:author,:original_title,:original_author,:description,:cover_url,:status,0,0,0,0,0,NOW(),NOW()) '
|
||||
'RETURNING id, title, slug, "authorName", status, "totalChapters", "coverUrl"'
|
||||
),
|
||||
{
|
||||
@@ -1077,7 +1042,6 @@ async def mod_create_novel(
|
||||
"description": (payload.description or "").strip(),
|
||||
"cover_url": (payload.coverUrl or "").strip() or None,
|
||||
"status": (payload.status or "Đang ra").strip() or "Đang ra",
|
||||
"series_id": resolved_series_id,
|
||||
},
|
||||
)
|
||||
).mappings().first()
|
||||
@@ -1100,7 +1064,7 @@ async def mod_update_novel(
|
||||
raise HTTPException(status_code=400, detail="id là bắt buộc")
|
||||
|
||||
current = (
|
||||
await db.execute(text('SELECT id, title, slug, "seriesId" FROM "Novel" WHERE id = :id LIMIT 1'), {"id": novel_id})
|
||||
await db.execute(text('SELECT id, title, slug FROM "Novel" WHERE id = :id LIMIT 1'), {"id": novel_id})
|
||||
).mappings().first()
|
||||
if not current:
|
||||
raise HTTPException(status_code=404, detail="Novel not found")
|
||||
@@ -1115,14 +1079,6 @@ async def mod_update_novel(
|
||||
slug_base = _norm_title(next_title).replace(" ", "-")[:120] or str(current.get("slug") or _new_id("n_"))
|
||||
next_slug = await _ensure_unique_slug(db, table="Novel", slug=slug_base, current_id=novel_id)
|
||||
|
||||
use_series_name = parsed.seriesName is not None and str(parsed.seriesName).strip() != ""
|
||||
if use_series_name:
|
||||
next_series_id = await _resolve_series_id(db, series_id=None, series_name=parsed.seriesName)
|
||||
elif parsed.seriesId is not None:
|
||||
next_series_id = await _resolve_series_id(db, series_id=parsed.seriesId, series_name=None)
|
||||
else:
|
||||
next_series_id = current.get("seriesId")
|
||||
|
||||
row = (
|
||||
await db.execute(
|
||||
text(
|
||||
@@ -1131,7 +1087,7 @@ async def mod_update_novel(
|
||||
'"authorName" = COALESCE(:author_name, "authorName"), '
|
||||
'"originalTitle" = :original_title, "originalAuthorName" = :original_author, '
|
||||
'description = :description, "coverUrl" = :cover_url, '
|
||||
'status = COALESCE(:status, status), "seriesId" = :series_id, "updatedAt" = NOW() '
|
||||
'status = COALESCE(:status, status), "updatedAt" = NOW() '
|
||||
'WHERE id = :id '
|
||||
'RETURNING id, title, slug, "authorName", status, "totalChapters", "coverUrl"'
|
||||
),
|
||||
@@ -1145,7 +1101,6 @@ async def mod_update_novel(
|
||||
"description": (parsed.description or "").strip(),
|
||||
"cover_url": (parsed.coverUrl or "").strip() or None,
|
||||
"status": (parsed.status or "").strip() or None,
|
||||
"series_id": next_series_id,
|
||||
},
|
||||
)
|
||||
).mappings().first()
|
||||
@@ -1230,8 +1185,7 @@ async def mod_list_missing_novels(
|
||||
rows = (
|
||||
await db.execute(
|
||||
text(
|
||||
'SELECT n.id, n.title, n.slug, n."authorName", n."coverUrl", n.description, n."totalChapters", n."updatedAt", '
|
||||
'NULL::text AS series_id, NULL::text AS series_name, NULL::text AS series_slug '
|
||||
'SELECT n.id, n.title, n.slug, n."authorName", n."coverUrl", n.description, n."totalChapters", n."updatedAt" '
|
||||
'FROM "Novel" n '
|
||||
f'{where_sql} '
|
||||
'ORDER BY n."updatedAt" DESC, n.title ASC LIMIT 2000'
|
||||
@@ -1269,11 +1223,6 @@ async def mod_list_missing_novels(
|
||||
"description": r.get("description") or "",
|
||||
"totalChapters": int(r.get("totalChapters") or 0),
|
||||
"updatedAt": _iso(r.get("updatedAt")),
|
||||
"series": (
|
||||
{"id": r["series_id"], "name": r["series_name"], "slug": r["series_slug"]}
|
||||
if r.get("series_id")
|
||||
else None
|
||||
),
|
||||
"genres": genres,
|
||||
"missing": {
|
||||
"author": author_blank,
|
||||
@@ -1350,12 +1299,10 @@ async def mod_overview(
|
||||
novel_count = (await db.execute(text('SELECT COUNT(*)::int FROM "Novel"'))).scalar_one()
|
||||
total_views = (await db.execute(text('SELECT COALESCE(SUM(views),0)::int FROM "Novel"'))).scalar_one()
|
||||
comment_count = (await db.execute(text('SELECT COUNT(*)::int FROM "Comment"'))).scalar_one()
|
||||
series_count = 0
|
||||
return {
|
||||
"novelCount": int(novel_count or 0),
|
||||
"totalViews": int(total_views or 0),
|
||||
"commentCount": int(comment_count or 0),
|
||||
"seriesCount": int(series_count or 0),
|
||||
}
|
||||
|
||||
|
||||
@@ -1926,11 +1873,9 @@ async def mod_epub_upload(
|
||||
description: str | None = Form(default=None),
|
||||
genreIds: str | None = Form(default=None),
|
||||
status: str | None = Form(default=None),
|
||||
seriesMode: str | None = Form(default=None),
|
||||
seriesId: str | None = Form(default=None),
|
||||
seriesName: str | None = Form(default=None),
|
||||
replaceExisting: str | None = Form(default=None),
|
||||
appendTargetNovelId: str | None = Form(default=None),
|
||||
enforceMaxChapters: str | None = Form(default=None),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
user: dict = Depends(require_current_user),
|
||||
):
|
||||
@@ -1966,9 +1911,19 @@ async def mod_epub_upload(
|
||||
parsed_genre_ids = [g.strip() for g in str(genreIds or "").split(",") if g.strip()]
|
||||
|
||||
n_chapters = len(chapters)
|
||||
if n_chapters > MOD_EPUB_MAX_CHAPTERS:
|
||||
enforce_cap = str(enforceMaxChapters or "").lower() in {"1", "true", "yes", "on"}
|
||||
if enforce_cap and n_chapters > MOD_EPUB_MAX_CHAPTERS:
|
||||
if str(preview or "").lower() == "true":
|
||||
n_ch = n_chapters
|
||||
# Vẫn trả mẫu chương để mod xác nhận tách TOC/regex đúng; chỉ chặn import do chính sách.
|
||||
cover_blocked = _extract_epub_cover(tmp_path) or _extract_epub_cover_from_zip(tmp_path)
|
||||
has_cover_b = bool(cover_blocked)
|
||||
cover_data_url_b: str | None = None
|
||||
if cover_blocked:
|
||||
cover_bytes_b, cover_ext_b = cover_blocked
|
||||
cover_ext_b = _guess_image_extension(cover_bytes_b)
|
||||
mime_b = _mime_from_extension(cover_ext_b)
|
||||
cover_data_url_b = f"data:{mime_b};base64,{base64.b64encode(cover_bytes_b).decode('ascii')}"
|
||||
return {
|
||||
"preview": True,
|
||||
"importBlocked": True,
|
||||
@@ -1979,8 +1934,8 @@ async def mod_epub_upload(
|
||||
"fileName": file.filename or "upload.epub",
|
||||
"splitMode": mode,
|
||||
"detectedStructureType": "standard",
|
||||
"hasCoverFromEpub": False,
|
||||
"coverPreviewDataUrl": None,
|
||||
"hasCoverFromEpub": has_cover_b,
|
||||
"coverPreviewDataUrl": cover_data_url_b,
|
||||
"parserInfo": {
|
||||
"splitMode": mode,
|
||||
"chapterRegexUsed": pattern,
|
||||
@@ -1989,9 +1944,9 @@ async def mod_epub_upload(
|
||||
"sectionsDroppedByFilter": max(0, len(source_sections) - len(sections_after_filter)),
|
||||
"chaptersDetected": n_ch,
|
||||
"chaptersFinal": n_ch,
|
||||
"insertedMissingChapters": 0,
|
||||
"detectedMaxChapterNumber": 0,
|
||||
"detectedNumberAssignments": 0,
|
||||
"insertedMissingChapters": len([c for c in chapters if c.get("is_placeholder")]),
|
||||
"detectedMaxChapterNumber": max([int(c.get("number") or 0) for c in chapters], default=0),
|
||||
"detectedNumberAssignments": len([c for c in chapters if int(c.get("number") or 0) > 0]),
|
||||
"policySkippedHeavyScan": True,
|
||||
},
|
||||
"novel": {
|
||||
@@ -2001,8 +1956,19 @@ async def mod_epub_upload(
|
||||
"detectedGenres": inferred_genres,
|
||||
"totalChapters": n_ch,
|
||||
},
|
||||
"chaptersPreview": [],
|
||||
"sample": [],
|
||||
"chaptersPreview": [
|
||||
{
|
||||
"number": int(c.get("number") or 0),
|
||||
"title": str(c.get("title") or ""),
|
||||
"isPlaceholder": bool(c.get("is_placeholder") or False),
|
||||
"volumeNumber": None,
|
||||
"volumeTitle": None,
|
||||
"volumeChapterNumber": None,
|
||||
"excerpt": str(c.get("txt") or "")[:200],
|
||||
}
|
||||
for c in chapters[:30]
|
||||
],
|
||||
"sample": _chapter_preview_samples(chapters, sample_size=10),
|
||||
}
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
@@ -2127,25 +2093,17 @@ async def mod_epub_upload(
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
target_series_id: str | None = None
|
||||
sm = str(seriesMode or "none").lower()
|
||||
if sm == "existing":
|
||||
target_series_id = await _resolve_series_id(db, series_id=seriesId, series_name=None)
|
||||
elif sm == "new":
|
||||
target_series_id = await _resolve_series_id(db, series_id=None, series_name=seriesName)
|
||||
|
||||
if existing_by_title and should_replace:
|
||||
novel_id = str(existing_by_title["id"])
|
||||
await db.execute(text('DELETE FROM "ChapterContentRef" WHERE "chapterId" IN (SELECT id FROM "ChapterMeta" WHERE "novelId" = :novel_id)'), {"novel_id": novel_id})
|
||||
await db.execute(text('DELETE FROM "ChapterMeta" WHERE "novelId" = :novel_id'), {"novel_id": novel_id})
|
||||
await db.execute(
|
||||
text('UPDATE "Novel" SET "authorName" = :author, description = :desc, "coverUrl" = COALESCE(:cover, "coverUrl"), "seriesId" = :series_id, "updatedAt" = NOW() WHERE id = :id'),
|
||||
text('UPDATE "Novel" SET "authorName" = :author, description = :desc, "coverUrl" = COALESCE(:cover, "coverUrl"), "updatedAt" = NOW() WHERE id = :id'),
|
||||
{
|
||||
"id": novel_id,
|
||||
"author": base_author,
|
||||
"desc": base_desc,
|
||||
"cover": uploaded_cover_url,
|
||||
"series_id": target_series_id,
|
||||
},
|
||||
)
|
||||
await db.execute(
|
||||
@@ -2163,7 +2121,7 @@ async def mod_epub_upload(
|
||||
novel_id = _new_id("n_")
|
||||
slug = await _ensure_unique_slug(db, table="Novel", slug=_norm_title(base_title).replace(" ", "-")[:120] or novel_id)
|
||||
await db.execute(
|
||||
text('INSERT INTO "Novel" (id, title, slug, "authorName", "originalTitle", "originalAuthorName", description, "coverUrl", status, "seriesId", "totalChapters", views, rating, "ratingCount", "bookmarkCount", "createdAt", "updatedAt") VALUES (:id,:title,:slug,:author,:original_title,:original_author,:desc,:cover,:status,:series_id,0,0,0,0,0,NOW(),NOW())'),
|
||||
text('INSERT INTO "Novel" (id, title, slug, "authorName", "originalTitle", "originalAuthorName", description, "coverUrl", status, "totalChapters", views, rating, "ratingCount", "bookmarkCount", "createdAt", "updatedAt") VALUES (:id,:title,:slug,:author,:original_title,:original_author,:desc,:cover,:status,0,0,0,0,0,NOW(),NOW())'),
|
||||
{
|
||||
"id": novel_id,
|
||||
"title": base_title,
|
||||
@@ -2174,7 +2132,6 @@ async def mod_epub_upload(
|
||||
"desc": base_desc,
|
||||
"cover": uploaded_cover_url,
|
||||
"status": base_status,
|
||||
"series_id": target_series_id,
|
||||
},
|
||||
)
|
||||
if parsed_genre_ids:
|
||||
@@ -2228,16 +2185,9 @@ async def browse_novels(
|
||||
sort: str = "latest",
|
||||
page: int = Query(default=1, ge=1),
|
||||
limit: int = Query(default=20, ge=1, le=500),
|
||||
collapse_series: bool = Query(default=False),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
skip = (page - 1) * limit
|
||||
outer_order_clause = {
|
||||
"popular": 'views DESC',
|
||||
"rating": 'rating DESC',
|
||||
"name": 'title ASC',
|
||||
"latest": '"updatedAt" DESC',
|
||||
}.get(sort, '"updatedAt" DESC')
|
||||
inner_order_clause = {
|
||||
"popular": 'n.views DESC',
|
||||
"rating": 'n.rating DESC',
|
||||
@@ -2271,55 +2221,30 @@ async def browse_novels(
|
||||
base_select = (
|
||||
'n.id, n.title, n.slug, n."originalTitle", n."authorName", n."coverUrl", n."coverColor", '
|
||||
'n.status, n."totalChapters", n.views, n.rating, n."ratingCount", n."bookmarkCount", '
|
||||
'n."seriesId", NULL::text AS series_id, NULL::text AS series_name, NULL::text AS series_slug, n."updatedAt"'
|
||||
'n."updatedAt"'
|
||||
)
|
||||
base_from = (
|
||||
'FROM "Novel" n '
|
||||
)
|
||||
|
||||
if collapse_series:
|
||||
# DISTINCT ON picks the best novel per series (most recent), then outer query sorts+paginates
|
||||
inner_sql = (
|
||||
f'SELECT DISTINCT ON (COALESCE(n."seriesId", n.id)) {base_select} '
|
||||
f'{base_from}'
|
||||
f'{where_sql} '
|
||||
f'ORDER BY COALESCE(n."seriesId", n.id), n."updatedAt" DESC'
|
||||
total_count = (
|
||||
await db.execute(
|
||||
text(f'SELECT COUNT(*)::int {base_from}{where_sql}'),
|
||||
params,
|
||||
)
|
||||
total_count = (
|
||||
await db.execute(
|
||||
text(f'SELECT COUNT(*)::int FROM ({inner_sql}) AS _c'),
|
||||
params,
|
||||
)
|
||||
).scalar_one()
|
||||
rows = (
|
||||
await db.execute(
|
||||
text(
|
||||
f'SELECT * FROM ({inner_sql}) AS collapsed '
|
||||
f'ORDER BY {outer_order_clause} '
|
||||
f'OFFSET :skip LIMIT :limit'
|
||||
),
|
||||
params,
|
||||
)
|
||||
).mappings().all()
|
||||
else:
|
||||
total_count = (
|
||||
await db.execute(
|
||||
text(f'SELECT COUNT(*)::int {base_from}{where_sql}'),
|
||||
params,
|
||||
)
|
||||
).scalar_one()
|
||||
rows = (
|
||||
await db.execute(
|
||||
text(
|
||||
f'SELECT {base_select} '
|
||||
f'{base_from}'
|
||||
f'{where_sql} '
|
||||
f'ORDER BY {inner_order_clause} '
|
||||
f'OFFSET :skip LIMIT :limit'
|
||||
),
|
||||
params,
|
||||
)
|
||||
).mappings().all()
|
||||
).scalar_one()
|
||||
rows = (
|
||||
await db.execute(
|
||||
text(
|
||||
f'SELECT {base_select} '
|
||||
f'{base_from}'
|
||||
f'{where_sql} '
|
||||
f'ORDER BY {inner_order_clause} '
|
||||
f'OFFSET :skip LIMIT :limit'
|
||||
),
|
||||
params,
|
||||
)
|
||||
).mappings().all()
|
||||
|
||||
novel_ids = [row["id"] for row in rows]
|
||||
genre_map: dict[str, list[dict[str, str]]] = {novel_id: [] for novel_id in novel_ids}
|
||||
@@ -2360,16 +2285,6 @@ async def browse_novels(
|
||||
"rating": float(row["rating"] or 0),
|
||||
"ratingCount": row["ratingCount"],
|
||||
"bookmarkCount": row["bookmarkCount"],
|
||||
"seriesId": row["seriesId"],
|
||||
"series": (
|
||||
{
|
||||
"id": row["series_id"],
|
||||
"name": row["series_name"],
|
||||
"slug": row["series_slug"],
|
||||
}
|
||||
if row["series_id"]
|
||||
else None
|
||||
),
|
||||
"genres": genre_map.get(row["id"], []),
|
||||
"updatedAt": _iso(row["updatedAt"]),
|
||||
"latestChapter": chapter_map.get(row["id"]),
|
||||
@@ -2392,8 +2307,7 @@ async def get_novel_detail(id_or_slug: str, db: AsyncSession = Depends(get_db_se
|
||||
text(
|
||||
'SELECT n.id, n.title, n.slug, n."originalTitle", n."authorName", n."originalAuthorName", '
|
||||
'n.description, n."coverUrl", n."coverColor", n.status, n."totalChapters", n.views, n.rating, '
|
||||
'n."ratingCount", n."bookmarkCount", n."seriesId", n."createdAt", n."updatedAt", '
|
||||
'NULL::text AS series_id, NULL::text AS series_name, NULL::text AS series_slug '
|
||||
'n."ratingCount", n."bookmarkCount", n."createdAt", n."updatedAt" '
|
||||
'FROM "Novel" n '
|
||||
'WHERE n.id = :value OR n.slug = :value '
|
||||
'LIMIT 1'
|
||||
@@ -2418,27 +2332,6 @@ async def get_novel_detail(id_or_slug: str, db: AsyncSession = Depends(get_db_se
|
||||
)
|
||||
).mappings().all()
|
||||
|
||||
series = None
|
||||
if row["seriesId"]:
|
||||
series_novels = (
|
||||
await db.execute(
|
||||
text(
|
||||
'SELECT id, title, slug, "totalChapters", status, "coverUrl" '
|
||||
'FROM "Novel" '
|
||||
'WHERE "seriesId" = :series_id '
|
||||
'ORDER BY title ASC'
|
||||
),
|
||||
{"series_id": row["seriesId"]},
|
||||
)
|
||||
).mappings().all()
|
||||
|
||||
series = {
|
||||
"id": row["series_id"],
|
||||
"name": row["series_name"],
|
||||
"slug": row["series_slug"],
|
||||
"novels": [dict(item) for item in series_novels],
|
||||
}
|
||||
|
||||
return {
|
||||
"id": row["id"],
|
||||
"title": row["title"],
|
||||
@@ -2455,8 +2348,6 @@ async def get_novel_detail(id_or_slug: str, db: AsyncSession = Depends(get_db_se
|
||||
"rating": float(row["rating"] or 0),
|
||||
"ratingCount": row["ratingCount"],
|
||||
"bookmarkCount": row["bookmarkCount"],
|
||||
"seriesId": row["seriesId"],
|
||||
"series": series,
|
||||
"genres": [dict(item) for item in genres],
|
||||
"createdAt": _iso(row["createdAt"]),
|
||||
"updatedAt": _iso(row["updatedAt"]),
|
||||
@@ -2604,7 +2495,7 @@ async def suggest_novels(q: str = "", db: AsyncSession = Depends(get_db_session)
|
||||
rows = (
|
||||
await db.execute(
|
||||
text(
|
||||
'SELECT n.id, n.title, n.slug, n."authorName", n."coverUrl", NULL::text AS series_id, NULL::text AS series_name '
|
||||
'SELECT n.id, n.title, n.slug, n."authorName", n."coverUrl" '
|
||||
'FROM "Novel" n '
|
||||
'WHERE n.title ILIKE :q OR n."authorName" ILIKE :q '
|
||||
'ORDER BY n.views DESC, n."updatedAt" DESC '
|
||||
@@ -2621,11 +2512,6 @@ async def suggest_novels(q: str = "", db: AsyncSession = Depends(get_db_session)
|
||||
"slug": row["slug"],
|
||||
"authorName": row["authorName"],
|
||||
"coverUrl": row["coverUrl"],
|
||||
"series": (
|
||||
{"id": row["series_id"], "name": row["series_name"]}
|
||||
if row["series_id"]
|
||||
else None
|
||||
),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
@@ -2657,8 +2543,6 @@ class ModNovelPayload(BaseModel):
|
||||
coverUrl: str | None = None
|
||||
status: str | None = None
|
||||
genreIds: list[str] | None = None
|
||||
seriesId: str | None = None
|
||||
seriesName: str | None = None
|
||||
|
||||
|
||||
class ModNovelBulkPayload(BaseModel):
|
||||
|
||||
@@ -82,8 +82,6 @@ model Novel {
|
||||
title String
|
||||
originalTitle String?
|
||||
slug String @unique
|
||||
seriesId String?
|
||||
series Series? @relation(fields: [seriesId], references: [id], onDelete: SetNull)
|
||||
authorName String // Tên tác giả nguyên bản của truyện
|
||||
originalAuthorName String?
|
||||
uploaderId String? // Tham chiếu đến User (Mod/Admin) đã upload
|
||||
@@ -123,18 +121,6 @@ model NovelViewDaily {
|
||||
@@index([day])
|
||||
}
|
||||
|
||||
model Series {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
slug String @unique
|
||||
description String? @db.Text
|
||||
|
||||
novels Novel[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Genre {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
|
||||
@@ -19,9 +19,11 @@ def main() -> None:
|
||||
|
||||
engine = create_engine(_normalize_database_url(database_url))
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text('ALTER TABLE "Novel" DROP CONSTRAINT IF EXISTS "Novel_seriesId_fkey"'))
|
||||
conn.execute(text('ALTER TABLE "Novel" DROP COLUMN IF EXISTS "seriesId"'))
|
||||
conn.execute(text('DROP TABLE IF EXISTS "Series" CASCADE'))
|
||||
|
||||
print("Dropped Series table")
|
||||
print('Dropped "Series" table and "Novel"."seriesId" column (if present).')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user