diff --git a/app/config.py b/app/config.py index 53368cc..7e63776 100644 --- a/app/config.py +++ b/app/config.py @@ -8,7 +8,7 @@ class Settings(BaseSettings): app_env: str = "development" database_url: str - mongodb_uri: str + mongodb_uri: str = "" google_client_id: str = "" nextauth_secret: str = "" @@ -23,6 +23,7 @@ class Settings(BaseSettings): nas_content_root: str = "./data/content" epub_source_root: str = "./data/epub-source" chapter_content_mode: str = "nas_first" # nas_first | mongo_first + auto_schema_bootstrap: str = "false" deepseek_key: str = "" deepseek_model: str = "deepseek-chat" diff --git a/app/database.py b/app/database.py index f776252..3371045 100644 --- a/app/database.py +++ b/app/database.py @@ -1,4 +1,3 @@ -from motor.motor_asyncio import AsyncIOMotorClient from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from app.config import settings @@ -33,10 +32,6 @@ def _normalize_database_url(url: str) -> str: engine = create_async_engine(_normalize_database_url(settings.database_url), pool_pre_ping=True) SessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) -mongo_client = AsyncIOMotorClient(settings.mongodb_uri) -mongo_db = mongo_client.get_default_database() - - async def get_db_session() -> AsyncSession: session = SessionLocal() try: diff --git a/app/epub_parser.py b/app/epub_parser.py new file mode 100644 index 0000000..5e1afe0 --- /dev/null +++ b/app/epub_parser.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import html2text +from ebooklib import ITEM_DOCUMENT +from ebooklib import epub as epublib + + +def _html_to_text(html_content: str) -> str: + h = html2text.HTML2Text() + h.ignore_links = True + h.ignore_images = True + h.ignore_emphasis = False + h.body_width = 0 + return h.handle(html_content).strip() + + +def build_chapters_from_epub(epub_path: Path) -> list[dict[str, Any]]: + book = epublib.read_epub(str(epub_path), options={"ignore_ncx": False}) + out: list[dict[str, Any]] = [] + idx = 1 + for item in book.get_items_of_type(ITEM_DOCUMENT): + content = item.get_content().decode("utf-8", errors="replace") + txt = _html_to_text(content) + if not txt: + continue + out.append( + { + "number": idx, + "title": item.get_name() or f"Chapter {idx}", + "content": content, + "txt": txt, + } + ) + idx += 1 + return out diff --git a/app/main.py b/app/main.py index e56c03a..13a4ca5 100644 --- a/app/main.py +++ b/app/main.py @@ -9,7 +9,6 @@ from contextlib import asynccontextmanager from pathlib import Path from typing import Any -from bson import ObjectId from fastapi import Depends, FastAPI, HTTPException, Query, Request from fastapi.middleware.cors import CORSMiddleware from google.auth.transport import requests as google_requests @@ -19,17 +18,16 @@ from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from app.auth import ACCESS_TOKEN_TTL_SECONDS, create_access_token, require_current_user -from app.routers import mod from app.config import settings -from app.database import get_db_session, mongo_client, mongo_db +from app.database import get_db_session from app.storage import storage @asynccontextmanager async def lifespan(_: FastAPI): - await _ensure_migration_tables() + if str(settings.auto_schema_bootstrap).lower() in {"1", "true", "yes", "on"}: + await _ensure_migration_tables() yield - mongo_client.close() async def _ensure_migration_tables() -> None: @@ -74,6 +72,20 @@ async def _ensure_migration_tables() -> None: ) ''', ''' + CREATE TABLE IF NOT EXISTS "ChapterMeta" ( + id TEXT PRIMARY KEY, + "novelId" TEXT NOT NULL, + number INT NOT NULL, + title TEXT, + views INT NOT NULL DEFAULT 0, + "volumeNumber" INT, + "volumeTitle" TEXT, + "volumeChapterNumber" INT, + "createdAt" TIMESTAMPTZ, + UNIQUE("novelId", number) + ) + ''', + ''' CREATE TABLE IF NOT EXISTS "AssetNovelMapping" ( id TEXT PRIMARY KEY, "sourceAssetId" TEXT NOT NULL REFERENCES "SourceAsset"(id) ON DELETE CASCADE, @@ -84,6 +96,32 @@ async def _ensure_migration_tables() -> None: "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() ) ''', + ''' + CREATE TABLE IF NOT EXISTS "UserRecommendationDoc" ( + id TEXT PRIMARY KEY, + "userId" TEXT NOT NULL, + "novelId" TEXT NOT NULL, + content TEXT, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + ''', + ''' + CREATE UNIQUE INDEX IF NOT EXISTS "UserRecommendationDoc_user_novel_key" + ON "UserRecommendationDoc"("userId", "novelId") + ''', + ''' + CREATE TABLE IF NOT EXISTS "EditorRecommendationDoc" ( + id TEXT PRIMARY KEY, + "editorId" TEXT NOT NULL, + "novelId" TEXT NOT NULL, + content TEXT, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + ''', + ''' + CREATE INDEX IF NOT EXISTS "EditorRecommendationDoc_novel_idx" + ON "EditorRecommendationDoc"("novelId") + ''', ] async with engine.begin() as conn: @@ -98,8 +136,6 @@ async def _ensure_migration_tables() -> None: app = FastAPI(title=settings.app_name, lifespan=lifespan) -app.include_router(mod.router) - app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origin_list, @@ -268,8 +304,18 @@ async def _fetch_home_random_pool(db: AsyncSession, *, take: int = 420) -> list[ async def _fetch_home_manual_recommendations(db: AsyncSession) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: - editor_docs = await mongo_db["editorrecommendations"].find({}).sort("createdAt", -1).limit(2000).to_list(2000) - user_docs = await mongo_db["userrecommendations"].find({}).sort("createdAt", -1).limit(5000).to_list(5000) + editor_rows = ( + await db.execute( + text('SELECT id, "editorId", "novelId", content, "createdAt" FROM "EditorRecommendationDoc" ORDER BY "createdAt" DESC LIMIT 2000') + ) + ).mappings().all() + user_rows = ( + await db.execute( + text('SELECT id, "userId", "novelId", content, "createdAt" FROM "UserRecommendationDoc" ORDER BY "createdAt" DESC LIMIT 5000') + ) + ).mappings().all() + editor_docs = [dict(r) for r in editor_rows] + user_docs = [dict(r) for r in user_rows] novel_ids = list( { @@ -391,10 +437,14 @@ async def _fetch_home_recent_comments(db: AsyncSession, *, take: int = 10) -> li async def _fetch_home_latest_novels(db: AsyncSession, *, take: int = 5) -> list[dict[str, Any]]: - recent_chapters = await mongo_db["chapters"].find( - {}, - {"novelId": 1, "number": 1, "title": 1, "createdAt": 1}, - ).sort("_id", -1).limit(400).to_list(400) + recent_chapters = ( + await db.execute( + text( + 'SELECT id, "novelId", number, title, "createdAt" ' + 'FROM "ChapterMeta" ORDER BY "createdAt" DESC NULLS LAST, id DESC LIMIT 400' + ) + ) + ).mappings().all() latest_novel_ids: list[str] = [] latest_seen_ids: set[str] = set() @@ -619,7 +669,6 @@ async def _update_reading_progress( @app.get("/api/health") async def healthcheck(db: AsyncSession = Depends(get_db_session)): db_ok = False - mongo_ok = False try: await db.execute(text("SELECT 1")) @@ -627,18 +676,12 @@ async def healthcheck(db: AsyncSession = Depends(get_db_session)): except Exception: db_ok = False - try: - await mongo_db.command("ping") - mongo_ok = True - except Exception: - mongo_ok = False - - status = "ok" if db_ok and mongo_ok else "degraded" + status = "ok" if db_ok else "degraded" return { "status": status, "service": settings.app_name, "environment": settings.app_env, - "checks": {"postgres": db_ok, "mongodb": mongo_ok}, + "checks": {"postgres": db_ok}, } @@ -944,22 +987,26 @@ async def get_novel_chapters( novel_id: str, page: int = Query(default=1, ge=1), limit: int = Query(default=100, ge=1, le=500), + db: AsyncSession = Depends(get_db_session), ): skip = (page - 1) * limit - chapters_cursor = ( - mongo_db["chapters"] - .find({"novelId": novel_id}, {"title": 1, "number": 1, "createdAt": 1, "views": 1, "volumeNumber": 1, "volumeTitle": 1, "volumeChapterNumber": 1}) - .sort("number", 1) - .skip(skip) - .limit(limit) - ) - chapters = await chapters_cursor.to_list(length=limit) - total_chapters = await mongo_db["chapters"].count_documents({"novelId": novel_id}) + chapters = ( + await db.execute( + text( + 'SELECT id, number, title, views, "volumeNumber", "volumeTitle", "volumeChapterNumber", "createdAt" ' + 'FROM "ChapterMeta" WHERE "novelId" = :novel_id ORDER BY number ASC OFFSET :skip LIMIT :limit' + ), + {"novel_id": novel_id, "skip": skip, "limit": limit}, + ) + ).mappings().all() + total_chapters = ( + await db.execute(text('SELECT COUNT(*)::int FROM "ChapterMeta" WHERE "novelId" = :novel_id'), {"novel_id": novel_id}) + ).scalar_one() return { "chapters": [ { - "id": str(item.get("_id")), + "id": str(item.get("id")), "number": item.get("number"), "title": item.get("title"), "views": item.get("views", 0), @@ -978,27 +1025,42 @@ async def get_novel_chapters( @app.get("/api/truyen/{novel_id}/chapters/by-number/{chapter_number}") async def get_chapter_by_number(novel_id: str, chapter_number: int, db: AsyncSession = Depends(get_db_session)): - chapter = await mongo_db["chapters"].find_one({"novelId": novel_id, "number": chapter_number}) + chapter = ( + await db.execute( + text( + 'SELECT id, "novelId", number, title, views, "volumeNumber", "volumeTitle", "volumeChapterNumber", "createdAt" ' + 'FROM "ChapterMeta" WHERE "novelId" = :novel_id AND number = :number LIMIT 1' + ), + {"novel_id": novel_id, "number": chapter_number}, + ) + ).mappings().first() if not chapter: raise HTTPException(status_code=404, detail="Chapter not found") - prev_chapter = await mongo_db["chapters"].find_one( - {"novelId": novel_id, "number": chapter_number - 1}, - {"number": 1}, - ) - next_chapter = await mongo_db["chapters"].find_one( - {"novelId": novel_id, "number": chapter_number + 1}, - {"number": 1}, - ) - max_chapter = await mongo_db["chapters"].count_documents({"novelId": novel_id}) + prev_chapter = ( + await db.execute( + text('SELECT number FROM "ChapterMeta" WHERE "novelId" = :novel_id AND number = :number LIMIT 1'), + {"novel_id": novel_id, "number": chapter_number - 1}, + ) + ).mappings().first() + next_chapter = ( + await db.execute( + text('SELECT number FROM "ChapterMeta" WHERE "novelId" = :novel_id AND number = :number LIMIT 1'), + {"novel_id": novel_id, "number": chapter_number + 1}, + ) + ).mappings().first() + max_chapter = ( + await db.execute(text('SELECT COUNT(*)::int FROM "ChapterMeta" WHERE "novelId" = :novel_id'), {"novel_id": novel_id}) + ).scalar_one() - await mongo_db["chapters"].update_one({"_id": chapter["_id"]}, {"$inc": {"views": 1}}) + await db.execute(text('UPDATE "ChapterMeta" SET views = views + 1 WHERE id = :id'), {"id": chapter["id"]}) + await db.commit() - chapter_id = str(chapter.get("_id")) - content = await _resolve_chapter_content(chapter_id, chapter.get("content"), db) + chapter_id = str(chapter.get("id")) + content = await _resolve_chapter_content(chapter_id, None, db) return { - "id": str(chapter.get("_id")), + "id": str(chapter.get("id")), "novelId": chapter.get("novelId"), "number": chapter.get("number"), "title": chapter.get("title"), @@ -1016,28 +1078,35 @@ async def get_chapter_by_number(novel_id: str, chapter_number: int, db: AsyncSes @app.get("/api/chapters/{chapter_id}") async def get_chapter_detail(chapter_id: str, db: AsyncSession = Depends(get_db_session)): - try: - object_id = ObjectId(chapter_id) - except Exception as exc: - raise HTTPException(status_code=400, detail="Invalid chapter id") from exc - - chapter = await mongo_db["chapters"].find_one({"_id": object_id}) + chapter = ( + await db.execute( + text( + 'SELECT id, "novelId", number, title, views, "volumeNumber", "volumeTitle", "volumeChapterNumber", "createdAt" ' + 'FROM "ChapterMeta" WHERE id = :id LIMIT 1' + ), + {"id": chapter_id}, + ) + ).mappings().first() if not chapter: raise HTTPException(status_code=404, detail="Chapter not found") - prev_chapter = await mongo_db["chapters"].find_one( - {"novelId": chapter.get("novelId"), "number": chapter.get("number", 0) - 1}, - {"number": 1}, - ) - next_chapter = await mongo_db["chapters"].find_one( - {"novelId": chapter.get("novelId"), "number": chapter.get("number", 0) + 1}, - {"number": 1}, - ) + prev_chapter = ( + await db.execute( + text('SELECT id, number FROM "ChapterMeta" WHERE "novelId" = :novel_id AND number = :number LIMIT 1'), + {"novel_id": chapter.get("novelId"), "number": int(chapter.get("number") or 0) - 1}, + ) + ).mappings().first() + next_chapter = ( + await db.execute( + text('SELECT id, number FROM "ChapterMeta" WHERE "novelId" = :novel_id AND number = :number LIMIT 1'), + {"novel_id": chapter.get("novelId"), "number": int(chapter.get("number") or 0) + 1}, + ) + ).mappings().first() - content = await _resolve_chapter_content(chapter_id, chapter.get("content"), db) + content = await _resolve_chapter_content(chapter_id, None, db) return { - "id": str(chapter.get("_id")), + "id": str(chapter.get("id")), "novelId": chapter.get("novelId"), "number": chapter.get("number"), "title": chapter.get("title"), @@ -1047,9 +1116,9 @@ async def get_chapter_detail(chapter_id: str, db: AsyncSession = Depends(get_db_ "volumeTitle": chapter.get("volumeTitle"), "volumeChapterNumber": chapter.get("volumeChapterNumber"), "createdAt": _iso(chapter.get("createdAt")), - "prevChapterId": str(prev_chapter.get("_id")) if prev_chapter else None, + "prevChapterId": str(prev_chapter.get("id")) if prev_chapter else None, "prevChapterNumber": prev_chapter.get("number") if prev_chapter else None, - "nextChapterId": str(next_chapter.get("_id")) if next_chapter else None, + "nextChapterId": str(next_chapter.get("id")) if next_chapter else None, "nextChapterNumber": next_chapter.get("number") if next_chapter else None, } @@ -1139,11 +1208,9 @@ def _asset_file_sha256(path: Path) -> str: def _extract_epub_chapters(epub_path: Path) -> list[dict[str, Any]]: - from app.routers.mod import _build_chapters_from_toc, _epub_html_to_text, _postprocess_extracted_chapters - from ebooklib import epub as epublib + from app.epub_parser import build_chapters_from_epub - book = epublib.read_epub(str(epub_path), options={"ignore_ncx": False}) - extracted = _postprocess_extracted_chapters(_build_chapters_from_toc(book), "toc") + extracted = build_chapters_from_epub(epub_path) chapters: list[dict[str, Any]] = [] for idx, ch in enumerate(extracted, start=1): @@ -1155,7 +1222,7 @@ def _extract_epub_chapters(epub_path: Path) -> list[dict[str, Any]]: "number": int(ch.get("number") or idx), "title": str(ch.get("title") or f"Chapter {idx}"), "raw_html": content, - "txt": _epub_html_to_text(content).strip(), + "txt": str(ch.get("txt") or "").strip(), } ) return chapters @@ -1192,6 +1259,7 @@ async def list_source_assets( status: str | None = None, unconvertedOnly: bool = Query(default=False), q: str | None = None, + offset: int = Query(default=0, ge=0), limit: int = Query(default=50, ge=1, le=200), db: AsyncSession = Depends(get_db_session), ): @@ -1203,8 +1271,9 @@ async def list_source_assets( if unconvertedOnly: where_parts.append( - 'NOT EXISTS (SELECT 1 FROM "ImportJob" j WHERE j."sourceAssetId" = s.id AND j.status = :completed_status)' + '(s.status <> :asset_completed_status AND NOT EXISTS (SELECT 1 FROM "ImportJob" j WHERE j."sourceAssetId" = s.id AND j.status = :completed_status))' ) + params["asset_completed_status"] = "completed" params["completed_status"] = "completed" if q and q.strip(): @@ -1219,14 +1288,65 @@ async def list_source_assets( await db.execute( text( f'SELECT id, path, sha256, opf_identifier, title, author, status, "createdAt", "updatedAt" ' - f'FROM "SourceAsset" s {where_sql} ORDER BY s."updatedAt" DESC LIMIT :limit' + f'FROM "SourceAsset" s {where_sql} ORDER BY s."updatedAt" DESC OFFSET :offset LIMIT :limit' ), - params, + {**params, "offset": offset}, ) ).mappings().all() return [dict(r) for r in rows] +@app.post("/api/import/assets/auto-review") +async def auto_review_assets( + limit: int = Query(default=1000, ge=1, le=10000), + 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") + + def normalize(v: str) -> str: + v = (v or "").strip().lower() + frm = "áàảãạăắằẳẵặâấầẩẫậéèẻẽẹêếềểễệíìỉĩịóòỏõọôốồổỗộơớờởỡợúùủũụưứừửữựýỳỷỹỵđ" + to = "aaaaaaaaaaaaaaaaaeeeeeeeeeeeiiiiiooooooooooooooooouuuuuuuuuuuyyyyyd" + v = v.translate(str.maketrans(frm, to)) + return "".join(ch for ch in v if ch.isalnum() or ch.isspace()).strip() + + novel_rows = ( + await db.execute(text('SELECT id, title, slug FROM "Novel"')) + ).mappings().all() + known = {normalize(str(r.get("title") or "")) for r in novel_rows} + known.update({normalize(str(r.get("slug") or "")) for r in novel_rows}) + + assets = ( + await db.execute( + text('SELECT id, path, status FROM "SourceAsset" WHERE status IN (:d,:a,:r) ORDER BY "updatedAt" DESC LIMIT :limit'), + {"d": "discovered", "a": "approved", "r": "review_required", "limit": limit}, + ) + ).mappings().all() + + approved = 0 + review_required = 0 + for a in assets: + path = str(a.get("path") or "") + base = path.rsplit("/", 1)[-1].rsplit(".", 1)[0] + folder = path.split("/", 1)[0] if "/" in path else base + key = normalize(base) + alt = normalize(folder) + status = "approved" if (key in known or alt in known) else "review_required" + await db.execute( + text('UPDATE "SourceAsset" SET status = :status, "updatedAt" = NOW() WHERE id = :id'), + {"id": a["id"], "status": status}, + ) + if status == "approved": + approved += 1 + else: + review_required += 1 + + await db.commit() + return {"processed": len(assets), "approved": approved, "reviewRequired": review_required} + + @app.post("/api/import/assets/upsert") async def upsert_source_asset( payload: SourceAssetUpsertPayload, @@ -1350,6 +1470,66 @@ async def approve_source_asset( return dict(row) +@app.post("/api/import/assets/{asset_id}/mark-converted") +async def mark_source_asset_converted( + asset_id: str, + 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") + + row = ( + await db.execute( + text('UPDATE "SourceAsset" SET status = :status, "updatedAt" = NOW() WHERE id = :id RETURNING id, status'), + {"id": asset_id, "status": "completed"}, + ) + ).mappings().first() + if not row: + raise HTTPException(status_code=404, detail="Source asset not found") + + await db.execute( + text( + 'INSERT INTO "ImportJob" (id, "sourceAssetId", status, error) ' + 'VALUES (:id, :asset_id, :status, :error)' + ), + { + "id": _new_id("job_"), + "asset_id": asset_id, + "status": "completed", + "error": "marked_converted_manually", + }, + ) + await db.commit() + return {"id": row["id"], "status": row["status"], "marked": True} + + +@app.post("/api/import/assets/{asset_id}/unmark-converted") +async def unmark_source_asset_converted( + asset_id: str, + 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") + + row = ( + await db.execute( + text('UPDATE "SourceAsset" SET status = :status, "updatedAt" = NOW() WHERE id = :id RETURNING id, status'), + {"id": asset_id, "status": "discovered"}, + ) + ).mappings().first() + if not row: + raise HTTPException(status_code=404, detail="Source asset not found") + + await db.execute( + text('DELETE FROM "ImportJob" WHERE "sourceAssetId" = :asset_id AND status = :status'), + {"asset_id": asset_id, "status": "completed"}, + ) + await db.commit() + return {"id": row["id"], "status": row["status"], "unmarked": True} + + @app.post("/api/import/jobs") async def create_import_job( payload: ImportJobCreatePayload, @@ -1495,15 +1675,17 @@ async def apply_import_job_mapping( if not chapter_token.isdigit(): continue chapter_number = int(chapter_token) - chapter = await mongo_db["chapters"].find_one( - {"novelId": payload.novelId, "number": chapter_number}, - {"_id": 1}, - ) + chapter = ( + await db.execute( + text('SELECT id FROM "ChapterMeta" WHERE "novelId" = :novel_id AND number = :number LIMIT 1'), + {"novel_id": payload.novelId, "number": chapter_number}, + ) + ).mappings().first() if not chapter: missing += 1 continue - chapter_id = str(chapter.get("_id")) + chapter_id = str(chapter.get("id")) txt_href = f"{asset_id}/{chapter_number}.txt" raw_href = f"{asset_id}/{chapter_number}.raw.html" content_hash = hashlib.sha256(storage.read_text(txt_href).encode("utf-8")).hexdigest() @@ -1616,10 +1798,12 @@ async def get_missing_mappings( if not token.isdigit(): continue chapter_number = int(token) - chapter = await mongo_db["chapters"].find_one( - {"novelId": novelId, "number": chapter_number}, - {"_id": 1}, - ) + chapter = ( + await db.execute( + text('SELECT id FROM "ChapterMeta" WHERE "novelId" = :novel_id AND number = :number LIMIT 1'), + {"novel_id": novelId, "number": chapter_number}, + ) + ).mappings().first() if not chapter: missing.append( { @@ -1655,10 +1839,12 @@ async def manual_map_chapter( except Exception as exc: raise HTTPException(status_code=404, detail="Source chapter content not found") from exc - target = await mongo_db["chapters"].find_one( - {"_id": ObjectId(payload.targetChapterId), "novelId": payload.novelId}, - {"_id": 1}, - ) + target = ( + await db.execute( + text('SELECT id FROM "ChapterMeta" WHERE id = :id AND "novelId" = :novel_id LIMIT 1'), + {"id": payload.targetChapterId, "novel_id": payload.novelId}, + ) + ).mappings().first() if not target: raise HTTPException(status_code=404, detail="Target chapter not found for novel") @@ -1810,15 +1996,17 @@ async def get_mapping_progress( continue chapter_number = int(token) total += 1 - chapter = await mongo_db["chapters"].find_one( - {"novelId": novelId, "number": chapter_number}, - {"_id": 1}, - ) + chapter = ( + await db.execute( + text('SELECT id FROM "ChapterMeta" WHERE "novelId" = :novel_id AND number = :number LIMIT 1'), + {"novel_id": novelId, "number": chapter_number}, + ) + ).mappings().first() if not chapter: missing_numbers.append(chapter_number) continue - chapter_id = str(chapter.get("_id")) + chapter_id = str(chapter.get("id")) ref = ( await db.execute( text('SELECT "chapterId" FROM "ChapterContentRef" WHERE "chapterId" = :id LIMIT 1'), @@ -1875,10 +2063,15 @@ async def auto_complete_import_job( continue chapter_number = int(token) total += 1 - chapter = await mongo_db["chapters"].find_one({"novelId": novelId, "number": chapter_number}, {"_id": 1}) + chapter = ( + await db.execute( + text('SELECT id FROM "ChapterMeta" WHERE "novelId" = :novel_id AND number = :number LIMIT 1'), + {"novel_id": novelId, "number": chapter_number}, + ) + ).mappings().first() if not chapter: continue - chapter_id = str(chapter.get("_id")) + chapter_id = str(chapter.get("id")) ref = ( await db.execute( text('SELECT "chapterId" FROM "ChapterContentRef" WHERE "chapterId" = :id LIMIT 1'), @@ -2394,12 +2587,14 @@ async def list_recommendations(request: Request, db: AsyncSession = Depends(get_ user = await require_current_user(request, db) docs = ( - await mongo_db["userrecommendations"] - .find({"userId": user["id"]}) - .sort("createdAt", -1) - .limit(1000) - .to_list(length=1000) - ) + await db.execute( + text( + 'SELECT id, "novelId", "createdAt" FROM "UserRecommendationDoc" ' + 'WHERE "userId" = :user_id ORDER BY "createdAt" DESC LIMIT 1000' + ), + {"user_id": user["id"]}, + ) + ).mappings().all() novel_ids = list({doc.get("novelId") for doc in docs if doc.get("novelId")}) novel_map: dict[str, dict[str, Any]] = {} @@ -2423,7 +2618,7 @@ async def list_recommendations(request: Request, db: AsyncSession = Depends(get_ continue items.append( { - "id": str(doc.get("_id")), + "id": str(doc.get("id")), "novelId": novel_id, "createdAt": _iso(doc.get("createdAt")), "novel": novel_map[novel_id], @@ -2454,20 +2649,23 @@ async def create_recommendation( if not novel_exists: raise HTTPException(status_code=404, detail="Truyện không tồn tại") - existing = await mongo_db["userrecommendations"].find_one( - {"userId": user["id"], "novelId": payload.novelId}, {"_id": 1} - ) + existing = ( + await db.execute( + text('SELECT id FROM "UserRecommendationDoc" WHERE "userId" = :user_id AND "novelId" = :novel_id LIMIT 1'), + {"user_id": user["id"], "novel_id": payload.novelId}, + ) + ).mappings().first() if existing: raise HTTPException(status_code=409, detail="Bạn đã đề cử truyện này rồi") now = dt.datetime.now(dt.timezone.utc) - result = await mongo_db["userrecommendations"].insert_one( - { - "userId": user["id"], - "novelId": payload.novelId, - "createdAt": now, - "updatedAt": now, - } + rec_id = _new_id("urec_") + await db.execute( + text( + 'INSERT INTO "UserRecommendationDoc" (id, "userId", "novelId", "createdAt") ' + 'VALUES (:id, :user_id, :novel_id, :created_at)' + ), + {"id": rec_id, "user_id": user["id"], "novel_id": payload.novelId, "created_at": now}, ) await db.execute( @@ -2476,7 +2674,7 @@ async def create_recommendation( ) await db.commit() - return {"id": str(result.inserted_id), "novelId": payload.novelId} + return {"id": rec_id, "novelId": payload.novelId} @app.delete("/api/user/recommendations") @@ -2487,13 +2685,19 @@ async def delete_recommendation( ): user = await require_current_user(request, db) - existing = await mongo_db["userrecommendations"].find_one( - {"userId": user["id"], "novelId": novelId}, {"_id": 1} - ) + existing = ( + await db.execute( + text('SELECT id FROM "UserRecommendationDoc" WHERE "userId" = :user_id AND "novelId" = :novel_id LIMIT 1'), + {"user_id": user["id"], "novel_id": novelId}, + ) + ).mappings().first() if not existing: raise HTTPException(status_code=404, detail="Bạn chưa đề cử truyện này") - await mongo_db["userrecommendations"].delete_one({"_id": existing["_id"]}) + await db.execute( + text('DELETE FROM "UserRecommendationDoc" WHERE id = :id'), + {"id": existing["id"]}, + ) await db.execute( text('UPDATE "Novel" SET "bookmarkCount" = GREATEST("bookmarkCount" - 1, 0) WHERE id = :novel_id'), {"novel_id": novelId}, diff --git a/app/routers/mod.py b/app/routers/mod.py deleted file mode 100644 index 65b1c11..0000000 --- a/app/routers/mod.py +++ /dev/null @@ -1,2635 +0,0 @@ -"""MOD panel API routes — all require MOD or ADMIN role.""" -from __future__ import annotations - -import datetime as dt -import io -import mimetypes -import os -import re -import tempfile -import uuid -from typing import Any - -import boto3 -import ebooklib -import html2text -import httpx -from bson import ObjectId -from ebooklib import epub as epublib -from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile -from pymongo import UpdateOne -from pydantic import BaseModel -from slugify import slugify -from sqlalchemy import text -from sqlalchemy.ext.asyncio import AsyncSession - -from app.auth import require_mod_user -from app.config import settings -from app.database import get_db_session, mongo_db - -router = APIRouter(prefix="/api", tags=["mod"]) - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -MAX_RECOMMENDATIONS_PER_EDITOR = 5 - - -def _to_iso(value: Any) -> str | None: - if value is None: - return None - if isinstance(value, dt.datetime): - if value.tzinfo is None: - value = value.replace(tzinfo=dt.timezone.utc) - return value.isoformat() - if isinstance(value, dt.date): - return dt.datetime.combine(value, dt.time(0, 0, tzinfo=dt.timezone.utc)).isoformat() - return str(value) - - -def _new_cuid() -> str: - return "c" + uuid.uuid4().hex[:24] - - -def _r2_client(): - return boto3.client( - "s3", - endpoint_url=f"https://{settings.r2_account_id}.r2.cloudflarestorage.com", - aws_access_key_id=settings.r2_access_key_id, - aws_secret_access_key=settings.r2_secret_access_key, - region_name="auto", - ) - - -def _r2_key_from_url(url: str | None) -> str | None: - if not url: - return None - base = (settings.r2_public_base_url or "").rstrip("/") - if base and url.startswith(base + "/"): - return url[len(base) + 1 :] - return None - - -async def _upload_to_r2(data: bytes, content_type: str, key_prefix: str = "covers", file_hint: str = "") -> str: - ext = mimetypes.guess_extension(content_type.split(";")[0].strip()) or "" - if not ext and file_hint: - ext = os.path.splitext(file_hint)[1] - key = f"{key_prefix}/{uuid.uuid4().hex}{ext}" - - def _sync(): - _r2_client().put_object( - Bucket=settings.r2_bucket_name, - Key=key, - Body=data, - ContentType=content_type, - ) - - import asyncio - - await asyncio.get_event_loop().run_in_executor(None, _sync) - base = (settings.r2_public_base_url or "").rstrip("/") - return f"{base}/{key}" - - -async def _delete_from_r2(url: str | None) -> bool: - key = _r2_key_from_url(url) - if not key: - return False - - def _sync(): - _r2_client().delete_object(Bucket=settings.r2_bucket_name, Key=key) - - import asyncio - - await asyncio.get_event_loop().run_in_executor(None, _sync) - return True - - -async def _sync_total_chapters(db: AsyncSession, novel_id: str) -> int: - count_result = await mongo_db.chapters.count_documents({"novelId": novel_id}) - await db.execute( - text('UPDATE "Novel" SET "totalChapters" = :count WHERE id = :id'), - {"count": count_result, "id": novel_id}, - ) - await db.commit() - return count_result - - -def _slugify_unique(base: str) -> str: - return slugify(base, allow_unicode=False) or base.lower().replace(" ", "-") - - -def _series_row_to_dict(row) -> dict: - d = dict(row.mappings() if hasattr(row, "mappings") else row) - return d - - -def _is_admin(user: dict) -> bool: - return user.get("role") == "ADMIN" - - -def _novel_ownership_filter(user: dict) -> str: - """SQL fragment for ownership check.""" - if _is_admin(user): - return "" # no extra condition - return 'AND (n."uploaderId" = :user_id OR n."uploaderId" IS NULL)' - - -# --------------------------------------------------------------------------- -# DEV route -# --------------------------------------------------------------------------- - - -@router.get("/dev/make-mod") -async def dev_make_mod( - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - if settings.app_env == "production": - raise HTTPException(status_code=404, detail="Not found") - result = await db.execute( - text('UPDATE "User" SET role = \'MOD\' WHERE email = :email RETURNING id, email, role'), - {"email": user["email"]}, - ) - await db.commit() - row = result.mappings().first() - return dict(row) if row else {} - - -# --------------------------------------------------------------------------- -# /api/mod/the-loai (genres) -# --------------------------------------------------------------------------- - - -@router.get("/mod/the-loai") -async def mod_list_genres( - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - result = await db.execute(text('SELECT id, name, slug, description, icon FROM "Genre" ORDER BY name ASC')) - return [dict(r) for r in result.mappings()] - - -class GenreCreateBody(BaseModel): - name: str - description: str | None = None - - -@router.post("/mod/the-loai", status_code=201) -async def mod_create_genre( - body: GenreCreateBody, - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - slug = _slugify_unique(body.name) - try: - result = await db.execute( - text( - 'INSERT INTO "Genre" (id, name, slug, description) VALUES (:id, :name, :slug, :desc) ' - "RETURNING id, name, slug, description" - ), - {"id": _new_cuid(), "name": body.name, "slug": slug, "desc": body.description}, - ) - await db.commit() - row = result.mappings().first() - if not row: - raise HTTPException(status_code=500, detail="Không thể tạo thể loại") - return dict(row) - except Exception as e: - await db.rollback() - if "unique" in str(e).lower() or "23505" in str(e): - raise HTTPException(status_code=400, detail="Thể loại đã tồn tại") - raise - - -@router.delete("/mod/the-loai") -async def mod_delete_genre( - id: str = Query(...), - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - await db.execute(text('DELETE FROM "Genre" WHERE id = :id'), {"id": id}) - await db.commit() - return {"success": True} - - -class GenreUpdateBody(BaseModel): - id: str - name: str - description: str | None = None - icon: str | None = None - - -@router.put("/mod/the-loai") -async def mod_update_genre( - body: GenreUpdateBody, - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - slug = slugify(body.name) - try: - result = await db.execute( - text( - 'UPDATE "Genre" SET name = :name, slug = :slug, description = :desc, icon = :icon ' - 'WHERE id = :id RETURNING id, name, slug, description, icon' - ), - {"id": body.id, "name": body.name, "slug": slug, "desc": body.description, "icon": body.icon}, - ) - await db.commit() - row = result.mappings().first() - if not row: - raise HTTPException(status_code=404, detail="Không tìm thấy thể loại") - return dict(row) - except Exception as e: - await db.rollback() - if "unique" in str(e).lower() or "23505" in str(e): - raise HTTPException(status_code=400, detail="Tên thể loại đã tồn tại") - raise - - -class GenreMergeBody(BaseModel): - sourceId: str # thể loại sẽ bị xóa - targetId: str # thể loại giữ lại - - -@router.post("/mod/the-loai/merge") -async def mod_merge_genre( - body: GenreMergeBody, - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - if body.sourceId == body.targetId: - raise HTTPException(status_code=400, detail="sourceId và targetId phải khác nhau") - - # Re-point NovelGenre rows: ON CONFLICT DO NOTHING để tránh duplicate - await db.execute( - text( - 'INSERT INTO "NovelGenre" ("novelId", "genreId") ' - 'SELECT "novelId", :target FROM "NovelGenre" WHERE "genreId" = :source ' - 'ON CONFLICT DO NOTHING' - ), - {"source": body.sourceId, "target": body.targetId}, - ) - await db.execute( - text('DELETE FROM "NovelGenre" WHERE "genreId" = :source'), - {"source": body.sourceId}, - ) - await db.execute(text('DELETE FROM "Genre" WHERE id = :id'), {"id": body.sourceId}) - await db.commit() - return {"success": True} - - -# --------------------------------------------------------------------------- -# /api/mod/series -# --------------------------------------------------------------------------- - - -@router.get("/mod/series") -async def mod_list_series( - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - if _is_admin(user): - result = await db.execute( - text( - 'SELECT s.id, s.name, s.slug, s.description, COUNT(n.id) AS "novelCount" ' - 'FROM "Series" s LEFT JOIN "Novel" n ON n."seriesId" = s.id ' - "GROUP BY s.id ORDER BY s.name ASC" - ) - ) - else: - result = await db.execute( - text( - 'SELECT s.id, s.name, s.slug, s.description, COUNT(n.id) AS "novelCount" ' - 'FROM "Series" s LEFT JOIN "Novel" n ON n."seriesId" = s.id ' - 'WHERE EXISTS (SELECT 1 FROM "Novel" n2 WHERE n2."seriesId" = s.id ' - 'AND (n2."uploaderId" = :uid OR n2."uploaderId" IS NULL)) ' - "GROUP BY s.id ORDER BY s.name ASC" - ), - {"uid": user["id"]}, - ) - return [dict(r) for r in result.mappings()] - - -class SeriesBody(BaseModel): - name: str - description: str | None = None - - -@router.post("/mod/series", status_code=201) -async def mod_create_series( - body: SeriesBody, - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - # Idempotent: return existing if same name - existing = await db.execute( - text('SELECT id, name, slug, description FROM "Series" WHERE LOWER(name) = LOWER(:name) LIMIT 1'), - {"name": body.name}, - ) - row = existing.mappings().first() - if row: - return dict(row) - - slug = _slugify_unique(body.name) - # Ensure slug uniqueness - count_result = await db.execute( - text('SELECT COUNT(*) FROM "Series" WHERE slug LIKE :pat'), {"pat": f"{slug}%"} - ) - count = count_result.scalar() or 0 - if count > 0: - slug = f"{slug}-{int(count) + 1}" - - result = await db.execute( - text( - 'INSERT INTO "Series" (id, name, slug, description, "createdAt", "updatedAt") ' - "VALUES (:id, :name, :slug, :desc, NOW(), NOW()) RETURNING id, name, slug, description" - ), - {"id": _new_cuid(), "name": body.name, "slug": slug, "desc": body.description}, - ) - await db.commit() - row = result.mappings().first() - if not row: - raise HTTPException(status_code=500, detail="Không thể tạo series") - return dict(row) - - -class SeriesUpdateBody(BaseModel): - id: str - name: str - description: str | None = None - - -@router.put("/mod/series") -async def mod_update_series( - body: SeriesUpdateBody, - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - dup = await db.execute( - text('SELECT id FROM "Series" WHERE LOWER(name) = LOWER(:name) AND id != :id LIMIT 1'), - {"name": body.name, "id": body.id}, - ) - if dup.mappings().first(): - raise HTTPException(status_code=400, detail="Tên series đã tồn tại") - slug = _slugify_unique(body.name) - result = await db.execute( - text( - 'UPDATE "Series" SET name = :name, slug = :slug, description = :desc, "updatedAt" = NOW() ' - "WHERE id = :id RETURNING id, name, slug, description" - ), - {"name": body.name, "slug": slug, "desc": body.description, "id": body.id}, - ) - await db.commit() - row = result.mappings().first() - if not row: - raise HTTPException(status_code=404, detail="Không tìm thấy series") - return dict(row) - - -@router.delete("/mod/series") -async def mod_delete_series( - id: str = Query(...), - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - count_result = await db.execute( - text('SELECT COUNT(*) FROM "Novel" WHERE "seriesId" = :id'), {"id": id} - ) - if (count_result.scalar() or 0) > 0: - raise HTTPException(status_code=409, detail="Series đang có truyện, không thể xóa") - await db.execute(text('DELETE FROM "Series" WHERE id = :id'), {"id": id}) - await db.commit() - return {"success": True} - - -# --------------------------------------------------------------------------- -# Helpers for novel routes -# --------------------------------------------------------------------------- - - -async def _resolve_series_id( - db: AsyncSession, - series_id_input: str | None, - series_name_input: str | None, - user: dict, -) -> str | None: - if series_id_input: - if not _is_admin(user): - owns = await db.execute( - text( - 'SELECT 1 FROM "Series" s WHERE s.id = :sid AND EXISTS ' - '(SELECT 1 FROM "Novel" n WHERE n."seriesId" = s.id AND (n."uploaderId" = :uid OR n."uploaderId" IS NULL))' - ), - {"sid": series_id_input, "uid": user["id"]}, - ) - if not owns.first(): - raise HTTPException(status_code=403, detail="Không có quyền dùng series này") - return series_id_input - if series_name_input and series_name_input.strip(): - existing = await db.execute( - text('SELECT id FROM "Series" WHERE LOWER(name) = LOWER(:name) LIMIT 1'), - {"name": series_name_input.strip()}, - ) - row = existing.mappings().first() - if row: - return row["id"] - # Create new - base_slug = _slugify_unique(series_name_input) - count_result = await db.execute( - text('SELECT COUNT(*) FROM "Series" WHERE slug LIKE :pat'), {"pat": f"{base_slug}%"} - ) - cnt = count_result.scalar() or 0 - slug = base_slug if cnt == 0 else f"{base_slug}-{int(cnt) + 1}" - new_id = _new_cuid() - await db.execute( - text( - 'INSERT INTO "Series" (id, name, slug, "createdAt", "updatedAt") VALUES (:id, :name, :slug, NOW(), NOW())' - ), - {"id": new_id, "name": series_name_input.strip(), "slug": slug}, - ) - return new_id - return None - - -# --------------------------------------------------------------------------- -# /api/mod/truyen -# --------------------------------------------------------------------------- - - -@router.get("/mod/overview") -async def mod_overview( - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - if _is_admin(user): - novel_count = (await db.execute(text('SELECT COUNT(*)::int FROM "Novel"'))).scalar() or 0 - total_views = (await db.execute(text('SELECT COALESCE(SUM(views), 0)::bigint FROM "Novel"'))).scalar() or 0 - comment_count = (await db.execute(text('SELECT COUNT(*)::int FROM "Comment"'))).scalar() or 0 - series_count = (await db.execute(text('SELECT COUNT(*)::int FROM "Series"'))).scalar() or 0 - else: - novel_count = ( - await db.execute( - text('SELECT COUNT(*)::int FROM "Novel" WHERE "uploaderId" = :uid OR "uploaderId" IS NULL'), - {"uid": user["id"]}, - ) - ).scalar() or 0 - total_views = ( - await db.execute( - text('SELECT COALESCE(SUM(views), 0)::bigint FROM "Novel" WHERE "uploaderId" = :uid OR "uploaderId" IS NULL'), - {"uid": user["id"]}, - ) - ).scalar() or 0 - comment_count = ( - await db.execute( - text( - 'SELECT COUNT(c.id)::int ' - 'FROM "Comment" c ' - 'JOIN "Novel" n ON n.id = c."novelId" ' - 'WHERE n."uploaderId" = :uid OR n."uploaderId" IS NULL' - ), - {"uid": user["id"]}, - ) - ).scalar() or 0 - series_count = ( - await db.execute( - text( - 'SELECT COUNT(s.id)::int ' - 'FROM "Series" s ' - 'WHERE EXISTS (' - ' SELECT 1 FROM "Novel" n ' - ' WHERE n."seriesId" = s.id AND (n."uploaderId" = :uid OR n."uploaderId" IS NULL)' - ') OR NOT EXISTS (' - ' SELECT 1 FROM "Novel" n2 WHERE n2."seriesId" = s.id' - ')' - ), - {"uid": user["id"]}, - ) - ).scalar() or 0 - - return { - "novelCount": int(novel_count), - "totalViews": int(total_views), - "commentCount": int(comment_count), - "seriesCount": int(series_count), - } - - -@router.get("/mod/truyen") -async def mod_list_novels( - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - if _is_admin(user): - result = await db.execute( - text( - 'SELECT n.id, n.title, n.slug, n."authorName", n."coverUrl", n.status, ' - 'n."totalChapters", n."createdAt", n."updatedAt", n."seriesId", ' - 's.id AS series_id, s.name AS series_name, s.slug AS series_slug ' - 'FROM "Novel" n LEFT JOIN "Series" s ON s.id = n."seriesId" ' - 'ORDER BY n."updatedAt" DESC' - ) - ) - else: - result = await db.execute( - text( - 'SELECT n.id, n.title, n.slug, n."authorName", n."coverUrl", n.status, ' - 'n."totalChapters", n."createdAt", n."updatedAt", n."seriesId", ' - 's.id AS series_id, s.name AS series_name, s.slug AS series_slug ' - 'FROM "Novel" n LEFT JOIN "Series" s ON s.id = n."seriesId" ' - 'WHERE n."uploaderId" = :uid OR n."uploaderId" IS NULL ' - 'ORDER BY n."updatedAt" DESC' - ), - {"uid": user["id"]}, - ) - rows = result.mappings().all() - novels = [] - for r in rows: - d = dict(r) - series = None - if d.get("series_id"): - series = {"id": d["series_id"], "name": d["series_name"], "slug": d["series_slug"]} - novels.append({ - "id": d["id"], "title": d["title"], "slug": d["slug"], - "authorName": d["authorName"], "coverUrl": d["coverUrl"], - "status": d["status"], "totalChapters": d["totalChapters"], - "createdAt": str(d["createdAt"]), "updatedAt": str(d["updatedAt"]), - "seriesId": d["seriesId"], "series": series, - }) - return novels - - -class NovelCreateBody(BaseModel): - title: str - originalTitle: str | None = None - authorName: str = "" - originalAuthorName: str | None = None - description: str = "" - coverUrl: str | None = None - genreIds: list[str] = [] - seriesId: str | None = None - seriesName: str | None = None - status: str = "Đang ra" - - -@router.post("/mod/truyen", status_code=201) -async def mod_create_novel( - body: NovelCreateBody, - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - series_id = await _resolve_series_id(db, body.seriesId, body.seriesName, user) - base_slug = _slugify_unique(body.title) - count_result = await db.execute( - text('SELECT COUNT(*) FROM "Novel" WHERE slug LIKE :pat'), {"pat": f"{base_slug}%"} - ) - cnt = count_result.scalar() or 0 - slug = base_slug if cnt == 0 else f"{base_slug}-{int(cnt) + 1}" - novel_id = _new_cuid() - await db.execute( - text( - 'INSERT INTO "Novel" (id, title, "originalTitle", slug, "authorName", "originalAuthorName", ' - 'description, "coverUrl", status, "uploaderId", "seriesId", "createdAt", "updatedAt") ' - "VALUES (:id, :title, :ot, :slug, :author, :oauthor, :desc, :cover, :status, :uid, :sid, NOW(), NOW())" - ), - { - "id": novel_id, "title": body.title, "ot": body.originalTitle, "slug": slug, - "author": body.authorName, "oauthor": body.originalAuthorName, - "desc": body.description, "cover": body.coverUrl, "status": body.status, - "uid": user["id"], "sid": series_id, - }, - ) - if body.genreIds: - for gid in body.genreIds: - await db.execute( - text('INSERT INTO "NovelGenre" ("novelId", "genreId") VALUES (:nid, :gid) ON CONFLICT DO NOTHING'), - {"nid": novel_id, "gid": gid}, - ) - await db.commit() - result = await db.execute(text('SELECT * FROM "Novel" WHERE id = :id'), {"id": novel_id}) - row = result.mappings().first() - if not row: - raise HTTPException(status_code=500, detail="Không thể tạo truyện") - return dict(row) - - -class NovelUpdateBody(BaseModel): - id: str - title: str | None = None - originalTitle: str | None = None - authorName: str | None = None - originalAuthorName: str | None = None - description: str | None = None - coverUrl: str | None = None - status: str | None = None - genreIds: list[str] | None = None - seriesId: str | None = None - seriesName: str | None = None - - -@router.put("/mod/truyen") -async def mod_update_novel( - body: NovelUpdateBody, - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - # Ownership check - if not _is_admin(user): - owns = await db.execute( - text('SELECT id, "seriesId" FROM "Novel" WHERE id = :id AND ("uploaderId" = :uid OR "uploaderId" IS NULL)'), - {"id": body.id, "uid": user["id"]}, - ) - else: - owns = await db.execute(text('SELECT id, "seriesId" FROM "Novel" WHERE id = :id'), {"id": body.id}) - novel_row = owns.mappings().first() - if not novel_row: - raise HTTPException(status_code=404, detail="Không tìm thấy truyện hoặc không có quyền") - - series_id = await _resolve_series_id(db, body.seriesId, body.seriesName, user) - - # Get series siblings - series_novel_ids: list[str] = [body.id] - current_series_id = series_id if series_id is not None else novel_row.get("seriesId") - if current_series_id: - sib = await db.execute( - text('SELECT id FROM "Novel" WHERE "seriesId" = :sid'), {"sid": current_series_id} - ) - series_novel_ids = [r["id"] for r in sib.mappings()] - if body.id not in series_novel_ids: - series_novel_ids.append(body.id) - - # Shared fields (apply to all series siblings) - shared_fields: dict[str, Any] = {} - if body.originalTitle is not None: - shared_fields["originalTitle"] = body.originalTitle - if body.authorName is not None: - shared_fields["authorName"] = body.authorName - if body.originalAuthorName is not None: - shared_fields["originalAuthorName"] = body.originalAuthorName - if body.description is not None: - shared_fields["description"] = body.description - if body.status is not None: - shared_fields["status"] = body.status - - # Own fields - own_fields: dict[str, Any] = {} - if body.title is not None: - own_fields["title"] = body.title - if body.coverUrl is not None: - own_fields["coverUrl"] = body.coverUrl - - if series_id is not None: - own_fields["seriesId"] = series_id - - # Update shared fields across siblings - if shared_fields and len(series_novel_ids) > 1: - set_clause = ", ".join(f'"{k}" = :{k}' for k in shared_fields) - ids_param = ",".join(f"'{sid}'" for sid in series_novel_ids) - await db.execute( - text(f'UPDATE "Novel" SET {set_clause}, "updatedAt" = NOW() WHERE id IN ({ids_param})'), - shared_fields, - ) - else: - own_fields.update(shared_fields) - - if own_fields: - set_clause = ", ".join(f'"{k}" = :{k}' for k in own_fields) - await db.execute( - text(f'UPDATE "Novel" SET {set_clause}, "updatedAt" = NOW() WHERE id = :id'), - {**own_fields, "id": body.id}, - ) - - if body.genreIds is not None: - for sid in series_novel_ids: - await db.execute(text('DELETE FROM "NovelGenre" WHERE "novelId" = :nid'), {"nid": sid}) - for gid in body.genreIds: - await db.execute( - text('INSERT INTO "NovelGenre" ("novelId", "genreId") VALUES (:nid, :gid) ON CONFLICT DO NOTHING'), - {"nid": sid, "gid": gid}, - ) - - await db.commit() - result = await db.execute(text('SELECT * FROM "Novel" WHERE id = :id'), {"id": body.id}) - row = result.mappings().first() - if not row: - raise HTTPException(status_code=404, detail="Không tìm thấy truyện") - return dict(row) - - -@router.delete("/mod/truyen") -async def mod_delete_novel( - id: str = Query(...), - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - if not _is_admin(user): - owns = await db.execute( - text('SELECT id, "coverUrl", "seriesId" FROM "Novel" WHERE id = :id AND ("uploaderId" = :uid OR "uploaderId" IS NULL)'), - {"id": id, "uid": user["id"]}, - ) - else: - owns = await db.execute( - text('SELECT id, "coverUrl", "seriesId" FROM "Novel" WHERE id = :id'), {"id": id} - ) - novel = owns.mappings().first() - if not novel: - raise HTTPException(status_code=404, detail="Không tìm thấy truyện hoặc không có quyền") - - await mongo_db.chapters.delete_many({"novelId": id}) - await db.execute(text('DELETE FROM "Novel" WHERE id = :id'), {"id": id}) - await db.commit() - - await _delete_from_r2(novel.get("coverUrl")) - - # Cleanup empty series - if novel.get("seriesId"): - cnt = await db.execute( - text('SELECT COUNT(*) FROM "Novel" WHERE "seriesId" = :sid'), {"sid": novel["seriesId"]} - ) - if (cnt.scalar() or 0) == 0: - await db.execute(text('DELETE FROM "Series" WHERE id = :id'), {"id": novel["seriesId"]}) - await db.commit() - - return {"success": True} - - -@router.get("/mod/truyen/{novel_id}/trash-words") -async def mod_get_trash_words( - novel_id: str, - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - if _is_admin(user): - result = await db.execute( - text('SELECT "trashWords", "uploaderId" FROM "Novel" WHERE id = :id'), {"id": novel_id} - ) - else: - result = await db.execute( - text('SELECT "trashWords", "uploaderId" FROM "Novel" WHERE id = :id AND ("uploaderId" = :uid OR "uploaderId" IS NULL)'), - {"id": novel_id, "uid": user["id"]}, - ) - row = result.mappings().first() - if not row: - raise HTTPException(status_code=404, detail="Không tìm thấy truyện") - return {"trashWords": row["trashWords"] or []} - - -class TrashWordsBody(BaseModel): - trashWords: list[str] - - -@router.put("/mod/truyen/{novel_id}/trash-words") -async def mod_update_trash_words( - novel_id: str, - body: TrashWordsBody, - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - if not isinstance(body.trashWords, list): - raise HTTPException(status_code=400, detail="trashWords phải là mảng") - if _is_admin(user): - await db.execute( - text('UPDATE "Novel" SET "trashWords" = :words WHERE id = :id'), - {"words": body.trashWords, "id": novel_id}, - ) - else: - await db.execute( - text('UPDATE "Novel" SET "trashWords" = :words WHERE id = :id AND ("uploaderId" = :uid OR "uploaderId" IS NULL)'), - {"words": body.trashWords, "id": novel_id, "uid": user["id"]}, - ) - await db.commit() - return {"trashWords": body.trashWords} - - -class BulkNovelBody(BaseModel): - action: str - ids: list[str] - - -@router.post("/mod/truyen/bulk") -async def mod_bulk_novels( - body: BulkNovelBody, - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - if body.action != "delete": - raise HTTPException(status_code=400, detail="Chỉ hỗ trợ action=delete") - - if _is_admin(user): - result = await db.execute( - text('SELECT id, "coverUrl" FROM "Novel" WHERE id = ANY(:ids)'), - {"ids": body.ids}, - ) - else: - result = await db.execute( - text('SELECT id, "coverUrl" FROM "Novel" WHERE id = ANY(:ids) AND ("uploaderId" = :uid OR "uploaderId" IS NULL)'), - {"ids": body.ids, "uid": user["id"]}, - ) - accessible = result.mappings().all() - accessible_ids = [r["id"] for r in accessible] - if not accessible_ids: - return {"deletedCount": 0} - - await mongo_db.chapters.delete_many({"novelId": {"$in": accessible_ids}}) - await db.execute( - text('DELETE FROM "Novel" WHERE id = ANY(:ids)'), {"ids": accessible_ids} - ) - await db.commit() - - import asyncio - await asyncio.gather(*[_delete_from_r2(r.get("coverUrl")) for r in accessible]) - - return {"deletedCount": len(accessible_ids)} - - -@router.get("/mod/truyen/missing") -async def mod_novels_missing( - q: str = Query(""), - missing: str = Query(""), - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - conds = ['1=1'] - params: dict[str, Any] = {} - - if not _is_admin(user): - conds.append('(n."uploaderId" = :uid OR n."uploaderId" IS NULL)') - params["uid"] = user["id"] - - if q.strip(): - conds.append('(n.title ILIKE :q OR n."authorName" ILIKE :q)') - params["q"] = f"%{q.strip()}%" - - missing_keys = [k.strip() for k in missing.split(",") if k.strip()] - missing_conds = [] - for key in missing_keys: - if key == "author": - missing_conds.append("(n.\"authorName\" = '' OR n.\"authorName\" IS NULL)") - elif key == "cover": - missing_conds.append("(n.\"coverUrl\" IS NULL OR n.\"coverUrl\" = '')") - elif key == "description": - missing_conds.append("(n.description = '' OR n.description IS NULL)") - elif key == "genres": - missing_conds.append('NOT EXISTS (SELECT 1 FROM "NovelGenre" ng WHERE ng."novelId" = n.id)') - - if missing_conds: - conds.append("(" + " OR ".join(missing_conds) + ")") - - where = " AND ".join(conds) - result = await db.execute( - text( - f'SELECT n.id, n.title, n.slug, n."authorName", n."coverUrl", n.status, ' - f'n."totalChapters", n."updatedAt" ' - f'FROM "Novel" n WHERE {where} ORDER BY n."updatedAt" DESC LIMIT 600' - ), - params, - ) - return {"items": [dict(r) for r in result.mappings()]} - - -class MissingPatchItem(BaseModel): - id: str - authorName: str | None = None - coverUrl: str | None = None - description: str | None = None - genreIds: list[str] | None = None - - -class MissingPatchBody(BaseModel): - updates: list[MissingPatchItem] - - -@router.patch("/mod/truyen/missing") -async def mod_patch_missing( - body: MissingPatchBody, - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - if len(body.updates) > 200: - raise HTTPException(status_code=400, detail="Tối đa 200 bản ghi") - - ids = [u.id for u in body.updates] - if _is_admin(user): - access_result = await db.execute( - text('SELECT id FROM "Novel" WHERE id = ANY(:ids)'), {"ids": ids} - ) - else: - access_result = await db.execute( - text('SELECT id FROM "Novel" WHERE id = ANY(:ids) AND ("uploaderId" = :uid OR "uploaderId" IS NULL)'), - {"ids": ids, "uid": user["id"]}, - ) - accessible_ids = {r["id"] for r in access_result.mappings()} - - updated = 0 - for item in body.updates: - if item.id not in accessible_ids: - continue - fields: dict[str, Any] = {} - if item.authorName is not None: - fields["authorName"] = item.authorName - if item.coverUrl is not None: - fields["coverUrl"] = item.coverUrl - if item.description is not None: - fields["description"] = item.description - if fields: - set_clause = ", ".join(f'"{k}" = :{k}' for k in fields) - await db.execute( - text(f'UPDATE "Novel" SET {set_clause}, "updatedAt" = NOW() WHERE id = :id'), - {**fields, "id": item.id}, - ) - if item.genreIds is not None: - await db.execute(text('DELETE FROM "NovelGenre" WHERE "novelId" = :nid'), {"nid": item.id}) - for gid in item.genreIds: - await db.execute( - text('INSERT INTO "NovelGenre" ("novelId", "genreId") VALUES (:nid, :gid) ON CONFLICT DO NOTHING'), - {"nid": item.id, "gid": gid}, - ) - updated += 1 - - await db.commit() - return {"updatedCount": updated} - - -@router.get("/mod/truyen/{novel_id}") -async def mod_get_novel( - novel_id: str, - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - if _is_admin(user): - result = await db.execute( - text('SELECT * FROM "Novel" WHERE id = :id'), {"id": novel_id} - ) - else: - result = await db.execute( - text('SELECT * FROM "Novel" WHERE id = :id AND ("uploaderId" = :uid OR "uploaderId" IS NULL)'), - {"id": novel_id, "uid": user["id"]}, - ) - row = result.mappings().first() - if not row: - raise HTTPException(status_code=404, detail="Không tìm thấy truyện") - - novel = dict(row) - - genres_result = await db.execute( - text('SELECT g.id, g.name, g.slug FROM "NovelGenre" ng JOIN "Genre" g ON g.id = ng."genreId" WHERE ng."novelId" = :nid'), - {"nid": novel_id}, - ) - novel["genres"] = [dict(r) for r in genres_result.mappings()] - - if novel.get("seriesId"): - s_result = await db.execute( - text('SELECT id, name, slug, description FROM "Series" WHERE id = :id LIMIT 1'), - {"id": novel["seriesId"]}, - ) - novel["series"] = dict(s_result.mappings().first() or {}) - - return novel - - -# --------------------------------------------------------------------------- -# /api/mod/chuong (chapters) -# --------------------------------------------------------------------------- - - -@router.get("/mod/chuong") -async def mod_list_chapters( - novelId: str = Query(...), - page: int = Query(1, ge=1), - limit: int = Query(20, ge=1, le=100), - db: AsyncSession = Depends(get_db_session), -): - skip = (page - 1) * limit - cursor = mongo_db.chapters.find({"novelId": novelId}, {"content": 0}).sort("number", 1).skip(skip).limit(limit) - chapters = [] - async for doc in cursor: - chapter_id = str(doc.pop("_id")) - # Keep backward compatibility for clients expecting either `id` or `_id`. - doc["id"] = chapter_id - doc["_id"] = chapter_id - chapters.append(doc) - total = await mongo_db.chapters.count_documents({"novelId": novelId}) - return { - "chapters": chapters, - "totalChapters": total, - "totalPages": (total + limit - 1) // limit, - "currentPage": page, - } - - -@router.get("/mod/chuong/{chapter_id}") -async def mod_get_chapter( - chapter_id: str, - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - try: - oid = ObjectId(chapter_id) - except Exception: - raise HTTPException(status_code=400, detail="ID không hợp lệ") - - doc = await mongo_db.chapters.find_one({"_id": oid}) - if not doc: - raise HTTPException(status_code=404, detail="Không tìm thấy chương") - - # Ownership check - if not _is_admin(user): - owns = await db.execute( - text('SELECT id FROM "Novel" WHERE id = :nid AND ("uploaderId" = :uid OR "uploaderId" IS NULL)'), - {"nid": doc["novelId"], "uid": user["id"]}, - ) - if not owns.first(): - raise HTTPException(status_code=403, detail="Không có quyền") - - chapter_id = str(doc.pop("_id")) - doc["id"] = chapter_id - doc["_id"] = chapter_id - return doc - - -class ChapterCreateBody(BaseModel): - novelId: str - number: int - title: str - content: str - volumeNumber: int | None = None - volumeTitle: str | None = None - volumeChapterNumber: int | None = None - - -@router.post("/mod/chuong", status_code=201) -async def mod_create_chapter( - body: ChapterCreateBody, - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - try: - # Validate input - if not body.title or not body.title.strip(): - raise HTTPException(status_code=400, detail="Tiêu đề chương không được trống") - if not body.content or not body.content.strip(): - raise HTTPException(status_code=400, detail="Nội dung chương không được trống") - if body.number <= 0: - raise HTTPException(status_code=400, detail="Số chương phải > 0") - - # Ownership check - MOD can edit default novels (uploaderId IS NULL) or their own - if not _is_admin(user): - owns = await db.execute( - text('SELECT id FROM "Novel" WHERE id = :nid AND ("uploaderId" = :uid OR "uploaderId" IS NULL)'), - {"nid": body.novelId, "uid": user["id"]}, - ) - if not owns.first(): - raise HTTPException(status_code=403, detail="Không có quyền") - - # Novel existence check - novel_check = await db.execute( - text('SELECT id FROM "Novel" WHERE id = :nid'), - {"nid": body.novelId}, - ) - if not novel_check.first(): - raise HTTPException(status_code=404, detail="Truyện không tồn tại") - - existing = await mongo_db.chapters.find_one({"novelId": body.novelId, "number": body.number}) - if existing: - raise HTTPException(status_code=400, detail=f"Chương {body.number} đã tồn tại") - - doc = { - "novelId": body.novelId, - "number": body.number, - "title": body.title.strip(), - "content": body.content.strip(), - "volumeNumber": body.volumeNumber, - "volumeTitle": body.volumeTitle, - "volumeChapterNumber": body.volumeChapterNumber, - } - - # Insert into MongoDB with error handling - try: - result = await mongo_db.chapters.insert_one(doc) - if not result.inserted_id: - raise Exception("MongoDB insert_one không trả về inserted_id") - inserted_chapter_id = str(result.inserted_id) - except Exception as mongo_err: - raise HTTPException(status_code=500, detail=f"Lỗi MongoDB: {str(mongo_err)}") - - # Sync total chapters count to PostgreSQL - try: - total = await _sync_total_chapters(db, body.novelId) - except Exception as pg_err: - # ⚠️ MongoDB has inserted but PostgreSQL update failed! - # Log this critical state for manual recovery - error_detail = f"⚠️ INCONSISTENT STATE: Chapter inserted in MongoDB (ID: {inserted_chapter_id}) but PostgreSQL sync failed: {str(pg_err)}" - print(f"[CRITICAL] {error_detail}") - # Still raise error so client knows something went wrong - raise HTTPException(status_code=500, detail=f"Dữ liệu has được lưu nhưng không thêm vào tổng số. Vui lòng liên hệ admin. {str(pg_err)}") - - # Prepare response - doc["id"] = inserted_chapter_id - doc.pop("_id", None) - - return doc - except HTTPException: - raise - except Exception as e: - import traceback - error_msg = str(e) - traceback.print_exc() - raise HTTPException(status_code=500, detail=f"Lỗi khi lưu chương: {error_msg}") - - -class ChapterUpdateBody(BaseModel): - id: str - novelId: str - number: int - title: str - content: str - volumeNumber: int | None = None - volumeTitle: str | None = None - volumeChapterNumber: int | None = None - - -@router.put("/mod/chuong") -async def mod_update_chapter( - body: ChapterUpdateBody, - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - # Validate input - if not body.title or not body.title.strip(): - raise HTTPException(status_code=400, detail="Tiêu đề chương không được trống") - if not body.content or not body.content.strip(): - raise HTTPException(status_code=400, detail="Nội dung chương không được trống") - if body.number <= 0: - raise HTTPException(status_code=400, detail="Số chương phải > 0") - - # Ownership check - MOD can edit default novels (uploaderId IS NULL) or their own - if not _is_admin(user): - owns = await db.execute( - text('SELECT id FROM "Novel" WHERE id = :nid AND ("uploaderId" = :uid OR "uploaderId" IS NULL)'), - {"nid": body.novelId, "uid": user["id"]}, - ) - if not owns.first(): - raise HTTPException(status_code=403, detail="Không có quyền") - - try: - oid = ObjectId(body.id) - except Exception: - raise HTTPException(status_code=400, detail="ID không hợp lệ") - - update_data = { - "number": body.number, - "title": body.title.strip(), - "content": body.content.strip(), - "volumeNumber": body.volumeNumber, - "volumeTitle": body.volumeTitle, - "volumeChapterNumber": body.volumeChapterNumber, - } - result = await mongo_db.chapters.find_one_and_update( - {"_id": oid, "novelId": body.novelId}, - {"$set": update_data}, - return_document=True, - ) - if not result: - raise HTTPException(status_code=404, detail="Không tìm thấy chương") - result["id"] = str(result.pop("_id")) - return result - - -@router.delete("/mod/chuong") -async def mod_delete_chapter( - id: str = Query(...), - novelId: str = Query(...), - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - if not _is_admin(user): - owns = await db.execute( - text('SELECT id FROM "Novel" WHERE id = :nid AND ("uploaderId" = :uid OR "uploaderId" IS NULL)'), - {"nid": novelId, "uid": user["id"]}, - ) - if not owns.first(): - raise HTTPException(status_code=403, detail="Không có quyền") - - try: - oid = ObjectId(id) - except Exception: - raise HTTPException(status_code=400, detail="ID không hợp lệ") - - result = await mongo_db.chapters.find_one_and_delete({"_id": oid, "novelId": novelId}) - if not result: - raise HTTPException(status_code=404, detail="Không tìm thấy chương") - - total = await _sync_total_chapters(db, novelId) - return {"success": True, "totalChapters": total} - - -class BulkDeleteChaptersBody(BaseModel): - novelId: str - fromNumber: int - toNumber: int - - -@router.post("/mod/chuong/bulk-delete") -async def mod_bulk_delete_chapters( - body: BulkDeleteChaptersBody, - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - if not _is_admin(user): - owns = await db.execute( - text('SELECT id FROM "Novel" WHERE id = :nid AND ("uploaderId" = :uid OR "uploaderId" IS NULL)'), - {"nid": body.novelId, "uid": user["id"]}, - ) - if not owns.first(): - raise HTTPException(status_code=403, detail="Không có quyền") - - delete_result = await mongo_db.chapters.delete_many({ - "novelId": body.novelId, - "number": {"$gte": body.fromNumber, "$lte": body.toNumber}, - }) - total = await _sync_total_chapters(db, body.novelId) - return {"success": True, "deletedCount": delete_result.deleted_count, "totalChapters": total} - - -class GlobalReplaceBody(BaseModel): - novelId: str - action: str = "replace" - findText: str | None = None - replaceText: str | None = None - matchCase: bool = False - trashWords: Any = None - preview: bool = False - - -@router.post("/mod/chuong/global-replace") -async def mod_global_replace( - body: GlobalReplaceBody, - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - if not _is_admin(user): - owns = await db.execute( - text('SELECT id FROM "Novel" WHERE id = :nid AND ("uploaderId" = :uid OR "uploaderId" IS NULL)'), - {"nid": body.novelId, "uid": user["id"]}, - ) - if not owns.first(): - raise HTTPException(status_code=403, detail="Không có quyền") - - def _build_patterns() -> list[tuple[re.Pattern, str]]: - pairs = [] - if body.action == "trash": - words = body.trashWords or [] - if isinstance(words, str): - words = [w.strip() for w in words.split(",") if w.strip()] - for w in words: - escaped = re.escape(w) - flags = 0 if body.matchCase else re.IGNORECASE - pairs.append((re.compile(escaped, flags), "")) - else: - if body.findText: - escaped = re.escape(body.findText) - flags = 0 if body.matchCase else re.IGNORECASE - pairs.append((re.compile(escaped, flags), body.replaceText or "")) - return pairs - - patterns = _build_patterns() - if not patterns: - return {"message": "Không có pattern nào để thay thế", "updatedChapters": 0} - - cursor = mongo_db.chapters.find({"novelId": body.novelId}).sort("number", 1) - updated_count = 0 - previews = [] - - async for chap in cursor: - original = chap.get("content", "") - new_content = original - for pattern, replacement in patterns: - new_content = pattern.sub(replacement, new_content) - - if new_content == original: - continue - - if body.preview: - if len(previews) < 5: - previews.append({ - "number": chap.get("number"), - "title": chap.get("title"), - "snippet": new_content[:300], - }) - updated_count += 1 - else: - await mongo_db.chapters.update_one({"_id": chap["_id"]}, {"$set": {"content": new_content}}) - updated_count += 1 - - return {"message": "Hoàn thành", "updatedChapters": updated_count, "previews": previews} - - -class OptimizeItem(BaseModel): - id: str - number: int - title: str - - -class OptimizeBody(BaseModel): - novelId: str - updates: list[OptimizeItem] - - -@router.put("/mod/chuong/optimize") -async def mod_optimize_chapters( - body: OptimizeBody, - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - if not _is_admin(user): - owns = await db.execute( - text('SELECT id FROM "Novel" WHERE id = :nid AND ("uploaderId" = :uid OR "uploaderId" IS NULL)'), - {"nid": body.novelId, "uid": user["id"]}, - ) - if not owns.first(): - raise HTTPException(status_code=403, detail="Không có quyền") - - ops = [] - for item in body.updates: - try: - oid = ObjectId(item.id) - except Exception: - continue - ops.append( - UpdateOne( - {"_id": oid, "novelId": body.novelId}, - {"$set": {"number": item.number, "title": item.title.strip()}}, - ) - ) - - if not ops: - return {"matchedCount": 0, "modifiedCount": 0} - - try: - result = await mongo_db.chapters.bulk_write(ops, ordered=False) - except Exception as e: - raise HTTPException(status_code=500, detail=f"Không thể tối ưu hàng loạt: {e}") - - return {"matchedCount": result.matched_count, "modifiedCount": result.modified_count} - - -# --------------------------------------------------------------------------- -# /api/mod/de-cu (editor recommendations) -# --------------------------------------------------------------------------- - - -@router.get("/mod/de-cu") -async def mod_list_recommendations( - q: str = Query(""), - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - recs = await mongo_db.editorrecommendations.find({}).sort("createdAt", -1).limit(1000).to_list(1000) - - # Collect novel and editor IDs - novel_ids = list({str(r["novelId"]) for r in recs if r.get("novelId")}) - editor_ids = list({str(r["editorId"]) for r in recs if r.get("editorId")}) - - novels_map: dict[str, dict] = {} - if novel_ids: - n_result = await db.execute( - text( - 'SELECT id, title, slug, "coverUrl", "authorName", status, "totalChapters" ' - 'FROM "Novel" WHERE id = ANY(:ids)' - ), - {"ids": novel_ids}, - ) - for r in n_result.mappings(): - novels_map[r["id"]] = dict(r) - - editors_map: dict[str, dict] = {} - if editor_ids: - e_result = await db.execute( - text('SELECT id, name, email FROM "User" WHERE id = ANY(:ids)'), - {"ids": editor_ids}, - ) - for r in e_result.mappings(): - editors_map[r["id"]] = dict(r) - - recommend_count_map: dict[str, int] = {} - for r in recs: - novel_id = str(r.get("novelId") or "") - if novel_id: - recommend_count_map[novel_id] = recommend_count_map.get(novel_id, 0) + 1 - - items = [] - for r in recs: - novel_id = str(r.get("novelId", "")) - editor_id = str(r.get("editorId", "")) - novel = novels_map.get(novel_id) - editor = editors_map.get(editor_id) - if not novel or not editor: - continue - - items.append( - { - "id": str(r["_id"]), - "createdAt": _to_iso(r.get("createdAt")), - "recommendCount": recommend_count_map.get(novel_id, 0), - "novel": { - "id": novel["id"], - "title": novel["title"], - "slug": novel["slug"], - "authorName": novel.get("authorName") or "", - "coverUrl": novel.get("coverUrl"), - "status": novel.get("status") or "Đang ra", - "totalChapters": int(novel.get("totalChapters") or 0), - }, - "editor": { - "id": editor["id"], - "name": editor.get("name") or "Biên tập viên", - }, - } - ) - - summary = [] - for novel_id, recommend_count in recommend_count_map.items(): - novel = novels_map.get(novel_id) - if not novel: - continue - summary.append( - { - "novel": { - "id": novel["id"], - "title": novel["title"], - "slug": novel["slug"], - "authorName": novel.get("authorName") or "", - "coverUrl": novel.get("coverUrl"), - "status": novel.get("status") or "Đang ra", - "totalChapters": int(novel.get("totalChapters") or 0), - }, - "recommendCount": recommend_count, - } - ) - summary.sort(key=lambda item: item["recommendCount"], reverse=True) - - # Search candidates - candidates = [] - if q.strip(): - c_result = await db.execute( - text( - 'SELECT id, title, slug, "authorName", "coverUrl", status, "totalChapters" ' - 'FROM "Novel" WHERE title ILIKE :q OR "authorName" ILIKE :q OR slug ILIKE :q LIMIT 20' - ), - {"q": f"%{q}%"}, - ) - candidates = [] - for r in c_result.mappings(): - row = dict(r) - novel_id = row["id"] - already_recommended = any( - str(item.get("novelId")) == novel_id and str(item.get("editorId")) == user["id"] - for item in recs - ) - candidates.append( - { - "id": row["id"], - "title": row["title"], - "slug": row["slug"], - "authorName": row.get("authorName") or "", - "coverUrl": row.get("coverUrl"), - "status": row.get("status") or "Đang ra", - "totalChapters": int(row.get("totalChapters") or 0), - "alreadyRecommended": already_recommended, - "recommendCount": int(recommend_count_map.get(novel_id, 0)), - } - ) - - my_novel_result = await db.execute( - text('SELECT id FROM "Novel" WHERE "uploaderId" = :uid OR "uploaderId" IS NULL'), - {"uid": user["id"]}, - ) - my_novel_ids = [r["id"] for r in my_novel_result.mappings()] - - current_user_rec_count = sum(1 for item in recs if str(item.get("editorId")) == user["id"]) - - return { - "items": items, - "summary": summary, - "candidates": candidates, - "myNovelIds": my_novel_ids, - "currentUser": { - "id": user["id"], - "role": user.get("role"), - "recommendationCount": current_user_rec_count, - "maxRecommendationCount": MAX_RECOMMENDATIONS_PER_EDITOR, - }, - } - - -class DeduBody(BaseModel): - novelId: str - - -@router.post("/mod/de-cu", status_code=201) -async def mod_create_recommendation( - body: DeduBody, - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - novel = await db.execute(text('SELECT id FROM "Novel" WHERE id = :id'), {"id": body.novelId}) - if not novel.first(): - raise HTTPException(status_code=404, detail="Không tìm thấy truyện") - - existing = await mongo_db.editorrecommendations.find_one({"novelId": body.novelId, "editorId": user["id"]}) - if existing: - raise HTTPException(status_code=400, detail="Đã đề cử truyện này rồi") - - count = await mongo_db.editorrecommendations.count_documents({"editorId": user["id"]}) - if count >= MAX_RECOMMENDATIONS_PER_EDITOR: - raise HTTPException(status_code=400, detail=f"Mỗi editor tối đa {MAX_RECOMMENDATIONS_PER_EDITOR} đề cử") - - import datetime as _dt - result = await mongo_db.editorrecommendations.insert_one({ - "novelId": body.novelId, - "editorId": user["id"], - "createdAt": _dt.datetime.now(_dt.timezone.utc), - }) - return {"id": str(result.inserted_id), "novelId": body.novelId, "editorId": user["id"]} - - -@router.delete("/mod/de-cu") -async def mod_delete_recommendation( - id: str = Query(...), - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - try: - oid = ObjectId(id) - except Exception: - raise HTTPException(status_code=400, detail="ID không hợp lệ") - - rec = await mongo_db.editorrecommendations.find_one({"_id": oid}) - if not rec: - raise HTTPException(status_code=404, detail="Không tìm thấy đề cử") - - if not _is_admin(user) and str(rec.get("editorId")) != user["id"]: - raise HTTPException(status_code=403, detail="Không có quyền xóa đề cử này") - - await mongo_db.editorrecommendations.delete_one({"_id": oid}) - return {"success": True} - - -# --------------------------------------------------------------------------- -# /api/mod/upload-cover -# --------------------------------------------------------------------------- - - -@router.post("/mod/upload-cover") -async def mod_upload_cover( - file: UploadFile = File(...), - user: dict = Depends(require_mod_user), -): - if not (file.content_type or "").startswith("image/"): - raise HTTPException(status_code=400, detail="File phải là ảnh") - data = await file.read() - url = await _upload_to_r2(data, file.content_type or "image/jpeg", "covers/manual", file.filename or "") - return {"url": url} - - -# --------------------------------------------------------------------------- -# /api/mod/epub — EPUB upload and parse -# --------------------------------------------------------------------------- - -_REGEX_PRESETS = { - "vi_chuong_hoi": r"^\s*(?:[#>*\-]+\s*)?(?:(?:Ch(?:ương|uong)|H(?:ồi|oi)|Ti(?:ết|et)|Ph(?:ần|an)|Th(?:ứ|u)|Quy(?:ển|en)|Ch\.)\s*\d+(?:\.\d+)?|第\s*\d+\s*章)[^\n]{0,120}$", - "mix_chapter": r"^\s*(?:[#>*\-]+\s*)?(?:(?:Ch(?:ương|uong)|H(?:ồi|oi)|Ti(?:ết|et)|Ph(?:ần|an)|Th(?:ứ|u)|Quy(?:ển|en)|Ch\.|Chapter|ch(?:apter)?)\s*\d+(?:\.\d+)?|第\s*\d+\s*章)[^\n]{0,120}$", - "numeric_only": r"^\s*(?:[#>*\-]+\s*)?\d+(?:\.\d+)?\s*[\.:\-\]\)]?(?:\s+|$)[^\n]{0,120}$", -} - -_REGEX_PRESET_ALIASES = { - "vi_chuong": "vi_chuong_hoi", - "en_chapter": "mix_chapter", - "bracket_chapter": "mix_chapter", -} - - -def _epub_html_to_text(html_content: str) -> str: - h = html2text.HTML2Text() - h.ignore_links = True - h.ignore_images = True - h.body_width = 0 - return h.handle(html_content).strip() - - -def _extract_chapter_number(title: str) -> int | None: - m = re.search( - r"(?:(?:Ch(?:ương|uong)|H(?:ồi|oi)|Ti(?:ết|et)|Ph(?:ần|an)|Th(?:ứ|u)|Quy(?:ển|en)|Chapter|CHAPTER|Ch\.)\s*|第\s*)(\d+)(?:\s*章)?", - title or "", - re.IGNORECASE, - ) - if not m: - return None - try: - n = int(m.group(1)) - return n if n > 0 else None - except Exception: - return None - - -def _chapter_heading_score(line: str) -> int: - s = re.sub(r"\s+", " ", (line or "").strip()) - s = re.sub(r"^\s*(?:[#>*\-]+\s*)+", "", s) - if len(s) < 4 or len(s) > 140: - return -10 - - number = _extract_chapter_number(s) - if number is None: - return -10 - - score = 0 - - if number <= 5000: - score += 3 - else: - score -= 2 - - m = re.match( - r"^(?:(?:Ch(?:ương|uong)|H(?:ồi|oi)|Ti(?:ết|et)|Ph(?:ần|an)|Th(?:ứ|u)|Quy(?:ển|en)|Chapter|CHAPTER|Ch\.)\s*\d+(?:\.\d+)?|第\s*\d+\s*章)(.*)$", - s, - re.IGNORECASE, - ) - if not m: - return -10 - - score += 3 - - tail = (m.group(1) or "").strip() - if not tail: - return score + 1 - - # Strong signal: explicit chapter delimiter after number (e.g. "Chương 81: ..."). - if re.match( - r"^(?:Ch(?:ương|uong)|H(?:ồi|oi)|Ti(?:ết|et)|Ph(?:ần|an)|Th(?:ứ|u)|Quy(?:ển|en)|Chapter|CHAPTER|Ch\.)\s*\d+(?:\.\d+)?\s*[::]", - s, - re.IGNORECASE, - ): - score += 4 - - word_count = len([w for w in re.split(r"\s+", tail) if w]) - punct_count = len(re.findall(r"[,:;!?]", tail)) - - if tail[0] in ':.-–—)]}"\'”’»': - score += 2 - elif tail[0] in '!?,;': - # Likely a sentence line, not chapter heading. - score -= 6 - else: - score -= 1 - - lower_tail = tail.lower() - bad_prefixes = ( - "và ", "va ", "là ", "la ", "của ", "cua ", "đã ", "da ", "đang ", - "thì ", "thi ", "vì ", "vi ", "nên ", "nen ", "trong ", "với ", "voi ", - "được ", "duoc ", "khi ", "theo ", "ở ", "o ", - ) - if lower_tail.startswith(bad_prefixes): - score -= 6 - - # Title-like tail should usually start with uppercase/digit/quote/bracket. - if re.match(r"^[A-ZÀ-Ỹ0-9\"'“‘\(\[\{]", tail): - score += 2 - elif re.match(r"^[a-zà-ỹ]", tail): - score -= 2 - - if len(tail) > 90: - score -= 2 - - # Penalize highly prose-like tails. - if "," in tail and len(tail) > 40: - score -= 1 - - # Without a clear delimiter, keep only short title-like tails. - if tail[0] not in ':.-–—)]}"\'”’»' and (word_count > 10 or punct_count >= 2): - score -= 4 - - # Long sentence-like heading is suspicious. - if word_count > 16: - score -= 4 - - return score - - -def _is_likely_chapter_heading(line: str) -> bool: - return _chapter_heading_score(line) >= 6 - - -def _is_front_matter_title(title: str) -> bool: - s = re.sub(r"\s+", " ", (title or "").strip()).lower() - if not s: - return True - - return bool( - re.search( - r"\b(mục lục|muc luc|contents?|toc|giới thiệu|gioi thieu|lời mở đầu|loi mo dau|foreword|preface|about|summary|tóm tắt|tom tat|tên truyện|ten truyen|book title)\b", - s, - re.IGNORECASE, - ) - ) - - -def _looks_like_toc_content(content: str) -> bool: - lines = [ln.strip() for ln in (content or "").splitlines() if ln.strip()] - if len(lines) < 8: - return False - - heading_like = 0 - for ln in lines: - if _extract_chapter_number(ln) is not None and len(ln) <= 140: - heading_like += 1 - - ratio = heading_like / max(1, len(lines)) - return heading_like >= 6 and ratio >= 0.18 - - -def _postprocess_extracted_chapters(chapters: list[dict], split_mode: str) -> list[dict]: - """Clean extracted chapters by removing front matter and TOC-like sections.""" - if not chapters: - return chapters - - normalized: list[dict] = [] - for c in chapters: - title = re.sub(r"\s+", " ", str(c.get("title") or "")).strip() - content = str(c.get("content") or "").strip() - if not content: - continue - normalized.append({ - **c, - "title": title, - "content": content, - "number": c.get("number") if isinstance(c.get("number"), int) else _extract_chapter_number(title), - }) - - if not normalized: - return [] - - # Remove obvious TOC/front-matter entries globally. - filtered = [] - for c in normalized: - title = c.get("title") or "" - content = c.get("content") or "" - if _is_front_matter_title(title): - continue - if _looks_like_toc_content(content): - continue - filtered.append(c) - - if not filtered: - return [] - - # Drop leading entries until first confident chapter heading appears. - first_confident_idx = None - for i, c in enumerate(filtered): - title = c.get("title") or "" - detected_num = _extract_chapter_number(title) - if detected_num is not None and not _is_front_matter_title(title): - first_confident_idx = i - break - - if first_confident_idx is not None and first_confident_idx > 0: - leading = filtered[:first_confident_idx] - # Only trim when leading items look like front matter. - if all(_is_front_matter_title(item.get("title") or "") or _looks_like_toc_content(item.get("content") or "") for item in leading): - filtered = filtered[first_confident_idx:] - - if split_mode == "regex": - # Deduplicate by chapter number for regex mode, prefer richer content and non-front-matter title. - by_number: dict[int, dict] = {} - tail: list[dict] = [] - for c in filtered: - n = c.get("number") - if isinstance(n, int) and n > 0: - prev = by_number.get(n) - if prev is None: - by_number[n] = c - else: - prev_bad = _is_front_matter_title(prev.get("title") or "") - cur_bad = _is_front_matter_title(c.get("title") or "") - if (prev_bad and not cur_bad) or len((c.get("content") or "")) > len((prev.get("content") or "")): - by_number[n] = c - else: - tail.append(c) - - ordered = [by_number[k] for k in sorted(by_number.keys())] + tail - # Reassign chapter number for any unnumbered tails to keep data consistent. - max_n = max([c.get("number") for c in ordered if isinstance(c.get("number"), int)] or [0]) - for c in ordered: - if not isinstance(c.get("number"), int) or c.get("number") <= 0: - max_n += 1 - c["number"] = max_n - return ordered - - # TOC mode: keep original order after filtering; normalize numbering sequentially. - for i, c in enumerate(filtered, start=1): - c["number"] = i - return filtered - - -def _normalize_chapter_preview(chapters: list[dict]) -> tuple[list[dict], dict]: - """Sort chapters by detected number and insert placeholders for gaps (preview only).""" - by_number: dict[int, dict] = {} - others: list[dict] = [] - - for c in chapters: - n = c.get("number") - if isinstance(n, int) and n > 0: - prev = by_number.get(n) - if prev is None or len((c.get("content") or "")) > len((prev.get("content") or "")): - by_number[n] = c - else: - others.append(c) - - if not by_number: - return chapters, { - "insertedMissingChapters": 0, - "detectedMaxChapterNumber": len(chapters), - "chaptersFinal": len(chapters), - } - - sorted_numbers = sorted(by_number.keys()) - out: list[dict] = [] - inserted_missing = 0 - expected = sorted_numbers[0] - - MAX_FILL_GAP = 5 - - for n in sorted_numbers: - gap = n - expected - # Only fill small gaps so preview remains readable even if parser misses many headings. - if 0 < gap <= MAX_FILL_GAP: - while expected < n: - inserted_missing += 1 - out.append({ - "number": expected, - "title": f"Chương {expected} (Thiếu)", - "content": "", - "isPlaceholder": True, - }) - expected += 1 - elif gap > MAX_FILL_GAP: - expected = n - - item = dict(by_number[n]) - item["isPlaceholder"] = bool(item.get("isPlaceholder", False)) - out.append(item) - expected = n + 1 - - # Keep non-numbered chapters at the end for manual review. - for c in others: - item = dict(c) - item["isPlaceholder"] = bool(item.get("isPlaceholder", False)) - out.append(item) - - stats = { - "insertedMissingChapters": inserted_missing, - "detectedMaxChapterNumber": sorted_numbers[-1], - "chaptersFinal": len(out), - } - return out, stats - - -def _build_chapters_from_toc(book: epublib.EpubBook) -> list[dict]: - """Extract chapters following the EPUB TOC order.""" - chapters: list[dict] = [] - num = 1 - seen_hrefs: set[str] = set() - - def _process_item(item) -> str | None: - if isinstance(item, epublib.EpubHtml): - return item.get_body_content().decode("utf-8", errors="replace") if item.get_body_content() else None - return None - - def _append_from_href(href: str, title: str | None): - nonlocal num - base_href = (href or "").split("#")[0] - if not base_href: - return - if base_href in seen_hrefs: - return - item = book.get_item_with_href(base_href) - if item and isinstance(item, epublib.EpubHtml): - content = item.get_body_content() - if content: - text = _epub_html_to_text(content.decode("utf-8", errors="replace")) - if text.strip(): - seen_hrefs.add(base_href) - chapters.append({ - "number": num, - "title": (title or item.title or f"Chương {num}").strip(), - "content": text, - }) - num += 1 - - def _walk(toc_items): - nonlocal num - for entry in toc_items: - if isinstance(entry, tuple): - section, children = entry - # Many EPUBs store usable title/href in section, children may be empty. - section_href = getattr(section, "href", "") if section is not None else "" - section_title = getattr(section, "title", None) if section is not None else None - if section_href: - _append_from_href(section_href, section_title) - _walk(children) - elif isinstance(entry, epublib.Link): - _append_from_href(entry.href, entry.title) - elif isinstance(entry, epublib.EpubHtml): - file_name = (entry.file_name or "").split("#")[0] - if file_name and file_name in seen_hrefs: - continue - content = entry.get_body_content() - if content: - text = _epub_html_to_text(content.decode("utf-8", errors="replace")) - if text.strip(): - if file_name: - seen_hrefs.add(file_name) - chapters.append({ - "number": num, - "title": entry.title or f"Chương {num}", - "content": text, - }) - num += 1 - - _walk(book.toc) - - # Detect broken TOC extraction and fallback to spine if needed. - use_spine_fallback = False - if not chapters: - use_spine_fallback = True - else: - non_empty_spine_items = 0 - for item_id, _linear in book.spine: - item = book.get_item_with_id(item_id) - if item and isinstance(item, epublib.EpubHtml) and item.get_body_content(): - non_empty_spine_items += 1 - - if len(chapters) <= 1 and non_empty_spine_items >= 3: - use_spine_fallback = True - - # If most chapters are effectively duplicated content, TOC is likely not usable. - content_sigs = [re.sub(r"\s+", " ", (c.get("content") or "").strip())[:1200] for c in chapters] - if len(content_sigs) >= 3: - unique_ratio = len(set(content_sigs)) / max(1, len(content_sigs)) - if unique_ratio < 0.6: - use_spine_fallback = True - - if use_spine_fallback: - chapters = [] - num = 1 - seen_hrefs = set() - for item_id, _linear in book.spine: - item = book.get_item_with_id(item_id) - if item and isinstance(item, epublib.EpubHtml): - file_name = (item.file_name or "").split("#")[0] - if file_name and file_name in seen_hrefs: - continue - content = item.get_body_content() - if content: - text = _epub_html_to_text(content.decode("utf-8", errors="replace")) - if text.strip(): - if file_name: - seen_hrefs.add(file_name) - chapters.append({ - "number": num, - "title": item.title or f"Chương {num}", - "content": text, - }) - num += 1 - - return chapters - - -def _build_chapters_from_regex(full_text: str, pattern: str) -> list[dict]: - """Split full book text by regex chapter headers.""" - try: - compiled = re.compile(pattern, re.MULTILINE | re.UNICODE | re.IGNORECASE) - except re.error: - raise HTTPException(status_code=400, detail="Regex không hợp lệ") - - all_matches = list(compiled.finditer(full_text)) - if not all_matches: - return [] - - # Heuristic filtering for built-in chapter presets to avoid false positives - # where normal body lines accidentally start with "Chương ...". - scored_matches: list[dict] = [ - { - "match": m, - "title": m.group(0).strip(), - "score": _chapter_heading_score(m.group(0)), - "detected": _extract_chapter_number(m.group(0).strip()), - } - for m in all_matches - ] - - filtered_scored = [row for row in scored_matches if row["score"] >= 5] - - # If the heuristic is too strict (e.g. custom regex with special formats), - # fallback to original matches to preserve user control. - use_filtered = len(filtered_scored) >= 3 and len(filtered_scored) >= max(3, int(len(scored_matches) * 0.25)) - if use_filtered: - selected_rows = filtered_scored - else: - selected_rows = scored_matches - - chapters = [] - next_auto_number = 1 - for i, row in enumerate(selected_rows): - m = row["match"] - start = m.start() - end = selected_rows[i + 1]["match"].start() if i + 1 < len(selected_rows) else len(full_text) - title = row["title"] - content = full_text[start:end].strip() - detected = row["detected"] - score = row["score"] - - # Keep real chapter number if detectable; fallback to sequential. - if detected is not None: - number = detected - if detected >= next_auto_number: - next_auto_number = detected + 1 - else: - number = next_auto_number - next_auto_number += 1 - - chapters.append({"number": number, "title": title, "content": content, "headingScore": score}) - - return chapters - - -@router.post("/mod/epub") -async def mod_upload_epub( - request: Request, - file: UploadFile = File(...), - preview: str = Form("false"), - splitMode: str = Form("toc"), - seriesMode: str = Form("none"), - seriesId: str = Form(""), - seriesName: str = Form(""), - replaceExisting: str = Form("false"), - appendTargetNovelId: str = Form(""), - title: str = Form(""), - authorName: str = Form(""), - description: str = Form(""), - regexInput: str = Form(""), - regexPreset: str = Form(""), - chapterRegex: str = Form(""), - chapterRegexPreset: str = Form(""), - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - preview_only = preview.lower() == "true" - replace_existing = replaceExisting.lower() == "true" - - if not file.filename or not file.filename.lower().endswith(".epub"): - raise HTTPException(status_code=400, detail="File phải có định dạng .epub") - - epub_data = await file.read() - - # Parse EPUB - try: - book = epublib.read_epub(io.BytesIO(epub_data), options={"ignore_ncx": False}) - except Exception as e: - raise HTTPException(status_code=400, detail=f"Không thể đọc file EPUB: {e}") - - meta_title = title.strip() or (book.get_metadata("DC", "title") or [("",)])[0][0] - meta_author = authorName.strip() or (book.get_metadata("DC", "creator") or [("",)])[0][0] - meta_description = description.strip() or (book.get_metadata("DC", "description") or [("",)])[0][0] - - # Extract chapters - preset_key: str = "" - pattern: str = "" - preview_chapters: list[dict] = [] - preview_stats = { - "insertedMissingChapters": 0, - "detectedMaxChapterNumber": 0, - "chaptersFinal": 0, - } - - if splitMode == "regex": - # Keep backward compatibility with old web client payload keys. - preset_key = (chapterRegexPreset or regexPreset or "").strip() - preset_key = _REGEX_PRESET_ALIASES.get(preset_key, preset_key) - pattern = (chapterRegex or regexInput or "").strip() or _REGEX_PRESETS.get(preset_key, "") - if not pattern: - raise HTTPException(status_code=400, detail="Cần nhập regex hoặc chọn preset") - # Get full text from all spine items - full_texts = [] - for item_id, _ in book.spine: - item = book.get_item_with_id(item_id) - if item and isinstance(item, epublib.EpubHtml): - content = item.get_body_content() - if content: - full_texts.append(_epub_html_to_text(content.decode("utf-8", errors="replace"))) - full_text = "\n".join(full_texts) - chapters = _build_chapters_from_regex(full_text, pattern) - chapters = _postprocess_extracted_chapters(chapters, "regex") - preview_chapters, preview_stats = _normalize_chapter_preview(chapters) - else: - chapters = _build_chapters_from_toc(book) - chapters = _postprocess_extracted_chapters(chapters, "toc") - preview_chapters = chapters - preview_stats = { - "insertedMissingChapters": 0, - "detectedMaxChapterNumber": len(chapters), - "chaptersFinal": len(chapters), - } - - if not chapters: - raise HTTPException(status_code=400, detail="Không tìm thấy chương nào trong file EPUB") - - # Extract cover - cover_url: str | None = None - for item in book.get_items_of_type(ebooklib.ITEM_COVER): - cover_url = await _upload_to_r2(item.get_content(), item.media_type or "image/jpeg", "covers", "cover.jpg") - break - if not cover_url: - for item in book.get_items_of_type(ebooklib.ITEM_IMAGE): - if "cover" in (item.file_name or "").lower(): - cover_url = await _upload_to_r2(item.get_content(), item.media_type or "image/jpeg", "covers", item.file_name) - break - - if preview_only: - parser_info = { - "splitMode": splitMode, - "chapterRegexUsed": pattern if splitMode == "regex" else None, - "regexPreset": preset_key if splitMode == "regex" else None, - "sourceSections": len(book.spine or []), - "chaptersDetected": len(chapters), - "chaptersFinal": preview_stats["chaptersFinal"], - "insertedMissingChapters": preview_stats["insertedMissingChapters"], - "detectedMaxChapterNumber": preview_stats["detectedMaxChapterNumber"], - } - return { - "preview": True, - "fileName": file.filename, - "splitMode": splitMode, - "detectedStructureType": "standard", - "hasCoverFromEpub": bool(cover_url), - "parserInfo": parser_info, - "novel": { - "title": meta_title, - "authorName": meta_author, - "description": meta_description, - "detectedGenres": [], - "totalChapters": preview_stats["chaptersFinal"], - }, - "chaptersPreview": [ - { - "number": c.get("number", i + 1), - "title": c.get("title") or f"Chương {i + 1}", - "isPlaceholder": bool(c.get("isPlaceholder", False)), - "volumeNumber": None, - "volumeTitle": None, - "volumeChapterNumber": None, - "excerpt": (c.get("content") or "")[:180], - } - for i, c in enumerate(preview_chapters[:30]) - ], - "totalChapters": preview_stats["chaptersFinal"], - # Keep this field lightweight to avoid oversized responses through proxy. - "sampleChapters": [ - { - "number": c.get("number", i + 1), - "title": c.get("title") or f"Chương {i + 1}", - "excerpt": (c.get("content") or "")[:240], - "isPlaceholder": bool(c.get("isPlaceholder", False)), - } - for i, c in enumerate(preview_chapters[:30]) - ], - "meta": {"title": meta_title, "authorName": meta_author, "description": meta_description, "coverUrl": cover_url}, - } - - # Check duplicate title - ONLY when creating new novel - # When appending (appendTargetNovelId exists), we don't check duplicate - # because we already know which novel to append to - if not appendTargetNovelId: # Create mode only - dup = await db.execute( - text('SELECT id FROM "Novel" WHERE LOWER(title) = LOWER(:title) LIMIT 1'), - {"title": meta_title}, - ) - if dup.first() and not replace_existing: - raise HTTPException(status_code=409, detail="Truyện đã tồn tại", headers={"X-Error-Code": "DUPLICATE_TITLE"}) - - # Extract genres from metadata - genre_names: list[str] = [] - for meta_key in ("subject", "genre", "tag", "category"): - for (value, _) in (book.get_metadata("DC", meta_key) or []): - if value: - genre_names.extend([v.strip() for v in value.split(",") if v.strip()]) - - genre_ids: list[str] = [] - for gn in genre_names: - g_result = await db.execute( - text('SELECT id FROM "Genre" WHERE LOWER(name) = LOWER(:name) LIMIT 1'), {"name": gn} - ) - row = g_result.mappings().first() - if row: - genre_ids.append(row["id"]) - - # Determine mode and validate - if appendTargetNovelId: - # APPEND MODE: adding chapters to existing novel - # Verify novel exists and user owns it - novel_check = await db.execute( - text('SELECT id, "uploaderId" FROM "Novel" WHERE id = :nid'), - {"nid": appendTargetNovelId} - ) - novel_row = novel_check.mappings().first() - if not novel_row: - raise HTTPException(status_code=404, detail="Không tìm thấy truyện đích") - - # Permission check - if not _is_admin(user) and novel_row["uploaderId"] != user["id"]: - raise HTTPException(status_code=403, detail="Không có quyền sửa truyện này") - - novel_id = appendTargetNovelId - - if replace_existing: - # Replace mode: delete existing chapters and update novel metadata - await db.execute( - text( - 'UPDATE "Novel" SET title = :title, "authorName" = :author, description = :desc, ' - '"coverUrl" = COALESCE(:cover, "coverUrl"), "updatedAt" = NOW() WHERE id = :id' - ), - {"title": meta_title, "author": meta_author, "desc": meta_description, "cover": cover_url, "id": novel_id}, - ) - # Replace genres - await db.execute(text('DELETE FROM "NovelGenre" WHERE "novelId" = :nid'), {"nid": novel_id}) - for gid in genre_ids: - await db.execute( - text('INSERT INTO "NovelGenre" ("novelId", "genreId") VALUES (:nid, :gid) ON CONFLICT DO NOTHING'), - {"nid": novel_id, "gid": gid}, - ) - # Delete existing chapters to make room for new ones - await mongo_db.chapters.delete_many({"novelId": novel_id}) - # else: append mode - just add chapters without modifying novel metadata - else: - # CREATE MODE: creating new novel with chapters from EPUB - # Resolve series - series_id = None - if seriesMode == "existing" and seriesId: - series_id = await _resolve_series_id(db, seriesId, None, user) - elif seriesMode == "new" and seriesName: - series_id = await _resolve_series_id(db, None, seriesName, user) - - base_slug = _slugify_unique(meta_title) - count_result = await db.execute( - text('SELECT COUNT(*) FROM "Novel" WHERE slug LIKE :pat'), {"pat": f"{base_slug}%"} - ) - cnt = count_result.scalar() or 0 - slug = base_slug if cnt == 0 else f"{base_slug}-{int(cnt) + 1}" - - novel_id = _new_cuid() - await db.execute( - text( - 'INSERT INTO "Novel" (id, title, "originalTitle", slug, "authorName", description, ' - '"coverUrl", status, "uploaderId", "seriesId", "createdAt", "updatedAt") ' - "VALUES (:id, :title, NULL, :slug, :author, :desc, :cover, 'Đang ra', :uid, :sid, NOW(), NOW())" - ), - { - "id": novel_id, "title": meta_title, "slug": slug, - "author": meta_author, "desc": meta_description or "", - "cover": cover_url, "uid": user["id"], "sid": series_id, - }, - ) - for gid in genre_ids: - await db.execute( - text('INSERT INTO "NovelGenre" ("novelId", "genreId") VALUES (:nid, :gid) ON CONFLICT DO NOTHING'), - {"nid": novel_id, "gid": gid}, - ) - - # Insert chapters to MongoDB - # For append mode: upsert by chapter number so existing chapters are overwritten correctly. - if appendTargetNovelId: - best_by_number: dict[int, dict] = {} - for c in chapters: - n = int(c.get("number") or 0) - if n <= 0: - continue - prev = best_by_number.get(n) - if prev is None or len((c.get("content") or "")) >= len((prev.get("content") or "")): - best_by_number[n] = c - - ops = [] - for n in sorted(best_by_number.keys()): - c = best_by_number[n] - ops.append( - UpdateOne( - {"novelId": novel_id, "number": n}, - { - "$set": { - "title": c.get("title"), - "content": c.get("content"), - "volumeNumber": c.get("volumeNumber"), - "volumeTitle": c.get("volumeTitle"), - "volumeChapterNumber": c.get("volumeChapterNumber"), - } - }, - upsert=True, - ) - ) - - if ops: - await mongo_db.chapters.bulk_write(ops, ordered=False) - else: - docs = [ - { - "novelId": novel_id, - "number": c["number"], - "title": c["title"], - "content": c["content"], - "volumeNumber": c.get("volumeNumber"), - "volumeTitle": c.get("volumeTitle"), - "volumeChapterNumber": c.get("volumeChapterNumber"), - } - for c in chapters - ] - if docs: - await mongo_db.chapters.insert_many(docs) - - total = await _sync_total_chapters(db, novel_id) - await db.commit() - - return { - "novelId": novel_id, - "title": meta_title, - "totalChapters": total, - "coverUrl": cover_url, - } - - -# --------------------------------------------------------------------------- -# /api/mod/ai-tools -# --------------------------------------------------------------------------- - - -@router.get("/mod/ai-tools/novel-search") -async def mod_ai_novel_search( - q: str = Query(""), - page: int = Query(1, ge=1), - missing: str = Query(""), - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - if not q.strip() and missing.lower() != "true": - return {"novels": [], "hasMore": False} - - skip = (page - 1) * 24 - conds = [] - params: dict[str, Any] = {"skip": skip} - - if not _is_admin(user): - conds.append('(n."uploaderId" = :uid OR n."uploaderId" IS NULL)') - params["uid"] = user["id"] - - if q.strip(): - conds.append('(n.title ILIKE :q OR n."authorName" ILIKE :q)') - params["q"] = f"%{q.strip()}%" - - if missing.lower() == "true": - conds.append( - "(n.\"authorName\" IN ('', 'Chưa rõ') OR n.description IN ('', 'Chưa có giới thiệu', 'Không có giới thiệu'))" - ) - - where = ("WHERE " + " AND ".join(conds)) if conds else "" - result = await db.execute( - text( - f'SELECT n.id, n.title, n.slug, n."authorName", n."coverUrl", n.status, n."totalChapters" ' - f'FROM "Novel" n {where} ORDER BY n."updatedAt" DESC LIMIT 25 OFFSET :skip' - ), - params, - ) - rows = result.mappings().all() - return {"novels": [dict(r) for r in rows[:24]], "hasMore": len(rows) == 25} - - -@router.get("/mod/ai-tools/test-connection") -async def mod_ai_test_connection(user: dict = Depends(require_mod_user)): - import time - - api_key = settings.deepseek_key - if not api_key: - raise HTTPException(status_code=503, detail="DEEPSEEK_KEY chưa được cấu hình") - - model = settings.deepseek_model or "deepseek-chat" - start = time.monotonic() - try: - async with httpx.AsyncClient(timeout=15) as client: - resp = await client.post( - "https://api.deepseek.com/v1/chat/completions", - headers={"Authorization": f"Bearer {api_key}"}, - json={ - "model": model, - "temperature": 0.1, - "max_tokens": 10, - "messages": [{"role": "user", "content": "Ping! Please reply with 'pong'."}], - }, - ) - resp.raise_for_status() - data = resp.json() - reply = data["choices"][0]["message"]["content"].strip() - except Exception as e: - return {"success": False, "message": str(e), "latencyMs": int((time.monotonic() - start) * 1000), "model": model} - - return {"success": True, "message": reply, "latencyMs": int((time.monotonic() - start) * 1000), "model": model} - - -def _build_enrich_prompt(novel: dict) -> str: - return f"""Bạn là chuyên gia tìm kiếm thông tin web tiểu thuyết Trung Quốc/Hàn Quốc. -Hãy tìm kiếm thông tin chính xác cho truyện sau trên Qidian, JJWXC, NovelUpdates hoặc các nguồn uy tín khác. - -Tiêu đề: {novel.get('title', '')} -Tên gốc: {novel.get('originalTitle', '') or 'Chưa rõ'} -Tác giả: {novel.get('authorName', '') or 'Chưa rõ'} -Thể loại: {novel.get('genres', '')} -Mô tả hiện tại: {(novel.get('description') or '')[:500]} - -Trả về JSON với schema: -{{"results": [{{"title": "", "originalTitle": "", "authorName": "", "originalAuthorName": "", -"description": "", "coverUrl": "", "status": "Đang ra|Hoàn thành|Tạm ngưng", -"genresSuggested": [], "firstPublishYear": null, "confidence": 1-99, "source": "", "sourceUrl": ""}}]}} - -Tối đa 3 kết quả, sắp xếp theo độ tin cậy giảm dần.""" - - -@router.get("/mod/ai-tools/novel-enrich") -async def mod_ai_enrich( - novelId: str = Query(...), - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - novel_result = await db.execute( - text('SELECT n.*, array_agg(g.name) AS genre_names FROM "Novel" n LEFT JOIN "NovelGenre" ng ON ng."novelId" = n.id LEFT JOIN "Genre" g ON g.id = ng."genreId" WHERE n.id = :id GROUP BY n.id'), - {"id": novelId}, - ) - row = novel_result.mappings().first() - if not row: - raise HTTPException(status_code=404, detail="Không tìm thấy truyện") - - novel = dict(row) - novel["genres"] = ", ".join(g for g in (novel.get("genre_names") or []) if g) - - prompt = _build_enrich_prompt(novel) - attempts = [] - - # DeepSeek - api_key = settings.deepseek_key - if api_key: - model = settings.deepseek_model or "deepseek-chat" - for max_tokens, timeout_s in [(1300, 60), (900, 45)]: - try: - async with httpx.AsyncClient(timeout=timeout_s) as client: - resp = await client.post( - "https://api.deepseek.com/v1/chat/completions", - headers={"Authorization": f"Bearer {api_key}"}, - json={ - "model": model, "temperature": 0.2, "max_tokens": max_tokens, - "response_format": {"type": "json_object"}, - "messages": [ - {"role": "system", "content": "You are a helpful assistant. Return only valid JSON."}, - {"role": "user", "content": prompt}, - ], - }, - ) - resp.raise_for_status() - data = resp.json() - content = data["choices"][0]["message"]["content"] - import json as _json - parsed = _json.loads(content) - results = parsed.get("results", []) - attempts.append({"provider": "deepseek", "model": model, "success": True}) - return { - "novel": novel, "provider": "deepseek", "model": model, - "attempts": attempts, "count": len(results), "results": results, - } - except Exception as e: - attempts.append({"provider": "deepseek", "model": model, "success": False, "error": str(e)}) - - # OpenRouter fallback - or_key = settings.openrouter_key - if or_key and not (settings.openrouter_paused or "").lower().startswith("true"): - try: - async with httpx.AsyncClient(timeout=15) as client: - models_resp = await client.get( - "https://openrouter.ai/api/v1/models", - headers={"Authorization": f"Bearer {or_key}"}, - ) - all_models = models_resp.json().get("data", []) - free_models = [m["id"] for m in all_models if m.get("id", "").endswith(":free")] - except Exception: - free_models = [] - - for free_model in free_models[:5]: - try: - async with httpx.AsyncClient(timeout=22) as client: - resp = await client.post( - "https://openrouter.ai/api/v1/chat/completions", - headers={ - "Authorization": f"Bearer {or_key}", - "HTTP-Referer": "http://localhost:3000", - "X-Title": "reader-mod-ai-tool", - }, - json={ - "model": free_model, "temperature": 0.2, "max_tokens": 1400, - "messages": [ - {"role": "system", "content": "Return only valid JSON."}, - {"role": "user", "content": prompt}, - ], - }, - ) - resp.raise_for_status() - data = resp.json() - content = data["choices"][0]["message"]["content"] - import json as _json - parsed = _json.loads(content) - results = parsed.get("results", []) - attempts.append({"provider": "openrouter", "model": free_model, "success": True}) - return { - "novel": novel, "provider": "openrouter", "model": free_model, - "attempts": attempts, "count": len(results), "results": results, - } - except Exception as e: - attempts.append({"provider": "openrouter", "model": free_model, "success": False, "error": str(e)}) - - return {"novel": novel, "provider": None, "model": None, "attempts": attempts, "count": 0, "results": []} - - -class BatchEnrichBody(BaseModel): - novelIds: list[str] - - -@router.post("/mod/ai-tools/novel-enrich-batch") -async def mod_ai_enrich_batch( - body: BatchEnrichBody, - db: AsyncSession = Depends(get_db_session), - user: dict = Depends(require_mod_user), -): - import json as _json - import time - - if len(body.novelIds) > 20: - raise HTTPException(status_code=400, detail="Tối đa 20 truyện mỗi lần") - - result = await db.execute( - text( - 'SELECT id, title, "originalTitle", "authorName", "originalAuthorName", description ' - 'FROM "Novel" WHERE id = ANY(:ids)' - ), - {"ids": body.novelIds}, - ) - novels = [dict(r) for r in result.mappings()] - if not novels: - raise HTTPException(status_code=404, detail="Không tìm thấy truyện nào") - - def _build_batch_prompt(novels_list: list[dict]) -> str: - parts = [] - for n in novels_list: - parts.append( - f"[ID: {n['id']}]\nTên: {n.get('title','')}\nTên gốc: {n.get('originalTitle','') or 'Chưa rõ'}\n" - f"Tác giả: {n.get('authorName','') or 'Chưa rõ'}\nMô tả: {(n.get('description') or '')[:1500]}\n---" - ) - return ( - "Bạn là chuyên gia tìm thông tin tiểu thuyết Trung/Hàn. Tìm thông tin cho các truyện sau:\n\n" - + "\n".join(parts) - + '\n\nTrả về JSON: {"results": [{"id":"...", "originalTitle":"...", "authorName":"...", "originalAuthorName":"...", "description":"...", "coverUrl":"...", "status":"..."}]}' - ) - - api_key = settings.deepseek_key - if not api_key: - raise HTTPException(status_code=503, detail="DEEPSEEK_KEY chưa được cấu hình") - - model = settings.deepseek_model or "deepseek-chat" - start = time.monotonic() - try: - async with httpx.AsyncClient(timeout=90) as client: - resp = await client.post( - "https://api.deepseek.com/v1/chat/completions", - headers={"Authorization": f"Bearer {api_key}"}, - json={ - "model": model, "temperature": 0.2, "max_tokens": 2500, - "response_format": {"type": "json_object"}, - "messages": [ - {"role": "system", "content": "output only valid standard JSON object."}, - {"role": "user", "content": _build_batch_prompt(novels)}, - ], - }, - ) - resp.raise_for_status() - data = resp.json() - parsed = _json.loads(data["choices"][0]["message"]["content"]) - results = parsed.get("results", []) - except Exception as e: - raise HTTPException(status_code=502, detail=f"AI API lỗi: {e}") - - latency_ms = int((time.monotonic() - start) * 1000) - return { - "success": True, "latencyMs": latency_ms, "model": model, - "count": len(results), "results": results, "sourceNovels": novels, - } diff --git a/package.json b/package.json index 5926486..7f81ebf 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,8 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev -p 3001", - "build": "next build", - "start": "next start -p 3001", - "lint": "eslint ." + "dev": "uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000", + "start": "uv run uvicorn app.main:app --host 0.0.0.0 --port 8000" }, "dependencies": { "@auth/prisma-adapter": "^2.11.1", @@ -52,7 +50,6 @@ "input-otp": "1.4.2", "lucide-react": "^0.564.0", "mammoth": "^1.11.0", - "mongoose": "^9.2.4", "next": "16.1.6", "next-auth": "^4.24.13", "next-themes": "^0.4.6", diff --git a/scripts/backfill_chapter_content_refs.py b/scripts/backfill_chapter_content_refs.py deleted file mode 100644 index b7fb47f..0000000 --- a/scripts/backfill_chapter_content_refs.py +++ /dev/null @@ -1,121 +0,0 @@ -from __future__ import annotations - -import argparse -import asyncio -import hashlib -import json -import sys -from pathlib import Path -from bson import ObjectId -from sqlalchemy import text - -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - -from app.config import settings -from app.database import SessionLocal, mongo_db -from app.storage import storage - - -async def backfill(limit: int, dry_run: bool, after_id: str | None, state_file: str | None) -> None: - query = { - "$or": [ - {"content": {"$exists": True, "$type": "string", "$ne": ""}}, - {"contentHtml": {"$exists": True, "$type": "string", "$ne": ""}}, - ] - } - if after_id: - query["_id"] = {"$gt": ObjectId(after_id)} - - docs = ( - await mongo_db["chapters"] - .find(query, {"content": 1, "contentHtml": 1}) - .sort("_id", 1) - .limit(limit) - .to_list(limit) - ) - - mapped = 0 - skipped = 0 - async with SessionLocal() as db: - for doc in docs: - chapter_id = str(doc.get("_id") or "") - if not chapter_id: - skipped += 1 - continue - - exists = ( - await db.execute( - text('SELECT "chapterId" FROM "ChapterContentRef" WHERE "chapterId" = :id LIMIT 1'), - {"id": chapter_id}, - ) - ).mappings().first() - if exists: - skipped += 1 - continue - - txt = str(doc.get("content") or "").strip() - raw_html = str(doc.get("contentHtml") or doc.get("content") or "") - if not txt: - skipped += 1 - continue - - txt_href = f"legacy/{chapter_id}.txt" - raw_href = f"legacy/{chapter_id}.raw.html" - content_hash = hashlib.sha256(txt.encode("utf-8")).hexdigest() - - if not dry_run: - storage.write_text(txt_href, txt) - storage.write_text(raw_href, raw_html) - await db.execute( - text( - 'INSERT INTO "ChapterContentRef" ("chapterId", "txtHref", "rawHtmlHref", "contentHash") ' - 'VALUES (:chapter_id, :txt_href, :raw_href, :hash) ' - 'ON CONFLICT ("chapterId") DO NOTHING' - ), - { - "chapter_id": chapter_id, - "txt_href": txt_href, - "raw_href": raw_href, - "hash": content_hash, - }, - ) - mapped += 1 - - if not dry_run: - await db.commit() - - last_id = str(docs[-1]["_id"]) if docs else None - summary = { - "scanned": len(docs), - "mapped": mapped, - "skipped": skipped, - "dryRun": dry_run, - "contentRoot": settings.nas_content_root, - "nextAfterId": last_id, - } - if state_file and last_id and not dry_run: - Path(state_file).write_text(json.dumps({"afterId": last_id}, ensure_ascii=True), encoding="utf-8") - print(summary) - - -def main() -> None: - parser = argparse.ArgumentParser(description="Backfill ChapterContentRef from Mongo chapters") - parser.add_argument("--limit", type=int, default=1000) - parser.add_argument("--dry-run", action="store_true") - parser.add_argument("--after-id", type=str, default="") - parser.add_argument("--state-file", type=str, default="") - args = parser.parse_args() - after_id = args.after_id.strip() or None - state_file = args.state_file.strip() or None - if state_file and not after_id: - p = Path(state_file) - if p.exists(): - try: - after_id = json.loads(p.read_text(encoding="utf-8")).get("afterId") - except Exception: - after_id = None - asyncio.run(backfill(limit=args.limit, dry_run=args.dry_run, after_id=after_id, state_file=state_file)) - - -if __name__ == "__main__": - main()