feat(api): update novel and chapter modification endpoints to use validated payloads
Build and Push Reader API Image / docker (push) Successful in 12s
Build and Push Reader API Image / docker (push) Successful in 12s
This commit is contained in:
+72
-33
@@ -1151,13 +1151,14 @@ async def mod_create_novel(
|
||||
|
||||
@app.put("/api/mod/truyen")
|
||||
async def mod_update_novel(
|
||||
payload: ModNovelPayload,
|
||||
payload: dict[str, Any] = Body(...),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
user: dict = Depends(require_current_user),
|
||||
):
|
||||
if user.get("role") not in ("MOD", "ADMIN"):
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
novel_id = str(payload.id or "").strip()
|
||||
parsed = ModNovelPayload.model_validate(payload)
|
||||
novel_id = str(parsed.id or "").strip()
|
||||
if not novel_id:
|
||||
raise HTTPException(status_code=400, detail="id là bắt buộc")
|
||||
|
||||
@@ -1167,21 +1168,21 @@ async def mod_update_novel(
|
||||
if not current:
|
||||
raise HTTPException(status_code=404, detail="Novel not found")
|
||||
|
||||
next_title = " ".join((payload.title or str(current.get("title") or "")).split()).strip()
|
||||
next_author = " ".join((payload.authorName or "").split()).strip()
|
||||
next_title = " ".join((parsed.title or str(current.get("title") or "")).split()).strip()
|
||||
next_author = " ".join((parsed.authorName or "").split()).strip()
|
||||
if not next_title:
|
||||
raise HTTPException(status_code=400, detail="title không hợp lệ")
|
||||
if payload.authorName is not None and not next_author:
|
||||
if parsed.authorName is not None and not next_author:
|
||||
raise HTTPException(status_code=400, detail="authorName không hợp lệ")
|
||||
|
||||
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 = payload.seriesName is not None and str(payload.seriesName).strip() != ""
|
||||
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=payload.seriesName)
|
||||
elif payload.seriesId is not None:
|
||||
next_series_id = await _resolve_series_id(db, series_id=payload.seriesId, series_name=None)
|
||||
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")
|
||||
|
||||
@@ -1202,19 +1203,19 @@ async def mod_update_novel(
|
||||
"title": next_title,
|
||||
"slug": next_slug,
|
||||
"author_name": next_author or None,
|
||||
"original_title": (payload.originalTitle or "").strip() or None,
|
||||
"original_author": (payload.originalAuthorName or "").strip() or None,
|
||||
"description": (payload.description or "").strip(),
|
||||
"cover_url": (payload.coverUrl or "").strip() or None,
|
||||
"status": (payload.status or "").strip() or None,
|
||||
"original_title": (parsed.originalTitle or "").strip() or None,
|
||||
"original_author": (parsed.originalAuthorName or "").strip() or None,
|
||||
"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()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Novel not found")
|
||||
if payload.genreIds is not None:
|
||||
await _set_novel_genres(db, novel_id, payload.genreIds)
|
||||
if parsed.genreIds is not None:
|
||||
await _set_novel_genres(db, novel_id, parsed.genreIds)
|
||||
await db.commit()
|
||||
return dict(row)
|
||||
|
||||
@@ -1735,13 +1736,14 @@ async def mod_create_chapter(
|
||||
|
||||
@app.put("/api/mod/chuong")
|
||||
async def mod_update_chapter(
|
||||
payload: ModChapterPayload,
|
||||
payload: dict[str, Any] = Body(...),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
user: dict = Depends(require_current_user),
|
||||
):
|
||||
if user.get("role") not in ("MOD", "ADMIN"):
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
chapter_id = str(payload.id or "").strip()
|
||||
parsed = ModChapterPayload.model_validate(payload)
|
||||
chapter_id = str(parsed.id or "").strip()
|
||||
if not chapter_id:
|
||||
raise HTTPException(status_code=400, detail="id is required")
|
||||
row = (
|
||||
@@ -1754,9 +1756,9 @@ async def mod_update_chapter(
|
||||
raise HTTPException(status_code=404, detail="Chapter not found")
|
||||
await db.execute(
|
||||
text('UPDATE "ChapterMeta" SET number = :num, title = :title WHERE id = :id'),
|
||||
{"id": chapter_id, "num": payload.number, "title": payload.title.strip()},
|
||||
{"id": chapter_id, "num": parsed.number, "title": parsed.title.strip()},
|
||||
)
|
||||
await _upsert_chapter_content(chapter_id, str(row["novelId"]), payload.number, payload.content, db)
|
||||
await _upsert_chapter_content(chapter_id, str(row["novelId"]), parsed.number, parsed.content, db)
|
||||
await db.execute(text('UPDATE "Novel" SET "totalChapters" = (SELECT COUNT(*) FROM "ChapterMeta" WHERE "novelId" = :novel_id), "updatedAt" = NOW() WHERE id = :novel_id'), {"novel_id": row["novelId"]})
|
||||
await db.commit()
|
||||
return {"id": chapter_id, "updated": True}
|
||||
@@ -1817,28 +1819,39 @@ async def mod_bulk_delete_chapters(
|
||||
|
||||
@app.put("/api/mod/chuong/optimize")
|
||||
async def mod_optimize_chapters(
|
||||
payload: ModChapterOptimizePayload,
|
||||
payload: dict[str, Any] = Body(...),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
user: dict = Depends(require_current_user),
|
||||
):
|
||||
if user.get("role") not in ("MOD", "ADMIN"):
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
parsed = ModChapterOptimizePayload.model_validate(payload)
|
||||
modified = 0
|
||||
for item in payload.updates:
|
||||
renumbered: list[tuple[str, int, int]] = []
|
||||
for item in parsed.updates:
|
||||
row = (
|
||||
await db.execute(
|
||||
text('SELECT id FROM "ChapterMeta" WHERE id = :id AND "novelId" = :novel_id LIMIT 1'),
|
||||
{"id": item.id, "novel_id": payload.novelId},
|
||||
text('SELECT id, number FROM "ChapterMeta" WHERE id = :id AND "novelId" = :novel_id LIMIT 1'),
|
||||
{"id": item.id, "novel_id": parsed.novelId},
|
||||
)
|
||||
).mappings().first()
|
||||
if not row:
|
||||
continue
|
||||
old_number = int(row.get("number") or 0)
|
||||
await db.execute(
|
||||
text('UPDATE "ChapterMeta" SET number = :number, title = :title WHERE id = :id'),
|
||||
{"id": item.id, "number": item.number, "title": item.title},
|
||||
)
|
||||
if old_number > 0 and int(item.number) > 0 and old_number != int(item.number):
|
||||
renumbered.append((str(item.id), old_number, int(item.number)))
|
||||
modified += 1
|
||||
await db.execute(text('UPDATE "Novel" SET "totalChapters" = (SELECT COUNT(*) FROM "ChapterMeta" WHERE "novelId" = :novel_id), "updatedAt" = NOW() WHERE id = :novel_id'), {"novel_id": payload.novelId})
|
||||
|
||||
for chapter_id, old_number, new_number in renumbered:
|
||||
content = await _resolve_chapter_content(chapter_id, db)
|
||||
if content is None:
|
||||
continue
|
||||
await _upsert_chapter_content(chapter_id, parsed.novelId, new_number, content, db)
|
||||
await db.execute(text('UPDATE "Novel" SET "totalChapters" = (SELECT COUNT(*) FROM "ChapterMeta" WHERE "novelId" = :novel_id), "updatedAt" = NOW() WHERE id = :novel_id'), {"novel_id": parsed.novelId})
|
||||
await db.commit()
|
||||
return {"modifiedCount": modified}
|
||||
|
||||
@@ -1970,8 +1983,12 @@ async def mod_epub_upload(
|
||||
splitMode: str | None = Form(default=None),
|
||||
chapterRegex: str | None = Form(default=None),
|
||||
title: str | None = Form(default=None),
|
||||
originalTitle: str | None = Form(default=None),
|
||||
authorName: str | None = Form(default=None),
|
||||
originalAuthorName: str | None = Form(default=None),
|
||||
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),
|
||||
@@ -1994,6 +2011,8 @@ async def mod_epub_upload(
|
||||
try:
|
||||
mode = "regex" if (splitMode or "").lower() == "regex" else "toc"
|
||||
pattern = (chapterRegex or "").strip() or None
|
||||
source_sections = _extract_epub_chapters(tmp_path)
|
||||
sections_after_filter = _filter_toc_chapters(source_sections) if mode == "toc" else source_sections
|
||||
chapters = _epub_extract_with_mode(tmp_path, mode, pattern)
|
||||
epub_meta = _extract_epub_metadata(tmp_path)
|
||||
inferred_title = str(epub_meta.get("title") or Path(file.filename or "novel").stem)
|
||||
@@ -2002,8 +2021,12 @@ async def mod_epub_upload(
|
||||
inferred_genres = [str(g).strip() for g in (epub_meta.get("genres") or []) if str(g).strip()]
|
||||
|
||||
base_title = " ".join((title or inferred_title).split()).strip() or "Untitled"
|
||||
base_original_title = " ".join((originalTitle or "").split()).strip()
|
||||
base_author = " ".join((authorName or inferred_author).split()).strip() or "Unknown"
|
||||
base_original_author = " ".join((originalAuthorName or "").split()).strip()
|
||||
base_desc = (description if description is not None else inferred_desc).strip()
|
||||
base_status = " ".join((status or "Đang ra").split()).strip() or "Đang ra"
|
||||
parsed_genre_ids = [g.strip() for g in str(genreIds or "").split(",") if g.strip()]
|
||||
cover_extracted = _extract_epub_cover(tmp_path) or _extract_epub_cover_from_zip(tmp_path)
|
||||
has_cover = bool(cover_extracted)
|
||||
cover_preview_data_url: str | None = None
|
||||
@@ -2026,7 +2049,9 @@ async def mod_epub_upload(
|
||||
"parserInfo": {
|
||||
"splitMode": mode,
|
||||
"chapterRegexUsed": pattern,
|
||||
"sourceSections": len(chapters),
|
||||
"sourceSections": len(source_sections),
|
||||
"sectionsAfterFilter": len(sections_after_filter),
|
||||
"sectionsDroppedByFilter": max(0, len(source_sections) - len(sections_after_filter)),
|
||||
"chaptersDetected": len(chapters),
|
||||
"chaptersFinal": len(chapters),
|
||||
"insertedMissingChapters": len([c for c in chapters if c.get("is_placeholder")]),
|
||||
@@ -2052,6 +2077,7 @@ async def mod_epub_upload(
|
||||
}
|
||||
for c in chapters[:30]
|
||||
],
|
||||
"sample": _chapter_preview_samples(chapters, sample_size=10),
|
||||
}
|
||||
|
||||
target_novel_id = str(appendTargetNovelId or "").strip()
|
||||
@@ -2137,22 +2163,37 @@ async def mod_epub_upload(
|
||||
"series_id": target_series_id,
|
||||
},
|
||||
)
|
||||
await db.execute(
|
||||
text('UPDATE "Novel" SET "originalTitle" = :original_title, "originalAuthorName" = :original_author, status = :status, "updatedAt" = NOW() WHERE id = :id'),
|
||||
{
|
||||
"id": novel_id,
|
||||
"original_title": base_original_title or None,
|
||||
"original_author": base_original_author or None,
|
||||
"status": base_status,
|
||||
},
|
||||
)
|
||||
if parsed_genre_ids:
|
||||
await _set_novel_genres(db, novel_id, parsed_genre_ids)
|
||||
else:
|
||||
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", description, "coverUrl", status, "seriesId", "totalChapters", views, rating, "ratingCount", "bookmarkCount", "createdAt", "updatedAt") VALUES (:id,:title,:slug,: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, "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())'),
|
||||
{
|
||||
"id": novel_id,
|
||||
"title": base_title,
|
||||
"slug": slug,
|
||||
"author": base_author,
|
||||
"original_title": base_original_title or None,
|
||||
"original_author": base_original_author or None,
|
||||
"desc": base_desc,
|
||||
"cover": uploaded_cover_url,
|
||||
"status": "Đang ra",
|
||||
"status": base_status,
|
||||
"series_id": target_series_id,
|
||||
},
|
||||
)
|
||||
if parsed_genre_ids:
|
||||
await _set_novel_genres(db, novel_id, parsed_genre_ids)
|
||||
|
||||
for ch in chapters:
|
||||
num = int(ch.get("number") or 0)
|
||||
@@ -2915,10 +2956,6 @@ def _is_toc_or_intro(chapter: dict[str, Any]) -> bool:
|
||||
if any(token in combined for token in intro_markers):
|
||||
return True
|
||||
|
||||
# Very short non-chapter sections are likely front/back matter.
|
||||
if len(str(chapter.get("txt") or "").strip()) < 300:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@@ -3023,7 +3060,7 @@ def _epub_extract_with_mode(epub_path: Path, split_mode: str, chapter_start_patt
|
||||
return _normalize_chapter_sequence(_extract_epub_chapters_by_regex(epub_path, effective_pattern))
|
||||
except re.error as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid chapterStartPattern: {exc}") from exc
|
||||
return _normalize_chapter_sequence(_filter_toc_chapters(_extract_epub_chapters(epub_path)))
|
||||
return _normalize_chapter_sequence(_extract_epub_chapters(epub_path))
|
||||
|
||||
|
||||
async def _ensure_genre_ids(db: AsyncSession, names: list[str]) -> list[str]:
|
||||
@@ -3884,6 +3921,8 @@ async def mod_epub_ai_suggest(
|
||||
try:
|
||||
mode = "regex" if (splitMode or "").lower() == "regex" else "toc"
|
||||
pattern = (chapterRegex or "").strip() or None
|
||||
source_sections = _extract_epub_chapters(tmp_path)
|
||||
sections_after_filter = _filter_toc_chapters(source_sections) if mode == "toc" else source_sections
|
||||
chapters = _epub_extract_with_mode(tmp_path, mode, pattern)
|
||||
meta = _extract_epub_metadata(tmp_path)
|
||||
resolved_title = " ".join((title or str(meta.get("title") or tmp_path.stem)).split()).strip() or tmp_path.stem
|
||||
|
||||
Reference in New Issue
Block a user